Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
949fc4eafa |
@@ -1,11 +1,8 @@
|
||||
.vscode
|
||||
ollama
|
||||
app
|
||||
macapp
|
||||
dist
|
||||
build
|
||||
scripts
|
||||
llm/llama.cpp/ggml
|
||||
llm/llama.cpp/gguf
|
||||
.env
|
||||
.cache
|
||||
test_data
|
||||
.git
|
||||
|
||||
|
||||
28
.gitattributes
vendored
@@ -1,28 +0,0 @@
|
||||
llama/**/*.cpp linguist-vendored
|
||||
llama/**/*.hpp linguist-vendored
|
||||
llama/**/*.h linguist-vendored
|
||||
llama/**/*.c linguist-vendored
|
||||
llama/**/*.cu linguist-vendored
|
||||
llama/**/*.cuh linguist-vendored
|
||||
llama/**/*.m linguist-vendored
|
||||
llama/**/*.metal linguist-vendored
|
||||
|
||||
ml/backend/**/*.c linguist-vendored
|
||||
ml/backend/**/*.h linguist-vendored
|
||||
ml/backend/**/*.cpp linguist-vendored
|
||||
ml/backend/**/*.hpp linguist-vendored
|
||||
ml/backend/**/*.cu linguist-vendored
|
||||
ml/backend/**/*.cuh linguist-vendored
|
||||
ml/backend/**/*.m linguist-vendored
|
||||
ml/backend/**/*.metal linguist-vendored
|
||||
ml/backend/**/*.comp linguist-vendored
|
||||
ml/backend/**/*.glsl linguist-vendored
|
||||
ml/backend/**/CMakeLists.txt linguist-vendored
|
||||
|
||||
app/webview linguist-vendored
|
||||
|
||||
llama/build-info.cpp linguist-generated
|
||||
ml/backend/ggml/ggml/src/ggml-metal/ggml-metal-embed.s linguist-generated
|
||||
|
||||
* text=auto
|
||||
*.go text eol=lf
|
||||
68
.github/ISSUE_TEMPLATE/10_bug_report.yml
vendored
@@ -1,68 +0,0 @@
|
||||
name: Bug report
|
||||
labels: [bug]
|
||||
description: Something isn't working right.
|
||||
body:
|
||||
- type: textarea
|
||||
id: description
|
||||
attributes:
|
||||
label: What is the issue?
|
||||
description: What happened? What did you expect to happen?
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: logs
|
||||
attributes:
|
||||
label: Relevant log output
|
||||
description: Please copy and paste any relevant log output. See [Troubleshooting Guide](https://github.com/ollama/ollama/blob/main/docs/troubleshooting.mdx#how-to-troubleshoot-issues) for details.
|
||||
render: shell
|
||||
validations:
|
||||
required: false
|
||||
- type: dropdown
|
||||
id: os
|
||||
attributes:
|
||||
label: OS
|
||||
description: Which operating system are you using?
|
||||
multiple: true
|
||||
options:
|
||||
- Linux
|
||||
- macOS
|
||||
- Windows
|
||||
- Docker
|
||||
- WSL2
|
||||
validations:
|
||||
required: false
|
||||
- type: dropdown
|
||||
id: gpu
|
||||
attributes:
|
||||
label: GPU
|
||||
description: Which GPU are you using?
|
||||
multiple: true
|
||||
options:
|
||||
- Nvidia
|
||||
- AMD
|
||||
- Intel
|
||||
- Apple
|
||||
- Other
|
||||
validations:
|
||||
required: false
|
||||
- type: dropdown
|
||||
id: cpu
|
||||
attributes:
|
||||
label: CPU
|
||||
description: Which CPU are you using?
|
||||
multiple: true
|
||||
options:
|
||||
- Intel
|
||||
- AMD
|
||||
- Apple
|
||||
- Other
|
||||
validations:
|
||||
required: false
|
||||
- type: input
|
||||
id: version
|
||||
attributes:
|
||||
label: Ollama version
|
||||
description: What version of Ollama are you using? (`ollama --version`)
|
||||
placeholder: e.g., 0.1.32
|
||||
validations:
|
||||
required: false
|
||||
6
.github/ISSUE_TEMPLATE/20_feature_request.md
vendored
@@ -1,6 +0,0 @@
|
||||
---
|
||||
name: Feature request
|
||||
about: Request a new feature
|
||||
labels: feature request
|
||||
---
|
||||
|
||||
5
.github/ISSUE_TEMPLATE/30_model_request.md
vendored
@@ -1,5 +0,0 @@
|
||||
---
|
||||
name: Model request
|
||||
about: Request support for a new model to be added to Ollama
|
||||
labels: model request
|
||||
---
|
||||
8
.github/ISSUE_TEMPLATE/config.yml
vendored
@@ -1,8 +0,0 @@
|
||||
blank_issues_enabled: true
|
||||
contact_links:
|
||||
- name: Help
|
||||
url: https://discord.com/invite/ollama
|
||||
about: Please join our Discord server for help using Ollama
|
||||
- name: Troubleshooting
|
||||
url: https://github.com/ollama/ollama/blob/main/docs/faq.md#faq
|
||||
about: See the FAQ for common issues and solutions
|
||||
24
.github/workflows/latest.yaml
vendored
@@ -1,24 +0,0 @@
|
||||
name: latest
|
||||
|
||||
on:
|
||||
release:
|
||||
types: [released]
|
||||
|
||||
jobs:
|
||||
update-latest:
|
||||
environment: release
|
||||
runs-on: linux
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Login to Docker Hub
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
username: ${{ vars.DOCKER_USER }}
|
||||
password: ${{ secrets.DOCKER_ACCESS_TOKEN }}
|
||||
- name: Tag images as latest
|
||||
env:
|
||||
PUSH: "1"
|
||||
shell: bash
|
||||
run: |
|
||||
export "VERSION=${GITHUB_REF_NAME#v}"
|
||||
./scripts/tag_latest.sh
|
||||
792
.github/workflows/release.yaml
vendored
@@ -1,792 +0,0 @@
|
||||
name: release
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- 'v*'
|
||||
|
||||
env:
|
||||
CGO_CFLAGS: '-O3'
|
||||
CGO_CXXFLAGS: '-O3'
|
||||
|
||||
jobs:
|
||||
setup-environment:
|
||||
runs-on: ubuntu-latest
|
||||
environment: release
|
||||
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
|
||||
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=$(cat LLAMA_CPP_VERSION)-$(cat MLX_VERSION)-$(cat MLX_C_VERSION) | tee -a $GITHUB_OUTPUT
|
||||
|
||||
darwin-build:
|
||||
runs-on: macos-26-xlarge
|
||||
environment: release
|
||||
needs: setup-environment
|
||||
env:
|
||||
GOFLAGS: ${{ needs.setup-environment.outputs.GOFLAGS }}
|
||||
VERSION: ${{ needs.setup-environment.outputs.VERSION }}
|
||||
APPLE_IDENTITY: ${{ secrets.APPLE_IDENTITY }}
|
||||
APPLE_PASSWORD: ${{ secrets.APPLE_PASSWORD }}
|
||||
APPLE_TEAM_ID: ${{ vars.APPLE_TEAM_ID }}
|
||||
APPLE_ID: ${{ vars.APPLE_ID }}
|
||||
MACOS_SIGNING_KEY: ${{ secrets.MACOS_SIGNING_KEY }}
|
||||
MACOS_SIGNING_KEY_PASSWORD: ${{ secrets.MACOS_SIGNING_KEY_PASSWORD }}
|
||||
DEVELOPER_DIR: /Applications/Xcode_26.4.1.app/Contents/Developer
|
||||
CGO_CFLAGS: '-mmacosx-version-min=14.0 -O3'
|
||||
CGO_CXXFLAGS: '-mmacosx-version-min=14.0 -O3'
|
||||
CGO_LDFLAGS: '-mmacosx-version-min=14.0 -O3'
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Select Xcode 26.4.1
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [ ! -d "${DEVELOPER_DIR}" ]; then
|
||||
echo "Missing ${DEVELOPER_DIR}"
|
||||
ls -1 /Applications | grep '^Xcode' || true
|
||||
exit 1
|
||||
fi
|
||||
|
||||
sudo xcode-select -s "${DEVELOPER_DIR}"
|
||||
sw_vers
|
||||
xcodebuild -version
|
||||
xcrun --sdk macosx --show-sdk-version
|
||||
xcrun --find metal
|
||||
- run: |
|
||||
echo $MACOS_SIGNING_KEY | base64 --decode > certificate.p12
|
||||
security create-keychain -p password build.keychain
|
||||
security default-keychain -s build.keychain
|
||||
security unlock-keychain -p password build.keychain
|
||||
security import certificate.p12 -k build.keychain -P $MACOS_SIGNING_KEY_PASSWORD -T /usr/bin/codesign
|
||||
security set-key-partition-list -S apple-tool:,apple:,codesign: -s -k password build.keychain
|
||||
security set-keychain-settings -lut 3600 build.keychain
|
||||
- uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
cache-dependency-path: |
|
||||
go.sum
|
||||
LLAMA_CPP_VERSION
|
||||
MLX_VERSION
|
||||
MLX_C_VERSION
|
||||
- run: |
|
||||
./scripts/build_darwin.sh
|
||||
- name: Log build results
|
||||
run: |
|
||||
ls -l dist/
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: bundles-darwin
|
||||
path: |
|
||||
dist/*.tgz
|
||||
dist/*.tar.zst
|
||||
dist/*.zip
|
||||
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"'
|
||||
- '"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: 'CUDA 13 ARM64'
|
||||
build-steps: cuda13Arm64Cross
|
||||
install: https://packages.nvidia.com/prerelease/cuda/13.4.0/local_installers/cuda_13.4.0_windows_x86_64.exe
|
||||
cuda-components:
|
||||
- '"cudart"'
|
||||
- '"cudart_cross"'
|
||||
- '"nvcc"'
|
||||
- '"nvcc_cross"'
|
||||
- '"cublas_cross"'
|
||||
- '"cublas_dev"'
|
||||
- '"crt"'
|
||||
- '"nvvm"'
|
||||
- '"nvptxcompiler"'
|
||||
cuda-version: '13.4'
|
||||
- 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
|
||||
- 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:
|
||||
- '"cudart"'
|
||||
- '"nvcc"'
|
||||
- '"cublas"'
|
||||
- '"cublas_dev"'
|
||||
- '"cufft"'
|
||||
- '"cufft_dev"'
|
||||
- '"nvrtc"'
|
||||
- '"nvrtc_dev"'
|
||||
- '"crt"'
|
||||
- '"nvvm"'
|
||||
- '"nvptxcompiler"'
|
||||
cuda-version: '13.0'
|
||||
runs-on: ${{ matrix.arch == 'arm64' && format('{0}-{1}', matrix.os, matrix.arch) || matrix.os }}
|
||||
environment: release
|
||||
env:
|
||||
GOFLAGS: ${{ needs.setup-environment.outputs.GOFLAGS }}
|
||||
VERSION: ${{ needs.setup-environment.outputs.VERSION }}
|
||||
steps:
|
||||
- if: startsWith(matrix.preset, 'MLX ')
|
||||
name: Increase pagefile to 200 GB
|
||||
uses: al-cheb/configure-pagefile-action@v1.5
|
||||
with:
|
||||
minimum-size: 16GB
|
||||
maximum-size: 200GB
|
||||
disk-root: "D:"
|
||||
- name: Install system dependencies
|
||||
run: |
|
||||
choco install -y --no-progress ccache ninja
|
||||
if (Get-Command ccache -ErrorAction SilentlyContinue) {
|
||||
ccache -o cache_dir=${{ github.workspace }}\.ccache
|
||||
}
|
||||
- if: matrix.preset == 'CPU'
|
||||
name: Install Windows ARM64 cross compiler
|
||||
run: |
|
||||
Invoke-WebRequest -Uri "https://github.com/mstorsjo/llvm-mingw/releases/download/20240619/llvm-mingw-20240619-ucrt-x86_64.zip" -OutFile "${{ runner.temp }}\llvm-mingw-ucrt.zip"
|
||||
Expand-Archive -Path ${{ runner.temp }}\llvm-mingw-ucrt.zip -DestinationPath "C:\Program Files\"
|
||||
$installPath=(Resolve-Path -Path "C:\Program Files\llvm-mingw-*-ucrt-x86_64").path
|
||||
if (!(Test-Path "$installPath\bin\aarch64-w64-mingw32-gcc.exe")) {
|
||||
throw "llvm-mingw x86_64 package is missing the aarch64 cross compiler"
|
||||
}
|
||||
- if: startsWith(matrix.preset, 'CUDA ') || startsWith(matrix.preset, 'ROCm ') || startsWith(matrix.preset, 'Vulkan') || startsWith(matrix.preset, 'MLX ')
|
||||
id: cache-install
|
||||
uses: actions/cache/restore@v4
|
||||
with:
|
||||
path: |
|
||||
C:\Program Files\NVIDIA GPU Computing Toolkit\CUDA
|
||||
C:\Program Files\AMD\ROCm
|
||||
C:\VulkanSDK
|
||||
C:\Program Files\NVIDIA\CUDNN
|
||||
key: ${{ matrix.install }}-${{ matrix.cudnn-install }}
|
||||
- if: startsWith(matrix.preset, 'CUDA ') || startsWith(matrix.preset, 'MLX ')
|
||||
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 ${{ 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 "-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: startsWith(matrix.preset, 'MLX ')
|
||||
name: Install cuDNN for MLX
|
||||
run: |
|
||||
$ErrorActionPreference = "Stop"
|
||||
$cudnnRoot = "C:\Program Files\NVIDIA\CUDNN"
|
||||
if ("${{ steps.cache-install.outputs.cache-hit }}" -ne 'true') {
|
||||
Invoke-WebRequest -Uri "${{ matrix.cudnn-install }}" -OutFile "cudnn.zip"
|
||||
Expand-Archive -Path cudnn.zip -DestinationPath cudnn-extracted
|
||||
$cudnnDir = (Get-ChildItem -Path cudnn-extracted -Directory)[0].FullName
|
||||
New-Item -ItemType Directory -Force -Path $cudnnRoot
|
||||
Copy-Item -Path "$cudnnDir\*" -Destination "$cudnnRoot\" -Recurse
|
||||
}
|
||||
|
||||
echo "CUDNN_ROOT_DIR=$cudnnRoot" | Out-File -FilePath $env:GITHUB_ENV -Append
|
||||
echo "CUDNN_INCLUDE_PATH=$cudnnRoot\include" | Out-File -FilePath $env:GITHUB_ENV -Append
|
||||
echo "CUDNN_LIBRARY_PATH=$cudnnRoot\lib\x64" | Out-File -FilePath $env:GITHUB_ENV -Append
|
||||
echo "$cudnnRoot\bin\x64" | Out-File -FilePath $env:GITHUB_PATH -Encoding utf8 -Append
|
||||
- if: ${{ !cancelled() && 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
|
||||
C:\Program Files\NVIDIA\CUDNN
|
||||
key: ${{ matrix.install }}-${{ matrix.cudnn-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
|
||||
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:
|
||||
runs-on: windows
|
||||
environment: release
|
||||
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"
|
||||
- run: |
|
||||
./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-windows-amd64
|
||||
path: |
|
||||
dist\*
|
||||
|
||||
windows-app:
|
||||
runs-on: windows
|
||||
environment: release
|
||||
needs: [setup-environment, windows-build, windows-depends]
|
||||
env:
|
||||
GOFLAGS: ${{ needs.setup-environment.outputs.GOFLAGS }}
|
||||
VERSION: ${{ needs.setup-environment.outputs.VERSION }}
|
||||
KEY_CONTAINER: ${{ vars.KEY_CONTAINER }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: google-github-actions/auth@v2
|
||||
with:
|
||||
project_id: ollama
|
||||
credentials_json: ${{ secrets.GOOGLE_SIGNING_CREDENTIALS }}
|
||||
- run: |
|
||||
$ErrorActionPreference = "Stop"
|
||||
Invoke-WebRequest -Uri "https://go.microsoft.com/fwlink/p/?LinkId=323507" -OutFile "${{ runner.temp }}\sdksetup.exe"
|
||||
Start-Process "${{ runner.temp }}\sdksetup.exe" -ArgumentList @("/q") -NoNewWindow -Wait
|
||||
|
||||
Invoke-WebRequest -Uri "https://github.com/GoogleCloudPlatform/kms-integrations/releases/download/cng-v1.0/kmscng-1.0-windows-amd64.zip" -OutFile "${{ runner.temp }}\plugin.zip"
|
||||
Expand-Archive -Path "${{ runner.temp }}\plugin.zip" -DestinationPath "${{ runner.temp }}\plugin\"
|
||||
& "${{ runner.temp }}\plugin\*\kmscng.msi" /quiet
|
||||
|
||||
echo "${{ vars.OLLAMA_CERT }}" >ollama_inc.crt
|
||||
- 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: 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
|
||||
run: |
|
||||
gci -path .\dist -Recurse -File | ForEach-Object { get-filehash -path $_.FullName -Algorithm SHA256 } | format-list
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: bundles-windows
|
||||
path: |
|
||||
dist/*.zip
|
||||
dist/*.ps1
|
||||
dist/OllamaSetup.exe
|
||||
|
||||
linux-depends:
|
||||
strategy:
|
||||
matrix:
|
||||
include:
|
||||
- arch: amd64
|
||||
target: llama-server-cpu
|
||||
- arch: amd64
|
||||
target: llama-server-cuda_v12
|
||||
- arch: amd64
|
||||
target: llama-server-cuda_v13
|
||||
- arch: amd64
|
||||
target: mlx
|
||||
- arch: amd64
|
||||
target: llama-server-rocm_v7_2
|
||||
- arch: amd64
|
||||
target: llama-server-vulkan
|
||||
- arch: arm64
|
||||
target: llama-server-cpu
|
||||
- arch: arm64
|
||||
target: llama-server-cuda_v12
|
||||
- arch: arm64
|
||||
target: llama-server-cuda_v13
|
||||
- arch: arm64
|
||||
target: jetpack-5
|
||||
- arch: arm64
|
||||
target: jetpack-6
|
||||
runs-on: ${{ matrix.arch == 'arm64' && 'linux-arm64' || 'linux' }}
|
||||
environment: release
|
||||
needs: setup-environment
|
||||
env:
|
||||
GOFLAGS: ${{ needs.setup-environment.outputs.GOFLAGS }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: docker/setup-buildx-action@v3
|
||||
- uses: docker/login-action@v3
|
||||
with:
|
||||
username: ${{ vars.DOCKER_USER }}
|
||||
password: ${{ secrets.DOCKER_ACCESS_TOKEN }}
|
||||
- if: matrix.target == 'mlx'
|
||||
name: Increase Linux swap to 200 GB
|
||||
shell: bash
|
||||
run: |
|
||||
set -e
|
||||
SWAP_PATH=/swapfile-mlx
|
||||
SWAP_SIZE_GB=200
|
||||
if [ -f "$SWAP_PATH" ]; then
|
||||
sudo swapoff "$SWAP_PATH" 2>/dev/null || true
|
||||
sudo rm -f "$SWAP_PATH"
|
||||
fi
|
||||
if ! sudo fallocate -l ${SWAP_SIZE_GB}G "$SWAP_PATH" 2>/dev/null; then
|
||||
echo "fallocate unsupported, falling back to dd"
|
||||
sudo dd if=/dev/zero of="$SWAP_PATH" bs=1M count=$((SWAP_SIZE_GB * 1024))
|
||||
fi
|
||||
sudo chmod 600 "$SWAP_PATH"
|
||||
sudo mkswap "$SWAP_PATH"
|
||||
sudo swapon "$SWAP_PATH"
|
||||
swapon --show
|
||||
free -h
|
||||
- uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
platforms: linux/${{ matrix.arch }}
|
||||
target: ${{ matrix.target }}
|
||||
provenance: false
|
||||
sbom: false
|
||||
build-args: |
|
||||
GOFLAGS=${{ env.GOFLAGS }}
|
||||
CGO_CFLAGS=${{ env.CGO_CFLAGS }}
|
||||
CGO_CXXFLAGS=${{ env.CGO_CXXFLAGS }}
|
||||
OLLAMA_MLX_BUILD_JOBS=16
|
||||
OLLAMA_MLX_NVCC_THREADS=6
|
||||
APT_MIRROR=http://azure.archive.ubuntu.com/ubuntu
|
||||
APT_PORTS_MIRROR=http://azure.ports.ubuntu.com/ubuntu-ports
|
||||
cache-from: |
|
||||
type=registry,ref=ollama/release:cache-${{ matrix.arch }}-${{ matrix.target }}
|
||||
type=registry,ref=${{ vars.DOCKER_REPO }}:latest
|
||||
cache-to: type=registry,ref=ollama/release:cache-${{ matrix.arch }}-${{ matrix.target }},mode=max
|
||||
|
||||
# Build each Docker variant (OS, arch, and flavor) separately. Using QEMU is unreliable and slower.
|
||||
# Heavy stages were pre-built by linux-depends; this job is cache-hit-only for those layers
|
||||
# and just assembles, runs the Go build, pushes the final image, and extracts release bundles.
|
||||
docker-build-push:
|
||||
strategy:
|
||||
matrix:
|
||||
include:
|
||||
- os: linux
|
||||
arch: arm64
|
||||
archive-target: archive
|
||||
build-args: |
|
||||
CGO_CFLAGS
|
||||
CGO_CXXFLAGS
|
||||
GOFLAGS
|
||||
APT_MIRROR=http://azure.archive.ubuntu.com/ubuntu
|
||||
APT_PORTS_MIRROR=http://azure.ports.ubuntu.com/ubuntu-ports
|
||||
OLLAMA_MLX_BUILD_JOBS=16
|
||||
OLLAMA_MLX_NVCC_THREADS=6
|
||||
cache-from: |
|
||||
type=registry,ref=ollama/release:cache-arm64-llama-server-cpu
|
||||
type=registry,ref=ollama/release:cache-arm64-llama-server-cuda_v12
|
||||
type=registry,ref=ollama/release:cache-arm64-llama-server-cuda_v13
|
||||
type=registry,ref=ollama/release:cache-arm64-jetpack-5
|
||||
type=registry,ref=ollama/release:cache-arm64-jetpack-6
|
||||
type=registry,ref=${{ vars.DOCKER_REPO }}:latest
|
||||
- os: linux
|
||||
arch: amd64
|
||||
archive-target: archive
|
||||
build-args: |
|
||||
CGO_CFLAGS
|
||||
CGO_CXXFLAGS
|
||||
GOFLAGS
|
||||
APT_MIRROR=http://azure.archive.ubuntu.com/ubuntu
|
||||
APT_PORTS_MIRROR=http://azure.ports.ubuntu.com/ubuntu-ports
|
||||
OLLAMA_MLX_BUILD_JOBS=16
|
||||
OLLAMA_MLX_NVCC_THREADS=6
|
||||
cache-from: |
|
||||
type=registry,ref=ollama/release:cache-amd64-llama-server-cpu
|
||||
type=registry,ref=ollama/release:cache-amd64-llama-server-cuda_v12
|
||||
type=registry,ref=ollama/release:cache-amd64-llama-server-cuda_v13
|
||||
type=registry,ref=ollama/release:cache-amd64-mlx
|
||||
type=registry,ref=ollama/release:cache-amd64-llama-server-rocm_v7_2
|
||||
type=registry,ref=ollama/release:cache-amd64-llama-server-vulkan
|
||||
type=registry,ref=${{ vars.DOCKER_REPO }}:latest
|
||||
- os: linux
|
||||
arch: amd64
|
||||
suffix: '-rocm'
|
||||
archive-target: image-archive
|
||||
build-args: |
|
||||
CGO_CFLAGS
|
||||
CGO_CXXFLAGS
|
||||
GOFLAGS
|
||||
FLAVOR=rocm
|
||||
APT_MIRROR=http://azure.archive.ubuntu.com/ubuntu
|
||||
APT_PORTS_MIRROR=http://azure.ports.ubuntu.com/ubuntu-ports
|
||||
OLLAMA_MLX_BUILD_JOBS=16
|
||||
OLLAMA_MLX_NVCC_THREADS=6
|
||||
cache-from: |
|
||||
type=registry,ref=ollama/release:cache-amd64-llama-server-cpu
|
||||
type=registry,ref=ollama/release:cache-amd64-llama-server-rocm_v7_2
|
||||
type=registry,ref=${{ vars.DOCKER_REPO }}:latest
|
||||
runs-on: ${{ matrix.arch == 'arm64' && format('{0}-{1}', matrix.os, matrix.arch) || matrix.os }}
|
||||
environment: release
|
||||
needs: [setup-environment, linux-depends]
|
||||
env:
|
||||
GOFLAGS: ${{ needs.setup-environment.outputs.GOFLAGS }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: docker/setup-buildx-action@v3
|
||||
- uses: docker/login-action@v3
|
||||
with:
|
||||
username: ${{ vars.DOCKER_USER }}
|
||||
password: ${{ secrets.DOCKER_ACCESS_TOKEN }}
|
||||
- id: build-push
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
platforms: ${{ matrix.os }}/${{ matrix.arch }}
|
||||
provenance: false
|
||||
sbom: false
|
||||
build-args: ${{ matrix.build-args }}
|
||||
outputs: type=image,name=${{ vars.DOCKER_REPO }},push-by-digest=true,name-canonical=true,push=true
|
||||
cache-from: ${{ matrix.cache-from }}
|
||||
cache-to: type=inline
|
||||
- run: |
|
||||
mkdir -p ${{ matrix.os }}-${{ matrix.arch }}
|
||||
echo "${{ steps.build-push.outputs.digest }}" >${{ matrix.os }}-${{ matrix.arch }}-${{ matrix.suffix }}.txt
|
||||
working-directory: ${{ runner.temp }}
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: digest-${{ matrix.os }}-${{ matrix.arch }}-${{ matrix.suffix }}
|
||||
path: |
|
||||
${{ runner.temp }}/${{ matrix.os }}-${{ matrix.arch }}-${{ matrix.suffix }}.txt
|
||||
- uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
platforms: ${{ matrix.os }}/${{ matrix.arch }}
|
||||
target: ${{ matrix.archive-target }}
|
||||
provenance: false
|
||||
sbom: false
|
||||
build-args: ${{ matrix.build-args }}
|
||||
outputs: type=local,dest=dist/${{ matrix.os }}-${{ matrix.arch }}
|
||||
cache-from: ${{ matrix.cache-from }}
|
||||
- name: Deduplicate CUDA libraries
|
||||
run: |
|
||||
./scripts/deduplicate_cuda_libs.sh dist/${{ matrix.os }}-${{ matrix.arch }}
|
||||
- name: Verify Linux build payloads
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
base="dist/${{ matrix.os }}-${{ matrix.arch }}"
|
||||
for payload in \
|
||||
"$base/bin/ollama" \
|
||||
"$base/lib/ollama/llama-server"
|
||||
do
|
||||
[ -f "$payload" ] || { echo "missing $payload"; exit 1; }
|
||||
done
|
||||
- run: |
|
||||
for COMPONENT in bin/* lib/ollama/*; do
|
||||
case "$COMPONENT" in
|
||||
bin/ollama*) echo $COMPONENT >>ollama-${{ matrix.os }}-${{ matrix.arch }}.tar.in ;;
|
||||
lib/ollama/*.so*) echo $COMPONENT >>ollama-${{ matrix.os }}-${{ matrix.arch }}.tar.in ;;
|
||||
lib/ollama/llama-server*|lib/ollama/llama-quantize*) echo $COMPONENT >>ollama-${{ matrix.os }}-${{ matrix.arch }}.tar.in ;;
|
||||
lib/ollama/cuda_v*) echo $COMPONENT >>ollama-${{ matrix.os }}-${{ matrix.arch }}.tar.in ;;
|
||||
lib/ollama/vulkan*) echo $COMPONENT >>ollama-${{ matrix.os }}-${{ matrix.arch }}.tar.in ;;
|
||||
lib/ollama/mlx*) echo $COMPONENT >>ollama-${{ matrix.os }}-${{ matrix.arch }}-mlx.tar.in ;;
|
||||
lib/ollama/include*) echo $COMPONENT >>ollama-${{ matrix.os }}-${{ matrix.arch }}-mlx.tar.in ;;
|
||||
lib/ollama/cuda_jetpack5) echo $COMPONENT >>ollama-${{ matrix.os }}-${{ matrix.arch }}-jetpack5.tar.in ;;
|
||||
lib/ollama/cuda_jetpack6) echo $COMPONENT >>ollama-${{ matrix.os }}-${{ matrix.arch }}-jetpack6.tar.in ;;
|
||||
lib/ollama/rocm_v*) echo $COMPONENT >>ollama-${{ matrix.os }}-${{ matrix.arch }}-rocm.tar.in ;;
|
||||
esac
|
||||
done
|
||||
working-directory: dist/${{ matrix.os }}-${{ matrix.arch }}
|
||||
- if: matrix.suffix == '-rocm'
|
||||
run: rm -f dist/${{ matrix.os }}-${{ matrix.arch }}/ollama-${{ matrix.os }}-${{ matrix.arch }}.tar.in
|
||||
- run: |
|
||||
echo "Manifests"
|
||||
for ARCHIVE in dist/${{ matrix.os }}-${{ matrix.arch }}/*.tar.in ; do
|
||||
echo $ARCHIVE
|
||||
cat $ARCHIVE
|
||||
done
|
||||
- run: |
|
||||
for ARCHIVE in dist/${{ matrix.os }}-${{ matrix.arch }}/*.tar.in; do
|
||||
tar c -C dist/${{ matrix.os }}-${{ matrix.arch }} -T $ARCHIVE --owner 0 --group 0 | zstd -19 -T0 >$(basename ${ARCHIVE//.*/}.tar.zst) &
|
||||
done
|
||||
wait
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: bundles-${{ matrix.os }}-${{ matrix.arch }}${{ matrix.suffix }}
|
||||
path: |
|
||||
*.tar.zst
|
||||
|
||||
# Merge Docker images for the same flavor into a single multi-arch manifest
|
||||
docker-merge-push:
|
||||
strategy:
|
||||
matrix:
|
||||
suffix: ['', '-rocm']
|
||||
runs-on: linux
|
||||
environment: release
|
||||
needs: [docker-build-push]
|
||||
steps:
|
||||
- uses: docker/login-action@v3
|
||||
with:
|
||||
username: ${{ vars.DOCKER_USER }}
|
||||
password: ${{ secrets.DOCKER_ACCESS_TOKEN }}
|
||||
- id: metadata
|
||||
uses: docker/metadata-action@v4
|
||||
with:
|
||||
flavor: |
|
||||
latest=false
|
||||
suffix=${{ matrix.suffix }}
|
||||
images: |
|
||||
${{ vars.DOCKER_REPO }}
|
||||
tags: |
|
||||
type=ref,enable=true,priority=600,prefix=pr-,event=pr
|
||||
type=semver,pattern={{version}}
|
||||
- uses: actions/download-artifact@v4
|
||||
with:
|
||||
pattern: digest-*
|
||||
path: ${{ runner.temp }}
|
||||
merge-multiple: true
|
||||
- run: |
|
||||
docker buildx imagetools create $(echo '${{ steps.metadata.outputs.json }}' | jq -cr '.tags | map("-t", .) | join(" ")') $(cat *-${{ matrix.suffix }}.txt | xargs printf '${{ vars.DOCKER_REPO }}@%s ')
|
||||
docker buildx imagetools inspect ${{ vars.DOCKER_REPO }}:${{ steps.metadata.outputs.version }}
|
||||
working-directory: ${{ runner.temp }}
|
||||
|
||||
# Final release process
|
||||
release:
|
||||
runs-on: ubuntu-latest
|
||||
environment: release
|
||||
needs: [darwin-build, windows-app, docker-build-push]
|
||||
permissions:
|
||||
contents: write
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/download-artifact@v4
|
||||
with:
|
||||
pattern: bundles-*
|
||||
path: dist
|
||||
merge-multiple: true
|
||||
- name: Log dist contents
|
||||
run: |
|
||||
ls -l dist/
|
||||
- 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
|
||||
- name: Create or update Release for tag
|
||||
run: |
|
||||
RELEASE_VERSION="$(echo ${GITHUB_REF_NAME} | cut -f1 -d-)"
|
||||
echo "Looking for existing release for ${RELEASE_VERSION}"
|
||||
OLD_TAG=$(gh release ls --json name,tagName | jq -r ".[] | select(.name == \"${RELEASE_VERSION}\") | .tagName")
|
||||
if [ -n "$OLD_TAG" ]; then
|
||||
echo "Updating release ${RELEASE_VERSION} to point to new tag ${GITHUB_REF_NAME}"
|
||||
gh release edit ${OLD_TAG} --tag ${GITHUB_REF_NAME}
|
||||
else
|
||||
echo "Creating new release ${RELEASE_VERSION} pointing to tag ${GITHUB_REF_NAME}"
|
||||
gh release create ${GITHUB_REF_NAME} \
|
||||
--title ${RELEASE_VERSION} \
|
||||
--draft \
|
||||
--generate-notes \
|
||||
--prerelease
|
||||
fi
|
||||
- name: Upload release artifacts
|
||||
run: |
|
||||
pids=()
|
||||
for payload in dist/*.txt dist/*.zip dist/*.tgz dist/*.tar.zst dist/*.exe dist/*.dmg dist/*.ps1 dist/*.sh ; do
|
||||
echo "Uploading $payload"
|
||||
gh release upload ${GITHUB_REF_NAME} $payload --clobber &
|
||||
pids+=($!)
|
||||
sleep 1
|
||||
done
|
||||
echo "Waiting for uploads to complete"
|
||||
failed=0
|
||||
for pid in "${pids[@]}"; do
|
||||
if ! wait $pid; then
|
||||
echo "::error::Upload failed (pid $pid)"
|
||||
failed=1
|
||||
fi
|
||||
done
|
||||
if [ $failed -ne 0 ]; then
|
||||
echo "One or more uploads failed"
|
||||
exit 1
|
||||
fi
|
||||
echo "done"
|
||||
22
.github/workflows/test-install.yaml
vendored
@@ -1,22 +0,0 @@
|
||||
name: test-install
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
paths:
|
||||
- 'scripts/install.sh'
|
||||
- '.github/workflows/test-install.yaml'
|
||||
|
||||
jobs:
|
||||
test:
|
||||
strategy:
|
||||
matrix:
|
||||
os: [ubuntu-latest, macos-latest]
|
||||
runs-on: ${{ matrix.os }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Run install script
|
||||
run: sh ./scripts/install.sh
|
||||
env:
|
||||
OLLAMA_NO_START: 1 # do not start app
|
||||
- name: Verify ollama is available
|
||||
run: ollama --version
|
||||
612
.github/workflows/test-llamacpp-update.yaml
vendored
@@ -1,612 +0,0 @@
|
||||
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: 'CUDA 13 ARM64'
|
||||
build-steps: cuda13Arm64Cross
|
||||
install: https://packages.nvidia.com/prerelease/cuda/13.4.0/local_installers/cuda_13.4.0_windows_x86_64.exe
|
||||
cuda-components:
|
||||
- '"cudart"'
|
||||
- '"cudart_cross"'
|
||||
- '"nvcc"'
|
||||
- '"nvcc_cross"'
|
||||
- '"cublas_cross"'
|
||||
- '"cublas_dev"'
|
||||
- '"crt"'
|
||||
- '"nvvm"'
|
||||
- '"nvptxcompiler"'
|
||||
cuda-version: '13.4'
|
||||
- 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
|
||||
420
.github/workflows/test.yaml
vendored
@@ -1,420 +0,0 @@
|
||||
name: test
|
||||
|
||||
concurrency:
|
||||
# For PRs, later CI runs preempt previous ones. e.g. a force push on a PR
|
||||
# cancels running CI jobs and starts all new ones.
|
||||
#
|
||||
# For non-PR pushes, concurrency.group needs to be unique for every distinct
|
||||
# CI run we want to have happen. Use run_id, which in practice means all
|
||||
# non-PR CI runs will be allowed to run without preempting each other.
|
||||
group: ${{ github.workflow }}-$${{ github.pull_request.number || github.run_id }}
|
||||
cancel-in-progress: true
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
paths:
|
||||
- '**/*'
|
||||
- '!docs/**'
|
||||
- '!README.md'
|
||||
|
||||
jobs:
|
||||
changes:
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
changed: ${{ steps.changes.outputs.changed }}
|
||||
app_changed: ${{ steps.changes.outputs.app_changed }}
|
||||
enginehash: ${{ steps.changes.outputs.enginehash }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- id: changes
|
||||
run: |
|
||||
changed() {
|
||||
local BASE=${{ github.event.pull_request.base.sha }}
|
||||
local HEAD=${{ github.event.pull_request.head.sha }}
|
||||
local MERGE_BASE=$(git merge-base $BASE $HEAD)
|
||||
git diff-tree -r --no-commit-id --name-only "$MERGE_BASE" "$HEAD" \
|
||||
| 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 \
|
||||
'CMakeLists.txt' \
|
||||
'CMakePresets.json' \
|
||||
'cmake/**' \
|
||||
'cmake/**/*' \
|
||||
'llama/server/**/*' \
|
||||
'llama/compat/**/*' \
|
||||
'LLAMA_CPP_VERSION' \
|
||||
'MLX_VERSION' \
|
||||
'MLX_C_VERSION' \
|
||||
'llama/llama.cpp/**/*' \
|
||||
'ml/backend/ggml/ggml/**/*' \
|
||||
'x/imagegen/mlx/**' \
|
||||
'x/imagegen/mlx/**/*' \
|
||||
'.github/**/*') | tee -a $GITHUB_OUTPUT
|
||||
echo app_changed=$(changed 'app/**' 'app/**/*') | tee -a $GITHUB_OUTPUT
|
||||
echo enginehash=$(cat LLAMA_CPP_VERSION)-$(cat MLX_VERSION)-$(cat MLX_C_VERSION) | tee -a $GITHUB_OUTPUT
|
||||
|
||||
patches:
|
||||
strategy:
|
||||
matrix:
|
||||
os: [ubuntu-latest, windows-latest]
|
||||
runs-on: ${{ matrix.os }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Verify patches apply cleanly
|
||||
shell: bash
|
||||
run: |
|
||||
cmake -S llama/server -B "$RUNNER_TEMP/llama-server-patch-check" \
|
||||
-DCMAKE_BUILD_TYPE=Release \
|
||||
-DBUILD_SHARED_LIBS=ON \
|
||||
-DGGML_BACKEND_DL=ON \
|
||||
-DGGML_NATIVE=OFF \
|
||||
-DGGML_OPENMP=OFF \
|
||||
-DGGML_CPU_ALL_VARIANTS=ON \
|
||||
-DOLLAMA_RUNNER_DIR=
|
||||
|
||||
linux:
|
||||
needs: [changes]
|
||||
if: needs.changes.outputs.changed == 'True'
|
||||
strategy:
|
||||
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
|
||||
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
|
||||
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 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
|
||||
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 }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- run: |
|
||||
[ -n "${{ matrix.container }}" ] || sudo=sudo
|
||||
$sudo apt-get update
|
||||
# Add LunarG Vulkan SDK apt repo for Ubuntu 22.04
|
||||
if [ "${{ matrix.preset }}" = "Vulkan" ]; then
|
||||
$sudo apt-get install -y --no-install-recommends wget gnupg ca-certificates software-properties-common
|
||||
wget -qO - https://packages.lunarg.com/lunarg-signing-key-pub.asc | $sudo gpg --dearmor -o /usr/share/keyrings/lunarg-archive-keyring.gpg
|
||||
# Use signed-by to bind the repo to the installed keyring to avoid NO_PUBKEY
|
||||
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 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
|
||||
fi
|
||||
env:
|
||||
DEBIAN_FRONTEND: noninteractive
|
||||
- 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" | $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.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'
|
||||
strategy:
|
||||
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
|
||||
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"'
|
||||
- '"cublas"'
|
||||
- '"cublas_dev"'
|
||||
- '"crt"'
|
||||
- '"nvvm"'
|
||||
- '"nvptxcompiler"'
|
||||
cuda-version: '13.0'
|
||||
- preset: ROCm
|
||||
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
|
||||
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"'
|
||||
- '"cublas"'
|
||||
- '"cublas_dev"'
|
||||
- '"cufft"'
|
||||
- '"cufft_dev"'
|
||||
- '"nvrtc"'
|
||||
- '"nvrtc_dev"'
|
||||
- '"crt"'
|
||||
- '"nvvm"'
|
||||
- '"nvptxcompiler"'
|
||||
cuda-version: '13.0'
|
||||
runs-on: windows
|
||||
steps:
|
||||
- run: |
|
||||
choco install -y --no-progress ccache ninja
|
||||
if (Get-Command ccache -ErrorAction SilentlyContinue) {
|
||||
ccache -o cache_dir=${{ github.workspace }}\.ccache
|
||||
}
|
||||
- if: matrix.preset == 'CUDA' || matrix.preset == 'ROCm' || matrix.preset == 'Vulkan' || matrix.preset == 'MLX CUDA 13'
|
||||
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
|
||||
C:\Program Files\NVIDIA\CUDNN
|
||||
key: ${{ matrix.install }}-${{ matrix.cudnn-install }}
|
||||
- if: matrix.preset == 'CUDA' || matrix.preset == 'MLX CUDA 13'
|
||||
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: 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 ${{ 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 "-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: matrix.preset == 'MLX CUDA 13'
|
||||
name: Install cuDNN for MLX
|
||||
run: |
|
||||
$ErrorActionPreference = "Stop"
|
||||
$cudnnRoot = "C:\Program Files\NVIDIA\CUDNN"
|
||||
if ("${{ steps.cache-install.outputs.cache-hit }}" -ne 'true') {
|
||||
Invoke-WebRequest -Uri "${{ matrix.cudnn-install }}" -OutFile "cudnn.zip"
|
||||
Expand-Archive -Path cudnn.zip -DestinationPath cudnn-extracted
|
||||
$cudnnDir = (Get-ChildItem -Path cudnn-extracted -Directory)[0].FullName
|
||||
New-Item -ItemType Directory -Force -Path $cudnnRoot
|
||||
Copy-Item -Path "$cudnnDir\*" -Destination "$cudnnRoot\" -Recurse
|
||||
}
|
||||
|
||||
echo "CUDNN_ROOT_DIR=$cudnnRoot" | Out-File -FilePath $env:GITHUB_ENV -Append
|
||||
echo "CUDNN_INCLUDE_PATH=$cudnnRoot\include" | Out-File -FilePath $env:GITHUB_ENV -Append
|
||||
echo "CUDNN_LIBRARY_PATH=$cudnnRoot\lib\x64" | Out-File -FilePath $env:GITHUB_ENV -Append
|
||||
echo "$cudnnRoot\bin\x64" | Out-File -FilePath $env:GITHUB_PATH -Encoding utf8 -Append
|
||||
- if: ${{ !cancelled() && 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
|
||||
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.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 -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:
|
||||
- uses: actions/checkout@v4
|
||||
- name: check that 'go mod tidy' is clean
|
||||
run: go mod tidy --diff || (echo "Please run 'go mod tidy'." && exit 1)
|
||||
|
||||
test:
|
||||
needs: [changes]
|
||||
strategy:
|
||||
matrix:
|
||||
os: [ubuntu-latest, macos-latest, windows-latest]
|
||||
runs-on: ${{ matrix.os }}
|
||||
env:
|
||||
CGO_ENABLED: '1'
|
||||
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/setup-node@v4
|
||||
with:
|
||||
node-version: '20'
|
||||
- name: Install UI dependencies
|
||||
working-directory: ./app/ui/app
|
||||
run: npm ci
|
||||
- name: Install tscriptify
|
||||
run: |
|
||||
go install github.com/tkrajina/typescriptify-golang-structs/tscriptify@latest
|
||||
- name: Run UI tests
|
||||
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 ./...
|
||||
|
||||
- name: go test
|
||||
if: always()
|
||||
run: go test -count=1 -benchtime=1x ./...
|
||||
|
||||
- name: go test app with live updater tag
|
||||
if: ${{ needs.changes.outputs.app_changed == 'True' && contains(fromJSON('["macos-latest","windows-latest"]'), matrix.os) }}
|
||||
run: go test -count=1 -tags updater_live ./app/...
|
||||
|
||||
- uses: golangci/golangci-lint-action@v9
|
||||
with:
|
||||
only-new-issues: true
|
||||
14
.gitignore
vendored
@@ -4,15 +4,5 @@
|
||||
.venv
|
||||
.swp
|
||||
dist
|
||||
build
|
||||
.cache
|
||||
.gocache
|
||||
*.exe
|
||||
.idea
|
||||
test_data
|
||||
*.crt
|
||||
__debug_bin*
|
||||
llama/build
|
||||
llama/vendor
|
||||
/ollama
|
||||
integration/testdata/models/
|
||||
ollama
|
||||
ggml-metal.metal
|
||||
|
||||
10
.gitmodules
vendored
Normal file
@@ -0,0 +1,10 @@
|
||||
[submodule "llm/llama.cpp/ggml"]
|
||||
path = llm/llama.cpp/ggml
|
||||
url = https://github.com/ggerganov/llama.cpp.git
|
||||
ignore = dirty
|
||||
shallow = true
|
||||
[submodule "llm/llama.cpp/gguf"]
|
||||
path = llm/llama.cpp/gguf
|
||||
url = https://github.com/ggerganov/llama.cpp.git
|
||||
ignore = dirty
|
||||
shallow = true
|
||||
@@ -1,51 +0,0 @@
|
||||
version: "2"
|
||||
linters:
|
||||
enable:
|
||||
- asasalint
|
||||
- bidichk
|
||||
- bodyclose
|
||||
- containedctx
|
||||
- gocheckcompilerdirectives
|
||||
- intrange
|
||||
- makezero
|
||||
- misspell
|
||||
- nilerr
|
||||
- nolintlint
|
||||
- nosprintfhostport
|
||||
- unconvert
|
||||
- usetesting
|
||||
- wastedassign
|
||||
- whitespace
|
||||
disable:
|
||||
- errcheck
|
||||
- usestdlibvars
|
||||
settings:
|
||||
govet:
|
||||
disable:
|
||||
- unusedresult
|
||||
staticcheck:
|
||||
checks:
|
||||
- all
|
||||
- -QF* # disable quick fix suggestions
|
||||
- -SA1019
|
||||
- -ST1000 # package comment format
|
||||
- -ST1003 # underscores in package names
|
||||
- -ST1005 # error strings should not be capitalized
|
||||
- -ST1012 # error var naming (ErrFoo)
|
||||
- -ST1016 # receiver name consistency
|
||||
- -ST1020 # comment on exported function format
|
||||
- -ST1021 # comment on exported type format
|
||||
- -ST1022 # comment on exported var format
|
||||
- -ST1023 # omit type from declaration
|
||||
severity:
|
||||
default: error
|
||||
rules:
|
||||
- linters:
|
||||
- gofmt
|
||||
- goimports
|
||||
- intrange
|
||||
severity: info
|
||||
formatters:
|
||||
enable:
|
||||
- gofmt
|
||||
- gofumpt
|
||||
10
.prettierrc.json
Normal file
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"trailingComma": "es5",
|
||||
"tabWidth": 2,
|
||||
"useTabs": false,
|
||||
"semi": false,
|
||||
"singleQuote": true,
|
||||
"jsxSingleQuote": true,
|
||||
"printWidth": 120,
|
||||
"arrowParens": "avoid"
|
||||
}
|
||||
21
AGENTS.md
@@ -1,21 +0,0 @@
|
||||
# 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.
|
||||
@@ -1,3 +0,0 @@
|
||||
# CLAUDE.md
|
||||
|
||||
See `AGENTS.md` for the shared agent instructions for this repository.
|
||||
@@ -1,66 +0,0 @@
|
||||
cmake_minimum_required(VERSION 3.24)
|
||||
|
||||
project(Ollama C CXX)
|
||||
|
||||
# Handle cross-compilation on macOS: when CMAKE_OSX_ARCHITECTURES is set to a
|
||||
# single architecture different from the host, override CMAKE_SYSTEM_PROCESSOR
|
||||
# to match. This is necessary because CMAKE_SYSTEM_PROCESSOR defaults to the
|
||||
# host architecture, but downstream projects (like MLX) use it to detect the
|
||||
# target architecture.
|
||||
if(CMAKE_OSX_ARCHITECTURES AND NOT CMAKE_OSX_ARCHITECTURES MATCHES ";")
|
||||
# Single architecture specified
|
||||
if(CMAKE_OSX_ARCHITECTURES STREQUAL "x86_64" AND NOT CMAKE_SYSTEM_PROCESSOR STREQUAL "x86_64")
|
||||
message(STATUS "Cross-compiling for x86_64: overriding CMAKE_SYSTEM_PROCESSOR from ${CMAKE_SYSTEM_PROCESSOR} to x86_64")
|
||||
set(CMAKE_SYSTEM_PROCESSOR "x86_64")
|
||||
elseif(CMAKE_OSX_ARCHITECTURES STREQUAL "arm64" AND NOT CMAKE_SYSTEM_PROCESSOR STREQUAL "arm64")
|
||||
message(STATUS "Cross-compiling for arm64: overriding CMAKE_SYSTEM_PROCESSOR from ${CMAKE_SYSTEM_PROCESSOR} to arm64")
|
||||
set(CMAKE_SYSTEM_PROCESSOR "arm64")
|
||||
endif()
|
||||
endif()
|
||||
|
||||
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()
|
||||
|
||||
# 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 require gnu++17 extensions to compile properly
|
||||
|
||||
# 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")
|
||||
set(CMAKE_INSTALL_RPATH "@loader_path")
|
||||
set(CMAKE_BUILD_WITH_INSTALL_RPATH ON)
|
||||
endif()
|
||||
|
||||
set(OLLAMA_BUILD_DIR ${CMAKE_BINARY_DIR}/lib/ollama)
|
||||
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})
|
||||
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(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)
|
||||
@@ -1,33 +0,0 @@
|
||||
{
|
||||
"version": 3,
|
||||
"configurePresets": [
|
||||
{
|
||||
"name": "Default",
|
||||
"binaryDir": "${sourceDir}/build",
|
||||
"installDir": "${sourceDir}/dist",
|
||||
"cacheVariables": {
|
||||
"CMAKE_BUILD_TYPE": "Release",
|
||||
"CMAKE_MSVC_RUNTIME_LIBRARY": "MultiThreaded"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "MLX Metal",
|
||||
"inherits": [ "Default" ],
|
||||
"cacheVariables": {
|
||||
"OLLAMA_MLX_BACKENDS": "metal_v3;metal_v4"
|
||||
}
|
||||
}
|
||||
],
|
||||
"buildPresets": [
|
||||
{
|
||||
"name": "Default",
|
||||
"configurePreset": "Default",
|
||||
"configuration": "Release"
|
||||
},
|
||||
{
|
||||
"name": "MLX Metal",
|
||||
"targets": [ "ollama-mlx-backends" ],
|
||||
"configurePreset": "MLX Metal"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,88 +0,0 @@
|
||||
# Contributing to Ollama
|
||||
|
||||
Thank you for your interest in contributing to Ollama! Here are a few guidelines to help get you started.
|
||||
|
||||
## Set up
|
||||
|
||||
See the [development documentation](./docs/development.md) for instructions on how to build and run Ollama locally.
|
||||
|
||||
### Ideal issues
|
||||
|
||||
* [Bugs](https://github.com/ollama/ollama/issues?q=is%3Aissue+is%3Aopen+label%3Abug): issues where Ollama stops working or where it results in an unexpected error.
|
||||
* [Performance](https://github.com/ollama/ollama/issues?q=is%3Aissue+is%3Aopen+label%3Aperformance): issues to make Ollama faster at model inference, downloading or uploading.
|
||||
* [Security](https://github.com/ollama/ollama/blob/main/SECURITY.md): issues that could lead to a security vulnerability. As mentioned in [SECURITY.md](https://github.com/ollama/ollama/blob/main/SECURITY.md), please do not disclose security vulnerabilities publicly.
|
||||
|
||||
### Issues that are harder to review
|
||||
|
||||
* New features: new features (e.g. API fields, environment variables) add surface area to Ollama and make it harder to maintain in the long run as they cannot be removed without potentially breaking users in the future.
|
||||
* Refactoring: large code improvements are important, but can be harder or take longer to review and merge.
|
||||
* Documentation: small updates to fill in or correct missing documentation are helpful, however large documentation additions can be hard to maintain over time.
|
||||
|
||||
### Issues that may not be accepted
|
||||
|
||||
* Changes that break backwards compatibility in Ollama's API (including the OpenAI-compatible API)
|
||||
* Changes that add significant friction to the user experience
|
||||
* Changes that create a large future maintenance burden for maintainers and contributors
|
||||
|
||||
## Proposing a (non-trivial) change
|
||||
|
||||
> By "non-trivial", we mean a change that is not a bug fix or small
|
||||
> documentation update. If you are unsure, please ask us on our [Discord
|
||||
> server](https://discord.gg/ollama).
|
||||
|
||||
Before opening a non-trivial Pull Request, please open an issue to discuss the change and
|
||||
get feedback from the maintainers. This helps us understand the context of the
|
||||
change and how it fits into Ollama's roadmap and prevents us from duplicating
|
||||
work or you from spending time on a change that we may not be able to accept.
|
||||
|
||||
Tips for proposals:
|
||||
|
||||
* Explain the problem you are trying to solve, not what you are trying to do.
|
||||
* Explain why the change is important.
|
||||
* Explain how the change will be used.
|
||||
* Explain how the change will be tested.
|
||||
|
||||
Additionally, for bonus points: Provide draft documentation you would expect to
|
||||
see if the changes were accepted.
|
||||
|
||||
## Pull requests
|
||||
|
||||
**Commit messages**
|
||||
|
||||
The title should look like:
|
||||
|
||||
<package>: <short description>
|
||||
|
||||
The package is the most affected Go package. If the change does not affect Go
|
||||
code, then use the directory name instead. Changes to a single well-known
|
||||
file in the root directory may use the file name.
|
||||
|
||||
The short description should start with a lowercase letter and be a
|
||||
continuation of the sentence:
|
||||
|
||||
"This changes Ollama to..."
|
||||
|
||||
Examples:
|
||||
|
||||
llm/backend/mlx: support the llama architecture
|
||||
CONTRIBUTING: provide clarity on good commit messages, and bad
|
||||
|
||||
Bad Examples:
|
||||
|
||||
feat: add more emoji
|
||||
fix: was not using famous web framework
|
||||
chore: generify code
|
||||
|
||||
**Tests**
|
||||
|
||||
Please include tests. Strive to test behavior, not implementation.
|
||||
|
||||
**New dependencies**
|
||||
|
||||
Dependencies should be added sparingly. If you are adding a new dependency,
|
||||
please explain why it is necessary and what other ways you attempted that
|
||||
did not work without it.
|
||||
|
||||
## Need help?
|
||||
|
||||
If you need help with anything, feel free to reach out to us on our [Discord server](https://discord.gg/ollama).
|
||||
326
Dockerfile
@@ -1,319 +1,23 @@
|
||||
# vim: filetype=dockerfile
|
||||
FROM nvidia/cuda:11.8.0-devel-ubuntu22.04
|
||||
|
||||
ARG FLAVOR=${TARGETARCH}
|
||||
|
||||
ARG ROCMVERSION=7.2.1
|
||||
ARG JETPACK5VERSION=r35.4.1
|
||||
ARG JETPACK6VERSION=r36.4.0
|
||||
ARG CMAKEVERSION=3.31.2
|
||||
ARG NINJAVERSION=1.12.1
|
||||
ARG VULKANVERSION=1.4.321.1
|
||||
|
||||
# Default empty stages for local MLX source overrides.
|
||||
# Override with: docker build --build-context local-mlx=../mlx --build-context local-mlx-c=../mlx-c
|
||||
FROM scratch AS local-mlx
|
||||
FROM scratch AS local-mlx-c
|
||||
|
||||
FROM --platform=linux/amd64 rocm/dev-almalinux-8:${ROCMVERSION}-complete AS base-amd64
|
||||
RUN dnf install -y yum-utils ccache gcc-toolset-13-gcc gcc-toolset-13-gcc-c++ gcc-toolset-13-binutils \
|
||||
&& yum-config-manager --add-repo https://developer.download.nvidia.com/compute/cuda/repos/rhel8/x86_64/cuda-rhel8.repo
|
||||
ENV PATH=/opt/rh/gcc-toolset-13/root/usr/bin:$PATH
|
||||
|
||||
FROM --platform=linux/arm64 almalinux:8 AS base-arm64
|
||||
# install epel-release for ccache
|
||||
RUN yum install -y yum-utils epel-release \
|
||||
&& dnf install -y clang ccache git \
|
||||
&& yum-config-manager --add-repo https://developer.download.nvidia.com/compute/cuda/repos/rhel8/sbsa/cuda-rhel8.repo
|
||||
ENV CC=clang CXX=clang++
|
||||
|
||||
FROM base-${TARGETARCH} AS base
|
||||
ARG CMAKEVERSION
|
||||
ARG NINJAVERSION
|
||||
RUN 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
|
||||
RUN dnf install -y unzip \
|
||||
&& curl -fsSL -o /tmp/ninja.zip https://github.com/ninja-build/ninja/releases/download/v${NINJAVERSION}/ninja-linux$([ "$(uname -m)" = "aarch64" ] && echo "-aarch64").zip \
|
||||
&& unzip /tmp/ninja.zip -d /usr/local/bin \
|
||||
&& rm /tmp/ninja.zip
|
||||
ENV CMAKE_GENERATOR=Ninja
|
||||
ENV LDFLAGS=-s
|
||||
|
||||
#
|
||||
# GPU toolchain stages — provide compilers for llama-server GPU builds
|
||||
#
|
||||
|
||||
FROM base AS cpu-deps
|
||||
RUN dnf install -y gcc-toolset-13-gcc gcc-toolset-13-gcc-c++
|
||||
ENV PATH=/opt/rh/gcc-toolset-13/root/usr/bin:$PATH
|
||||
|
||||
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
|
||||
|
||||
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
|
||||
|
||||
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 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
|
||||
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 -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-13/root/usr/lib64/libgomp.so* \
|
||||
/opt/rh/gcc-toolset-13/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++ CXXFLAGS=--gcc-toolchain=/opt/rh/gcc-toolset-13/root/usr
|
||||
COPY LLAMA_CPP_VERSION .
|
||||
COPY llama/server llama/server
|
||||
COPY llama/compat llama/compat
|
||||
RUN --mount=type=cache,target=/root/.ccache \
|
||||
cmake -S llama/server --preset rocm_v7_2_linux \
|
||||
&& cmake --build build/llama-server-rocm_v7_2 -- -l $(nproc) \
|
||||
&& cmake --install build/llama-server-rocm_v7_2 --component llama-server --strip
|
||||
RUN rm -f dist/lib/ollama/rocm_v7_2/rocblas/library/*gfx90[06]*
|
||||
|
||||
FROM scratch AS publish-llama-server-rocm_v7_2
|
||||
COPY --from=llama-server-rocm_v7_2 dist/lib/ollama /lib/ollama/
|
||||
|
||||
FROM vulkan-deps AS llama-server-vulkan
|
||||
COPY LLAMA_CPP_VERSION .
|
||||
COPY llama/server llama/server
|
||||
COPY llama/compat llama/compat
|
||||
RUN --mount=type=cache,target=/root/.ccache \
|
||||
cmake -S llama/server --preset vulkan \
|
||||
&& cmake --build build/llama-server-vulkan -- -l $(nproc) \
|
||||
&& cmake --install build/llama-server-vulkan --component llama-server --strip
|
||||
|
||||
FROM scratch AS publish-llama-server-vulkan
|
||||
COPY --from=llama-server-vulkan dist/lib/ollama /lib/ollama/
|
||||
|
||||
#
|
||||
# JetPack stages — self-contained with their own base images
|
||||
#
|
||||
|
||||
FROM --platform=linux/arm64 nvcr.io/nvidia/l4t-jetpack:${JETPACK5VERSION} AS jetpack-5
|
||||
ARG CMAKEVERSION
|
||||
ARG NINJAVERSION
|
||||
RUN apt-get update && apt-get install -y curl ccache git unzip \
|
||||
&& curl -fsSL https://github.com/Kitware/CMake/releases/download/v${CMAKEVERSION}/cmake-${CMAKEVERSION}-linux-$(uname -m).tar.gz | tar xz -C /usr/local --strip-components 1 \
|
||||
&& curl -fsSL -o /tmp/ninja.zip https://github.com/ninja-build/ninja/releases/download/v${NINJAVERSION}/ninja-linux-aarch64.zip \
|
||||
&& unzip /tmp/ninja.zip -d /usr/local/bin \
|
||||
&& rm /tmp/ninja.zip
|
||||
ENV CMAKE_GENERATOR=Ninja
|
||||
COPY LLAMA_CPP_VERSION .
|
||||
COPY llama/server llama/server
|
||||
COPY llama/compat llama/compat
|
||||
RUN --mount=type=cache,target=/root/.ccache \
|
||||
cmake -S llama/server --preset llama_cuda_jetpack5 \
|
||||
&& cmake --build build/llama-server-cuda_jetpack5 -- -l $(nproc) \
|
||||
&& cmake --install build/llama-server-cuda_jetpack5 --component llama-server --strip
|
||||
|
||||
FROM scratch AS publish-llama-server-cuda_jetpack5
|
||||
COPY --from=jetpack-5 dist/lib/ollama /lib/ollama/
|
||||
|
||||
FROM --platform=linux/arm64 nvcr.io/nvidia/l4t-jetpack:${JETPACK6VERSION} AS jetpack-6
|
||||
ARG CMAKEVERSION
|
||||
ARG NINJAVERSION
|
||||
RUN apt-get update && apt-get install -y curl ccache git unzip \
|
||||
&& curl -fsSL https://github.com/Kitware/CMake/releases/download/v${CMAKEVERSION}/cmake-${CMAKEVERSION}-linux-$(uname -m).tar.gz | tar xz -C /usr/local --strip-components 1 \
|
||||
&& curl -fsSL -o /tmp/ninja.zip https://github.com/ninja-build/ninja/releases/download/v${NINJAVERSION}/ninja-linux-aarch64.zip \
|
||||
&& unzip /tmp/ninja.zip -d /usr/local/bin \
|
||||
&& rm /tmp/ninja.zip
|
||||
ENV CMAKE_GENERATOR=Ninja
|
||||
COPY LLAMA_CPP_VERSION .
|
||||
COPY llama/server llama/server
|
||||
COPY llama/compat llama/compat
|
||||
RUN --mount=type=cache,target=/root/.ccache \
|
||||
cmake -S llama/server --preset llama_cuda_jetpack6 \
|
||||
&& cmake --build build/llama-server-cuda_jetpack6 -- -l $(nproc) \
|
||||
&& cmake --install build/llama-server-cuda_jetpack6 --component llama-server --strip
|
||||
|
||||
FROM scratch AS publish-llama-server-cuda_jetpack6
|
||||
COPY --from=jetpack-6 dist/lib/ollama /lib/ollama/
|
||||
|
||||
#
|
||||
# MLX stage
|
||||
#
|
||||
|
||||
FROM base AS mlx
|
||||
ARG CUDA13VERSION=13.0
|
||||
ARG OLLAMA_MLX_BUILD_JOBS=
|
||||
ARG OLLAMA_MLX_NVCC_THREADS=2
|
||||
ARG MLX_CUDA_RAM_MB=
|
||||
RUN dnf install -y cuda-toolkit-${CUDA13VERSION//./-} \
|
||||
&& dnf install -y openblas-devel lapack-devel \
|
||||
&& dnf install -y libcudnn9-cuda-13 libcudnn9-devel-cuda-13 \
|
||||
&& dnf install -y libnccl libnccl-devel
|
||||
ENV PATH=/usr/local/cuda-13/bin:$PATH
|
||||
ENV BLAS_INCLUDE_DIRS=/usr/include/openblas
|
||||
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 cmake cmake
|
||||
COPY x/imagegen/mlx x/imagegen/mlx
|
||||
COPY go.mod go.sum .
|
||||
COPY MLX_VERSION MLX_C_VERSION .
|
||||
RUN curl -fsSL https://golang.org/dl/go$(awk '/^go/ { print $2 }' go.mod).linux-$(case $(uname -m) in x86_64) echo amd64 ;; aarch64) echo arm64 ;; esac).tar.gz | tar xz -C /usr/local
|
||||
ENV PATH=/usr/local/go/bin:$PATH
|
||||
RUN go mod download
|
||||
RUN --mount=type=cache,target=/root/.ccache \
|
||||
--mount=type=bind,from=local-mlx,target=/tmp/local-mlx \
|
||||
--mount=type=bind,from=local-mlx-c,target=/tmp/local-mlx-c \
|
||||
if [ -f /tmp/local-mlx/CMakeLists.txt ]; then \
|
||||
export OLLAMA_MLX_SOURCE=/tmp/local-mlx; \
|
||||
fi \
|
||||
&& if [ -f /tmp/local-mlx-c/CMakeLists.txt ]; then \
|
||||
export OLLAMA_MLX_C_SOURCE=/tmp/local-mlx-c; \
|
||||
fi \
|
||||
&& 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
|
||||
COPY go.mod go.sum .
|
||||
RUN curl -fsSL https://golang.org/dl/go$(awk '/^go/ { print $2 }' go.mod).linux-$(case $(uname -m) in x86_64) echo amd64 ;; aarch64) echo arm64 ;; esac).tar.gz | tar xz -C /usr/local
|
||||
ENV PATH=/usr/local/go/bin:$PATH
|
||||
RUN go mod download
|
||||
COPY . .
|
||||
ARG TARGETARCH
|
||||
ARG VERSION=0.0.0
|
||||
ARG GOFLAGS="'-ldflags=-w -s'"
|
||||
ENV CGO_ENABLED=1
|
||||
ARG CGO_CFLAGS
|
||||
ARG CGO_CXXFLAGS
|
||||
ENV CGO_CFLAGS="${CGO_CFLAGS}"
|
||||
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
|
||||
WORKDIR /go/src/github.com/jmorganca/ollama
|
||||
RUN apt-get update && apt-get install -y git build-essential cmake
|
||||
ADD https://dl.google.com/go/go1.21.1.linux-$TARGETARCH.tar.gz /tmp/go1.21.1.tar.gz
|
||||
RUN mkdir -p /usr/local && tar xz -C /usr/local </tmp/go1.21.1.tar.gz
|
||||
|
||||
#
|
||||
# Assembly stages — combine llama-server variants + GPU runtime libs
|
||||
#
|
||||
COPY . .
|
||||
ENV GOARCH=$TARGETARCH
|
||||
RUN /usr/local/go/bin/go generate ./... \
|
||||
&& /usr/local/go/bin/go build .
|
||||
|
||||
FROM --platform=linux/amd64 scratch AS amd64
|
||||
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=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=llama-server-cpu dist/lib/ollama /lib/ollama
|
||||
COPY --from=llama-server-rocm_v7_2 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
|
||||
ARG APT_PORTS_MIRROR=http://ports.ubuntu.com/ubuntu-ports
|
||||
RUN sed -i \
|
||||
-e "s|http://archive.ubuntu.com/ubuntu|$APT_MIRROR|g" \
|
||||
-e "s|http://ports.ubuntu.com/ubuntu-ports|$APT_PORTS_MIRROR|g" \
|
||||
/etc/apt/sources.list.d/ubuntu.sources \
|
||||
&& apt-get update \
|
||||
&& apt-get install -y ca-certificates libvulkan1 libopenblas0 \
|
||||
&& sed -i \
|
||||
-e "s|$APT_MIRROR|http://archive.ubuntu.com/ubuntu|g" \
|
||||
-e "s|$APT_PORTS_MIRROR|http://ports.ubuntu.com/ubuntu-ports|g" \
|
||||
/etc/apt/sources.list.d/ubuntu.sources \
|
||||
&& apt-get clean \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
COPY --from=image-archive /bin /usr/bin
|
||||
ENV PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
|
||||
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
|
||||
ENV OLLAMA_HOST=0.0.0.0:11434
|
||||
FROM ubuntu:22.04
|
||||
RUN apt-get update && apt-get install -y ca-certificates
|
||||
COPY --from=0 /go/src/github.com/jmorganca/ollama/ollama /bin/ollama
|
||||
EXPOSE 11434
|
||||
ENV OLLAMA_HOST 0.0.0.0
|
||||
ENTRYPOINT ["/bin/ollama"]
|
||||
CMD ["serve"]
|
||||
|
||||
32
Dockerfile.build
Normal file
@@ -0,0 +1,32 @@
|
||||
|
||||
# centos7 amd64 dependencies
|
||||
FROM --platform=linux/amd64 nvidia/cuda:11.8.0-devel-centos7 AS base-amd64
|
||||
RUN yum install -y https://repo.ius.io/ius-release-el7.rpm centos-release-scl && \
|
||||
yum update -y && \
|
||||
yum install -y devtoolset-10-gcc devtoolset-10-gcc-c++ git236 wget
|
||||
RUN wget "https://github.com/Kitware/CMake/releases/download/v3.27.6/cmake-3.27.6-linux-x86_64.sh" -O cmake-installer.sh && chmod +x cmake-installer.sh && ./cmake-installer.sh --skip-license --prefix=/usr/local
|
||||
ENV PATH /opt/rh/devtoolset-10/root/usr/bin:$PATH
|
||||
|
||||
# centos8 arm64 dependencies
|
||||
FROM --platform=linux/arm64 nvidia/cuda:11.4.3-devel-centos8 AS base-arm64
|
||||
RUN sed -i -e 's/mirrorlist/#mirrorlist/g' -e 's|#baseurl=http://mirror.centos.org|baseurl=http://vault.centos.org|g' /etc/yum.repos.d/CentOS-*
|
||||
RUN yum install -y git cmake
|
||||
|
||||
FROM base-${TARGETARCH}
|
||||
ARG TARGETARCH
|
||||
|
||||
# install go
|
||||
ADD https://dl.google.com/go/go1.21.1.linux-$TARGETARCH.tar.gz /tmp/go1.21.1.tar.gz
|
||||
RUN mkdir -p /usr/local && tar xz -C /usr/local </tmp/go1.21.1.tar.gz
|
||||
|
||||
# build the final binary
|
||||
WORKDIR /go/src/github.com/jmorganca/ollama
|
||||
COPY . .
|
||||
ENV GOOS=linux
|
||||
ENV GOARCH=$TARGETARCH
|
||||
|
||||
ARG VERSION=0.0.0
|
||||
ARG GOFLAGS="'-ldflags -w -s'"
|
||||
|
||||
RUN /usr/local/go/bin/go generate ./... && \
|
||||
/usr/local/go/bin/go build .
|
||||
@@ -1 +0,0 @@
|
||||
b10091
|
||||
@@ -1 +0,0 @@
|
||||
fba4470b89073180056c9ea46c443051375f7399
|
||||
@@ -1 +0,0 @@
|
||||
33c03c486c34a7dadab5339563612c9933c4a406
|
||||
501
README.md
@@ -1,356 +1,223 @@
|
||||
<p align="center">
|
||||
<a href="https://ollama.com">
|
||||
<img src="https://github.com/ollama/ollama/assets/3325447/0d0b44e2-8f4a-4e99-9b52-a5c1c741c8f7" alt="ollama" width="200"/>
|
||||
</a>
|
||||
</p>
|
||||
<div align="center">
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" height="200px" srcset="https://github.com/jmorganca/ollama/assets/3325447/56ea1849-1284-4645-8970-956de6e51c3c">
|
||||
<img alt="logo" height="200px" src="https://github.com/jmorganca/ollama/assets/3325447/0d0b44e2-8f4a-4e99-9b52-a5c1c741c8f7">
|
||||
</picture>
|
||||
</div>
|
||||
|
||||
# Ollama
|
||||
|
||||
Start building with open models.
|
||||
[](https://discord.gg/ollama)
|
||||
|
||||
## Download
|
||||
Get up and running with large language models locally.
|
||||
|
||||
### macOS
|
||||
|
||||
```shell
|
||||
curl -fsSL https://ollama.com/install.sh | sh
|
||||
```
|
||||
[Download](https://ollama.ai/download/Ollama-darwin.zip)
|
||||
|
||||
or [download manually](https://ollama.com/download/Ollama.dmg)
|
||||
|
||||
### Windows
|
||||
|
||||
```shell
|
||||
irm https://ollama.com/install.ps1 | iex
|
||||
```
|
||||
|
||||
or [download manually](https://ollama.com/download/OllamaSetup.exe)
|
||||
|
||||
### Linux
|
||||
|
||||
```shell
|
||||
curl -fsSL https://ollama.com/install.sh | sh
|
||||
```
|
||||
|
||||
[Manual install instructions](https://docs.ollama.com/linux#manual-install)
|
||||
|
||||
### Docker
|
||||
|
||||
The official [Ollama Docker image](https://hub.docker.com/r/ollama/ollama) `ollama/ollama` is available on Docker Hub.
|
||||
|
||||
### Libraries
|
||||
|
||||
- [ollama-python](https://github.com/ollama/ollama-python)
|
||||
- [ollama-js](https://github.com/ollama/ollama-js)
|
||||
|
||||
### Community
|
||||
|
||||
- [Discord](https://discord.gg/ollama)
|
||||
- [𝕏 (Twitter)](https://x.com/ollama)
|
||||
- [Reddit](https://reddit.com/r/ollama)
|
||||
|
||||
## Get started
|
||||
### Linux & WSL2
|
||||
|
||||
```
|
||||
ollama
|
||||
curl https://ollama.ai/install.sh | sh
|
||||
```
|
||||
|
||||
You'll be prompted to run a model or connect Ollama to your existing agents or applications such as `Claude Code`, `OpenClaw`, `OpenCode` , `Codex`, `Copilot`, and more.
|
||||
[Manual install instructions](https://github.com/jmorganca/ollama/blob/main/docs/linux.md)
|
||||
|
||||
### Coding
|
||||
### Windows
|
||||
|
||||
To launch a specific integration:
|
||||
coming soon
|
||||
|
||||
## Quickstart
|
||||
|
||||
To run and chat with [Llama 2](https://ollama.ai/library/llama2):
|
||||
|
||||
```
|
||||
ollama launch claude
|
||||
ollama run llama2
|
||||
```
|
||||
|
||||
Supported integrations include [Claude Code](https://docs.ollama.com/integrations/claude-code), [Codex](https://docs.ollama.com/integrations/codex), [Copilot CLI](https://docs.ollama.com/integrations/copilot-cli), [Droid](https://docs.ollama.com/integrations/droid), and [OpenCode](https://docs.ollama.com/integrations/opencode).
|
||||
## Model library
|
||||
|
||||
### AI assistant
|
||||
Ollama supports a list of open-source models available on [ollama.ai/library](https://ollama.ai/library "ollama model library")
|
||||
|
||||
Use [OpenClaw](https://docs.ollama.com/integrations/openclaw) to turn Ollama into a personal AI assistant across WhatsApp, Telegram, Slack, Discord, and more:
|
||||
Here are some example open-source models that can be downloaded:
|
||||
|
||||
| Model | Parameters | Size | Download |
|
||||
| ------------------ | ---------- | ----- | ------------------------------ |
|
||||
| Mistral | 7B | 4.1GB | `ollama run mistral` |
|
||||
| Llama 2 | 7B | 3.8GB | `ollama run llama2` |
|
||||
| Code Llama | 7B | 3.8GB | `ollama run codellama` |
|
||||
| Llama 2 Uncensored | 7B | 3.8GB | `ollama run llama2-uncensored` |
|
||||
| Llama 2 13B | 13B | 7.3GB | `ollama run llama2:13b` |
|
||||
| Llama 2 70B | 70B | 39GB | `ollama run llama2:70b` |
|
||||
| Orca Mini | 3B | 1.9GB | `ollama run orca-mini` |
|
||||
| Vicuna | 7B | 3.8GB | `ollama run vicuna` |
|
||||
|
||||
> Note: You should have at least 8 GB of RAM to run the 3B models, 16 GB to run the 7B models, and 32 GB to run the 13B models.
|
||||
|
||||
## Customize your own model
|
||||
|
||||
### Import from GGUF or GGML
|
||||
|
||||
Ollama supports importing GGUF and GGML file formats in the Modelfile. This means if you have a model that is not in the Ollama library, you can create it, iterate on it, and upload it to the Ollama library to share with others when you are ready.
|
||||
|
||||
1. Create a file named Modelfile, and add a `FROM` instruction with the local filepath to the model you want to import.
|
||||
|
||||
```
|
||||
FROM ./vicuna-33b.Q4_0.gguf
|
||||
```
|
||||
|
||||
3. Create the model in Ollama
|
||||
|
||||
```
|
||||
ollama create name -f path_to_modelfile
|
||||
```
|
||||
|
||||
5. Run the model
|
||||
|
||||
```
|
||||
ollama run name
|
||||
```
|
||||
|
||||
### Customize a prompt
|
||||
|
||||
Models from the Ollama library can be customized with a prompt. The example
|
||||
|
||||
```
|
||||
ollama launch openclaw
|
||||
ollama pull llama2
|
||||
```
|
||||
|
||||
### Chat with a model
|
||||
|
||||
Run and chat with [Gemma 4](https://ollama.com/library/gemma4):
|
||||
Create a `Modelfile`:
|
||||
|
||||
```
|
||||
ollama run gemma4
|
||||
FROM llama2
|
||||
|
||||
# set the temperature to 1 [higher is more creative, lower is more coherent]
|
||||
PARAMETER temperature 1
|
||||
|
||||
# set the system prompt
|
||||
SYSTEM """
|
||||
You are Mario from Super Mario Bros. Answer as Mario, the assistant, only.
|
||||
"""
|
||||
```
|
||||
|
||||
See [ollama.com/library](https://ollama.com/library) for the full list.
|
||||
Next, create and run the model:
|
||||
|
||||
See the [quickstart guide](https://docs.ollama.com/quickstart) for more details.
|
||||
```
|
||||
ollama create mario -f ./Modelfile
|
||||
ollama run mario
|
||||
>>> hi
|
||||
Hello! It's your friend Mario.
|
||||
```
|
||||
|
||||
For more examples, see the [examples](./examples) directory. For more information on working with a Modelfile, see the [Modelfile](./docs/modelfile.md) documentation.
|
||||
|
||||
## CLI Reference
|
||||
|
||||
### Create a model
|
||||
|
||||
`ollama create` is used to create a model from a Modelfile.
|
||||
|
||||
### Pull a model
|
||||
|
||||
```
|
||||
ollama pull llama2
|
||||
```
|
||||
|
||||
> This command can also be used to update a local model. Only the diff will be pulled.
|
||||
|
||||
### Remove a model
|
||||
|
||||
```
|
||||
ollama rm llama2
|
||||
```
|
||||
|
||||
### Copy a model
|
||||
|
||||
```
|
||||
ollama cp llama2 my-llama2
|
||||
```
|
||||
|
||||
### Multiline input
|
||||
|
||||
For multiline input, you can wrap text with `"""`:
|
||||
|
||||
```
|
||||
>>> """Hello,
|
||||
... world!
|
||||
... """
|
||||
I'm a basic program that prints the famous "Hello, world!" message to the console.
|
||||
```
|
||||
|
||||
### Pass in prompt as arguments
|
||||
|
||||
```
|
||||
$ ollama run llama2 "summarize this file:" "$(cat README.md)"
|
||||
Ollama is a lightweight, extensible framework for building and running language models on the local machine. It provides a simple API for creating, running, and managing models, as well as a library of pre-built models that can be easily used in a variety of applications.
|
||||
```
|
||||
|
||||
### List models on your computer
|
||||
|
||||
```
|
||||
ollama list
|
||||
```
|
||||
|
||||
### Start Ollama
|
||||
|
||||
`ollama serve` is used when you want to start ollama without running the desktop application.
|
||||
|
||||
## Building
|
||||
|
||||
Install `cmake` and `go`:
|
||||
|
||||
```
|
||||
brew install cmake
|
||||
brew install go
|
||||
```
|
||||
|
||||
Then generate dependencies and build:
|
||||
|
||||
```
|
||||
go generate ./...
|
||||
go build .
|
||||
```
|
||||
|
||||
Next, start the server:
|
||||
|
||||
```
|
||||
./ollama serve
|
||||
```
|
||||
|
||||
Finally, in a separate shell, run a model:
|
||||
|
||||
```
|
||||
./ollama run llama2
|
||||
```
|
||||
|
||||
## REST API
|
||||
|
||||
Ollama has a REST API for running and managing models.
|
||||
> See the [API documentation](./docs/api.md) for all endpoints.
|
||||
|
||||
Ollama has an API for running and managing models. For example to generate text from a model:
|
||||
|
||||
```
|
||||
curl http://localhost:11434/api/chat -d '{
|
||||
"model": "gemma4",
|
||||
"messages": [{
|
||||
"role": "user",
|
||||
"content": "Why is the sky blue?"
|
||||
}],
|
||||
"stream": false
|
||||
curl -X POST http://localhost:11434/api/generate -d '{
|
||||
"model": "llama2",
|
||||
"prompt":"Why is the sky blue?"
|
||||
}'
|
||||
```
|
||||
|
||||
See the [API documentation](https://docs.ollama.com/api) for all endpoints.
|
||||
|
||||
### Python
|
||||
|
||||
```
|
||||
pip install ollama
|
||||
```
|
||||
|
||||
```python
|
||||
from ollama import chat
|
||||
|
||||
response = chat(model='gemma4', messages=[
|
||||
{
|
||||
'role': 'user',
|
||||
'content': 'Why is the sky blue?',
|
||||
},
|
||||
])
|
||||
print(response.message.content)
|
||||
```
|
||||
|
||||
### JavaScript
|
||||
|
||||
```
|
||||
npm i ollama
|
||||
```
|
||||
|
||||
```javascript
|
||||
import ollama from "ollama";
|
||||
|
||||
const response = await ollama.chat({
|
||||
model: "gemma4",
|
||||
messages: [{ role: "user", content: "Why is the sky blue?" }],
|
||||
});
|
||||
console.log(response.message.content);
|
||||
```
|
||||
|
||||
## Supported backends
|
||||
|
||||
- [llama.cpp](https://github.com/ggml-org/llama.cpp) project founded by Georgi Gerganov.
|
||||
|
||||
## Documentation
|
||||
|
||||
- [CLI reference](https://docs.ollama.com/cli)
|
||||
- [REST API reference](https://docs.ollama.com/api)
|
||||
- [Importing models](https://docs.ollama.com/import)
|
||||
- [Modelfile reference](https://docs.ollama.com/modelfile)
|
||||
- [Building from source](https://github.com/ollama/ollama/blob/main/docs/development.md)
|
||||
|
||||
## Community Integrations
|
||||
|
||||
> Want to add your project? Open a pull request.
|
||||
|
||||
### Chat Interfaces
|
||||
|
||||
#### Web
|
||||
|
||||
- [Open WebUI](https://github.com/open-webui/open-webui) - Extensible, self-hosted AI interface
|
||||
- [Onyx](https://github.com/onyx-dot-app/onyx) - Connected AI workspace
|
||||
- [LibreChat](https://github.com/danny-avila/LibreChat) - Enhanced ChatGPT clone with multi-provider support
|
||||
- [Lobe Chat](https://github.com/lobehub/lobe-chat) - Modern chat framework with plugin ecosystem ([docs](https://lobehub.com/docs/self-hosting/examples/ollama))
|
||||
- [NextChat](https://github.com/ChatGPTNextWeb/ChatGPT-Next-Web) - Cross-platform ChatGPT UI ([docs](https://docs.nextchat.dev/models/ollama))
|
||||
- [Perplexica](https://github.com/ItzCrazyKns/Perplexica) - AI-powered search engine, open-source Perplexity alternative
|
||||
- [big-AGI](https://github.com/enricoros/big-AGI) - AI suite for professionals
|
||||
- [Lollms WebUI](https://github.com/ParisNeo/lollms-webui) - Multi-model web interface
|
||||
- [ChatOllama](https://github.com/sugarforever/chat-ollama) - Chatbot with knowledge bases
|
||||
- [Bionic GPT](https://github.com/bionic-gpt/bionic-gpt) - On-premise AI platform
|
||||
- [Chatbot UI](https://github.com/ivanfioravanti/chatbot-ollama) - ChatGPT-style web interface
|
||||
- [Hollama](https://github.com/fmaclen/hollama) - Minimal web interface
|
||||
- [Chatbox](https://github.com/Bin-Huang/Chatbox) - Desktop and web AI client
|
||||
- [chat](https://github.com/swuecho/chat) - Chat web app for teams
|
||||
- [Ollama RAG Chatbot](https://github.com/datvodinh/rag-chatbot.git) - Chat with multiple PDFs using RAG
|
||||
- [Tkinter-based client](https://github.com/chyok/ollama-gui) - Python desktop client
|
||||
|
||||
#### Desktop
|
||||
|
||||
- [Dify.AI](https://github.com/langgenius/dify) - LLM app development platform
|
||||
- [AnythingLLM](https://github.com/Mintplex-Labs/anything-llm) - All-in-one AI app for Mac, Windows, and Linux
|
||||
- [Maid](https://github.com/Mobile-Artificial-Intelligence/maid) - Cross-platform mobile and desktop client
|
||||
- [Witsy](https://github.com/nbonamy/witsy) - AI desktop app for Mac, Windows, and Linux
|
||||
- [Cherry Studio](https://github.com/kangfenmao/cherry-studio) - Multi-provider desktop client
|
||||
- [Ollama App](https://github.com/JHubi1/ollama-app) - Multi-platform client for desktop and mobile
|
||||
- [PyGPT](https://github.com/szczyglis-dev/py-gpt) - AI desktop assistant for Linux, Windows, and Mac
|
||||
- [Alpaca](https://github.com/Jeffser/Alpaca) - GTK4 client for Linux and macOS
|
||||
- [SwiftChat](https://github.com/aws-samples/swift-chat) - Cross-platform including iOS, Android, and Apple Vision Pro
|
||||
- [Enchanted](https://github.com/AugustDev/enchanted) - Native macOS and iOS client
|
||||
- [RWKV-Runner](https://github.com/josStorer/RWKV-Runner) - Multi-model desktop runner
|
||||
- [Ollama Grid Search](https://github.com/dezoito/ollama-grid-search) - Evaluate and compare models
|
||||
- [macai](https://github.com/Renset/macai) - macOS client for Ollama and ChatGPT
|
||||
- [AI Studio](https://github.com/MindWorkAI/AI-Studio) - Multi-provider desktop IDE
|
||||
- [Reins](https://github.com/ibrahimcetin/reins) - Parameter tuning and reasoning model support
|
||||
- [ConfiChat](https://github.com/1runeberg/confichat) - Privacy-focused with optional encryption
|
||||
- [LLocal.in](https://github.com/kartikm7/llocal) - Electron desktop client
|
||||
- [MindMac](https://mindmac.app) - AI chat client for Mac
|
||||
- [Msty](https://msty.app) - Multi-model desktop client
|
||||
- [BoltAI for Mac](https://boltai.com) - AI chat client for Mac
|
||||
- [IntelliBar](https://intellibar.app/) - AI-powered assistant for macOS
|
||||
- [Kerlig AI](https://www.kerlig.com/) - AI writing assistant for macOS
|
||||
- [Hillnote](https://hillnote.com) - Markdown-first AI workspace
|
||||
- [Perfect Memory AI](https://www.perfectmemory.ai/) - Productivity AI personalized by screen and meeting history
|
||||
|
||||
#### Mobile
|
||||
|
||||
- [Ollama Android Chat](https://github.com/sunshine0523/OllamaServer) - One-click Ollama on Android
|
||||
|
||||
> SwiftChat, Enchanted, Maid, Ollama App, Reins, and ConfiChat listed above also support mobile platforms.
|
||||
|
||||
### Code Editors & Development
|
||||
|
||||
- [Cline](https://github.com/cline/cline) - VS Code extension for multi-file/whole-repo coding
|
||||
- [Continue](https://github.com/continuedev/continue) - Open-source AI code assistant for any IDE
|
||||
- [Void](https://github.com/voideditor/void) - Open source AI code editor, Cursor alternative
|
||||
- [Copilot for Obsidian](https://github.com/logancyang/obsidian-copilot) - AI assistant for Obsidian
|
||||
- [twinny](https://github.com/rjmacarthy/twinny) - Copilot and Copilot chat alternative
|
||||
- [gptel Emacs client](https://github.com/karthink/gptel) - LLM client for Emacs
|
||||
- [Ollama Copilot](https://github.com/bernardo-bruning/ollama-copilot) - Use Ollama as GitHub Copilot
|
||||
- [Obsidian Local GPT](https://github.com/pfrankov/obsidian-local-gpt) - Local AI for Obsidian
|
||||
- [Ellama Emacs client](https://github.com/s-kostyaev/ellama) - LLM tool for Emacs
|
||||
- [orbiton](https://github.com/xyproto/orbiton) - Config-free text editor with Ollama tab completion
|
||||
- [AI ST Completion](https://github.com/yaroslavyaroslav/OpenAI-sublime-text) - Sublime Text 4 AI assistant
|
||||
- [VT Code](https://github.com/vinhnx/vtcode) - Rust-based terminal coding agent with Tree-sitter
|
||||
- [QodeAssist](https://github.com/Palm1r/QodeAssist) - AI coding assistant for Qt Creator
|
||||
- [AI Toolkit for VS Code](https://aka.ms/ai-tooklit/ollama-docs) - Microsoft-official VS Code extension
|
||||
- [Open Interpreter](https://docs.openinterpreter.com/language-model-setup/local-models/ollama) - Natural language interface for computers
|
||||
|
||||
### Libraries & SDKs
|
||||
|
||||
- [LiteLLM](https://github.com/BerriAI/litellm) - Unified API for 100+ LLM providers
|
||||
- [Semantic Kernel](https://github.com/microsoft/semantic-kernel/tree/main/python/semantic_kernel/connectors/ai/ollama) - Microsoft AI orchestration SDK
|
||||
- [LangChain4j](https://github.com/langchain4j/langchain4j) - Java LangChain ([example](https://github.com/langchain4j/langchain4j-examples/tree/main/ollama-examples/src/main/java))
|
||||
- [LangChainGo](https://github.com/tmc/langchaingo/) - Go LangChain ([example](https://github.com/tmc/langchaingo/tree/main/examples/ollama-completion-example))
|
||||
- [Spring AI](https://github.com/spring-projects/spring-ai) - Spring framework AI support ([docs](https://docs.spring.io/spring-ai/reference/api/chat/ollama-chat.html))
|
||||
- [LangChain](https://python.langchain.com/docs/integrations/chat/ollama/) and [LangChain.js](https://js.langchain.com/docs/integrations/chat/ollama/) with [example](https://js.langchain.com/docs/tutorials/local_rag/)
|
||||
- [Ollama for Ruby](https://github.com/crmne/ruby_llm) - Ruby LLM library
|
||||
- [any-llm](https://github.com/mozilla-ai/any-llm) - Unified LLM interface by Mozilla
|
||||
- [OllamaSharp for .NET](https://github.com/awaescher/OllamaSharp) - .NET SDK
|
||||
- [LangChainRust](https://github.com/Abraxas-365/langchain-rust) - Rust LangChain ([example](https://github.com/Abraxas-365/langchain-rust/blob/main/examples/llm_ollama.rs))
|
||||
- [Agents-Flex for Java](https://github.com/agents-flex/agents-flex) - Java agent framework ([example](https://github.com/agents-flex/agents-flex/tree/main/agents-flex-llm/agents-flex-llm-ollama/src/test/java/com/agentsflex/llm/ollama))
|
||||
- [Elixir LangChain](https://github.com/brainlid/langchain) - Elixir LangChain
|
||||
- [Ollama-rs for Rust](https://github.com/pepperoni21/ollama-rs) - Rust SDK
|
||||
- [LangChain for .NET](https://github.com/tryAGI/LangChain) - .NET LangChain ([example](https://github.com/tryAGI/LangChain/blob/main/examples/LangChain.Samples.OpenAI/Program.cs))
|
||||
- [chromem-go](https://github.com/philippgille/chromem-go) - Go vector database with Ollama embeddings ([example](https://github.com/philippgille/chromem-go/tree/v0.5.0/examples/rag-wikipedia-ollama))
|
||||
- [LangChainDart](https://github.com/davidmigloz/langchain_dart) - Dart LangChain
|
||||
- [LlmTornado](https://github.com/lofcz/llmtornado) - Unified C# interface for multiple inference APIs
|
||||
- [Ollama4j for Java](https://github.com/ollama4j/ollama4j) - Java SDK
|
||||
- [Ollama for Laravel](https://github.com/cloudstudio/ollama-laravel) - Laravel integration
|
||||
- [Ollama for Swift](https://github.com/mattt/ollama-swift) - Swift SDK
|
||||
- [LlamaIndex](https://docs.llamaindex.ai/en/stable/examples/llm/ollama/) and [LlamaIndexTS](https://ts.llamaindex.ai/modules/llms/available_llms/ollama) - Data framework for LLM apps
|
||||
- [Haystack](https://github.com/deepset-ai/haystack-integrations/blob/main/integrations/ollama.md) - AI pipeline framework
|
||||
- [Firebase Genkit](https://firebase.google.com/docs/genkit/plugins/ollama) - Google AI framework
|
||||
- [Ollama-hpp for C++](https://github.com/jmont-dev/ollama-hpp) - C++ SDK
|
||||
- [PromptingTools.jl](https://github.com/svilupp/PromptingTools.jl) - Julia LLM toolkit ([example](https://svilupp.github.io/PromptingTools.jl/dev/examples/working_with_ollama))
|
||||
- [Ollama for R - rollama](https://github.com/JBGruber/rollama) - R SDK
|
||||
- [Portkey](https://portkey.ai/docs/welcome/integration-guides/ollama) - AI gateway
|
||||
- [Testcontainers](https://testcontainers.com/modules/ollama/) - Container-based testing
|
||||
- [LLPhant](https://github.com/theodo-group/LLPhant?tab=readme-ov-file#ollama) - PHP AI framework
|
||||
|
||||
### Frameworks & Agents
|
||||
|
||||
- [AutoGPT](https://github.com/Significant-Gravitas/AutoGPT/blob/master/docs/content/platform/ollama.md) - Autonomous AI agent platform
|
||||
- [crewAI](https://github.com/crewAIInc/crewAI) - Multi-agent orchestration framework
|
||||
- [Strands Agents](https://github.com/strands-agents/sdk-python) - Model-driven agent building by AWS
|
||||
- [Cheshire Cat](https://github.com/cheshire-cat-ai/core) - AI assistant framework
|
||||
- [any-agent](https://github.com/mozilla-ai/any-agent) - Unified agent framework interface by Mozilla
|
||||
- [Stakpak](https://github.com/stakpak/agent) - Open source DevOps agent
|
||||
- [Hexabot](https://github.com/hexastack/hexabot) - Conversational AI builder
|
||||
- [Neuro SAN](https://github.com/cognizant-ai-lab/neuro-san-studio) - Multi-agent orchestration ([docs](https://github.com/cognizant-ai-lab/neuro-san-studio/blob/main/docs/user_guide.md#ollama))
|
||||
|
||||
### RAG & Knowledge Bases
|
||||
|
||||
- [RAGFlow](https://github.com/infiniflow/ragflow) - RAG engine based on deep document understanding
|
||||
- [R2R](https://github.com/SciPhi-AI/R2R) - Open-source RAG engine
|
||||
- [MaxKB](https://github.com/1Panel-dev/MaxKB/) - Ready-to-use RAG chatbot
|
||||
- [Minima](https://github.com/dmayboroda/minima) - On-premises or fully local RAG
|
||||
- [Chipper](https://github.com/TilmanGriesel/chipper) - AI interface with Haystack RAG
|
||||
- [ARGO](https://github.com/xark-argo/argo) - RAG and deep research on Mac/Windows/Linux
|
||||
- [Archyve](https://github.com/nickthecook/archyve) - RAG-enabling document library
|
||||
- [Casibase](https://casibase.org) - AI knowledge base with RAG and SSO
|
||||
- [BrainSoup](https://www.nurgo-software.com/products/brainsoup) - Native client with RAG and multi-agent automation
|
||||
|
||||
### Bots & Messaging
|
||||
|
||||
- [LangBot](https://github.com/RockChinQ/LangBot) - Multi-platform messaging bots with agents and RAG
|
||||
- [AstrBot](https://github.com/Soulter/AstrBot/) - Multi-platform chatbot with RAG and plugins
|
||||
- [Discord-Ollama Chat Bot](https://github.com/kevinthedang/discord-ollama) - TypeScript Discord bot
|
||||
- [Ollama Telegram Bot](https://github.com/ruecat/ollama-telegram) - Telegram bot
|
||||
- [LLM Telegram Bot](https://github.com/innightwolfsleep/llm_telegram_bot) - Telegram bot for roleplay
|
||||
|
||||
### Terminal & CLI
|
||||
|
||||
- [aichat](https://github.com/sigoden/aichat) - All-in-one LLM CLI with Shell Assistant, RAG, and AI tools
|
||||
- [oterm](https://github.com/ggozad/oterm) - Terminal client for Ollama
|
||||
- [gollama](https://github.com/sammcj/gollama) - Go-based model manager for Ollama
|
||||
- [tlm](https://github.com/yusufcanb/tlm) - Local shell copilot
|
||||
- [tenere](https://github.com/pythops/tenere) - TUI for LLMs
|
||||
- [ParLlama](https://github.com/paulrobello/parllama) - TUI for Ollama
|
||||
- [llm-ollama](https://github.com/taketwo/llm-ollama) - Plugin for [Datasette's LLM CLI](https://llm.datasette.io/en/stable/)
|
||||
- [ShellOracle](https://github.com/djcopley/ShellOracle) - Shell command suggestions
|
||||
- [LLM-X](https://github.com/mrdjohnson/llm-x) - Progressive web app for LLMs
|
||||
- [cmdh](https://github.com/pgibler/cmdh) - Natural language to shell commands
|
||||
- [VT](https://github.com/vinhnx/vt.ai) - Minimal multimodal AI chat app
|
||||
|
||||
### Productivity & Apps
|
||||
|
||||
- [AppFlowy](https://github.com/AppFlowy-IO/AppFlowy) - AI collaborative workspace, self-hostable Notion alternative
|
||||
- [Screenpipe](https://github.com/mediar-ai/screenpipe) - 24/7 screen and mic recording with AI-powered search
|
||||
- [Vibe](https://github.com/thewh1teagle/vibe) - Transcribe and analyze meetings
|
||||
- [Page Assist](https://github.com/n4ze3m/page-assist) - Chrome extension for AI-powered browsing
|
||||
- [NativeMind](https://github.com/NativeMindBrowser/NativeMindExtension) - Private, on-device browser AI assistant
|
||||
- [Ollama Fortress](https://github.com/ParisNeo/ollama_proxy_server) - Security proxy for Ollama
|
||||
- [1Panel](https://github.com/1Panel-dev/1Panel/) - Web-based Linux server management
|
||||
- [Writeopia](https://github.com/Writeopia/Writeopia) - Text editor with Ollama integration
|
||||
- [QA-Pilot](https://github.com/reid41/QA-Pilot) - GitHub code repository understanding
|
||||
- [Raycast extension](https://github.com/MassimilianoPasquini97/raycast_ollama) - Ollama in Raycast
|
||||
- [Painting Droid](https://github.com/mateuszmigas/painting-droid) - Painting app with AI integrations
|
||||
- [Serene Pub](https://github.com/doolijb/serene-pub) - AI roleplaying app
|
||||
- [Mayan EDMS](https://gitlab.com/mayan-edms/mayan-edms) - Document management with Ollama workflows
|
||||
- [TagSpaces](https://www.tagspaces.org) - File management with [AI tagging](https://docs.tagspaces.org/ai/)
|
||||
|
||||
### Observability & Monitoring
|
||||
|
||||
- [Opik](https://www.comet.com/docs/opik/cookbook/ollama) - Debug, evaluate, and monitor LLM applications
|
||||
- [OpenLIT](https://github.com/openlit/openlit) - OpenTelemetry-native monitoring for Ollama and GPUs
|
||||
- [Lunary](https://lunary.ai/docs/integrations/ollama) - LLM observability with analytics and PII masking
|
||||
- [Langfuse](https://langfuse.com/docs/integrations/ollama) - Open source LLM observability
|
||||
- [HoneyHive](https://docs.honeyhive.ai/integrations/ollama) - AI observability and evaluation for agents
|
||||
- [MLflow Tracing](https://mlflow.org/docs/latest/llms/tracing/index.html#automatic-tracing) - Open source LLM observability
|
||||
|
||||
### Database & Embeddings
|
||||
|
||||
- [pgai](https://github.com/timescale/pgai) - PostgreSQL as a vector database ([guide](https://github.com/timescale/pgai/blob/main/docs/vectorizer-quick-start.md))
|
||||
- [MindsDB](https://github.com/mindsdb/mindsdb/blob/staging/mindsdb/integrations/handlers/ollama_handler/README.md) - Connect Ollama with 200+ data platforms
|
||||
- [chromem-go](https://github.com/philippgille/chromem-go/blob/v0.5.0/embed_ollama.go) - Embeddable vector database for Go ([example](https://github.com/philippgille/chromem-go/tree/v0.5.0/examples/rag-wikipedia-ollama))
|
||||
- [Kangaroo](https://github.com/dbkangaroo/kangaroo) - AI-powered SQL client
|
||||
|
||||
### Infrastructure & Deployment
|
||||
|
||||
#### Cloud
|
||||
|
||||
- [Google Cloud](https://cloud.google.com/run/docs/tutorials/gpu-gemma2-with-ollama)
|
||||
- [Fly.io](https://fly.io/docs/python/do-more/add-ollama/)
|
||||
- [Koyeb](https://www.koyeb.com/deploy/ollama)
|
||||
- [Harbor](https://github.com/av/harbor) - Containerized LLM toolkit with Ollama as default backend
|
||||
|
||||
#### Package Managers
|
||||
|
||||
- [Pacman](https://archlinux.org/packages/extra/x86_64/ollama/)
|
||||
- [Homebrew](https://formulae.brew.sh/formula/ollama)
|
||||
- [Nix package](https://search.nixos.org/packages?show=ollama&from=0&size=50&sort=relevance&type=packages&query=ollama)
|
||||
- [Helm Chart](https://artifacthub.io/packages/helm/ollama-helm/ollama)
|
||||
- [Gentoo](https://github.com/gentoo/guru/tree/master/app-misc/ollama)
|
||||
- [Flox](https://flox.dev/blog/ollama-part-one)
|
||||
- [Guix channel](https://codeberg.org/tusharhero/ollama-guix)
|
||||
- [LangChain](https://python.langchain.com/docs/integrations/llms/ollama) and [LangChain.js](https://js.langchain.com/docs/modules/model_io/models/llms/integrations/ollama) with [example](https://js.langchain.com/docs/use_cases/question_answering/local_retrieval_qa)
|
||||
- [LlamaIndex](https://gpt-index.readthedocs.io/en/stable/examples/llm/ollama.html)
|
||||
- [Raycast extension](https://github.com/MassimilianoPasquini97/raycast_ollama)
|
||||
- [Discollama](https://github.com/mxyng/discollama) (Discord bot inside the Ollama discord channel)
|
||||
- [Continue](https://github.com/continuedev/continue)
|
||||
- [Obsidian Ollama plugin](https://github.com/hinterdupfinger/obsidian-ollama)
|
||||
- [Dagger Chatbot](https://github.com/samalba/dagger-chatbot)
|
||||
- [LiteLLM](https://github.com/BerriAI/litellm)
|
||||
- [Discord AI Bot](https://github.com/mekb-turtle/discord-ai-bot)
|
||||
- [HTML UI](https://github.com/rtcfirefly/ollama-ui)
|
||||
- [Typescript UI](https://github.com/ollama-interface/Ollama-Gui?tab=readme-ov-file)
|
||||
- [Dumbar](https://github.com/JerrySievert/Dumbar)
|
||||
- [Emacs client](https://github.com/zweifisch/ollama)
|
||||
|
||||
25
SECURITY.md
@@ -1,25 +0,0 @@
|
||||
# Security
|
||||
|
||||
The Ollama maintainer team takes security seriously and will actively work to resolve security issues.
|
||||
|
||||
## Reporting a vulnerability
|
||||
|
||||
If you discover a security vulnerability, please do not open a public issue. Instead, please report it by emailing hello@ollama.com. We ask that you give us sufficient time to investigate and address the vulnerability before disclosing it publicly.
|
||||
|
||||
Please include the following details in your report:
|
||||
- A description of the vulnerability
|
||||
- Steps to reproduce the issue
|
||||
- Your assessment of the potential impact
|
||||
- Any possible mitigations
|
||||
|
||||
## Security best practices
|
||||
|
||||
While the maintainer team does its best to secure Ollama, users are encouraged to implement their own security best practices, such as:
|
||||
|
||||
- Regularly updating to the latest version of Ollama
|
||||
- Securing access to hosted instances of Ollama
|
||||
- Monitoring systems for unusual activity
|
||||
|
||||
## Contact
|
||||
|
||||
For any other questions or concerns related to security, please contact us at hello@ollama.com
|
||||
@@ -1,198 +0,0 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"sync"
|
||||
)
|
||||
|
||||
type ApprovalRequest struct {
|
||||
WorkingDir string
|
||||
Calls []ApprovalToolCall
|
||||
}
|
||||
|
||||
func (r *ApprovalRequest) AddToolCall(id, name, scope string, args map[string]any) {
|
||||
r.Calls = append(r.Calls, ApprovalToolCall{
|
||||
ToolCallID: id,
|
||||
ToolName: name,
|
||||
Args: args,
|
||||
ApprovalScope: scope,
|
||||
})
|
||||
}
|
||||
|
||||
type ApprovalToolCall struct {
|
||||
ToolCallID string
|
||||
ToolName string
|
||||
Args map[string]any
|
||||
ApprovalScope string
|
||||
}
|
||||
|
||||
type Approval struct {
|
||||
Allow bool
|
||||
AllowAll bool
|
||||
AllowScopes []string
|
||||
Reason string
|
||||
}
|
||||
|
||||
type ApprovalPrompter interface {
|
||||
PromptApproval(context.Context, ApprovalRequest) (Approval, error)
|
||||
}
|
||||
|
||||
type ApprovalState struct {
|
||||
mu sync.RWMutex
|
||||
allowAll bool
|
||||
scopes map[string]bool
|
||||
}
|
||||
|
||||
func (s *ApprovalState) Set(allowAll bool, scopes map[string]bool) {
|
||||
if s == nil {
|
||||
return
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.allowAll = allowAll
|
||||
s.scopes = cloneApprovalScopes(scopes)
|
||||
}
|
||||
|
||||
// GrantAll grants blanket approval for all future tool calls.
|
||||
func (s *ApprovalState) GrantAll() {
|
||||
if s == nil {
|
||||
return
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.allowAll = true
|
||||
}
|
||||
|
||||
// AllGranted reports whether blanket approval has been granted.
|
||||
func (s *ApprovalState) AllGranted() bool {
|
||||
if s == nil {
|
||||
return false
|
||||
}
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
return s.allowAll
|
||||
}
|
||||
|
||||
func (s *ApprovalState) Allows(scope string) bool {
|
||||
if s == nil {
|
||||
return false
|
||||
}
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
return s.allowAll || s.scopes[scope]
|
||||
}
|
||||
|
||||
// Apply merges an approval's scopes and allow-all flag into the state. It
|
||||
// returns true if the approval grants permission (allow-all or at least one
|
||||
// scope). It does not mutate the approval; the caller sets Allow based on the
|
||||
// returned value.
|
||||
func (s *ApprovalState) Apply(result *Approval) bool {
|
||||
if s == nil || result == nil {
|
||||
return false
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
granted := false
|
||||
if result.AllowAll {
|
||||
s.allowAll = true
|
||||
granted = true
|
||||
}
|
||||
if len(result.AllowScopes) > 0 {
|
||||
granted = true
|
||||
s.grantScopesLocked(result.AllowScopes)
|
||||
}
|
||||
return granted
|
||||
}
|
||||
|
||||
// GrantScopes merges the given scopes into the state.
|
||||
func (s *ApprovalState) GrantScopes(scopes []string) {
|
||||
if s == nil {
|
||||
return
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.grantScopesLocked(scopes)
|
||||
}
|
||||
|
||||
// grantScopesLocked adds trimmed, non-empty scopes to the state. Caller must
|
||||
// hold s.mu.
|
||||
func (s *ApprovalState) grantScopesLocked(scopes []string) {
|
||||
if s.scopes == nil {
|
||||
s.scopes = make(map[string]bool, len(scopes))
|
||||
}
|
||||
for _, scope := range scopes {
|
||||
scope = strings.TrimSpace(scope)
|
||||
if scope != "" {
|
||||
s.scopes[scope] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func cloneApprovalScopes(src map[string]bool) map[string]bool {
|
||||
if len(src) == 0 {
|
||||
return nil
|
||||
}
|
||||
dst := make(map[string]bool, len(src))
|
||||
for scope, allowed := range src {
|
||||
if allowed {
|
||||
dst[scope] = true
|
||||
}
|
||||
}
|
||||
return dst
|
||||
}
|
||||
|
||||
func (s *Session) needsApproval(tool Tool, name string, args map[string]any) bool {
|
||||
return ToolRequiresApproval(tool, args) && !s.allows(toolApprovalScope(tool, name, args))
|
||||
}
|
||||
|
||||
// allows reports whether scope is permitted by the session's accumulated approval state.
|
||||
func (s *Session) allows(scope string) bool {
|
||||
if s == nil || s.ApprovalState == nil {
|
||||
return false
|
||||
}
|
||||
return s.ApprovalState.Allows(scope)
|
||||
}
|
||||
|
||||
// applyApproval merges an approval result into the session's state and marks
|
||||
// the result as allowed when scopes or allow-all were granted.
|
||||
func (s *Session) applyApproval(result *Approval) {
|
||||
if s == nil || result == nil {
|
||||
return
|
||||
}
|
||||
if s.ApprovalState == nil {
|
||||
s.ApprovalState = &ApprovalState{}
|
||||
}
|
||||
if s.ApprovalState.Apply(result) {
|
||||
result.Allow = true
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Session) authorizeToolCalls(ctx context.Context, req ApprovalRequest) (Approval, error) {
|
||||
if s == nil || len(req.Calls) == 0 || (s.ApprovalState != nil && s.ApprovalState.AllGranted()) {
|
||||
return Approval{Allow: true}, nil
|
||||
}
|
||||
if s.ApprovalPrompter == nil {
|
||||
return Approval{
|
||||
Reason: "Tool execution requires approval, but no approval prompter is available.",
|
||||
}, nil
|
||||
}
|
||||
|
||||
result, err := s.ApprovalPrompter.PromptApproval(ctx, req)
|
||||
if err != nil {
|
||||
return Approval{}, err
|
||||
}
|
||||
s.applyApproval(&result)
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// toolApprovalScope returns the approval scope key for a tool invocation.
|
||||
// If the tool implements ScopedTool, its ApprovalScope method determines the
|
||||
// scope (e.g. shell tools scope to "<tool>\x00<command>"). Otherwise the scope
|
||||
// is the trimmed tool name.
|
||||
func toolApprovalScope(tool Tool, toolName string, args map[string]any) string {
|
||||
if scoped, ok := tool.(ScopedTool); ok {
|
||||
return scoped.ApprovalScope(args)
|
||||
}
|
||||
return strings.TrimSpace(toolName)
|
||||
}
|
||||
@@ -1,95 +0,0 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/ollama/ollama/api"
|
||||
)
|
||||
|
||||
type mockTool struct {
|
||||
name string
|
||||
}
|
||||
|
||||
func (m mockTool) Name() string { return m.name }
|
||||
func (m mockTool) Description() string { return "" }
|
||||
func (m mockTool) Schema() api.ToolFunction {
|
||||
return api.ToolFunction{Name: m.name}
|
||||
}
|
||||
|
||||
func (m mockTool) Execute(context.Context, ToolContext, map[string]any) (ToolResult, error) {
|
||||
return ToolResult{}, nil
|
||||
}
|
||||
|
||||
func TestToolApprovalScopeUsesScopedTool(t *testing.T) {
|
||||
shellTool := mockScopedTool{
|
||||
mockTool: mockTool{name: "bash"},
|
||||
scope: func(args map[string]any) string {
|
||||
if cmd, ok := args["command"].(string); ok {
|
||||
cmd = strings.TrimSpace(cmd)
|
||||
if cmd != "" {
|
||||
return "bash\x00" + cmd
|
||||
}
|
||||
}
|
||||
return "bash"
|
||||
},
|
||||
}
|
||||
plainTool := mockTool{name: "edit"}
|
||||
|
||||
tests := []struct {
|
||||
tool Tool
|
||||
name string
|
||||
args map[string]any
|
||||
want string
|
||||
}{
|
||||
{shellTool, "bash", map[string]any{"command": " pwd "}, "bash\x00pwd"},
|
||||
{shellTool, "bash", map[string]any{"command": "Get-ChildItem"}, "bash\x00Get-ChildItem"},
|
||||
{plainTool, "edit", map[string]any{"path": "README.md"}, "edit"},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
if got := toolApprovalScope(tt.tool, tt.name, tt.args); got != tt.want {
|
||||
t.Fatalf("toolApprovalScope(%q) = %q, want %q", tt.name, got, tt.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
type mockScopedTool struct {
|
||||
mockTool
|
||||
scope func(args map[string]any) string
|
||||
}
|
||||
|
||||
func (m mockScopedTool) ApprovalScope(args map[string]any) string {
|
||||
return m.scope(args)
|
||||
}
|
||||
|
||||
func TestSessionApplyApprovalScopes(t *testing.T) {
|
||||
session := &Session{}
|
||||
result := Approval{AllowScopes: []string{"edit", "bash\x00pwd", " "}}
|
||||
|
||||
session.applyApproval(&result)
|
||||
|
||||
if !result.Allow {
|
||||
t.Fatal("scoped approval should allow the current request")
|
||||
}
|
||||
if !session.allows("edit") || !session.allows("bash\x00pwd") {
|
||||
t.Fatal("scoped approval was not saved")
|
||||
}
|
||||
if session.allows("bash") || session.allows("bash\x00ls") {
|
||||
t.Fatal("shell approval was too broad")
|
||||
}
|
||||
if session.ApprovalState.AllGranted() {
|
||||
t.Fatal("allow all = true, want false for scoped approval")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSessionApplyApprovalAllowAll(t *testing.T) {
|
||||
session := &Session{}
|
||||
result := Approval{AllowAll: true}
|
||||
|
||||
session.applyApproval(&result)
|
||||
|
||||
if !result.Allow || !session.allows("anything") {
|
||||
t.Fatalf("allow all = %v result = %#v, want allow all", session.ApprovalState.AllGranted(), result)
|
||||
}
|
||||
}
|
||||
@@ -1,667 +0,0 @@
|
||||
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.
|
||||
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
|
||||
|
||||
maxCompactionSummaryRunes = 16 * 1024
|
||||
|
||||
compactionSystemPrompt = "Summarize the archived part of an Ollama 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)
|
||||
|
||||
// ContextWindowTokens returns the effective context window size in
|
||||
// tokens, resolving runtime options against configured defaults.
|
||||
ContextWindowTokens(options map[string]any) int
|
||||
|
||||
// Threshold returns the compaction threshold as a fraction of the
|
||||
// context window (e.g. 0.8 means compact at 80% capacity).
|
||||
Threshold() float64
|
||||
|
||||
// ShouldCompact reports whether a compaction should run and returns the
|
||||
// trigger reason. An empty trigger means compaction is not needed.
|
||||
ShouldCompact(req CompactionRequest) (trigger string, should bool)
|
||||
}
|
||||
|
||||
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
|
||||
Options CompactionOptions
|
||||
}
|
||||
|
||||
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, _, 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 == "" {
|
||||
result.Reason = "summary was empty"
|
||||
return result, nil
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
return estimateCompactionRequestTokens(req) >= threshold
|
||||
}
|
||||
|
||||
func (c *SimpleCompactor) contextWindowTokens(options map[string]any) int {
|
||||
return ResolveContextWindowTokens(options, c.Options.ContextWindowTokens)
|
||||
}
|
||||
|
||||
// ContextWindowTokens resolves the effective context window from runtime
|
||||
// options or configured defaults. Satisfies the Compactor interface.
|
||||
func (c *SimpleCompactor) ContextWindowTokens(options map[string]any) int {
|
||||
if c == nil {
|
||||
return 0
|
||||
}
|
||||
return c.contextWindowTokens(options)
|
||||
}
|
||||
|
||||
func (c *SimpleCompactor) threshold() float64 {
|
||||
return ResolveCompactionThreshold(c.Options.Threshold)
|
||||
}
|
||||
|
||||
// Threshold returns the configured compaction threshold fraction. Satisfies
|
||||
// the Compactor interface.
|
||||
func (c *SimpleCompactor) Threshold() float64 {
|
||||
if c == nil {
|
||||
return 0
|
||||
}
|
||||
return c.threshold()
|
||||
}
|
||||
|
||||
// ShouldCompact reports whether compaction is due and the trigger reason.
|
||||
// Satisfies the Compactor interface.
|
||||
func (c *SimpleCompactor) ShouldCompact(req CompactionRequest) (string, bool) {
|
||||
if c == nil {
|
||||
return "", false
|
||||
}
|
||||
if req.Force {
|
||||
return "force", true
|
||||
}
|
||||
if c.shouldCompact(req) {
|
||||
contextWindow := c.contextWindowTokens(req.Options)
|
||||
threshold := int(float64(contextWindow) * c.threshold())
|
||||
if req.Latest.PromptEvalCount > 0 && req.Latest.PromptEvalCount >= threshold {
|
||||
return "prompt_eval", true
|
||||
}
|
||||
return "estimate", true
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
|
||||
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 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.
|
||||
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 {
|
||||
return Truncate(summary, TruncateConfig{
|
||||
MaxRunes: maxCompactionSummaryRunes,
|
||||
Label: "summary",
|
||||
})
|
||||
}
|
||||
|
||||
func estimateCompactionTokens(text string) int {
|
||||
text = strings.TrimSpace(text)
|
||||
if text == "" {
|
||||
return 0
|
||||
}
|
||||
return ApproximateTokens(len([]rune(text)))
|
||||
}
|
||||
|
||||
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 (s *Session) estimateRunPromptTokens(opts RunOptions, messages []api.Message) int {
|
||||
return estimateCompactionRequestTokens(CompactionRequest{
|
||||
SystemPrompt: opts.SystemPrompt,
|
||||
Messages: messages,
|
||||
Tools: s.availableTools(),
|
||||
Format: opts.Format,
|
||||
Options: opts.Options,
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Session) checkPreflightPromptBudget(opts RunOptions, messages []api.Message) error {
|
||||
contextWindow := s.contextWindowTokens(opts)
|
||||
if contextWindow <= 0 {
|
||||
return nil
|
||||
}
|
||||
estimated := s.estimateRunPromptTokens(opts, messages)
|
||||
if estimated < contextWindow {
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("prompt is too large for the current context (~%d/%d tokens). Reduce the system prompt or message history, compact the conversation, or use a model with a larger context", estimated, contextWindow)
|
||||
}
|
||||
|
||||
func (s *Session) checkPostCompactionPromptBudget(opts RunOptions, messages []api.Message) error {
|
||||
contextWindow := s.contextWindowTokens(opts)
|
||||
if contextWindow <= 0 {
|
||||
return nil
|
||||
}
|
||||
estimated := s.estimateRunPromptTokens(opts, messages)
|
||||
if estimated < contextWindow {
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("history is still too large after compaction (~%d/%d tokens). Start a fresh request, reduce the system prompt or history, or use a model with a larger context", estimated, contextWindow)
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -1,773 +0,0 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/ollama/ollama/api"
|
||||
)
|
||||
|
||||
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"}},
|
||||
}},
|
||||
}
|
||||
compactor := &SimpleCompactor{Client: client, Options: 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 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"}},
|
||||
}},
|
||||
}
|
||||
compactor := &SimpleCompactor{Client: client, Options: 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 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"}},
|
||||
}},
|
||||
}
|
||||
compactor := &SimpleCompactor{Client: client, Options: 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)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSimpleCompactorTruncatesOversizedSummary(t *testing.T) {
|
||||
longSummary := strings.Repeat("x", maxCompactionSummaryRunes+1024)
|
||||
client := &fakeClient{
|
||||
responses: [][]api.ChatResponse{{
|
||||
{Message: api.Message{Role: "assistant", Content: longSummary}},
|
||||
}},
|
||||
}
|
||||
compactor := &SimpleCompactor{Client: client, Options: 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 runeCount := len([]rune(result.Summary)); runeCount > maxCompactionSummaryRunes+200 {
|
||||
t.Fatalf("summary runes = %d, want <= %d (plus marker)", runeCount, maxCompactionSummaryRunes)
|
||||
}
|
||||
if !strings.Contains(result.Summary, "[summary truncated:") {
|
||||
t.Fatalf("summary missing truncation marker: %q", result.Summary)
|
||||
}
|
||||
if !strings.Contains(result.Messages[1].Content, "[summary truncated:") {
|
||||
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 := &SimpleCompactor{Client: client, Options: 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 := &SimpleCompactor{Client: client, Options: 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 := &SimpleCompactor{Client: client, Options: 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"}},
|
||||
}},
|
||||
}
|
||||
compactor := &SimpleCompactor{Client: client, Options: 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 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"}},
|
||||
}},
|
||||
}
|
||||
compactor := &SimpleCompactor{Client: client, Options: 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 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 := &SimpleCompactor{Client: client, Options: 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 := &SimpleCompactor{Client: client, Options: 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 := &SimpleCompactor{Client: nil, Options: 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 := &SimpleCompactor{Client: client, Options: 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"}},
|
||||
}},
|
||||
}
|
||||
compactor := &SimpleCompactor{Client: client, Options: 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")
|
||||
}
|
||||
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 := &SimpleCompactor{Client: client, Options: 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 := &SimpleCompactor{Client: client, Options: 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)
|
||||
}
|
||||
}
|
||||
177
agent/events.go
@@ -1,177 +0,0 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
|
||||
"github.com/ollama/ollama/api"
|
||||
)
|
||||
|
||||
type EventType string
|
||||
|
||||
const (
|
||||
EventMessageDelta EventType = "message_delta"
|
||||
EventThinkingDelta EventType = "thinking_delta"
|
||||
EventToolCallDetected EventType = "tool_call_detected"
|
||||
EventToolStarted EventType = "tool_started"
|
||||
EventToolFinished EventType = "tool_finished"
|
||||
EventCompactionStarted EventType = "compaction_started"
|
||||
EventCompactionProgress EventType = "compaction_progress"
|
||||
EventCompacted EventType = "compacted"
|
||||
EventCompactionSkipped EventType = "compaction_skipped"
|
||||
EventRunFinished EventType = "run_finished"
|
||||
EventError EventType = "error"
|
||||
)
|
||||
|
||||
// ToolStatus is the typed lifecycle state for a tool call, carried on
|
||||
// Event.ToolStatus for tool events.
|
||||
type ToolStatus string
|
||||
|
||||
const (
|
||||
ToolStatusRunning ToolStatus = "running"
|
||||
ToolStatusDone ToolStatus = "done"
|
||||
ToolStatusFailed ToolStatus = "failed"
|
||||
ToolStatusDenied ToolStatus = "denied"
|
||||
ToolStatusDisabled ToolStatus = "disabled"
|
||||
ToolStatusSkipped ToolStatus = "skipped"
|
||||
)
|
||||
|
||||
// RunStatus is the typed terminal outcome of a run, carried on Event.Status for
|
||||
// run_finished events.
|
||||
type RunStatus string
|
||||
|
||||
const (
|
||||
RunStatusDone RunStatus = "done"
|
||||
RunStatusDenied RunStatus = "denied"
|
||||
RunStatusCanceled RunStatus = "canceled"
|
||||
)
|
||||
|
||||
// CompactionTrigger is the typed reason a compaction ran or was attempted,
|
||||
// carried on Event.CompactionTrigger for compaction events.
|
||||
type CompactionTrigger string
|
||||
|
||||
const (
|
||||
CompactionTriggerForce CompactionTrigger = "force"
|
||||
CompactionTriggerPromptEval CompactionTrigger = "prompt_eval"
|
||||
CompactionTriggerEstimate CompactionTrigger = "estimate"
|
||||
CompactionTriggerToolOutput CompactionTrigger = "tool_output"
|
||||
CompactionTriggerError CompactionTrigger = "error"
|
||||
CompactionTriggerDue CompactionTrigger = "due"
|
||||
)
|
||||
|
||||
type Event struct {
|
||||
Type EventType `json:"type"`
|
||||
RunID string `json:"runId,omitempty"`
|
||||
ChatID string `json:"chatId,omitempty"`
|
||||
Model string `json:"model,omitempty"`
|
||||
Status RunStatus `json:"status,omitempty"`
|
||||
ToolStatus ToolStatus `json:"toolStatus,omitempty"`
|
||||
CompactionTrigger CompactionTrigger `json:"compactionTrigger,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"`
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
type EventSink interface {
|
||||
Emit(Event) error
|
||||
}
|
||||
|
||||
type EventSinkFunc func(Event) error
|
||||
|
||||
func (fn EventSinkFunc) Emit(event Event) error {
|
||||
if fn == nil {
|
||||
return nil
|
||||
}
|
||||
return fn(event)
|
||||
}
|
||||
|
||||
// eventMetadata carries the run identification fields shared by all events.
|
||||
type eventMetadata struct {
|
||||
runID string
|
||||
chatID string
|
||||
model string
|
||||
}
|
||||
|
||||
func newEventMetadata(runID string, opts RunOptions) eventMetadata {
|
||||
return eventMetadata{runID: runID, chatID: opts.ChatID, model: opts.Model}
|
||||
}
|
||||
|
||||
func newMessageDelta(m eventMetadata, content string) Event {
|
||||
return Event{Type: EventMessageDelta, RunID: m.runID, ChatID: m.chatID, Model: m.model, Content: content}
|
||||
}
|
||||
|
||||
func newThinkingDelta(m eventMetadata, thinking string) Event {
|
||||
return Event{Type: EventThinkingDelta, RunID: m.runID, ChatID: m.chatID, Model: m.model, Thinking: thinking}
|
||||
}
|
||||
|
||||
func newToolCallDetected(m eventMetadata, calls []api.ToolCall) Event {
|
||||
return Event{Type: EventToolCallDetected, RunID: m.runID, ChatID: m.chatID, Model: m.model, ToolCalls: calls}
|
||||
}
|
||||
|
||||
func newToolStarted(m eventMetadata, callID, toolName, workingDir string, args map[string]any) Event {
|
||||
return Event{Type: EventToolStarted, RunID: m.runID, ChatID: m.chatID, Model: m.model, ToolStatus: ToolStatusRunning, ToolCallID: callID, ToolName: toolName, WorkingDir: workingDir, Args: args}
|
||||
}
|
||||
|
||||
func newToolFinished(m eventMetadata, status ToolStatus, callID, toolName, workingDir string, args map[string]any, content, errMsg string) Event {
|
||||
ev := Event{Type: EventToolFinished, RunID: m.runID, ChatID: m.chatID, Model: m.model, ToolStatus: status, ToolCallID: callID, ToolName: toolName, WorkingDir: workingDir, Args: args, Content: content}
|
||||
if errMsg != "" {
|
||||
ev.Error = errMsg
|
||||
}
|
||||
return ev
|
||||
}
|
||||
|
||||
func newRunFinished(m eventMetadata, status RunStatus) Event {
|
||||
return Event{Type: EventRunFinished, RunID: m.runID, ChatID: m.chatID, Model: m.model, Status: status}
|
||||
}
|
||||
|
||||
func newErrorEvent(m eventMetadata, errMsg string) Event {
|
||||
return Event{Type: EventError, RunID: m.runID, ChatID: m.chatID, Model: m.model, Error: errMsg}
|
||||
}
|
||||
|
||||
func newCompactionProgress(m eventMetadata, tokens int) Event {
|
||||
return Event{Type: EventCompactionProgress, RunID: m.runID, ChatID: m.chatID, Model: m.model, Tokens: tokens}
|
||||
}
|
||||
|
||||
func newCompactionStarted(m eventMetadata, trigger CompactionTrigger) Event {
|
||||
return Event{Type: EventCompactionStarted, RunID: m.runID, ChatID: m.chatID, Model: m.model, CompactionTrigger: trigger}
|
||||
}
|
||||
|
||||
func newCompactionSkipped(m eventMetadata, trigger CompactionTrigger, content string) Event {
|
||||
return Event{Type: EventCompactionSkipped, RunID: m.runID, ChatID: m.chatID, Model: m.model, CompactionTrigger: trigger, Content: content}
|
||||
}
|
||||
|
||||
func newCompacted(m eventMetadata, messages []api.Message, trigger CompactionTrigger, content string) Event {
|
||||
return Event{Type: EventCompacted, RunID: m.runID, ChatID: m.chatID, Model: m.model, CompactionTrigger: trigger, Content: content, Messages: messages}
|
||||
}
|
||||
|
||||
func (s *Session) emit(event Event) error {
|
||||
if s == nil {
|
||||
return nil
|
||||
}
|
||||
var errs []error
|
||||
for _, sink := range s.EventSinks {
|
||||
if sink == nil {
|
||||
continue
|
||||
}
|
||||
if err := sink.Emit(event); err != nil {
|
||||
errs = append(errs, err)
|
||||
}
|
||||
}
|
||||
return errors.Join(errs...)
|
||||
}
|
||||
|
||||
func (s *Session) emitIgnoringCanceled(ctx context.Context, event Event) error {
|
||||
err := s.emit(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
|
||||
}
|
||||
@@ -1,104 +0,0 @@
|
||||
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
|
||||
}
|
||||
|
||||
// ScopedTool is implemented by tools that need per-invocation approval
|
||||
// scoping beyond the tool name (e.g. shell commands scoped to the exact
|
||||
// command string). Tools that don't implement this are scoped by name only.
|
||||
type ScopedTool interface {
|
||||
ApprovalScope(args map[string]any) string
|
||||
}
|
||||
|
||||
type Registry struct {
|
||||
tools map[string]Tool
|
||||
}
|
||||
|
||||
func (r *Registry) Register(tool Tool) {
|
||||
if r == nil || tool == nil {
|
||||
return
|
||||
}
|
||||
if r.tools == nil {
|
||||
r.tools = make(map[string]Tool)
|
||||
}
|
||||
r.tools[tool.Name()] = tool
|
||||
}
|
||||
|
||||
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 {
|
||||
if r == nil {
|
||||
return nil
|
||||
}
|
||||
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
|
||||
}
|
||||
1099
agent/session.go
@@ -1,57 +0,0 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
|
||||
"github.com/google/uuid"
|
||||
|
||||
"github.com/ollama/ollama/api"
|
||||
)
|
||||
|
||||
// activateSkill loads opts.SkillName from the catalog and injects a synthetic
|
||||
// assistant tool call plus tool result before the first model request, so the
|
||||
// transcript looks like a real skill tool invocation. It emits the same
|
||||
// tool_call_detected -> tool_started -> tool_finished lifecycle the model path
|
||||
// uses, and returns the messages to prepend. A blank SkillName is a no-op.
|
||||
func (s *Session) activateSkill(ctx context.Context, runID string, opts RunOptions) ([]api.Message, error) {
|
||||
name := strings.TrimSpace(opts.SkillName)
|
||||
if name == "" {
|
||||
return nil, nil
|
||||
}
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return nil, ctx.Err()
|
||||
default:
|
||||
}
|
||||
skill, err := s.Skills.Load(name)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
args := api.NewToolCallFunctionArguments()
|
||||
args.Set("name", skill.Name)
|
||||
call := api.ToolCall{
|
||||
ID: "call_skill_" + uuid.NewString(),
|
||||
Function: api.ToolCallFunction{Name: "skill", Arguments: args},
|
||||
}
|
||||
result := api.Message{
|
||||
Role: "tool",
|
||||
ToolName: "skill",
|
||||
ToolCallID: call.ID,
|
||||
Content: skill.Content(),
|
||||
}
|
||||
meta := newEventMetadata(runID, opts)
|
||||
if err := s.emit(newToolCallDetected(meta, []api.ToolCall{call})); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := s.emit(newToolStarted(meta, call.ID, "skill", s.currentWorkingDir(), args.ToMap())); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := s.emitIgnoringCanceled(ctx, newToolFinished(meta, ToolStatusDone, call.ID, "skill", s.currentWorkingDir(), args.ToMap(), result.Content, "")); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return []api.Message{
|
||||
{Role: "assistant", ToolCalls: []api.ToolCall{call}},
|
||||
result,
|
||||
}, nil
|
||||
}
|
||||
@@ -1,74 +0,0 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/ollama/ollama/api"
|
||||
)
|
||||
|
||||
type skillTestClient struct{ requests []*api.ChatRequest }
|
||||
|
||||
func (c *skillTestClient) Chat(_ context.Context, req *api.ChatRequest, fn api.ChatResponseFunc) error {
|
||||
c.requests = append(c.requests, req)
|
||||
return fn(api.ChatResponse{Message: api.Message{Role: "assistant", Content: "Done."}})
|
||||
}
|
||||
|
||||
func testSkillCatalog(t *testing.T) *SkillCatalog {
|
||||
t.Helper()
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "release-notes")
|
||||
if err := os.Mkdir(path, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(path, "SKILL.md"), []byte("---\nname: release-notes\ndescription: Draft release notes.\n---\nUse concise bullets."), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
catalog, err := DiscoverSkills(dir)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return catalog
|
||||
}
|
||||
|
||||
func TestSessionSkillActivationPreservesCallAndResultOrder(t *testing.T) {
|
||||
catalog := testSkillCatalog(t)
|
||||
client := &skillTestClient{}
|
||||
events := &recordingEventSink{}
|
||||
result, err := (&Session{Client: client, Skills: catalog, EventSinks: []EventSink{events}}).Run(context.Background(), RunOptions{
|
||||
Model: "test",
|
||||
NewMessages: []api.Message{{Role: "user", Content: "draft release notes"}},
|
||||
SkillName: "release-notes",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(result.Messages) != 4 {
|
||||
t.Fatalf("transcript = %#v", result.Messages)
|
||||
}
|
||||
call, toolTranscript := result.Messages[1], result.Messages[2]
|
||||
if call.Role != "assistant" || len(call.ToolCalls) != 1 || call.ToolCalls[0].Function.Name != "skill" || !strings.HasPrefix(call.ToolCalls[0].ID, "call_skill_") {
|
||||
t.Fatalf("call message = %#v", call)
|
||||
}
|
||||
if toolTranscript.Role != "tool" || toolTranscript.ToolName != "skill" || toolTranscript.ToolCallID != call.ToolCalls[0].ID || !strings.Contains(toolTranscript.Content, "Use concise bullets.") {
|
||||
t.Fatalf("tool result = %#v", toolTranscript)
|
||||
}
|
||||
if len(client.requests) != 1 || len(client.requests[0].Messages) != 3 || client.requests[0].Messages[2].ToolCallID != call.ToolCalls[0].ID {
|
||||
t.Fatalf("model request did not preserve transcript: %#v", client.requests)
|
||||
}
|
||||
var skillEvents []EventType
|
||||
for _, event := range events.events {
|
||||
if event.ToolName == "skill" || event.Type == EventToolCallDetected {
|
||||
skillEvents = append(skillEvents, event.Type)
|
||||
}
|
||||
}
|
||||
if len(skillEvents) < 3 {
|
||||
t.Fatalf("skill event order = %#v, want tool_call_detected,tool_started,tool_finished", skillEvents)
|
||||
}
|
||||
if got, want := strings.Join([]string{string(skillEvents[0]), string(skillEvents[1]), string(skillEvents[2])}, ","), "tool_call_detected,tool_started,tool_finished"; got != want {
|
||||
t.Fatalf("skill event order = %#v, want %s", skillEvents, want)
|
||||
}
|
||||
}
|
||||
813
agent/skills.go
@@ -1,813 +0,0 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"io/fs"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
const (
|
||||
// SkillsDirEnv overrides the user-level Ollama-owned skills directory. The
|
||||
// cross-client .agents/skills/ convention and project-level .ollama/skills/
|
||||
// are also scanned (see LoadDefaultSkills); on a name collision, Ollama-owned
|
||||
// directories take precedence over .agents/skills/, and project-level takes
|
||||
// precedence over user-level.
|
||||
SkillsDirEnv = "OLLAMA_SKILLS"
|
||||
skillFilename = "SKILL.md"
|
||||
maxSkillBytes = 1 << 20
|
||||
|
||||
bundledSkillCreatorName = "skill-creator"
|
||||
bundledSkillCreatorContent = `---
|
||||
name: skill-creator
|
||||
description: Create or improve reusable skills. Use when the user wants a reusable skill, asks how to author SKILL.md, or needs help installing a skill.
|
||||
---
|
||||
|
||||
# Create a skill
|
||||
|
||||
Create a focused, reusable instruction package. Treat a skill as guidance for the model, not as a way to gain new permissions or bypass safety controls.
|
||||
|
||||
## Choose the location
|
||||
|
||||
Create user skills beside this one. The skill directory shown in the loaded skill context is this skill's location; its parent is the user skill root. This bundled skill normally lives at ~/.ollama/skills/skill-creator, so new user skills normally go at ~/.ollama/skills/<skill-name>/SKILL.md.
|
||||
|
||||
Use a project-local skill directory only when the user asks to keep the skill with that project. Do not overwrite an existing skill without the user's approval. New and changed skills are discovered when the agent starts, so tell the user to begin a new agent session afterward.
|
||||
|
||||
## Follow the required shape
|
||||
|
||||
Use the directory name as the skill name. Use lowercase letters, numbers, and single hyphens only. Keep the name short and no longer than 64 characters.
|
||||
|
||||
Every skill needs a SKILL.md with YAML frontmatter followed by Markdown instructions:
|
||||
|
||||
~~~md
|
||||
---
|
||||
name: release-notes
|
||||
description: Draft concise release notes from completed changes. Use when the user asks for a changelog, release notes, or GitHub release copy.
|
||||
---
|
||||
|
||||
# Draft release notes
|
||||
|
||||
Write the workflow here.
|
||||
~~~
|
||||
|
||||
Require a non-empty description that says both what the skill does and when to use it. Keep the body procedural and concise. Put detailed schemas, long examples, and variant-specific guidance in references/ only when the skill needs them.
|
||||
|
||||
Use scripts/ for repeatable or fragile operations that benefit from deterministic execution. Use assets/ for files that belong in generated output. Do not add README files, changelogs, or setup notes that do not help the model perform the task.
|
||||
|
||||
## Create safely
|
||||
|
||||
1. Identify the repeated task, expected inputs, and useful output.
|
||||
2. Choose the smallest name and description that reliably trigger the skill.
|
||||
3. Create the folder and SKILL.md; add resources only when they remove real repeated work.
|
||||
4. Re-read the completed file and verify its frontmatter, directory-name match, and relative resource paths.
|
||||
5. Tell the user where it was created and that a new agent session will discover it.
|
||||
|
||||
Skills provide instructions only. They do not grant filesystem, network, shell, or approval privileges, and they do not make a tool available. Use only the tools that are actually available, follow their normal approval rules, and ask before actions that need user authorization.
|
||||
`
|
||||
)
|
||||
|
||||
var skillName = regexp.MustCompile(`^[a-z0-9]+(?:-[a-z0-9]+)*$`)
|
||||
|
||||
// SkillsDir returns the canonical runtime-owned skill directory.
|
||||
func SkillsDir() (string, error) {
|
||||
if path := strings.TrimSpace(os.Getenv(SkillsDirEnv)); path != "" {
|
||||
return filepath.Abs(path)
|
||||
}
|
||||
if xdg := strings.TrimSpace(os.Getenv("XDG_CONFIG_HOME")); xdg != "" {
|
||||
return filepath.Join(xdg, "ollama", "skills"), nil
|
||||
}
|
||||
home, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return filepath.Join(home, ".ollama", "skills"), nil
|
||||
}
|
||||
|
||||
// Skill is a validated, loadable instruction set. It never grants tool
|
||||
// permissions; it is supplied to the model as ordinary tool-result content.
|
||||
type Skill struct {
|
||||
Name string
|
||||
Description string
|
||||
Instructions string
|
||||
Path string
|
||||
}
|
||||
|
||||
func (s Skill) Content() string {
|
||||
var b strings.Builder
|
||||
fmt.Fprintf(&b, "<skill name=%q>\n%s\n", s.Name, strings.TrimSpace(s.Instructions))
|
||||
if s.Path != "" {
|
||||
dir := filepath.Dir(s.Path)
|
||||
fmt.Fprintf(&b, "Skill directory: %s\n", dir)
|
||||
b.WriteString("Relative paths in this skill are relative to the skill directory.\n")
|
||||
}
|
||||
if resources := s.resources(); len(resources) > 0 {
|
||||
b.WriteString("<skill_resources>\n")
|
||||
for _, r := range resources {
|
||||
fmt.Fprintf(&b, " <file>%s</file>\n", r)
|
||||
}
|
||||
b.WriteString("</skill_resources>\n")
|
||||
}
|
||||
b.WriteString("</skill>")
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// resources lists bundled files one level deep under scripts/, references/,
|
||||
// and assets/ without reading them, so the model can load them on demand.
|
||||
func (s Skill) resources() []string {
|
||||
if s.Path == "" {
|
||||
return nil
|
||||
}
|
||||
dir := filepath.Dir(s.Path)
|
||||
var resources []string
|
||||
for _, sub := range []string{"scripts", "references", "assets"} {
|
||||
entries, err := os.ReadDir(filepath.Join(dir, sub))
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
for _, e := range entries {
|
||||
if e.IsDir() {
|
||||
continue
|
||||
}
|
||||
resources = append(resources, sub+"/"+e.Name())
|
||||
}
|
||||
}
|
||||
sort.Strings(resources)
|
||||
return resources
|
||||
}
|
||||
|
||||
// SkillCatalog contains valid skills and diagnostics for ignored invalid
|
||||
// entries, so one malformed skill cannot hide the rest.
|
||||
type SkillCatalog struct {
|
||||
dir string
|
||||
skills map[string]Skill
|
||||
diagnostics []error
|
||||
}
|
||||
|
||||
func DiscoverSkills(dir string) (*SkillCatalog, error) {
|
||||
dir, err := filepath.Abs(strings.TrimSpace(dir))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
catalog := &SkillCatalog{dir: dir, skills: make(map[string]Skill)}
|
||||
entries, err := os.ReadDir(dir)
|
||||
if errors.Is(err, fs.ErrNotExist) {
|
||||
return catalog, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read skills directory: %w", err)
|
||||
}
|
||||
for _, entry := range entries {
|
||||
name := entry.Name()
|
||||
// Follow symlinks so users can point at shared skill repositories.
|
||||
// The link name (not the target) is the canonical skill name.
|
||||
info, err := os.Stat(filepath.Join(dir, name))
|
||||
if err != nil {
|
||||
if errors.Is(err, fs.ErrNotExist) {
|
||||
continue
|
||||
}
|
||||
catalog.diagnostics = append(catalog.diagnostics, fmt.Errorf("skill %q: %w", name, err))
|
||||
continue
|
||||
}
|
||||
if !info.IsDir() {
|
||||
continue
|
||||
}
|
||||
if !skillName.MatchString(name) {
|
||||
catalog.diagnostics = append(catalog.diagnostics, fmt.Errorf("invalid skill directory %q", name))
|
||||
continue
|
||||
}
|
||||
skill, err := parseSkill(filepath.Join(dir, name, skillFilename), name)
|
||||
if errors.Is(err, fs.ErrNotExist) {
|
||||
continue
|
||||
}
|
||||
if err != nil {
|
||||
catalog.diagnostics = append(catalog.diagnostics, err)
|
||||
continue
|
||||
}
|
||||
catalog.skills[skill.Name] = skill
|
||||
}
|
||||
return catalog, nil
|
||||
}
|
||||
|
||||
// LoadDefaultSkills discovers skills from the spec's scopes, merged with
|
||||
// deterministic precedence. Roots are scanned lowest-precedence first so later
|
||||
// roots override earlier ones on name collisions (recording a diagnostic):
|
||||
//
|
||||
// 1. ~/.agents/skills/ (user, cross-client)
|
||||
// 2. user Ollama skills dir (user, Ollama-owned; SkillsDir)
|
||||
// 3. <project>/.agents/skills/ (project, cross-client)
|
||||
// 4. <project>/.ollama/skills/ (project, Ollama-owned)
|
||||
//
|
||||
// Project-level overrides user-level, and within a scope Ollama-owned
|
||||
// directories override .agents/skills/. projectDir is the agent's working
|
||||
// directory at startup (discovery is a session-start snapshot per the spec).
|
||||
func LoadDefaultSkills(projectDir string) (*SkillCatalog, error) {
|
||||
roots, err := defaultSkillRoots(projectDir)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
catalog := &SkillCatalog{skills: make(map[string]Skill)}
|
||||
bundled, err := bundledSkillCreator()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
catalog.skills[bundled.Name] = bundled
|
||||
if err := installBundledSkillCreator(); err != nil {
|
||||
catalog.diagnostics = append(catalog.diagnostics, err)
|
||||
}
|
||||
for _, root := range roots {
|
||||
sub, err := DiscoverSkills(root.path)
|
||||
if err != nil {
|
||||
catalog.diagnostics = append(catalog.diagnostics, fmt.Errorf("discover skills in %s: %w", root.path, err))
|
||||
continue
|
||||
}
|
||||
catalog.diagnostics = append(catalog.diagnostics, sub.diagnostics...)
|
||||
for _, skill := range sub.skills {
|
||||
// Name collisions across roots are expected precedence resolution,
|
||||
// not errors: later (higher-precedence) roots legitimately override
|
||||
// earlier ones. The skill is still loaded; no diagnostic needed.
|
||||
catalog.skills[skill.Name] = skill
|
||||
}
|
||||
}
|
||||
return catalog, nil
|
||||
}
|
||||
|
||||
func bundledSkillCreator() (Skill, error) {
|
||||
skill, err := parseSkillContent("", bundledSkillCreatorName, bundledSkillCreatorContent)
|
||||
if err != nil {
|
||||
return Skill{}, fmt.Errorf("load bundled %s skill: %w", bundledSkillCreatorName, err)
|
||||
}
|
||||
return skill, nil
|
||||
}
|
||||
|
||||
func installBundledSkillCreator() error {
|
||||
dir, err := SkillsDir()
|
||||
if err != nil {
|
||||
return fmt.Errorf("resolve bundled skill directory: %w", err)
|
||||
}
|
||||
path := filepath.Join(dir, bundledSkillCreatorName, skillFilename)
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
||||
return fmt.Errorf("create bundled skill directory: %w", err)
|
||||
}
|
||||
contents, err := os.ReadFile(path)
|
||||
if err == nil && string(contents) == bundledSkillCreatorContent {
|
||||
return nil
|
||||
}
|
||||
if err != nil && !errors.Is(err, fs.ErrNotExist) {
|
||||
return fmt.Errorf("read bundled skill: %w", err)
|
||||
}
|
||||
if err := os.WriteFile(path, []byte(bundledSkillCreatorContent), 0o644); err != nil {
|
||||
return fmt.Errorf("write bundled skill: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type skillRoot struct {
|
||||
path string
|
||||
}
|
||||
|
||||
// SkillImportResult describes one import attempt. Failed skills do not prevent
|
||||
// other valid skills in the same source root from being imported.
|
||||
type SkillImportResult struct {
|
||||
Source string
|
||||
SourceDir string
|
||||
Destination string
|
||||
Imported []string
|
||||
Existing []string
|
||||
Failures []SkillImportFailure
|
||||
}
|
||||
|
||||
// SkillImportFailure identifies a source skill that was deliberately skipped.
|
||||
// The destination is never changed for a failed skill.
|
||||
type SkillImportFailure struct {
|
||||
Name string
|
||||
Err error
|
||||
}
|
||||
|
||||
// ImportSkills imports skills from a conventional coding-agent source into the
|
||||
// canonical Ollama skills directory. Supported sources are codex, claude, and
|
||||
// pi. Existing skills are left untouched: an identical directory is reported
|
||||
// as existing, and a differing one is reported as a conflict.
|
||||
func ImportSkills(source string) (SkillImportResult, error) {
|
||||
home, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
return SkillImportResult{}, fmt.Errorf("resolve home directory: %w", err)
|
||||
}
|
||||
|
||||
destination, err := SkillsDir()
|
||||
if err != nil {
|
||||
return SkillImportResult{}, fmt.Errorf("resolve Ollama skills directory: %w", err)
|
||||
}
|
||||
return importSkillsFromRoots(source, conventionalSkillImportRoots(home), destination)
|
||||
}
|
||||
|
||||
func conventionalSkillImportRoots(home string) map[string]string {
|
||||
return map[string]string{
|
||||
"codex": filepath.Join(home, ".codex", "skills"),
|
||||
"claude": filepath.Join(home, ".claude", "skills"),
|
||||
"pi": filepath.Join(home, ".pi", "agent", "skills"),
|
||||
}
|
||||
}
|
||||
|
||||
func importSkillsFromRoots(source string, roots map[string]string, destination string) (SkillImportResult, error) {
|
||||
source = strings.ToLower(strings.TrimSpace(source))
|
||||
sourceDir, ok := roots[source]
|
||||
if !ok {
|
||||
return SkillImportResult{}, fmt.Errorf("unknown skill source %q", source)
|
||||
}
|
||||
return importSkillsFromDir(source, sourceDir, destination)
|
||||
}
|
||||
|
||||
func importSkillsFromDir(source, sourceDir, destination string) (SkillImportResult, error) {
|
||||
result := SkillImportResult{Source: source, SourceDir: sourceDir, Destination: destination}
|
||||
info, err := os.Lstat(sourceDir)
|
||||
if errors.Is(err, fs.ErrNotExist) {
|
||||
return result, nil
|
||||
}
|
||||
if err != nil {
|
||||
return result, fmt.Errorf("inspect %s skills directory: %w", source, err)
|
||||
}
|
||||
if info.Mode()&os.ModeSymlink != 0 {
|
||||
return result, fmt.Errorf("inspect %s skills directory: symlinks are not supported", source)
|
||||
}
|
||||
if !info.IsDir() {
|
||||
return result, fmt.Errorf("inspect %s skills directory: not a directory", source)
|
||||
}
|
||||
|
||||
entries, err := os.ReadDir(sourceDir)
|
||||
if err != nil {
|
||||
return result, fmt.Errorf("read %s skills directory: %w", source, err)
|
||||
}
|
||||
for _, entry := range entries {
|
||||
name := entry.Name()
|
||||
path := filepath.Join(sourceDir, name)
|
||||
if entry.Type()&os.ModeSymlink != 0 {
|
||||
result.Failures = append(result.Failures, SkillImportFailure{Name: name, Err: errors.New("symlinked skill directories are not supported")})
|
||||
continue
|
||||
}
|
||||
info, err := entry.Info()
|
||||
if err != nil {
|
||||
result.Failures = append(result.Failures, SkillImportFailure{Name: name, Err: fmt.Errorf("inspect source: %w", err)})
|
||||
continue
|
||||
}
|
||||
if !info.IsDir() {
|
||||
continue
|
||||
}
|
||||
if !skillName.MatchString(name) {
|
||||
result.Failures = append(result.Failures, SkillImportFailure{Name: name, Err: errors.New("invalid skill directory name")})
|
||||
continue
|
||||
}
|
||||
if err := validateImportSkill(path, name); err != nil {
|
||||
result.Failures = append(result.Failures, SkillImportFailure{Name: name, Err: err})
|
||||
continue
|
||||
}
|
||||
|
||||
state, err := importSkillDirectory(path, filepath.Join(destination, name))
|
||||
if err != nil {
|
||||
result.Failures = append(result.Failures, SkillImportFailure{Name: name, Err: err})
|
||||
continue
|
||||
}
|
||||
if state == skillImportExisting {
|
||||
result.Existing = append(result.Existing, name)
|
||||
} else {
|
||||
result.Imported = append(result.Imported, name)
|
||||
}
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func validateImportSkill(dir, name string) error {
|
||||
manifest := filepath.Join(dir, skillFilename)
|
||||
info, err := os.Lstat(manifest)
|
||||
if err != nil {
|
||||
return fmt.Errorf("inspect %s: %w", skillFilename, err)
|
||||
}
|
||||
if info.Mode()&os.ModeSymlink != 0 || !info.Mode().IsRegular() {
|
||||
return fmt.Errorf("%s must be a regular, non-symlinked file", skillFilename)
|
||||
}
|
||||
if _, err := parseSkill(manifest, name); err != nil {
|
||||
return err
|
||||
}
|
||||
return walkImportTree(dir, func(path string, entry fs.DirEntry, info fs.FileInfo) error {
|
||||
if info.IsDir() || path == dir {
|
||||
return nil
|
||||
}
|
||||
if !info.Mode().IsRegular() {
|
||||
return fmt.Errorf("only regular files may be imported: %s", path)
|
||||
}
|
||||
file, err := os.Open(path)
|
||||
if err != nil {
|
||||
return fmt.Errorf("read %s: %w", path, err)
|
||||
}
|
||||
return file.Close()
|
||||
})
|
||||
}
|
||||
|
||||
func walkImportTree(root string, visit func(string, fs.DirEntry, fs.FileInfo) error) error {
|
||||
return filepath.WalkDir(root, func(path string, entry fs.DirEntry, err error) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
rel, err := filepath.Rel(root, path)
|
||||
if err != nil || rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) {
|
||||
return fmt.Errorf("unsafe skill path %q", path)
|
||||
}
|
||||
if entry.Type()&os.ModeSymlink != 0 {
|
||||
return fmt.Errorf("symlinks may not be imported: %s", path)
|
||||
}
|
||||
info, err := entry.Info()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return visit(path, entry, info)
|
||||
})
|
||||
}
|
||||
|
||||
type skillImportState int
|
||||
|
||||
const (
|
||||
skillImportCopied skillImportState = iota
|
||||
skillImportExisting
|
||||
)
|
||||
|
||||
func importSkillDirectory(source, destination string) (skillImportState, error) {
|
||||
if info, err := os.Lstat(destination); err == nil {
|
||||
if info.Mode()&os.ModeSymlink != 0 || !info.IsDir() {
|
||||
return 0, errors.New("destination exists but is not a regular directory")
|
||||
}
|
||||
same, err := sameImportTree(source, destination)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("inspect existing destination: %w", err)
|
||||
}
|
||||
if same {
|
||||
return skillImportExisting, nil
|
||||
}
|
||||
return 0, errors.New("destination skill already exists with different contents")
|
||||
} else if !errors.Is(err, fs.ErrNotExist) {
|
||||
return 0, fmt.Errorf("inspect destination: %w", err)
|
||||
}
|
||||
|
||||
if err := ensureImportDestination(filepath.Dir(destination)); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
stage, err := os.MkdirTemp(filepath.Dir(destination), "."+filepath.Base(destination)+".import-")
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("create import staging directory: %w", err)
|
||||
}
|
||||
defer os.RemoveAll(stage)
|
||||
if err := copyImportTree(source, stage); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if _, err := os.Lstat(destination); err == nil {
|
||||
return 0, errors.New("destination skill was created during import")
|
||||
} else if !errors.Is(err, fs.ErrNotExist) {
|
||||
return 0, fmt.Errorf("inspect destination before install: %w", err)
|
||||
}
|
||||
if err := os.Rename(stage, destination); err != nil {
|
||||
return 0, fmt.Errorf("install imported skill: %w", err)
|
||||
}
|
||||
return skillImportCopied, nil
|
||||
}
|
||||
|
||||
func ensureImportDestination(dir string) error {
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
return fmt.Errorf("create Ollama skills directory: %w", err)
|
||||
}
|
||||
info, err := os.Lstat(dir)
|
||||
if err != nil {
|
||||
return fmt.Errorf("inspect Ollama skills directory: %w", err)
|
||||
}
|
||||
if info.Mode()&os.ModeSymlink != 0 || !info.IsDir() {
|
||||
return errors.New("Ollama skills directory must be a regular, non-symlinked directory")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func copyImportTree(source, destination string) error {
|
||||
return walkImportTree(source, func(path string, entry fs.DirEntry, info fs.FileInfo) error {
|
||||
rel, err := filepath.Rel(source, path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
target := destination
|
||||
if rel != "." {
|
||||
target = filepath.Join(destination, rel)
|
||||
}
|
||||
if info.IsDir() {
|
||||
if rel == "." {
|
||||
return nil
|
||||
}
|
||||
return os.Mkdir(target, info.Mode().Perm())
|
||||
}
|
||||
if !info.Mode().IsRegular() {
|
||||
return fmt.Errorf("only regular files may be imported: %s", path)
|
||||
}
|
||||
return copyImportFile(path, target, info.Mode().Perm())
|
||||
})
|
||||
}
|
||||
|
||||
func copyImportFile(source, destination string, mode fs.FileMode) error {
|
||||
in, err := os.Open(source)
|
||||
if err != nil {
|
||||
return fmt.Errorf("read %s: %w", source, err)
|
||||
}
|
||||
defer in.Close()
|
||||
out, err := os.OpenFile(destination, os.O_WRONLY|os.O_CREATE|os.O_EXCL, mode)
|
||||
if err != nil {
|
||||
return fmt.Errorf("create %s: %w", destination, err)
|
||||
}
|
||||
_, copyErr := io.Copy(out, in)
|
||||
closeErr := out.Close()
|
||||
if copyErr != nil {
|
||||
return fmt.Errorf("copy %s: %w", source, copyErr)
|
||||
}
|
||||
if closeErr != nil {
|
||||
return fmt.Errorf("write %s: %w", destination, closeErr)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func sameImportTree(source, destination string) (bool, error) {
|
||||
seen := make(map[string]struct{})
|
||||
same := true
|
||||
err := walkImportTree(source, func(path string, entry fs.DirEntry, info fs.FileInfo) error {
|
||||
rel, err := filepath.Rel(source, path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
seen[rel] = struct{}{}
|
||||
other := destination
|
||||
if rel != "." {
|
||||
other = filepath.Join(destination, rel)
|
||||
}
|
||||
otherInfo, err := os.Lstat(other)
|
||||
if errors.Is(err, fs.ErrNotExist) {
|
||||
same = false
|
||||
return nil
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if otherInfo.Mode()&os.ModeSymlink != 0 || otherInfo.IsDir() != info.IsDir() || (!info.IsDir() && !otherInfo.Mode().IsRegular()) {
|
||||
same = false
|
||||
return nil
|
||||
}
|
||||
if info.Mode().IsRegular() {
|
||||
equal, err := sameImportFile(path, other)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !equal {
|
||||
same = false
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil || !same {
|
||||
return same, err
|
||||
}
|
||||
err = walkImportTree(destination, func(path string, entry fs.DirEntry, info fs.FileInfo) error {
|
||||
rel, err := filepath.Rel(destination, path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if _, ok := seen[rel]; !ok {
|
||||
same = false
|
||||
}
|
||||
return nil
|
||||
})
|
||||
return same, err
|
||||
}
|
||||
|
||||
func sameImportFile(first, second string) (bool, error) {
|
||||
a, err := os.Open(first)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
defer a.Close()
|
||||
b, err := os.Open(second)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
defer b.Close()
|
||||
|
||||
left := make([]byte, 32*1024)
|
||||
right := make([]byte, len(left))
|
||||
for {
|
||||
n, errA := a.Read(left)
|
||||
m, errB := b.Read(right)
|
||||
if n != m || !bytes.Equal(left[:n], right[:m]) {
|
||||
return false, nil
|
||||
}
|
||||
if errA == io.EOF && errB == io.EOF {
|
||||
return true, nil
|
||||
}
|
||||
if errA != nil && errA != io.EOF {
|
||||
return false, errA
|
||||
}
|
||||
if errB != nil && errB != io.EOF {
|
||||
return false, errB
|
||||
}
|
||||
if errA == io.EOF || errB == io.EOF {
|
||||
return false, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// defaultSkillRoots returns skill directories ordered lowest- to
|
||||
// highest-precedence. Non-existent directories are scanned harmlessly
|
||||
// (DiscoverSkills skips them).
|
||||
func defaultSkillRoots(projectDir string) ([]skillRoot, error) {
|
||||
var roots []skillRoot
|
||||
|
||||
if home, err := os.UserHomeDir(); err == nil && home != "" {
|
||||
roots = append(roots, skillRoot{path: filepath.Join(home, ".agents", "skills")})
|
||||
}
|
||||
|
||||
userOllama, err := SkillsDir()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
roots = append(roots, skillRoot{path: userOllama})
|
||||
|
||||
projectDir = strings.TrimSpace(projectDir)
|
||||
if projectDir != "" {
|
||||
if abs, err := filepath.Abs(projectDir); err == nil {
|
||||
roots = append(roots,
|
||||
skillRoot{path: filepath.Join(abs, ".agents", "skills")},
|
||||
skillRoot{path: filepath.Join(abs, ".ollama", "skills")},
|
||||
)
|
||||
}
|
||||
}
|
||||
return roots, nil
|
||||
}
|
||||
|
||||
func (c *SkillCatalog) Dir() string {
|
||||
if c == nil {
|
||||
return ""
|
||||
}
|
||||
return c.dir
|
||||
}
|
||||
|
||||
func (c *SkillCatalog) List() []Skill {
|
||||
if c == nil {
|
||||
return nil
|
||||
}
|
||||
list := make([]Skill, 0, len(c.skills))
|
||||
for _, skill := range c.skills {
|
||||
list = append(list, skill)
|
||||
}
|
||||
sort.Slice(list, func(i, j int) bool { return list[i].Name < list[j].Name })
|
||||
return list
|
||||
}
|
||||
|
||||
func (c *SkillCatalog) Diagnostics() []error {
|
||||
if c == nil {
|
||||
return nil
|
||||
}
|
||||
return append([]error(nil), c.diagnostics...)
|
||||
}
|
||||
|
||||
// ExcludeNames removes skills whose names are reserved by a caller. It returns
|
||||
// the excluded names in sorted order.
|
||||
func (c *SkillCatalog) ExcludeNames(names []string) []string {
|
||||
if c == nil {
|
||||
return nil
|
||||
}
|
||||
reserved := make(map[string]struct{}, len(names))
|
||||
for _, name := range names {
|
||||
name = strings.TrimPrefix(strings.ToLower(strings.TrimSpace(name)), "/")
|
||||
if name != "" {
|
||||
reserved[name] = struct{}{}
|
||||
}
|
||||
}
|
||||
var excluded []string
|
||||
for name := range c.skills {
|
||||
if _, ok := reserved[name]; !ok {
|
||||
continue
|
||||
}
|
||||
delete(c.skills, name)
|
||||
excluded = append(excluded, name)
|
||||
}
|
||||
sort.Strings(excluded)
|
||||
return excluded
|
||||
}
|
||||
|
||||
func (c *SkillCatalog) Load(name string) (Skill, error) {
|
||||
name = strings.TrimSpace(name)
|
||||
if !skillName.MatchString(name) {
|
||||
return Skill{}, fmt.Errorf("invalid skill name %q", name)
|
||||
}
|
||||
if c == nil {
|
||||
return Skill{}, errors.New("skills are unavailable")
|
||||
}
|
||||
skill, ok := c.skills[name]
|
||||
if !ok {
|
||||
return Skill{}, fmt.Errorf("skill %q not found in %s", name, c.dir)
|
||||
}
|
||||
return skill, nil
|
||||
}
|
||||
|
||||
// SystemContext advertises the catalog without expanding full instructions in
|
||||
// every request. The skill call is the explicit loading boundary.
|
||||
func (c *SkillCatalog) SystemContext() string {
|
||||
list := c.List()
|
||||
if len(list) == 0 {
|
||||
return ""
|
||||
}
|
||||
lines := []string{"<available_skills>"}
|
||||
for _, skill := range list {
|
||||
description := skill.Description
|
||||
if description == "" {
|
||||
description = "No description provided."
|
||||
}
|
||||
lines = append(lines, fmt.Sprintf("- %s: %s", skill.Name, description))
|
||||
}
|
||||
lines = append(lines, "</available_skills>", "Load a matching skill with the skill tool before following its instructions. Skills only provide instructions; use ordinary tools for filesystem or network access, with their normal approval rules.")
|
||||
return strings.Join(lines, "\n")
|
||||
}
|
||||
|
||||
func parseSkill(path, directoryName string) (Skill, error) {
|
||||
// Stat (not Lstat) so a symlinked SKILL.md resolves to its target file.
|
||||
info, err := os.Stat(path)
|
||||
if err != nil {
|
||||
return Skill{}, err
|
||||
}
|
||||
if !info.Mode().IsRegular() {
|
||||
return Skill{}, fmt.Errorf("skill %q: %s is not a regular file", directoryName, skillFilename)
|
||||
}
|
||||
if info.Size() > maxSkillBytes {
|
||||
return Skill{}, fmt.Errorf("skill %q: %s exceeds %d bytes", directoryName, skillFilename, maxSkillBytes)
|
||||
}
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return Skill{}, fmt.Errorf("read skill %q: %w", directoryName, err)
|
||||
}
|
||||
return parseSkillContent(path, directoryName, string(data))
|
||||
}
|
||||
|
||||
func parseSkillContent(path, directoryName, input string) (Skill, error) {
|
||||
instructions := strings.TrimSpace(input)
|
||||
if instructions == "" {
|
||||
return Skill{}, fmt.Errorf("skill %q: %s is empty", directoryName, skillFilename)
|
||||
}
|
||||
if !strings.HasPrefix(instructions, "---\n") && !strings.HasPrefix(instructions, "---\r\n") {
|
||||
return Skill{}, fmt.Errorf("skill %q: missing YAML front matter", directoryName)
|
||||
}
|
||||
metadata, body, err := skillFrontMatter(instructions)
|
||||
if err != nil {
|
||||
return Skill{}, fmt.Errorf("skill %q: %w", directoryName, err)
|
||||
}
|
||||
if metadata.Name == "" {
|
||||
return Skill{}, fmt.Errorf("skill %q: front matter requires name", directoryName)
|
||||
}
|
||||
if metadata.Description == "" {
|
||||
return Skill{}, fmt.Errorf("skill %q: front matter requires description", directoryName)
|
||||
}
|
||||
if !skillName.MatchString(metadata.Name) {
|
||||
return Skill{}, fmt.Errorf("skill %q: invalid front matter name %q", directoryName, metadata.Name)
|
||||
}
|
||||
if metadata.Name != directoryName {
|
||||
return Skill{}, fmt.Errorf("skill %q: front matter name %q must match directory name", directoryName, metadata.Name)
|
||||
}
|
||||
skill := Skill{Name: metadata.Name, Description: metadata.Description, Path: path}
|
||||
instructions = body
|
||||
if strings.TrimSpace(instructions) == "" {
|
||||
return Skill{}, fmt.Errorf("skill %q: instructions are empty", directoryName)
|
||||
}
|
||||
skill.Instructions = strings.TrimSpace(instructions)
|
||||
return skill, nil
|
||||
}
|
||||
|
||||
type skillFrontMatterMetadata struct {
|
||||
Name string `yaml:"name"`
|
||||
Description string `yaml:"description"`
|
||||
Metadata map[string]any `yaml:"metadata"`
|
||||
}
|
||||
|
||||
func skillFrontMatter(input string) (skillFrontMatterMetadata, string, error) {
|
||||
input = strings.ReplaceAll(input, "\r\n", "\n")
|
||||
lines := strings.Split(input, "\n")
|
||||
if len(lines) < 3 || lines[0] != "---" {
|
||||
return skillFrontMatterMetadata{}, "", errors.New("invalid front matter")
|
||||
}
|
||||
for i := 1; i < len(lines); i++ {
|
||||
if lines[i] == "---" {
|
||||
var metadata skillFrontMatterMetadata
|
||||
if err := yaml.Unmarshal([]byte(strings.Join(lines[1:i], "\n")), &metadata); err != nil {
|
||||
return skillFrontMatterMetadata{}, "", fmt.Errorf("parse YAML front matter: %w", err)
|
||||
}
|
||||
metadata.Name = strings.TrimSpace(metadata.Name)
|
||||
metadata.Description = strings.TrimSpace(metadata.Description)
|
||||
return metadata, strings.Join(lines[i+1:], "\n"), nil
|
||||
}
|
||||
}
|
||||
return skillFrontMatterMetadata{}, "", errors.New("front matter is not closed")
|
||||
}
|
||||
@@ -1,516 +0,0 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func writeCatalogSkill(t *testing.T, dir, name, content string) {
|
||||
t.Helper()
|
||||
path := filepath.Join(dir, name)
|
||||
if err := os.MkdirAll(path, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.HasPrefix(content, "---") {
|
||||
content = "---\nname: " + name + "\ndescription: Test skill.\n---\n" + content
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(path, skillFilename), []byte(content), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func writeImportFixtureSkill(t *testing.T, dir string) {
|
||||
t.Helper()
|
||||
contents, err := os.ReadFile(filepath.Join("testdata", "import", "release-notes", skillFilename))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
path := filepath.Join(dir, "release-notes", skillFilename)
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(path, contents, 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDiscoverAndLoadSkills(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
writeCatalogSkill(t, dir, "release-notes", "---\nname: release-notes\ndescription: Draft concise release notes.\nmetadata:\n author: Ollama\n labels:\n - release\n - docs\n---\n# Release notes\n\nUse short bullets.")
|
||||
catalog, err := DiscoverSkills(dir)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
list := catalog.List()
|
||||
if len(list) != 1 || list[0].Name != "release-notes" || list[0].Description != "Draft concise release notes." {
|
||||
t.Fatalf("skills = %#v", list)
|
||||
}
|
||||
skill, err := catalog.Load("release-notes")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.Contains(skill.Content(), `<skill name="release-notes">`) || !strings.Contains(skill.Content(), "Use short bullets.") {
|
||||
t.Fatalf("skill content = %q", skill.Content())
|
||||
}
|
||||
if context := catalog.SystemContext(); !strings.Contains(context, "release-notes: Draft concise release notes.") || !strings.Contains(context, "normal approval rules") {
|
||||
t.Fatalf("system context = %q", context)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDiscoverSkillsSkipsMalformedEntries(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
writeCatalogSkill(t, dir, "valid", "do the useful thing")
|
||||
writeCatalogSkill(t, dir, "mismatched", "---\nname: whatever\ndescription: wrong name\n---\nbody")
|
||||
// Genuinely malformed front matter (a line without a key:value pair) is still rejected.
|
||||
writeCatalogSkill(t, dir, "broken", "---\nname: broken\ndescription\n---\nnope")
|
||||
writeCatalogSkill(t, dir, "missing-name", "---\ndescription: missing name\n---\nbody")
|
||||
writeCatalogSkill(t, dir, "missing-description", "---\nname: missing-description\n---\nbody")
|
||||
writeCatalogSkill(t, dir, "bad-name", "---\nname: bad_name\ndescription: invalid name\n---\nbody")
|
||||
writeCatalogSkill(t, dir, "under_score", "---\nname: under_score\ndescription: invalid directory\n---\nbody")
|
||||
if err := os.MkdirAll(filepath.Join(dir, "no-front-matter"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(dir, "no-front-matter", skillFilename), []byte("body"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
catalog, err := DiscoverSkills(dir)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got, want := len(catalog.List()), 1; got != want {
|
||||
t.Fatalf("valid skills = %d, want %d", got, want)
|
||||
}
|
||||
if got, want := len(catalog.Diagnostics()), 7; got != want {
|
||||
t.Fatalf("diagnostics = %d, want %d: %#v", got, want, catalog.Diagnostics())
|
||||
}
|
||||
if _, err := catalog.Load("broken"); err == nil || !strings.Contains(err.Error(), "not found") {
|
||||
t.Fatalf("load broken error = %v", err)
|
||||
}
|
||||
if _, err := catalog.Load("../valid"); err == nil || !strings.Contains(err.Error(), "invalid skill name") {
|
||||
t.Fatalf("unsafe name error = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDiscoverSkillsFollowsSymlinks(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
target := t.TempDir()
|
||||
writeCatalogSkill(t, target, "shared", "---\nname: shared\ndescription: From a linked repo.\n---\nshared instructions")
|
||||
if err := os.Symlink(filepath.Join(target, "shared"), filepath.Join(dir, "shared")); err != nil {
|
||||
t.Skipf("symlink not supported: %v", err)
|
||||
}
|
||||
catalog, err := DiscoverSkills(dir)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
list := catalog.List()
|
||||
if len(list) != 1 || list[0].Name != "shared" || list[0].Description != "From a linked repo." {
|
||||
t.Fatalf("symlinked skills = %#v", list)
|
||||
}
|
||||
if !strings.Contains(list[0].Content(), "shared instructions") {
|
||||
t.Fatalf("symlinked skill content = %q", list[0].Content())
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadDefaultSkillsContinuesAfterBadRoot(t *testing.T) {
|
||||
home := t.TempDir()
|
||||
t.Setenv("HOME", home)
|
||||
t.Setenv("USERPROFILE", home)
|
||||
project := t.TempDir()
|
||||
writeCatalogSkill(t, filepath.Join(project, ".ollama", "skills"), "release-notes", "project instructions")
|
||||
|
||||
badRoot := filepath.Join(t.TempDir(), "not-a-directory")
|
||||
if err := os.WriteFile(badRoot, []byte("not a directory"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Setenv(SkillsDirEnv, badRoot)
|
||||
|
||||
catalog, err := LoadDefaultSkills(project)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := catalog.Load("release-notes"); err != nil {
|
||||
t.Fatalf("valid skill was hidden by bad root: %v", err)
|
||||
}
|
||||
if _, err := catalog.Load(bundledSkillCreatorName); err != nil {
|
||||
t.Fatalf("bundled skill was hidden by bad root: %v", err)
|
||||
}
|
||||
var foundDiagnostic bool
|
||||
for _, diagnostic := range catalog.Diagnostics() {
|
||||
if strings.Contains(diagnostic.Error(), badRoot) {
|
||||
foundDiagnostic = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !foundDiagnostic {
|
||||
t.Fatalf("diagnostics = %#v, want bad root %q", catalog.Diagnostics(), badRoot)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadDefaultSkillsInstallsBundledSkillCreator(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
t.Setenv(SkillsDirEnv, dir)
|
||||
|
||||
catalog, err := LoadDefaultSkills("")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
skill, err := catalog.Load(bundledSkillCreatorName)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
path := filepath.Join(dir, bundledSkillCreatorName, skillFilename)
|
||||
contents, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if string(contents) != bundledSkillCreatorContent {
|
||||
t.Fatalf("installed skill = %q, want bundled contents", contents)
|
||||
}
|
||||
if skill.Path != path {
|
||||
t.Fatalf("skill path = %q, want %q", skill.Path, path)
|
||||
}
|
||||
if !strings.Contains(skill.Content(), "Skill directory: "+filepath.Dir(path)) {
|
||||
t.Fatalf("skill content does not identify its directory: %q", skill.Content())
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadDefaultSkillsUpdatesExistingSkillCreator(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
t.Setenv(SkillsDirEnv, dir)
|
||||
writeCatalogSkill(t, dir, bundledSkillCreatorName, "custom instructions")
|
||||
|
||||
if _, err := LoadDefaultSkills(""); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
contents, err := os.ReadFile(filepath.Join(dir, bundledSkillCreatorName, skillFilename))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if string(contents) != bundledSkillCreatorContent {
|
||||
t.Fatalf("installed skill = %q, want bundled contents", contents)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSkillsDirUsesOverrideAndXDG(t *testing.T) {
|
||||
base := t.TempDir()
|
||||
|
||||
override := filepath.Join(base, "skills-override")
|
||||
t.Setenv(SkillsDirEnv, override)
|
||||
got, err := SkillsDir()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
want, err := filepath.Abs(override)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got != want {
|
||||
t.Fatalf("SkillsDir override = %q, want %q", got, want)
|
||||
}
|
||||
|
||||
t.Setenv(SkillsDirEnv, "")
|
||||
xdg := filepath.Join(base, "xdg")
|
||||
t.Setenv("XDG_CONFIG_HOME", xdg)
|
||||
if got, err := SkillsDir(); err != nil || got != filepath.Join(xdg, "ollama", "skills") {
|
||||
t.Fatalf("SkillsDir xdg = %q, want %q, %v", got, filepath.Join(xdg, "ollama", "skills"), err)
|
||||
}
|
||||
|
||||
t.Setenv("XDG_CONFIG_HOME", "")
|
||||
home := filepath.Join(base, "home")
|
||||
t.Setenv("HOME", home)
|
||||
t.Setenv("USERPROFILE", home)
|
||||
if got, err := SkillsDir(); err != nil || got != filepath.Join(home, ".ollama", "skills") {
|
||||
t.Fatalf("SkillsDir default = %q, want %q, %v", got, filepath.Join(home, ".ollama", "skills"), err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadDefaultSkillsPrecedenceAndCollisions(t *testing.T) {
|
||||
home := t.TempDir()
|
||||
t.Setenv("HOME", home)
|
||||
t.Setenv("USERPROFILE", home) // Windows: os.UserHomeDir uses %USERPROFILE%
|
||||
|
||||
userOllama := t.TempDir()
|
||||
t.Setenv(SkillsDirEnv, userOllama)
|
||||
|
||||
userAgents := filepath.Join(home, ".agents", "skills")
|
||||
project := t.TempDir()
|
||||
projectAgents := filepath.Join(project, ".agents", "skills")
|
||||
projectOllama := filepath.Join(project, ".ollama", "skills")
|
||||
|
||||
// release-notes exists in all four roots; project ollama must win.
|
||||
writeCatalogSkill(t, userAgents, "release-notes", "from user agents")
|
||||
writeCatalogSkill(t, userOllama, "release-notes", "from user ollama")
|
||||
writeCatalogSkill(t, projectOllama, "release-notes", "from project ollama")
|
||||
// code-review exists in both project roots; project ollama beats project agents.
|
||||
writeCatalogSkill(t, projectAgents, "code-review", "from project agents")
|
||||
writeCatalogSkill(t, projectOllama, "code-review", "from project ollama")
|
||||
// unique appears only in user ollama (via env override).
|
||||
writeCatalogSkill(t, userOllama, "unique", "only here")
|
||||
|
||||
catalog, err := LoadDefaultSkills(project)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
rn, err := catalog.Load("release-notes")
|
||||
if err != nil || !strings.Contains(rn.Instructions, "from project ollama") || !strings.Contains(rn.Path, ".ollama") {
|
||||
t.Fatalf("release-notes = %#v, want project ollama to win", rn)
|
||||
}
|
||||
cr, err := catalog.Load("code-review")
|
||||
if err != nil || !strings.Contains(cr.Instructions, "from project ollama") {
|
||||
t.Fatalf("code-review = %#v, want project ollama to win over project agents", cr)
|
||||
}
|
||||
if _, err := catalog.Load("unique"); err != nil {
|
||||
t.Fatalf("unique should load from user ollama: %v", err)
|
||||
}
|
||||
// Collisions are resolved silently by precedence — no diagnostics.
|
||||
for _, d := range catalog.Diagnostics() {
|
||||
if strings.Contains(d.Error(), "shadows") {
|
||||
t.Fatalf("unexpected shadow diagnostic: %v", d)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSkillCatalogExcludeNames(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
for _, name := range []string{"release-notes", "system", "exit"} {
|
||||
writeCatalogSkill(t, dir, name, "instructions")
|
||||
}
|
||||
catalog, err := DiscoverSkills(dir)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if got, want := strings.Join(catalog.ExcludeNames([]string{"/system", "EXIT"}), ","), "exit,system"; got != want {
|
||||
t.Fatalf("excluded skills = %q, want %q", got, want)
|
||||
}
|
||||
if _, err := catalog.Load("system"); err == nil {
|
||||
t.Fatal("excluded system skill should not load")
|
||||
}
|
||||
if _, err := catalog.Load("exit"); err == nil {
|
||||
t.Fatal("excluded exit skill should not load")
|
||||
}
|
||||
if _, err := catalog.Load("release-notes"); err != nil {
|
||||
t.Fatalf("non-conflicting skill should remain available: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSkillContentListsDirectoryAndResources(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
skillDir := filepath.Join(root, "pdf-processing")
|
||||
if err := os.MkdirAll(filepath.Join(skillDir, "scripts"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Join(skillDir, "references"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(skillDir, "SKILL.md"), []byte("---\nname: pdf-processing\ndescription: Handle PDFs.\n---\nHandle PDFs."), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(skillDir, "scripts", "extract.py"), []byte("#!/usr/bin/env python3"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(skillDir, "references", "ref.md"), []byte("ref"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
catalog, err := DiscoverSkills(root)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
skill, err := catalog.Load("pdf-processing")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
content := skill.Content()
|
||||
if !strings.Contains(content, "Skill directory:") || !strings.Contains(content, skillDir) {
|
||||
t.Fatalf("content missing skill directory: %q", content)
|
||||
}
|
||||
if !strings.Contains(content, "<file>scripts/extract.py</file>") || !strings.Contains(content, "<file>references/ref.md</file>") {
|
||||
t.Fatalf("content missing resource listing: %q", content)
|
||||
}
|
||||
}
|
||||
|
||||
func TestImportSkillsCopiesFixtureAndIsIdempotent(t *testing.T) {
|
||||
source := t.TempDir()
|
||||
destination := t.TempDir()
|
||||
writeImportFixtureSkill(t, source)
|
||||
writeCatalogSkill(t, source, "broken", "---\nname: another-skill\ndescription: Deliberately invalid.\n---\nIgnore this.")
|
||||
if err := os.MkdirAll(filepath.Join(source, "release-notes", "references"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(source, "release-notes", "references", "style.txt"), []byte("Keep it short.\n"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Join(source, "release-notes", "scripts"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(source, "release-notes", "scripts", "prepare.sh"), []byte("#!/bin/sh\n"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(source, "ignored.md"), []byte("Ignored root file.\n"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
result, err := importSkillsFromDir("codex", source, destination)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got, want := strings.Join(result.Imported, ","), "release-notes"; got != want {
|
||||
t.Fatalf("imported = %q, want %q", got, want)
|
||||
}
|
||||
catalog, err := DiscoverSkills(destination)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
skill, err := catalog.Load("release-notes")
|
||||
if err != nil || skill.Description != "Draft concise release notes." {
|
||||
t.Fatalf("imported skill = %#v, %v", skill, err)
|
||||
}
|
||||
if got := len(result.Failures); got != 1 || result.Failures[0].Name != "broken" {
|
||||
t.Fatalf("failures = %#v, want broken fixture failure", result.Failures)
|
||||
}
|
||||
for _, file := range []string{skillFilename, filepath.Join("references", "style.txt"), filepath.Join("scripts", "prepare.sh")} {
|
||||
if _, err := os.Stat(filepath.Join(destination, "release-notes", file)); err != nil {
|
||||
t.Fatalf("imported fixture file %q: %v", file, err)
|
||||
}
|
||||
}
|
||||
|
||||
result, err = importSkillsFromDir("codex", source, destination)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got, want := strings.Join(result.Existing, ","), "release-notes"; got != want {
|
||||
t.Fatalf("existing = %q, want %q", got, want)
|
||||
}
|
||||
if len(result.Imported) != 0 {
|
||||
t.Fatalf("repeated import copied skills: %#v", result.Imported)
|
||||
}
|
||||
}
|
||||
|
||||
func TestImportSkillsLeavesConflictsAndUnsafeSourcesUntouched(t *testing.T) {
|
||||
source := t.TempDir()
|
||||
destination := t.TempDir()
|
||||
writeCatalogSkill(t, source, "release-notes", "source instructions")
|
||||
writeCatalogSkill(t, destination, "release-notes", "existing instructions")
|
||||
writeCatalogSkill(t, source, "nested-link", "safe manifest")
|
||||
if err := os.Symlink(filepath.Join(source, "release-notes", skillFilename), filepath.Join(source, "nested-link", "reference")); err != nil {
|
||||
t.Skipf("symlink not supported: %v", err)
|
||||
}
|
||||
if err := os.Symlink(filepath.Join(source, "release-notes"), filepath.Join(source, "linked-skill")); err != nil {
|
||||
t.Skipf("symlink not supported: %v", err)
|
||||
}
|
||||
|
||||
result, err := importSkillsFromDir("codex", source, destination)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(result.Imported) != 0 || len(result.Existing) != 0 {
|
||||
t.Fatalf("unexpected successful import: %#v", result)
|
||||
}
|
||||
if got, err := os.ReadFile(filepath.Join(destination, "release-notes", skillFilename)); err != nil || !strings.Contains(string(got), "existing instructions") {
|
||||
t.Fatalf("conflicting destination changed: %q, %v", got, err)
|
||||
}
|
||||
failed := make(map[string]bool)
|
||||
for _, failure := range result.Failures {
|
||||
failed[failure.Name] = true
|
||||
}
|
||||
for _, name := range []string{"release-notes", "nested-link", "linked-skill"} {
|
||||
if !failed[name] {
|
||||
t.Fatalf("missing failure for %q: %#v", name, result.Failures)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestImportSkillsRejectsSymlinkedRoot(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
source := filepath.Join(t.TempDir(), "codex-skills")
|
||||
if err := os.Symlink(root, source); err != nil {
|
||||
t.Skipf("symlink not supported: %v", err)
|
||||
}
|
||||
result, err := importSkillsFromDir("codex", source, t.TempDir())
|
||||
if err == nil || !strings.Contains(err.Error(), "symlinks are not supported") {
|
||||
t.Fatalf("symlinked root error = %v", err)
|
||||
}
|
||||
if len(result.Imported) != 0 || len(result.Existing) != 0 || len(result.Failures) != 0 {
|
||||
t.Fatalf("symlinked root result = %#v", result)
|
||||
}
|
||||
}
|
||||
|
||||
func TestImportSkillsMissingRootAndConfiguredRoots(t *testing.T) {
|
||||
result, err := importSkillsFromDir("codex", filepath.Join(t.TempDir(), "missing"), t.TempDir())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(result.Imported) != 0 || len(result.Existing) != 0 || len(result.Failures) != 0 {
|
||||
t.Fatalf("missing root result = %#v", result)
|
||||
}
|
||||
|
||||
destination := t.TempDir()
|
||||
rootBase := t.TempDir()
|
||||
roots := map[string]string{
|
||||
"codex": filepath.Join(rootBase, "codex"),
|
||||
"claude": filepath.Join(rootBase, "claude"),
|
||||
"pi": filepath.Join(rootBase, "pi"),
|
||||
}
|
||||
for _, test := range []struct {
|
||||
source string
|
||||
root string
|
||||
name string
|
||||
}{
|
||||
{source: "codex", root: roots["codex"], name: "from-codex"},
|
||||
{source: "claude", root: roots["claude"], name: "from-claude"},
|
||||
{source: "pi", root: roots["pi"], name: "from-pi"},
|
||||
} {
|
||||
t.Run(test.source, func(t *testing.T) {
|
||||
writeCatalogSkill(t, test.root, test.name, "from "+test.source)
|
||||
result, err = importSkillsFromRoots(test.source, roots, destination)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if result.SourceDir != test.root {
|
||||
t.Fatalf("source dir = %q, want %q", result.SourceDir, test.root)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(destination, test.name, skillFilename)); err != nil {
|
||||
t.Fatalf("conventional source was not imported: %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
if _, err := importSkillsFromRoots("unknown", roots, destination); err == nil || !strings.Contains(err.Error(), "unknown skill source") {
|
||||
t.Fatalf("unknown source error = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConventionalSkillImportRoots(t *testing.T) {
|
||||
home := t.TempDir()
|
||||
roots := conventionalSkillImportRoots(home)
|
||||
for source, want := range map[string]string{
|
||||
"codex": filepath.Join(home, ".codex", "skills"),
|
||||
"claude": filepath.Join(home, ".claude", "skills"),
|
||||
"pi": filepath.Join(home, ".pi", "agent", "skills"),
|
||||
} {
|
||||
if got := roots[source]; got != want {
|
||||
t.Fatalf("%s root = %q, want %q", source, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestImportSkillsRejectsUnreadableManifest(t *testing.T) {
|
||||
source := t.TempDir()
|
||||
writeCatalogSkill(t, source, "private", "do not read")
|
||||
manifest := filepath.Join(source, "private", skillFilename)
|
||||
if err := os.Chmod(manifest, 0); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _ = os.Chmod(manifest, 0o644) })
|
||||
if _, err := os.ReadFile(manifest); err == nil {
|
||||
t.Skip("test user can read a mode-000 file")
|
||||
}
|
||||
result, err := importSkillsFromDir("codex", source, t.TempDir())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(result.Failures) != 1 || result.Failures[0].Name != "private" {
|
||||
t.Fatalf("failures = %#v", result.Failures)
|
||||
}
|
||||
}
|
||||
8
agent/testdata/import/release-notes/SKILL.md
vendored
@@ -1,8 +0,0 @@
|
||||
---
|
||||
name: release-notes
|
||||
description: Draft concise release notes.
|
||||
---
|
||||
|
||||
# Release notes
|
||||
|
||||
Use short bullets.
|
||||
@@ -1,450 +0,0 @@
|
||||
package tools
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"time"
|
||||
"unicode/utf8"
|
||||
|
||||
"github.com/ollama/ollama/agent"
|
||||
"github.com/ollama/ollama/api"
|
||||
)
|
||||
|
||||
const (
|
||||
bashTimeout = 3 * time.Minute
|
||||
bashWaitDelay = 1 * time.Second
|
||||
maxBashOutputBytes = 60_000
|
||||
)
|
||||
|
||||
type Bash struct{}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
// ApprovalScope scopes shell approval to the exact, trimmed command string
|
||||
// using a NUL separator: "<tool>\x00<command>". "Always allow this command"
|
||||
// matches ONLY that precise string — any whitespace, quoting, or casing
|
||||
// variant re-prompts. The NUL separator is safe because a shell command
|
||||
// string cannot contain a literal NUL.
|
||||
func (b *Bash) ApprovalScope(args map[string]any) string {
|
||||
name := b.Name()
|
||||
if command, ok := args["command"].(string); ok {
|
||||
command = strings.TrimSpace(command)
|
||||
if command != "" {
|
||||
return name + "\x00" + command
|
||||
}
|
||||
}
|
||||
return name
|
||||
}
|
||||
|
||||
func (b *Bash) Execute(ctx context.Context, toolCtx agent.ToolContext, args map[string]any) (agent.ToolResult, error) {
|
||||
// TODO: use shared agent.RequiredStringArg for the "command" parameter (see agent package cleanup plan).
|
||||
command, ok := args["command"].(string)
|
||||
if !ok || strings.TrimSpace(command) == "" {
|
||||
return agent.ToolResult{}, fmt.Errorf("command parameter is required")
|
||||
}
|
||||
if err := rejectUnsafeShellCommand(command); err != nil {
|
||||
return agent.ToolResult{}, err
|
||||
}
|
||||
|
||||
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.WaitDelay = bashWaitDelay
|
||||
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: bashContentWithError(sb.String(), "Error: command timed out after "+bashTimeout.String()), WorkingDir: finalWorkingDir}, nil
|
||||
}
|
||||
if ctx.Err() == context.Canceled {
|
||||
return agent.ToolResult{Content: bashContentWithError(sb.String(), "Error: command was canceled"), WorkingDir: finalWorkingDir}, nil
|
||||
}
|
||||
if errors.Is(err, exec.ErrWaitDelay) {
|
||||
_ = killBashCommand(cmd)
|
||||
return agent.ToolResult{Content: bashContentWithError(sb.String(), "Error: command output pipes did not close after "+bashWaitDelay.String()), WorkingDir: finalWorkingDir}, nil
|
||||
}
|
||||
if exitErr, ok := err.(*exec.ExitError); ok {
|
||||
return agent.ToolResult{Content: bashContentWithError(sb.String(), fmt.Sprintf("Exit 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 bashContentWithError(content, msg string) string {
|
||||
if content == "" {
|
||||
return msg
|
||||
}
|
||||
return content + "\n\n" + msg
|
||||
}
|
||||
|
||||
// rejectUnsafeShellCommand applies a best-effort blocklist for obviously
|
||||
// destructive or credential-exfiltrating commands. It is defense-in-depth
|
||||
// ONLY: the interactive approval prompt is the real security control, and
|
||||
// this check must not be relied upon as a sandbox. Sophisticated or novel
|
||||
// dangerous commands (e.g. find / -delete, dd, fork bombs, custom binaries)
|
||||
// are NOT caught here and will simply be routed through approval like any
|
||||
// other command. Keep the approval prompt as the gate.
|
||||
func rejectUnsafeShellCommand(command string) error {
|
||||
switch {
|
||||
case hasUnsafeRecursiveDelete(command):
|
||||
return fmt.Errorf("refusing to run unsafe command: recursive delete target is too broad")
|
||||
case readsCredentialPath(command):
|
||||
return fmt.Errorf("refusing to run unsafe command: credential file reads are not allowed")
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func hasUnsafeRecursiveDelete(command string) bool {
|
||||
// Check each command segment independently. shellSafetyText flattens
|
||||
// separators (; & | newlines) to spaces, which would otherwise let the
|
||||
// rm target scan bleed across command boundaries — e.g.
|
||||
// "rm -rf build && echo ~/.ssh/config" flattened to one token stream
|
||||
// would treat the unrelated ~/.ssh/config (a ~/-prefixed "unsafe
|
||||
// target") as an rm argument. Splitting on separators first restores
|
||||
// command boundaries while still catching multi-target single commands
|
||||
// like "rm -rf build /etc".
|
||||
for _, segment := range shellSegments(command) {
|
||||
fields := shellSafetyFields(segment)
|
||||
for i, field := range fields {
|
||||
if isRMCommand(field) && rmCommandDeletesUnsafeTarget(fields[i+1:]) {
|
||||
return true
|
||||
}
|
||||
if isPowerShellDeleteCommand(field) && powerShellDeleteCommandDeletesUnsafeTarget(fields[i+1:]) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// shellSegments splits a command on shell control operators (;, &, |, &&,
|
||||
// ||) and newlines, returning the individual command segments. It operates on
|
||||
// the lowercased raw command before quote/separator normalization so that
|
||||
// command boundaries are preserved for per-segment checks. Subshell parens are
|
||||
// intentionally NOT treated as separators: splitting on them would fragment
|
||||
// command substitutions like "rm -rf $(echo /)" into "rm -rf $" and "echo /",
|
||||
// hiding the destructive "/" target from the per-segment scan. Empty segments
|
||||
// are dropped.
|
||||
func shellSegments(command string) []string {
|
||||
command = strings.ToLower(command)
|
||||
var segments []string
|
||||
for _, segment := range strings.FieldsFunc(command, func(r rune) bool {
|
||||
switch r {
|
||||
case ';', '&', '|', '\n', '\r':
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}) {
|
||||
if segment = strings.TrimSpace(segment); segment != "" {
|
||||
segments = append(segments, segment)
|
||||
}
|
||||
}
|
||||
return segments
|
||||
}
|
||||
|
||||
func rmCommandDeletesUnsafeTarget(fields []string) bool {
|
||||
var flags string
|
||||
for _, field := range fields {
|
||||
if field == "--" {
|
||||
continue
|
||||
}
|
||||
if strings.HasPrefix(field, "-") {
|
||||
flags += field
|
||||
continue
|
||||
}
|
||||
if strings.Contains(flags, "r") && strings.Contains(flags, "f") && isUnsafeDeleteTarget(field) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func powerShellDeleteCommandDeletesUnsafeTarget(fields []string) bool {
|
||||
var recurse, force bool
|
||||
var targets []string
|
||||
for _, field := range fields {
|
||||
switch field {
|
||||
case "-r", "-recurse", "-recursive":
|
||||
recurse = true
|
||||
case "-f", "-force":
|
||||
force = true
|
||||
default:
|
||||
if !strings.HasPrefix(field, "-") {
|
||||
targets = append(targets, field)
|
||||
}
|
||||
}
|
||||
}
|
||||
if !recurse || !force {
|
||||
return false
|
||||
}
|
||||
for _, target := range targets {
|
||||
if isUnsafeDeleteTarget(target) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func readsCredentialPath(command string) bool {
|
||||
fields := shellSafetyFields(command)
|
||||
if !hasCredentialReadVerb(fields) {
|
||||
return false
|
||||
}
|
||||
normalized := shellSafetyText(command)
|
||||
for _, fragment := range []string{
|
||||
"/.ssh/id_rsa",
|
||||
"/.ssh/id_dsa",
|
||||
"/.ssh/id_ecdsa",
|
||||
"/.ssh/id_ed25519",
|
||||
"/.ssh/config",
|
||||
"/.ssh/known_hosts",
|
||||
"/.aws/credentials",
|
||||
"/.aws/config",
|
||||
"/.config/gcloud/application_default_credentials.json",
|
||||
"/.kube/config",
|
||||
"/.netrc",
|
||||
"/.npmrc",
|
||||
"/.docker/config.json",
|
||||
"/.config/gh/hosts.yml",
|
||||
"/.gnupg/",
|
||||
"/etc/shadow",
|
||||
} {
|
||||
if strings.Contains(normalized, fragment) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func hasCredentialReadVerb(fields []string) bool {
|
||||
for _, field := range fields {
|
||||
switch field {
|
||||
case "cat", "less", "more", "head", "tail", "type", "get-content", "gc", "select-string", "grep", "rg", "sed", "awk":
|
||||
return true
|
||||
case "env", "printenv":
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func isRMCommand(field string) bool {
|
||||
return field == "rm" || strings.HasSuffix(field, "/rm")
|
||||
}
|
||||
|
||||
func isPowerShellDeleteCommand(field string) bool {
|
||||
switch field {
|
||||
case "remove-item", "del", "erase", "rd", "rmdir":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func isUnsafeDeleteTarget(target string) bool {
|
||||
if target == "." || target == "./" || target == "*" {
|
||||
return true
|
||||
}
|
||||
if target == "/*" {
|
||||
return true
|
||||
}
|
||||
target = strings.TrimSuffix(target, "/*")
|
||||
for _, prefix := range []string{"~/", "$home/", "${home}/", "$env:home/", "$env:userprofile/", "%userprofile%/"} {
|
||||
if strings.HasPrefix(target, prefix) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
for _, prefix := range []string{"/etc/", "/bin/", "/sbin/", "/usr/", "/var/", "/lib/", "/library/", "/system/", "/applications/", "c:/windows/", "c:/program files/"} {
|
||||
if strings.HasPrefix(target, prefix) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
for _, exact := range []string{"/", "~", "$home", "${home}", "$env:home", "$env:userprofile", "%userprofile%", "c:", "c:/", "/etc", "/bin", "/sbin", "/usr", "/var", "/lib", "/library", "/system", "/applications", "c:/windows", "c:/program files"} {
|
||||
if target == exact {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func shellSafetyFields(command string) []string {
|
||||
return strings.Fields(shellSafetyText(command))
|
||||
}
|
||||
|
||||
func shellSafetyText(command string) string {
|
||||
command = strings.ToLower(command)
|
||||
return strings.NewReplacer(
|
||||
"\\", "/",
|
||||
"\n", " ",
|
||||
"\t", " ",
|
||||
";", " ",
|
||||
"&", " ",
|
||||
"|", " ",
|
||||
"(", " ",
|
||||
")", " ",
|
||||
"\"", "",
|
||||
"'", "",
|
||||
"`", "",
|
||||
).Replace(command)
|
||||
}
|
||||
|
||||
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')
|
||||
}
|
||||
|
||||
type boundedOutput struct {
|
||||
Limit int
|
||||
buf []byte
|
||||
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 - len(b.buf)
|
||||
if remaining <= 0 {
|
||||
b.omitted += len(p)
|
||||
return len(p), nil
|
||||
}
|
||||
if len(p) <= remaining {
|
||||
b.buf = append(b.buf, p...)
|
||||
return len(p), nil
|
||||
}
|
||||
writeLen := utf8SafePrefixLen(p[:remaining])
|
||||
b.buf = append(b.buf, p[:writeLen]...)
|
||||
b.omitted += len(p) - writeLen
|
||||
return len(p), nil
|
||||
}
|
||||
|
||||
func (b *boundedOutput) Len() int {
|
||||
return len(b.buf) + b.omitted
|
||||
}
|
||||
|
||||
func (b *boundedOutput) String(label string) string {
|
||||
safeLen := utf8SafePrefixLen(b.buf)
|
||||
content := string(b.buf[:safeLen])
|
||||
omitted := b.omitted + len(b.buf) - safeLen
|
||||
if omitted == 0 {
|
||||
return content
|
||||
}
|
||||
return content + agent.TruncMarker(label, safeLen, 0, omitted, false, "")
|
||||
}
|
||||
|
||||
func utf8SafePrefixLen(p []byte) int {
|
||||
if len(p) == 0 {
|
||||
return 0
|
||||
}
|
||||
for i := 0; i < len(p); {
|
||||
r, size := utf8.DecodeRune(p[i:])
|
||||
if r == utf8.RuneError && size == 1 {
|
||||
return i
|
||||
}
|
||||
i += size
|
||||
}
|
||||
return len(p)
|
||||
}
|
||||
@@ -1,258 +0,0 @@
|
||||
package tools
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"testing"
|
||||
"unicode/utf8"
|
||||
|
||||
"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 := (&Bash{}).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 := (&Bash{}).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: showing first ~") || !strings.Contains(result.Content, "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 TestBoundedOutputTruncatesAtUTF8Boundary(t *testing.T) {
|
||||
var out boundedOutput
|
||||
out.Limit = len([]byte("abc")) + 1
|
||||
|
||||
if _, err := out.Write([]byte("abcédef")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
content := out.String("stdout")
|
||||
if !utf8.ValidString(content) {
|
||||
t.Fatalf("content is not valid UTF-8: %q", content)
|
||||
}
|
||||
if strings.ContainsRune(content, utf8.RuneError) {
|
||||
t.Fatalf("content contains replacement rune: %q", content)
|
||||
}
|
||||
if !strings.HasPrefix(content, "abc\n\n[stdout truncated:") {
|
||||
t.Fatalf("content = %q, want complete ASCII prefix and truncation marker", content)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBoundedOutputKeepsCompleteUTF8AtBoundary(t *testing.T) {
|
||||
var out boundedOutput
|
||||
out.Limit = len([]byte("abcé"))
|
||||
|
||||
if _, err := out.Write([]byte("abcédef")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if content := out.String("stdout"); !strings.HasPrefix(content, "abcé\n\n[stdout truncated:") {
|
||||
t.Fatalf("content = %q, want complete UTF-8 prefix", content)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBoundedOutputTrimsTrailingPartialUTF8(t *testing.T) {
|
||||
var out boundedOutput
|
||||
out.Limit = 4
|
||||
|
||||
if _, err := out.Write([]byte{'a', 'b', 'c', 0xc3}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := out.Write([]byte{0xa9}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if content := out.String("stdout"); !utf8.ValidString(content) || !strings.HasPrefix(content, "abc\n\n[stdout truncated:") {
|
||||
t.Fatalf("content = %q, want valid UTF-8 with partial suffix trimmed", content)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUTF8SafePrefixRejectsMalformedLeadByte(t *testing.T) {
|
||||
input := []byte{'a', 0xc0, 0x80, 'b'}
|
||||
if got := utf8SafePrefixLen(input); got != 1 {
|
||||
t.Fatalf("safe prefix length = %d, want 1", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBoundedOutputDropsMalformedUTF8(t *testing.T) {
|
||||
var out boundedOutput
|
||||
out.Limit = 4
|
||||
|
||||
if _, err := out.Write([]byte{'a', 0xc0, 0x80, 'b'}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
content := out.String("stdout")
|
||||
if !utf8.ValidString(content) {
|
||||
t.Fatalf("content is not valid UTF-8: %q", content)
|
||||
}
|
||||
if strings.ContainsRune(content, utf8.RuneError) {
|
||||
t.Fatalf("content contains replacement rune: %q", content)
|
||||
}
|
||||
if !strings.HasPrefix(content, "a\n\n[stdout truncated:") {
|
||||
t.Fatalf("content = %q, want valid prefix and truncation marker", content)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBashReportsCanceledCommand(t *testing.T) {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cancel()
|
||||
|
||||
result, err := (&Bash{}).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 TestRejectUnsafeShellCommand(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
command string
|
||||
wantErr bool
|
||||
}{
|
||||
{name: "rm root", command: "rm -rf /", wantErr: true},
|
||||
{name: "sudo rm root", command: "sudo rm -rf -- /", wantErr: true},
|
||||
{name: "rm home", command: "rm -fr $HOME", wantErr: true},
|
||||
{name: "rm root wildcard", command: "rm -rf /*", wantErr: true},
|
||||
{name: "rm system subdir", command: "rm -rf /etc/ssh", wantErr: true},
|
||||
{name: "rm cwd", command: "rm -rf .", wantErr: true},
|
||||
{name: "powershell remove root", command: `Remove-Item -Recurse -Force C:\`, wantErr: true},
|
||||
{name: "powershell remove system subdir", command: `Remove-Item -Recurse -Force C:\Windows\Temp`, wantErr: true},
|
||||
{name: "ssh private key", command: "cat ~/.ssh/id_rsa", wantErr: true},
|
||||
{name: "aws credentials", command: "Get-Content $HOME/.aws/credentials", wantErr: true},
|
||||
{name: "shadow", command: "head /etc/shadow", wantErr: true},
|
||||
{name: "netrc", command: "cat ~/.netrc", wantErr: true},
|
||||
{name: "docker config", command: "cat ~/.docker/config.json", wantErr: true},
|
||||
{name: "gnupg dir", command: "cat ~/.gnupg/private-keys-v1.d/key", wantErr: true},
|
||||
{name: "gh hosts", command: "cat ~/.config/gh/hosts.yml", wantErr: true},
|
||||
{name: "ssh config", command: "cat ~/.ssh/config", wantErr: true},
|
||||
{name: "printenv dump", command: "printenv", wantErr: false},
|
||||
{name: "delete build dir", command: "rm -rf build", wantErr: false},
|
||||
{name: "read project file", command: "cat README.md", wantErr: false},
|
||||
{name: "mention key text", command: "rg id_rsa docs", wantErr: false},
|
||||
{name: "env example", command: "cat .env.example", wantErr: false},
|
||||
{name: "rm build then unrelated tilde path", command: "rm -rf build && echo ~/.ssh/config", wantErr: false},
|
||||
{name: "rm build then unrelated slash path", command: "rm -rf build; cat /etc/passwd", wantErr: false},
|
||||
{name: "rm build then unrelated star glob", command: "rm -rf build && ls *.go", wantErr: false},
|
||||
{name: "rm multiple targets one unsafe", command: "rm -rf build /etc", wantErr: true},
|
||||
{name: "rm unsafe then safe piped", command: "rm -rf / | tee log", wantErr: true},
|
||||
{name: "rm unsafe via command substitution", command: "rm -rf $(echo /)", wantErr: true},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
err := rejectUnsafeShellCommand(tt.command)
|
||||
if tt.wantErr && err == nil {
|
||||
t.Fatal("expected unsafe command to be rejected")
|
||||
}
|
||||
if !tt.wantErr && err != nil {
|
||||
t.Fatalf("command rejected: %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestBashRejectsUnsafeCommandBeforeExecution(t *testing.T) {
|
||||
_, err := (&Bash{}).Execute(context.Background(), agent.ToolContext{WorkingDir: t.TempDir()}, map[string]any{
|
||||
"command": "rm -rf /",
|
||||
})
|
||||
if err == nil || !strings.Contains(err.Error(), "refusing to run unsafe command") {
|
||||
t.Fatalf("err = %v, want unsafe command rejection", err)
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -1,49 +0,0 @@
|
||||
//go:build !windows
|
||||
|
||||
package tools
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os/exec"
|
||||
"strings"
|
||||
"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 shellQuote(value string) string {
|
||||
return "'" + strings.ReplaceAll(value, "'", "'\\''") + "'"
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
@@ -1,40 +0,0 @@
|
||||
//go:build !windows
|
||||
|
||||
package tools
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os/exec"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/ollama/ollama/agent"
|
||||
)
|
||||
|
||||
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")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBashWaitDelayBoundsBackgroundOutputPipe(t *testing.T) {
|
||||
start := time.Now()
|
||||
result, err := (&Bash{}).Execute(context.Background(), agent.ToolContext{WorkingDir: t.TempDir()}, map[string]any{
|
||||
"command": "sleep 5 & echo done",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if elapsed := time.Since(start); elapsed > bashWaitDelay+2*time.Second {
|
||||
t.Fatalf("command elapsed = %s, want bounded near %s", elapsed, bashWaitDelay)
|
||||
}
|
||||
if !strings.Contains(result.Content, "done") {
|
||||
t.Fatalf("content = %q, want command output", result.Content)
|
||||
}
|
||||
if !strings.Contains(result.Content, "output pipes did not close") {
|
||||
t.Fatalf("content = %q, want wait delay message", result.Content)
|
||||
}
|
||||
}
|
||||
@@ -1,134 +0,0 @@
|
||||
//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 -Width 4096",
|
||||
"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)
|
||||
}
|
||||
}
|
||||
@@ -1,15 +0,0 @@
|
||||
//go:build windows
|
||||
|
||||
package tools
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestPowerShellCommandScriptUsesWideOutString(t *testing.T) {
|
||||
script := powerShellCommandScript("Get-ChildItem", `C:\cwd.txt`)
|
||||
if !strings.Contains(script, "Out-String -Stream -Width 4096") {
|
||||
t.Fatalf("script = %q, want explicit Out-String width", script)
|
||||
}
|
||||
}
|
||||
@@ -1,558 +0,0 @@
|
||||
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 (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", api.ToolProperty{
|
||||
Type: api.PropertyType{"integer"},
|
||||
Description: "Optional 1-based line to start reading from.",
|
||||
})
|
||||
props.Set("end", api.ToolProperty{
|
||||
Type: api.PropertyType{"integer"},
|
||||
Description: "Optional 1-based inclusive line to stop reading at.",
|
||||
})
|
||||
return api.ToolFunction{
|
||||
Name: r.Name(),
|
||||
Description: r.Description(),
|
||||
Parameters: api.ToolFunctionParameters{
|
||||
Type: "object",
|
||||
Properties: props,
|
||||
Required: []string{"path"},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func (r *Read) RequiresApproval(map[string]any) bool {
|
||||
return true
|
||||
}
|
||||
|
||||
func (r *Read) Execute(ctx context.Context, toolCtx agent.ToolContext, args map[string]any) (agent.ToolResult, error) {
|
||||
// TODO: use shared agent.RequiredStringArg / agent.OptionalIntArg for args (see agent package cleanup plan).
|
||||
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, true)
|
||||
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 = readAllWithinLimit(file, maxReadBytes)
|
||||
content = string(contentBytes)
|
||||
}
|
||||
if err != nil {
|
||||
return agent.ToolResult{}, err
|
||||
}
|
||||
return agent.ToolResult{Content: content}, nil
|
||||
}
|
||||
|
||||
type Edit struct{}
|
||||
|
||||
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) {
|
||||
// TODO: use shared agent.RequiredStringArg / agent.OptionalBoolArg for args (see agent package cleanup plan).
|
||||
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, false)
|
||||
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 := readAllWithinLimit(file, maxReadBytes)
|
||||
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, allowAbsolute bool) (*os.File, os.FileInfo, error) {
|
||||
path = strings.TrimSpace(path)
|
||||
if path == "" {
|
||||
return nil, nil, fmt.Errorf("path parameter is required")
|
||||
}
|
||||
if allowAbsolute && filepath.IsAbs(path) {
|
||||
cleaned := filepath.Clean(path)
|
||||
info, err := os.Lstat(cleaned)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
if info.Mode()&os.ModeSymlink != 0 {
|
||||
return nil, nil, fmt.Errorf("%s is a symlink; read the target file directly", path)
|
||||
}
|
||||
if err := rejectNonRegularFile(path, info); err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
file, err := os.Open(cleaned)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
info, err = file.Stat()
|
||||
if err != nil {
|
||||
file.Close()
|
||||
return nil, nil, err
|
||||
}
|
||||
if err := rejectNonRegularFile(path, info); err != nil {
|
||||
file.Close()
|
||||
return nil, nil, err
|
||||
}
|
||||
return file, info, nil
|
||||
}
|
||||
|
||||
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()
|
||||
|
||||
if _, err := regularRootFileInfo(root, rel, path); err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
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 err := rejectNonRegularFile(path, info); err != nil {
|
||||
file.Close()
|
||||
return nil, nil, err
|
||||
}
|
||||
return file, info, nil
|
||||
}
|
||||
|
||||
func regularRootFileInfo(root *os.Root, rel, path string) (os.FileInfo, error) {
|
||||
info, err := root.Lstat(rel)
|
||||
if err != nil {
|
||||
return nil, rootPathError(err)
|
||||
}
|
||||
// Reject symlinks outright. os.Root.Open follows symlinks via openat
|
||||
// without O_NOFOLLOW, so a symlink inside the working root that points
|
||||
// outside it (e.g. ./notes -> ~/.ssh/id_rsa) would otherwise be read
|
||||
// transparently, bypassing the working-directory confinement that the
|
||||
// bash denylist enforces for direct credential reads. The caller must
|
||||
// operate on the real target file instead.
|
||||
if info.Mode()&os.ModeSymlink != 0 {
|
||||
return nil, fmt.Errorf("%s is a symlink; read the target file directly", path)
|
||||
}
|
||||
if err := rejectNonRegularFile(path, info); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return info, nil
|
||||
}
|
||||
|
||||
func rejectNonRegularFile(path string, info os.FileInfo) error {
|
||||
if info.IsDir() {
|
||||
return fmt.Errorf("%s is a directory", path)
|
||||
}
|
||||
if !info.Mode().IsRegular() {
|
||||
return fmt.Errorf("%s is not a regular file", path)
|
||||
}
|
||||
return 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)
|
||||
}
|
||||
if err := file.Chmod(perm); err != nil {
|
||||
closeErr := file.Close()
|
||||
_ = root.Remove(candidate)
|
||||
if closeErr != nil {
|
||||
return closeErr
|
||||
}
|
||||
return 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 readAllWithinLimit(reader io.Reader, limit int) ([]byte, error) {
|
||||
if limit < 0 {
|
||||
limit = 0
|
||||
}
|
||||
content, err := io.ReadAll(io.LimitReader(reader, int64(limit)+1))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(content) > limit {
|
||||
return nil, fmt.Errorf("content is too large (%d byte limit)", limit)
|
||||
}
|
||||
return content, nil
|
||||
}
|
||||
|
||||
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}
|
||||
|
||||
if start, ok, err := intReadArg(args, "start"); err != nil {
|
||||
return readSelection{}, err
|
||||
} else if ok {
|
||||
selection.enabled = true
|
||||
selection.start = start
|
||||
}
|
||||
if end, ok, err := intReadArg(args, "end"); err != nil {
|
||||
return readSelection{}, err
|
||||
} else if ok {
|
||||
selection.enabled = true
|
||||
selection.end = end
|
||||
}
|
||||
|
||||
if !selection.enabled {
|
||||
return selection, nil
|
||||
}
|
||||
if selection.start < 1 {
|
||||
return readSelection{}, fmt.Errorf("start must be greater than 0")
|
||||
}
|
||||
if selection.end > 0 && selection.end < selection.start {
|
||||
return readSelection{}, fmt.Errorf("end must be greater than or equal to start")
|
||||
}
|
||||
return selection, nil
|
||||
}
|
||||
|
||||
func readLineSelection(file *os.File, selection readSelection) (string, error) {
|
||||
reader := bufio.NewReader(file)
|
||||
var b strings.Builder
|
||||
for lineNo := 1; ; {
|
||||
line, err := reader.ReadSlice('\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.Write(line)
|
||||
}
|
||||
if err != nil {
|
||||
if err == bufio.ErrBufferFull {
|
||||
continue
|
||||
}
|
||||
if err == io.EOF {
|
||||
break
|
||||
}
|
||||
return "", err
|
||||
}
|
||||
if selection.end > 0 && lineNo >= selection.end {
|
||||
break
|
||||
}
|
||||
lineNo++
|
||||
}
|
||||
return b.String(), nil
|
||||
}
|
||||
|
||||
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"
|
||||
}
|
||||
@@ -1,338 +0,0 @@
|
||||
package tools
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"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 := (&Edit{}).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 := (&Edit{}).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 := (&Edit{}).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 := (&Edit{}).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 := (&Edit{}).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 := (&Read{}).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 TestReadRequiresApproval(t *testing.T) {
|
||||
if !agent.ToolRequiresApproval((&Read{}), map[string]any{"path": "note.txt"}) {
|
||||
t.Fatal("read should require approval")
|
||||
}
|
||||
}
|
||||
|
||||
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 := (&Read{}).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 TestReadAllowsAbsolutePath(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "note.txt")
|
||||
content := "one\ntwo\nthree\n"
|
||||
if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
result, err := (&Read{}).Execute(context.Background(), agent.ToolContext{WorkingDir: t.TempDir()}, map[string]any{
|
||||
"path": path,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if result.Content != content {
|
||||
t.Fatalf("content = %q", result.Content)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadRejectsAbsoluteSymlink(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
target := filepath.Join(dir, "target.txt")
|
||||
if err := os.WriteFile(target, []byte("hello\n"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
link := filepath.Join(dir, "alias")
|
||||
if err := os.Symlink(target, link); err != nil {
|
||||
t.Skipf("symlinks unavailable: %v", err)
|
||||
}
|
||||
|
||||
_, err := (&Read{}).Execute(context.Background(), agent.ToolContext{WorkingDir: t.TempDir()}, map[string]any{
|
||||
"path": link,
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected absolute symlink to be rejected")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "symlink") {
|
||||
t.Fatalf("err = %v, want symlink rejection", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadStartEnd(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 := (&Read{}).Execute(context.Background(), agent.ToolContext{WorkingDir: dir}, map[string]any{
|
||||
"path": "note.txt",
|
||||
"start": 2,
|
||||
"end": 3,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if result.Content != "two\nthree\n" {
|
||||
t.Fatalf("content = %q", result.Content)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadStartOnly(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 := (&Read{}).Execute(context.Background(), agent.ToolContext{WorkingDir: dir}, map[string]any{
|
||||
"path": "note.txt",
|
||||
"start": 3,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if result.Content != "three\nfour\n" {
|
||||
t.Fatalf("content = %q", result.Content)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadEndOnly(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 := (&Read{}).Execute(context.Background(), agent.ToolContext{WorkingDir: dir}, map[string]any{
|
||||
"path": "note.txt",
|
||||
"end": 2,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if result.Content != "one\ntwo\n" {
|
||||
t.Fatalf("content = %q", result.Content)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadSelectionRejectsHugeSingleLine(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
if err := os.WriteFile(filepath.Join(dir, "note.txt"), []byte(strings.Repeat("x", maxReadBytes+1)), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
_, err := (&Read{}).Execute(context.Background(), agent.ToolContext{WorkingDir: dir}, map[string]any{
|
||||
"path": "note.txt",
|
||||
"start": 1,
|
||||
"end": 1,
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected huge selected line to fail")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "selected content is too large") {
|
||||
t.Fatalf("err = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadAllWithinLimitRejectsGrowingRead(t *testing.T) {
|
||||
reader := io.MultiReader(
|
||||
strings.NewReader(strings.Repeat("x", maxReadBytes)),
|
||||
strings.NewReader("x"),
|
||||
)
|
||||
|
||||
_, err := readAllWithinLimit(reader, maxReadBytes)
|
||||
if err == nil {
|
||||
t.Fatal("expected over-limit read to fail")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "content is too large") {
|
||||
t.Fatalf("err = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadRejectsInvalidRange(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 := (&Read{}).Execute(context.Background(), agent.ToolContext{WorkingDir: dir}, map[string]any{
|
||||
"path": "note.txt",
|
||||
"start": 4,
|
||||
"end": 2,
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected invalid range to fail")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "end must") {
|
||||
t.Fatalf("err = %v", err)
|
||||
}
|
||||
}
|
||||
@@ -1,121 +0,0 @@
|
||||
//go:build !windows
|
||||
|
||||
package tools
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"syscall"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/ollama/ollama/agent"
|
||||
)
|
||||
|
||||
func TestOpenRegularFileRejectsFIFO(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "pipe")
|
||||
if err := syscall.Mkfifo(path, 0o600); err != nil {
|
||||
t.Skipf("mkfifo unavailable: %v", err)
|
||||
}
|
||||
|
||||
done := make(chan error, 1)
|
||||
go func() {
|
||||
file, _, err := openRegularFile(dir, "pipe", false)
|
||||
if file != nil {
|
||||
file.Close()
|
||||
}
|
||||
done <- err
|
||||
}()
|
||||
|
||||
select {
|
||||
case err := <-done:
|
||||
if err == nil {
|
||||
t.Fatal("expected FIFO to be rejected")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "not a regular file") {
|
||||
t.Fatalf("err = %v", err)
|
||||
}
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("openRegularFile blocked on FIFO")
|
||||
}
|
||||
}
|
||||
|
||||
func TestEditPreservesModeDespiteUmask(t *testing.T) {
|
||||
oldUmask := syscall.Umask(0o077)
|
||||
defer syscall.Umask(oldUmask)
|
||||
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "note.txt")
|
||||
if err := os.WriteFile(path, []byte("hello\n"), 0o666); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.Chmod(path, 0o666); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
_, err := (&Edit{}).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)
|
||||
}
|
||||
|
||||
info, err := os.Stat(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := info.Mode().Perm(); got != 0o666 {
|
||||
t.Fatalf("mode = %#o, want 0666", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadRejectsSymlinkEscapingWorkingDir(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
secret := filepath.Join(t.TempDir(), "secret.txt")
|
||||
if err := os.WriteFile(secret, []byte("top secret\n"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
link := filepath.Join(root, "notes")
|
||||
if err := os.Symlink(secret, link); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
_, err := (&Read{}).Execute(context.Background(), agent.ToolContext{WorkingDir: root}, map[string]any{
|
||||
"path": "notes",
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected symlink escaping working dir to be rejected")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "symlink") {
|
||||
t.Fatalf("err = %v, want symlink rejection", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadRejectsSymlinkInsideWorkingDirToOutside(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
target := filepath.Join(root, "real.txt")
|
||||
if err := os.WriteFile(target, []byte("hello\n"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// A symlink to a sibling file still resolves inside the root; Read must
|
||||
// reject it regardless, consistent with Edit's rejectFinalSymlink.
|
||||
link := filepath.Join(root, "alias")
|
||||
if err := os.Symlink(target, link); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
_, err := (&Read{}).Execute(context.Background(), agent.ToolContext{WorkingDir: root}, map[string]any{
|
||||
"path": "alias",
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected symlink to be rejected even when target is inside root")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "symlink") {
|
||||
t.Fatalf("err = %v, want symlink rejection", err)
|
||||
}
|
||||
}
|
||||
@@ -1,41 +0,0 @@
|
||||
package tools
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
|
||||
"github.com/ollama/ollama/agent"
|
||||
"github.com/ollama/ollama/api"
|
||||
)
|
||||
|
||||
// Skill is the model-facing adapter for the core agent skill catalog.
|
||||
// Model-initiated loads require approval because a skill's instructions can
|
||||
// influence the rest of the run. Explicit user activation is handled by the
|
||||
// session's synthetic skill call and bypasses this adapter.
|
||||
type Skill struct{ Catalog *agent.SkillCatalog }
|
||||
|
||||
func (t *Skill) Name() string { return "skill" }
|
||||
|
||||
func (t *Skill) Description() string {
|
||||
return "Load a named Ollama skill and return its instructions."
|
||||
}
|
||||
|
||||
func (t *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: t.Name(), Description: t.Description(), Parameters: api.ToolFunctionParameters{Type: "object", Properties: props, Required: []string{"name"}}}
|
||||
}
|
||||
|
||||
func (t *Skill) RequiresApproval(map[string]any) bool { return true }
|
||||
|
||||
func (t *Skill) Execute(_ context.Context, _ agent.ToolContext, args map[string]any) (agent.ToolResult, error) {
|
||||
name, ok := args["name"].(string)
|
||||
if !ok {
|
||||
return agent.ToolResult{}, errors.New("name parameter is required")
|
||||
}
|
||||
skill, err := t.Catalog.Load(name)
|
||||
if err != nil {
|
||||
return agent.ToolResult{}, err
|
||||
}
|
||||
return agent.ToolResult{Content: skill.Content()}, nil
|
||||
}
|
||||
@@ -1,163 +0,0 @@
|
||||
package tools
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/ollama/ollama/agent"
|
||||
"github.com/ollama/ollama/api"
|
||||
)
|
||||
|
||||
func TestSkillLoadsCoreCatalogWithApproval(t *testing.T) {
|
||||
catalog := testSkillCatalog(t)
|
||||
tool := &Skill{Catalog: catalog}
|
||||
if !agent.ToolRequiresApproval(tool, map[string]any{"name": "release-notes"}) {
|
||||
t.Fatal("model-initiated skill loading should require approval")
|
||||
}
|
||||
result, err := tool.Execute(context.Background(), agent.ToolContext{}, map[string]any{"name": "release-notes"})
|
||||
if err != nil || !strings.Contains(result.Content, "Use concise bullets.") {
|
||||
t.Fatalf("tool result = %#v, %v", result, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestModelSkillLoadRequiresApproval(t *testing.T) {
|
||||
for _, tt := range []struct {
|
||||
name string
|
||||
approval agent.Approval
|
||||
prompt bool
|
||||
wantCalls int
|
||||
wantPrompts int
|
||||
wantResult string
|
||||
}{
|
||||
{name: "rejected", approval: agent.Approval{Reason: "Skill loading denied."}, prompt: true, wantCalls: 1, wantPrompts: 1, wantResult: "Skill loading denied."},
|
||||
{name: "approved", approval: agent.Approval{Allow: true}, prompt: true, wantCalls: 2, wantPrompts: 1, wantResult: "Use concise bullets."},
|
||||
{name: "headless denied", wantCalls: 1, wantResult: "Tool execution requires approval"},
|
||||
} {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
catalog := testSkillCatalog(t)
|
||||
args := api.NewToolCallFunctionArguments()
|
||||
args.Set("name", "release-notes")
|
||||
client := &skillTestClient{responses: [][]api.ChatResponse{
|
||||
{{Message: api.Message{Role: "assistant", ToolCalls: []api.ToolCall{{
|
||||
ID: "call_skill_1",
|
||||
Function: api.ToolCallFunction{Name: "skill", Arguments: args},
|
||||
}}}}},
|
||||
{{Message: api.Message{Role: "assistant", Content: "done"}}},
|
||||
}}
|
||||
var prompter *skillApprovalPrompter
|
||||
var approvalPrompter agent.ApprovalPrompter
|
||||
if tt.prompt {
|
||||
prompter = &skillApprovalPrompter{result: tt.approval}
|
||||
approvalPrompter = prompter
|
||||
}
|
||||
registry := &agent.Registry{}
|
||||
registry.Register(&Skill{Catalog: catalog})
|
||||
|
||||
result, err := (&agent.Session{
|
||||
Client: client,
|
||||
Tools: registry,
|
||||
ApprovalPrompter: approvalPrompter,
|
||||
}).Run(context.Background(), agent.RunOptions{
|
||||
Model: "test",
|
||||
NewMessages: []api.Message{{Role: "user", Content: "load the release-notes skill"}},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if tt.prompt {
|
||||
if got := len(prompter.requests); got != tt.wantPrompts {
|
||||
t.Fatalf("approval prompts = %d, want %d", got, tt.wantPrompts)
|
||||
}
|
||||
request := prompter.requests[0]
|
||||
if len(request.Calls) != 1 || request.Calls[0].ToolName != "skill" || request.Calls[0].ApprovalScope != "skill" || request.Calls[0].Args["name"] != "release-notes" {
|
||||
t.Fatalf("approval request = %#v", request)
|
||||
}
|
||||
}
|
||||
if got := client.calls; got != tt.wantCalls {
|
||||
t.Fatalf("model calls = %d, want %d", got, tt.wantCalls)
|
||||
}
|
||||
var toolResult string
|
||||
for _, message := range result.Messages {
|
||||
if message.Role == "tool" && message.ToolCallID == "call_skill_1" {
|
||||
toolResult = message.Content
|
||||
break
|
||||
}
|
||||
}
|
||||
if !strings.Contains(toolResult, tt.wantResult) {
|
||||
t.Fatalf("skill tool result = %q, want it to contain %q", toolResult, tt.wantResult)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestExplicitSkillActivationBypassesApproval(t *testing.T) {
|
||||
catalog := testSkillCatalog(t)
|
||||
client := &skillTestClient{responses: [][]api.ChatResponse{{{Message: api.Message{Role: "assistant", Content: "done"}}}}}
|
||||
prompter := &skillApprovalPrompter{result: agent.Approval{}}
|
||||
result, err := (&agent.Session{
|
||||
Client: client,
|
||||
Skills: catalog,
|
||||
ApprovalPrompter: prompter,
|
||||
}).Run(context.Background(), agent.RunOptions{
|
||||
Model: "test",
|
||||
NewMessages: []api.Message{{Role: "user", Content: "draft release notes"}},
|
||||
SkillName: "release-notes",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(prompter.requests) != 0 {
|
||||
t.Fatalf("explicit activation prompted for approval: %#v", prompter.requests)
|
||||
}
|
||||
if len(result.Messages) != 4 || result.Messages[2].ToolName != "skill" || !strings.Contains(result.Messages[2].Content, "Use concise bullets.") {
|
||||
t.Fatalf("synthetic skill activation = %#v", result.Messages)
|
||||
}
|
||||
}
|
||||
|
||||
func testSkillCatalog(t *testing.T) *agent.SkillCatalog {
|
||||
t.Helper()
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "release-notes")
|
||||
if err := os.Mkdir(path, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(path, "SKILL.md"), []byte("---\nname: release-notes\ndescription: Draft release notes.\n---\nUse concise bullets."), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
catalog, err := agent.DiscoverSkills(dir)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return catalog
|
||||
}
|
||||
|
||||
type skillTestClient struct {
|
||||
responses [][]api.ChatResponse
|
||||
calls int
|
||||
}
|
||||
|
||||
func (c *skillTestClient) Chat(_ context.Context, _ *api.ChatRequest, fn api.ChatResponseFunc) error {
|
||||
if c.calls >= len(c.responses) {
|
||||
return nil
|
||||
}
|
||||
for _, response := range c.responses[c.calls] {
|
||||
if err := fn(response); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
c.calls++
|
||||
return nil
|
||||
}
|
||||
|
||||
type skillApprovalPrompter struct {
|
||||
requests []agent.ApprovalRequest
|
||||
result agent.Approval
|
||||
}
|
||||
|
||||
func (p *skillApprovalPrompter) PromptApproval(_ context.Context, request agent.ApprovalRequest) (agent.Approval, error) {
|
||||
p.requests = append(p.requests, request)
|
||||
return p.result, nil
|
||||
}
|
||||
@@ -1,186 +0,0 @@
|
||||
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"
|
||||
)
|
||||
|
||||
const (
|
||||
maxWebFetchContentRunes = 60_000
|
||||
webSearchTimeout = 15 * time.Second
|
||||
webFetchTimeout = 30 * time.Second
|
||||
)
|
||||
|
||||
var ErrWebAuthRequired = errors.New("Not authenticated. Run `ollama signin` and try again.")
|
||||
|
||||
type WebSearch struct{}
|
||||
|
||||
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) RequiresApproval(map[string]any) bool {
|
||||
return true
|
||||
}
|
||||
|
||||
func (w *WebSearch) Execute(ctx context.Context, _ agent.ToolContext, args map[string]any) (agent.ToolResult, error) {
|
||||
// TODO: use shared agent.RequiredStringArg for the "query" parameter (see agent package cleanup plan).
|
||||
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{}, ErrWebAuthRequired
|
||||
}
|
||||
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 (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) RequiresApproval(map[string]any) bool {
|
||||
return true
|
||||
}
|
||||
|
||||
func (w *WebFetch) Execute(ctx context.Context, _ agent.ToolContext, args map[string]any) (agent.ToolResult, error) {
|
||||
// TODO: use shared agent.RequiredStringArg for the "url" parameter (see agent package cleanup plan).
|
||||
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")
|
||||
}
|
||||
parsed, err := url.Parse(urlStr)
|
||||
if err != nil {
|
||||
return agent.ToolResult{}, fmt.Errorf("invalid URL: %w", err)
|
||||
}
|
||||
if scheme := strings.ToLower(parsed.Scheme); scheme != "http" && scheme != "https" {
|
||||
return agent.ToolResult{}, fmt.Errorf("unsupported URL scheme %q: only http and https are allowed", parsed.Scheme)
|
||||
}
|
||||
|
||||
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{}, ErrWebAuthRequired
|
||||
}
|
||||
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 {
|
||||
return agent.Truncate(content, agent.TruncateConfig{
|
||||
MaxRunes: maxWebFetchContentRunes,
|
||||
Label: "tool output",
|
||||
Hint: "Use a narrower request or search query if more detail is needed.",
|
||||
})
|
||||
}
|
||||
@@ -1,214 +0,0 @@
|
||||
package tools
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
coreagent "github.com/ollama/ollama/agent"
|
||||
"github.com/ollama/ollama/api"
|
||||
"github.com/ollama/ollama/envconfig"
|
||||
internalcloud "github.com/ollama/ollama/internal/cloud"
|
||||
)
|
||||
|
||||
func TestWebToolsRequireApproval(t *testing.T) {
|
||||
if !coreagent.ToolRequiresApproval((&WebSearch{}), map[string]any{"query": "ollama"}) {
|
||||
t.Fatal("web search should require approval")
|
||||
}
|
||||
if !coreagent.ToolRequiresApproval((&WebFetch{}), map[string]any{"url": "https://ollama.com"}) {
|
||||
t.Fatal("web fetch should require approval")
|
||||
}
|
||||
}
|
||||
|
||||
var webToolCases = []struct {
|
||||
name string
|
||||
tool coreagent.Tool
|
||||
args map[string]any
|
||||
path string
|
||||
operation string
|
||||
}{
|
||||
{"search", &WebSearch{}, map[string]any{"query": "ollama"}, "/api/experimental/web_search", "web search is unavailable"},
|
||||
{"fetch", &WebFetch{}, map[string]any{"url": "https://ollama.com"}, "/api/experimental/web_fetch", "web fetch is unavailable"},
|
||||
}
|
||||
|
||||
// enableWebToolsForTest isolates web tool tests from the runner's cloud
|
||||
// policy. In particular, Windows can inherit both OLLAMA_NO_CLOUD and a
|
||||
// server.json from USERPROFILE.
|
||||
func enableWebToolsForTest(t *testing.T) {
|
||||
t.Helper()
|
||||
|
||||
// Register before t.Setenv so the cache is refreshed after t.Setenv has
|
||||
// restored the runner's environment during cleanup.
|
||||
t.Cleanup(envconfig.ReloadServerConfig)
|
||||
|
||||
home := t.TempDir()
|
||||
t.Setenv("HOME", home)
|
||||
t.Setenv("USERPROFILE", home)
|
||||
t.Setenv("OLLAMA_NO_CLOUD", "")
|
||||
envconfig.ReloadServerConfig()
|
||||
}
|
||||
|
||||
// runWebTool executes tool against a stub server that responds to every
|
||||
// request with status and body, returning the resulting error.
|
||||
func runWebTool(t *testing.T, tool coreagent.Tool, args map[string]any, path string, status int, body string) error {
|
||||
t.Helper()
|
||||
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != path {
|
||||
t.Fatalf("path = %q, want %q", r.URL.Path, path)
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(status)
|
||||
_, _ = w.Write([]byte(body))
|
||||
}))
|
||||
t.Cleanup(ts.Close)
|
||||
t.Setenv("OLLAMA_HOST", ts.URL)
|
||||
_, err := tool.Execute(t.Context(), coreagent.ToolContext{}, args)
|
||||
return err
|
||||
}
|
||||
|
||||
func TestWebToolsReportAuthenticationError(t *testing.T) {
|
||||
enableWebToolsForTest(t)
|
||||
|
||||
for _, tt := range webToolCases {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
err := runWebTool(t, tt.tool, tt.args, tt.path, http.StatusUnauthorized,
|
||||
`{"error":"unauthorized","signin_url":"https://ollama.com/signin"}`)
|
||||
if !errors.Is(err, ErrWebAuthRequired) {
|
||||
t.Fatalf("error = %v, want %v", err, ErrWebAuthRequired)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestWebToolsPreserveNonAuthenticationErrors(t *testing.T) {
|
||||
enableWebToolsForTest(t)
|
||||
|
||||
for _, tt := range webToolCases {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
err := runWebTool(t, tt.tool, tt.args, tt.path, http.StatusTooManyRequests,
|
||||
`{"error":"web search quota exceeded"}`)
|
||||
if err == nil {
|
||||
t.Fatal("expected error")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "web search quota exceeded") {
|
||||
t.Fatalf("error = %q, want original error message", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestWebToolsIgnoreInheritedCloudPolicy(t *testing.T) {
|
||||
// This cleanup is registered before the test environment, so it restores
|
||||
// the server config cache after t.Setenv restores the runner's values.
|
||||
t.Cleanup(envconfig.ReloadServerConfig)
|
||||
|
||||
home := t.TempDir()
|
||||
configPath := filepath.Join(home, ".ollama", "server.json")
|
||||
if err := os.MkdirAll(filepath.Dir(configPath), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(configPath, []byte(`{"disable_ollama_cloud":true}`), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Setenv("HOME", home)
|
||||
t.Setenv("USERPROFILE", home)
|
||||
t.Setenv("OLLAMA_NO_CLOUD", "1")
|
||||
envconfig.ReloadServerConfig()
|
||||
|
||||
enableWebToolsForTest(t)
|
||||
err := runWebTool(t, &WebSearch{}, map[string]any{"query": "ollama"}, "/api/experimental/web_search", http.StatusUnauthorized,
|
||||
`{"error":"unauthorized","signin_url":"https://ollama.com/signin"}`)
|
||||
if !errors.Is(err, ErrWebAuthRequired) {
|
||||
t.Fatalf("error = %v, want %v", err, ErrWebAuthRequired)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWebFetchRejectsUnsupportedScheme(t *testing.T) {
|
||||
enableWebToolsForTest(t)
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
url string
|
||||
wantErr bool
|
||||
}{
|
||||
{name: "file scheme", url: "file:///etc/passwd", wantErr: true},
|
||||
{name: "data scheme", url: "data:text/plain,secret", wantErr: true},
|
||||
{name: "ftp scheme", url: "ftp://example.com/secret", wantErr: true},
|
||||
{name: "http allowed", url: "http://example.com", wantErr: false},
|
||||
{name: "https allowed", url: "https://example.com", wantErr: false},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
_, err := (&WebFetch{}).Execute(t.Context(), coreagent.ToolContext{}, map[string]any{"url": tt.url})
|
||||
if tt.wantErr && err == nil {
|
||||
t.Fatal("expected unsupported scheme to be rejected")
|
||||
}
|
||||
// For allowed schemes we expect an error only from the missing
|
||||
// server/auth path, not from scheme validation. The http/https
|
||||
// cases reach the client and may fail on connection/auth; we only
|
||||
// assert that the error is NOT a scheme error.
|
||||
if !tt.wantErr && err != nil && strings.Contains(err.Error(), "unsupported URL scheme") {
|
||||
t.Fatalf("http/https rejected as unsupported: %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestWebFetchBoundsContentBeforeReturning(t *testing.T) {
|
||||
enableWebToolsForTest(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 := (&WebFetch{}).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)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWebToolsRejectWhenCloudDisabled(t *testing.T) {
|
||||
t.Setenv("OLLAMA_NO_CLOUD", "1")
|
||||
|
||||
for _, tt := range webToolCases {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
_, err := tt.tool.Execute(t.Context(), coreagent.ToolContext{}, tt.args)
|
||||
want := internalcloud.DisabledError(tt.operation)
|
||||
if err == nil || err.Error() != want {
|
||||
t.Fatalf("error = %v, want %q", err, want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1,352 +0,0 @@
|
||||
package anthropic
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"sort"
|
||||
|
||||
"github.com/ollama/ollama/api"
|
||||
)
|
||||
|
||||
// Trace truncation limits.
|
||||
const (
|
||||
TraceMaxStringRunes = 240
|
||||
TraceMaxSliceItems = 8
|
||||
TraceMaxMapEntries = 16
|
||||
TraceMaxDepth = 4
|
||||
)
|
||||
|
||||
// TraceTruncateString shortens s to TraceMaxStringRunes, appending a count of
|
||||
// omitted characters when truncated.
|
||||
func TraceTruncateString(s string) string {
|
||||
if len(s) == 0 {
|
||||
return s
|
||||
}
|
||||
runes := []rune(s)
|
||||
if len(runes) <= TraceMaxStringRunes {
|
||||
return s
|
||||
}
|
||||
return fmt.Sprintf("%s...(+%d chars)", string(runes[:TraceMaxStringRunes]), len(runes)-TraceMaxStringRunes)
|
||||
}
|
||||
|
||||
// TraceJSON round-trips v through JSON and returns a compacted representation.
|
||||
func TraceJSON(v any) any {
|
||||
if v == nil {
|
||||
return nil
|
||||
}
|
||||
data, err := json.Marshal(v)
|
||||
if err != nil {
|
||||
return map[string]any{"marshal_error": err.Error(), "type": fmt.Sprintf("%T", v)}
|
||||
}
|
||||
var out any
|
||||
if err := json.Unmarshal(data, &out); err != nil {
|
||||
return TraceTruncateString(string(data))
|
||||
}
|
||||
return TraceCompactValue(out, 0)
|
||||
}
|
||||
|
||||
// TraceCompactValue recursively truncates strings, slices, and maps for trace
|
||||
// output. depth tracks recursion to enforce TraceMaxDepth.
|
||||
func TraceCompactValue(v any, depth int) any {
|
||||
if v == nil {
|
||||
return nil
|
||||
}
|
||||
if depth >= TraceMaxDepth {
|
||||
switch t := v.(type) {
|
||||
case string:
|
||||
return TraceTruncateString(t)
|
||||
case []any:
|
||||
return fmt.Sprintf("<array len=%d>", len(t))
|
||||
case map[string]any:
|
||||
return fmt.Sprintf("<object keys=%d>", len(t))
|
||||
default:
|
||||
return fmt.Sprintf("<%T>", v)
|
||||
}
|
||||
}
|
||||
switch t := v.(type) {
|
||||
case string:
|
||||
return TraceTruncateString(t)
|
||||
case []any:
|
||||
limit := min(len(t), TraceMaxSliceItems)
|
||||
out := make([]any, 0, limit+1)
|
||||
for i := range limit {
|
||||
out = append(out, TraceCompactValue(t[i], depth+1))
|
||||
}
|
||||
if len(t) > limit {
|
||||
out = append(out, fmt.Sprintf("... +%d more items", len(t)-limit))
|
||||
}
|
||||
return out
|
||||
case map[string]any:
|
||||
keys := make([]string, 0, len(t))
|
||||
for k := range t {
|
||||
keys = append(keys, k)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
limit := min(len(keys), TraceMaxMapEntries)
|
||||
out := make(map[string]any, limit+1)
|
||||
for i := range limit {
|
||||
out[keys[i]] = TraceCompactValue(t[keys[i]], depth+1)
|
||||
}
|
||||
if len(keys) > limit {
|
||||
out["__truncated_keys"] = len(keys) - limit
|
||||
}
|
||||
return out
|
||||
default:
|
||||
return t
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Anthropic request/response tracing
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// TraceMessagesRequest returns a compact trace representation of a MessagesRequest.
|
||||
func TraceMessagesRequest(r MessagesRequest) map[string]any {
|
||||
return map[string]any{
|
||||
"model": r.Model,
|
||||
"max_tokens": r.MaxTokens,
|
||||
"messages": traceMessageParams(r.Messages),
|
||||
"system": traceAnthropicContent(r.System),
|
||||
"stream": r.Stream,
|
||||
"tools": traceTools(r.Tools),
|
||||
"tool_choice": TraceJSON(r.ToolChoice),
|
||||
"thinking": TraceJSON(r.Thinking),
|
||||
"stop_sequences": r.StopSequences,
|
||||
"temperature": ptrVal(r.Temperature),
|
||||
"top_p": ptrVal(r.TopP),
|
||||
"top_k": ptrVal(r.TopK),
|
||||
}
|
||||
}
|
||||
|
||||
// TraceMessagesResponse returns a compact trace representation of a MessagesResponse.
|
||||
func TraceMessagesResponse(r MessagesResponse) map[string]any {
|
||||
return map[string]any{
|
||||
"id": r.ID,
|
||||
"model": r.Model,
|
||||
"content": TraceJSON(r.Content),
|
||||
"stop_reason": r.StopReason,
|
||||
"usage": r.Usage,
|
||||
}
|
||||
}
|
||||
|
||||
func traceMessageParams(msgs []MessageParam) []map[string]any {
|
||||
out := make([]map[string]any, 0, len(msgs))
|
||||
for _, m := range msgs {
|
||||
out = append(out, map[string]any{
|
||||
"role": m.Role,
|
||||
"content": traceAnthropicContent(m.Content),
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func traceAnthropicContent(content any) any {
|
||||
switch c := content.(type) {
|
||||
case nil:
|
||||
return nil
|
||||
case string:
|
||||
return TraceTruncateString(c)
|
||||
case []any:
|
||||
blocks := make([]any, 0, len(c))
|
||||
for _, block := range c {
|
||||
blockMap, ok := block.(map[string]any)
|
||||
if !ok {
|
||||
blocks = append(blocks, TraceCompactValue(block, 0))
|
||||
continue
|
||||
}
|
||||
blocks = append(blocks, traceAnthropicBlock(blockMap))
|
||||
}
|
||||
return blocks
|
||||
default:
|
||||
return TraceJSON(c)
|
||||
}
|
||||
}
|
||||
|
||||
func traceAnthropicBlock(block map[string]any) map[string]any {
|
||||
blockType, _ := block["type"].(string)
|
||||
out := map[string]any{"type": blockType}
|
||||
switch blockType {
|
||||
case "text":
|
||||
if text, ok := block["text"].(string); ok {
|
||||
out["text"] = TraceTruncateString(text)
|
||||
} else {
|
||||
out["text"] = TraceCompactValue(block["text"], 0)
|
||||
}
|
||||
case "thinking":
|
||||
if thinking, ok := block["thinking"].(string); ok {
|
||||
out["thinking"] = TraceTruncateString(thinking)
|
||||
} else {
|
||||
out["thinking"] = TraceCompactValue(block["thinking"], 0)
|
||||
}
|
||||
case "tool_use", "server_tool_use":
|
||||
out["id"] = block["id"]
|
||||
out["name"] = block["name"]
|
||||
out["input"] = TraceCompactValue(block["input"], 0)
|
||||
case "tool_result", "web_search_tool_result":
|
||||
out["tool_use_id"] = block["tool_use_id"]
|
||||
out["content"] = TraceCompactValue(block["content"], 0)
|
||||
case "image":
|
||||
if source, ok := block["source"].(map[string]any); ok {
|
||||
out["source"] = map[string]any{
|
||||
"type": source["type"],
|
||||
"media_type": source["media_type"],
|
||||
"url": source["url"],
|
||||
"data_len": len(fmt.Sprint(source["data"])),
|
||||
}
|
||||
}
|
||||
default:
|
||||
out["block"] = TraceCompactValue(block, 0)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func traceTools(tools []Tool) []map[string]any {
|
||||
out := make([]map[string]any, 0, len(tools))
|
||||
for _, t := range tools {
|
||||
out = append(out, TraceTool(t))
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// TraceTool returns a compact trace representation of an Anthropic Tool.
|
||||
func TraceTool(t Tool) map[string]any {
|
||||
return map[string]any{
|
||||
"type": t.Type,
|
||||
"name": t.Name,
|
||||
"description": TraceTruncateString(t.Description),
|
||||
"input_schema": TraceJSON(t.InputSchema),
|
||||
"max_uses": t.MaxUses,
|
||||
}
|
||||
}
|
||||
|
||||
// ContentBlockTypes returns the type strings from content (when it's []any blocks).
|
||||
func ContentBlockTypes(content any) []string {
|
||||
blocks, ok := content.([]any)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
types := make([]string, 0, len(blocks))
|
||||
for _, block := range blocks {
|
||||
blockMap, ok := block.(map[string]any)
|
||||
if !ok {
|
||||
types = append(types, fmt.Sprintf("%T", block))
|
||||
continue
|
||||
}
|
||||
t, _ := blockMap["type"].(string)
|
||||
types = append(types, t)
|
||||
}
|
||||
return types
|
||||
}
|
||||
|
||||
func ptrVal[T any](v *T) any {
|
||||
if v == nil {
|
||||
return nil
|
||||
}
|
||||
return *v
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Ollama api.* tracing (shared between anthropic and middleware packages)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// TraceChatRequest returns a compact trace representation of an Ollama ChatRequest.
|
||||
func TraceChatRequest(req *api.ChatRequest) map[string]any {
|
||||
if req == nil {
|
||||
return nil
|
||||
}
|
||||
stream := false
|
||||
if req.Stream != nil {
|
||||
stream = *req.Stream
|
||||
}
|
||||
return map[string]any{
|
||||
"model": req.Model,
|
||||
"messages": TraceAPIMessages(req.Messages),
|
||||
"tools": TraceAPITools(req.Tools),
|
||||
"stream": stream,
|
||||
"options": req.Options,
|
||||
"think": TraceJSON(req.Think),
|
||||
}
|
||||
}
|
||||
|
||||
// TraceChatResponse returns a compact trace representation of an Ollama ChatResponse.
|
||||
func TraceChatResponse(resp api.ChatResponse) map[string]any {
|
||||
return map[string]any{
|
||||
"model": resp.Model,
|
||||
"done": resp.Done,
|
||||
"done_reason": resp.DoneReason,
|
||||
"message": TraceAPIMessage(resp.Message),
|
||||
"metrics": TraceJSON(resp.Metrics),
|
||||
}
|
||||
}
|
||||
|
||||
// TraceAPIMessages returns compact trace representations for a slice of api.Message.
|
||||
func TraceAPIMessages(msgs []api.Message) []map[string]any {
|
||||
out := make([]map[string]any, 0, len(msgs))
|
||||
for _, m := range msgs {
|
||||
out = append(out, TraceAPIMessage(m))
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// TraceAPIMessage returns a compact trace representation of a single api.Message.
|
||||
func TraceAPIMessage(m api.Message) map[string]any {
|
||||
return map[string]any{
|
||||
"role": m.Role,
|
||||
"content": TraceTruncateString(m.Content),
|
||||
"thinking": TraceTruncateString(m.Thinking),
|
||||
"images": traceImageSizes(m.Images),
|
||||
"tool_calls": traceToolCalls(m.ToolCalls),
|
||||
"tool_name": m.ToolName,
|
||||
"tool_call_id": m.ToolCallID,
|
||||
}
|
||||
}
|
||||
|
||||
func traceImageSizes(images []api.ImageData) []int {
|
||||
if len(images) == 0 {
|
||||
return nil
|
||||
}
|
||||
sizes := make([]int, 0, len(images))
|
||||
for _, img := range images {
|
||||
sizes = append(sizes, len(img))
|
||||
}
|
||||
return sizes
|
||||
}
|
||||
|
||||
// TraceAPITools returns compact trace representations for a slice of api.Tool.
|
||||
func TraceAPITools(tools api.Tools) []map[string]any {
|
||||
out := make([]map[string]any, 0, len(tools))
|
||||
for _, t := range tools {
|
||||
out = append(out, TraceAPITool(t))
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// TraceAPITool returns a compact trace representation of a single api.Tool.
|
||||
func TraceAPITool(t api.Tool) map[string]any {
|
||||
return map[string]any{
|
||||
"type": t.Type,
|
||||
"name": t.Function.Name,
|
||||
"description": TraceTruncateString(t.Function.Description),
|
||||
"parameters": TraceJSON(t.Function.Parameters),
|
||||
}
|
||||
}
|
||||
|
||||
// TraceToolCall returns a compact trace representation of an api.ToolCall.
|
||||
func TraceToolCall(tc api.ToolCall) map[string]any {
|
||||
return map[string]any{
|
||||
"id": tc.ID,
|
||||
"name": tc.Function.Name,
|
||||
"args": TraceJSON(tc.Function.Arguments),
|
||||
}
|
||||
}
|
||||
|
||||
func traceToolCalls(tcs []api.ToolCall) []map[string]any {
|
||||
if len(tcs) == 0 {
|
||||
return nil
|
||||
}
|
||||
out := make([]map[string]any, 0, len(tcs))
|
||||
for _, tc := range tcs {
|
||||
out = append(out, TraceToolCall(tc))
|
||||
}
|
||||
return out
|
||||
}
|
||||
343
api/client.go
@@ -1,16 +1,3 @@
|
||||
// Package api implements the client-side API for code wishing to interact
|
||||
// with the ollama service. The methods of the [Client] type correspond to
|
||||
// the ollama REST API as described in [the API documentation].
|
||||
// The ollama command-line client itself uses this package to interact with
|
||||
// the backend service.
|
||||
//
|
||||
// # Examples
|
||||
//
|
||||
// Several examples of using this package are available [in the GitHub
|
||||
// repository].
|
||||
//
|
||||
// [the API documentation]: https://github.com/ollama/ollama/blob/main/docs/api.md
|
||||
// [in the GitHub repository]: https://github.com/ollama/ollama/tree/main/api/examples
|
||||
package api
|
||||
|
||||
import (
|
||||
@@ -18,26 +5,27 @@ import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"runtime"
|
||||
"strconv"
|
||||
"time"
|
||||
"strings"
|
||||
|
||||
"github.com/ollama/ollama/auth"
|
||||
"github.com/ollama/ollama/envconfig"
|
||||
"github.com/ollama/ollama/format"
|
||||
"github.com/ollama/ollama/version"
|
||||
"github.com/jmorganca/ollama/version"
|
||||
)
|
||||
|
||||
const DefaultHost = "127.0.0.1:11434"
|
||||
|
||||
var (
|
||||
envHost = os.Getenv("OLLAMA_HOST")
|
||||
)
|
||||
|
||||
// Client encapsulates client state for interacting with the ollama
|
||||
// service. Use [ClientFromEnvironment] to create new Clients.
|
||||
type Client struct {
|
||||
base *url.URL
|
||||
http *http.Client
|
||||
Base url.URL
|
||||
HTTP http.Client
|
||||
Headers http.Header
|
||||
}
|
||||
|
||||
func checkError(resp *http.Response, body []byte) error {
|
||||
@@ -45,12 +33,6 @@ func checkError(resp *http.Response, body []byte) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
if resp.StatusCode == http.StatusUnauthorized {
|
||||
authError := AuthorizationError{StatusCode: resp.StatusCode}
|
||||
json.Unmarshal(body, &authError)
|
||||
return authError
|
||||
}
|
||||
|
||||
apiError := StatusError{StatusCode: resp.StatusCode}
|
||||
|
||||
err := json.Unmarshal(body, &apiError)
|
||||
@@ -62,73 +44,49 @@ func checkError(resp *http.Response, body []byte) error {
|
||||
return apiError
|
||||
}
|
||||
|
||||
// ClientFromEnvironment creates a new [Client] using configuration from the
|
||||
// environment variable OLLAMA_HOST, which points to the network host and
|
||||
// port on which the ollama service is listening. The format of this variable
|
||||
// is:
|
||||
//
|
||||
// <scheme>://<host>:<port>
|
||||
//
|
||||
// If the variable is not specified, a default ollama host and port will be
|
||||
// used.
|
||||
func ClientFromEnvironment() (*Client, error) {
|
||||
return &Client{
|
||||
base: envconfig.Host(),
|
||||
http: http.DefaultClient,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func NewClient(base *url.URL, http *http.Client) *Client {
|
||||
return &Client{
|
||||
base: base,
|
||||
http: http,
|
||||
// Host returns the default host to use for the client. It is determined in the following order:
|
||||
// 1. The OLLAMA_HOST environment variable
|
||||
// 2. The default host (localhost:11434)
|
||||
func Host() string {
|
||||
if envHost != "" {
|
||||
return envHost
|
||||
}
|
||||
return DefaultHost
|
||||
}
|
||||
|
||||
func getAuthorizationToken(ctx context.Context, challenge string) (string, error) {
|
||||
token, err := auth.Sign(ctx, []byte(challenge))
|
||||
// FromEnv creates a new client using Host() as the host. An error is returns
|
||||
// if the host is invalid.
|
||||
func FromEnv() (*Client, error) {
|
||||
h := Host()
|
||||
if !strings.HasPrefix(h, "http://") && !strings.HasPrefix(h, "https://") {
|
||||
h = "http://" + h
|
||||
}
|
||||
|
||||
u, err := url.Parse(h)
|
||||
if err != nil {
|
||||
return "", err
|
||||
return nil, fmt.Errorf("could not parse host: %w", err)
|
||||
}
|
||||
return token, nil
|
||||
|
||||
if u.Port() == "" {
|
||||
u.Host += ":11434"
|
||||
}
|
||||
|
||||
return &Client{Base: *u, HTTP: http.Client{}}, nil
|
||||
}
|
||||
|
||||
func (c *Client) do(ctx context.Context, method, path string, reqData, respData any) error {
|
||||
var reqBody io.Reader
|
||||
var data []byte
|
||||
var err error
|
||||
|
||||
switch reqData := reqData.(type) {
|
||||
case io.Reader:
|
||||
// reqData is already an io.Reader
|
||||
reqBody = reqData
|
||||
case nil:
|
||||
// noop
|
||||
default:
|
||||
if reqData != nil {
|
||||
data, err = json.Marshal(reqData)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
reqBody = bytes.NewReader(data)
|
||||
}
|
||||
|
||||
requestURL := c.base.JoinPath(path)
|
||||
|
||||
var token string
|
||||
if envconfig.UseAuth() || c.base.Hostname() == "ollama.com" {
|
||||
now := strconv.FormatInt(time.Now().Unix(), 10)
|
||||
chal := fmt.Sprintf("%s,%s?ts=%s", method, path, now)
|
||||
token, err = getAuthorizationToken(ctx, chal)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
q := requestURL.Query()
|
||||
q.Set("ts", now)
|
||||
requestURL.RawQuery = q.Encode()
|
||||
}
|
||||
|
||||
requestURL := c.Base.JoinPath(path)
|
||||
request, err := http.NewRequestWithContext(ctx, method, requestURL.String(), reqBody)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -138,11 +96,11 @@ func (c *Client) do(ctx context.Context, method, path string, reqData, respData
|
||||
request.Header.Set("Accept", "application/json")
|
||||
request.Header.Set("User-Agent", fmt.Sprintf("ollama/%s (%s %s) Go/%s", version.Version, runtime.GOARCH, runtime.GOOS, runtime.Version()))
|
||||
|
||||
if token != "" {
|
||||
request.Header.Set("Authorization", token)
|
||||
for k, v := range c.Headers {
|
||||
request.Header[k] = v
|
||||
}
|
||||
|
||||
respObj, err := c.http.Do(request)
|
||||
respObj, err := c.HTTP.Do(request)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -165,10 +123,8 @@ func (c *Client) do(ctx context.Context, method, path string, reqData, respData
|
||||
return nil
|
||||
}
|
||||
|
||||
const maxBufferSize = 8 * format.MegaByte
|
||||
|
||||
func (c *Client) stream(ctx context.Context, method, path string, data any, fn func([]byte) error) error {
|
||||
var buf io.Reader
|
||||
var buf *bytes.Buffer
|
||||
if data != nil {
|
||||
bts, err := json.Marshal(data)
|
||||
if err != nil {
|
||||
@@ -178,71 +134,38 @@ func (c *Client) stream(ctx context.Context, method, path string, data any, fn f
|
||||
buf = bytes.NewBuffer(bts)
|
||||
}
|
||||
|
||||
requestURL := c.base.JoinPath(path)
|
||||
|
||||
var token string
|
||||
if envconfig.UseAuth() || c.base.Hostname() == "ollama.com" {
|
||||
var err error
|
||||
now := strconv.FormatInt(time.Now().Unix(), 10)
|
||||
chal := fmt.Sprintf("%s,%s?ts=%s", method, path, now)
|
||||
token, err = getAuthorizationToken(ctx, chal)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
q := requestURL.Query()
|
||||
q.Set("ts", now)
|
||||
requestURL.RawQuery = q.Encode()
|
||||
}
|
||||
|
||||
requestURL := c.Base.JoinPath(path)
|
||||
request, err := http.NewRequestWithContext(ctx, method, requestURL.String(), buf)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
request.Header.Set("Content-Type", "application/json")
|
||||
request.Header.Set("Accept", "application/x-ndjson")
|
||||
request.Header.Set("Accept", "application/json")
|
||||
request.Header.Set("User-Agent", fmt.Sprintf("ollama/%s (%s %s) Go/%s", version.Version, runtime.GOARCH, runtime.GOOS, runtime.Version()))
|
||||
|
||||
if token != "" {
|
||||
request.Header.Set("Authorization", token)
|
||||
}
|
||||
|
||||
response, err := c.http.Do(request)
|
||||
response, err := http.DefaultClient.Do(request)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer response.Body.Close()
|
||||
|
||||
scanner := bufio.NewScanner(response.Body)
|
||||
// increase the buffer size to avoid running out of space
|
||||
scanBuf := make([]byte, 0, maxBufferSize)
|
||||
scanner.Buffer(scanBuf, maxBufferSize)
|
||||
for scanner.Scan() {
|
||||
var errorResponse struct {
|
||||
Error string `json:"error,omitempty"`
|
||||
SigninURL string `json:"signin_url,omitempty"`
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
bts := scanner.Bytes()
|
||||
if err := json.Unmarshal(bts, &errorResponse); err != nil {
|
||||
if response.StatusCode >= http.StatusBadRequest {
|
||||
return StatusError{
|
||||
StatusCode: response.StatusCode,
|
||||
Status: response.Status,
|
||||
ErrorMessage: string(bts),
|
||||
}
|
||||
}
|
||||
return errors.New(string(bts))
|
||||
return fmt.Errorf("unmarshal: %w", err)
|
||||
}
|
||||
|
||||
if response.StatusCode == http.StatusUnauthorized {
|
||||
return AuthorizationError{
|
||||
StatusCode: response.StatusCode,
|
||||
Status: response.Status,
|
||||
SigninURL: errorResponse.SigninURL,
|
||||
}
|
||||
} else if response.StatusCode >= http.StatusBadRequest {
|
||||
if errorResponse.Error != "" {
|
||||
return fmt.Errorf(errorResponse.Error)
|
||||
}
|
||||
|
||||
if response.StatusCode >= http.StatusBadRequest {
|
||||
return StatusError{
|
||||
StatusCode: response.StatusCode,
|
||||
Status: response.Status,
|
||||
@@ -250,30 +173,16 @@ func (c *Client) stream(ctx context.Context, method, path string, data any, fn f
|
||||
}
|
||||
}
|
||||
|
||||
if errorResponse.Error != "" {
|
||||
return errors.New(errorResponse.Error)
|
||||
}
|
||||
|
||||
if err := fn(bts); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
if err := scanner.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// GenerateResponseFunc is a function that [Client.Generate] invokes every time
|
||||
// a response is received from the service. If this function returns an error,
|
||||
// [Client.Generate] will stop generating and return this error.
|
||||
type GenerateResponseFunc func(GenerateResponse) error
|
||||
|
||||
// Generate generates a response for a given prompt. The req parameter should
|
||||
// be populated with prompt details. fn is called for each response (there may
|
||||
// be multiple responses, e.g. in case streaming is enabled).
|
||||
func (c *Client) Generate(ctx context.Context, req *GenerateRequest, fn GenerateResponseFunc) error {
|
||||
return c.stream(ctx, http.MethodPost, "/api/generate", req, func(bts []byte) error {
|
||||
var resp GenerateResponse
|
||||
@@ -285,34 +194,8 @@ func (c *Client) Generate(ctx context.Context, req *GenerateRequest, fn Generate
|
||||
})
|
||||
}
|
||||
|
||||
// ChatResponseFunc is a function that [Client.Chat] invokes every time
|
||||
// a response is received from the service. If this function returns an error,
|
||||
// [Client.Chat] will stop generating and return this error.
|
||||
type ChatResponseFunc func(ChatResponse) error
|
||||
|
||||
// Chat generates the next message in a chat. [ChatRequest] may contain a
|
||||
// sequence of messages which can be used to maintain chat history with a model.
|
||||
// fn is called for each response (there may be multiple responses, e.g. if case
|
||||
// streaming is enabled).
|
||||
func (c *Client) Chat(ctx context.Context, req *ChatRequest, fn ChatResponseFunc) error {
|
||||
return c.stream(ctx, http.MethodPost, "/api/chat", req, func(bts []byte) error {
|
||||
var resp ChatResponse
|
||||
if err := json.Unmarshal(bts, &resp); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return fn(resp)
|
||||
})
|
||||
}
|
||||
|
||||
// PullProgressFunc is a function that [Client.Pull] invokes every time there
|
||||
// is progress with a "pull" request sent to the service. If this function
|
||||
// returns an error, [Client.Pull] will stop the process and return this error.
|
||||
type PullProgressFunc func(ProgressResponse) error
|
||||
|
||||
// Pull downloads a model from the ollama library. fn is called each time
|
||||
// progress is made on the request and can be used to display a progress bar,
|
||||
// etc.
|
||||
func (c *Client) Pull(ctx context.Context, req *PullRequest, fn PullProgressFunc) error {
|
||||
return c.stream(ctx, http.MethodPost, "/api/pull", req, func(bts []byte) error {
|
||||
var resp ProgressResponse
|
||||
@@ -324,14 +207,8 @@ func (c *Client) Pull(ctx context.Context, req *PullRequest, fn PullProgressFunc
|
||||
})
|
||||
}
|
||||
|
||||
// PushProgressFunc is a function that [Client.Push] invokes when progress is
|
||||
// made.
|
||||
// It's similar to other progress function types like [PullProgressFunc].
|
||||
type PushProgressFunc func(ProgressResponse) error
|
||||
|
||||
// Push uploads a model to the model library; requires registering for ollama.ai
|
||||
// and adding a public key first. fn is called each time progress is made on
|
||||
// the request and can be used to display a progress bar, etc.
|
||||
func (c *Client) Push(ctx context.Context, req *PushRequest, fn PushProgressFunc) error {
|
||||
return c.stream(ctx, http.MethodPost, "/api/push", req, func(bts []byte) error {
|
||||
var resp ProgressResponse
|
||||
@@ -343,15 +220,8 @@ func (c *Client) Push(ctx context.Context, req *PushRequest, fn PushProgressFunc
|
||||
})
|
||||
}
|
||||
|
||||
// CreateProgressFunc is a function that [Client.Create] invokes when progress
|
||||
// is made.
|
||||
// It's similar to other progress function types like [PullProgressFunc].
|
||||
type CreateProgressFunc func(ProgressResponse) error
|
||||
|
||||
// Create creates a model from a [Modelfile]. fn is a progress function that
|
||||
// behaves similarly to other methods (see [Client.Pull]).
|
||||
//
|
||||
// [Modelfile]: https://github.com/ollama/ollama/blob/main/docs/modelfile.mdx
|
||||
func (c *Client) Create(ctx context.Context, req *CreateRequest, fn CreateProgressFunc) error {
|
||||
return c.stream(ctx, http.MethodPost, "/api/create", req, func(bts []byte) error {
|
||||
var resp ProgressResponse
|
||||
@@ -363,7 +233,6 @@ func (c *Client) Create(ctx context.Context, req *CreateRequest, fn CreateProgre
|
||||
})
|
||||
}
|
||||
|
||||
// List lists models that are available locally.
|
||||
func (c *Client) List(ctx context.Context) (*ListResponse, error) {
|
||||
var lr ListResponse
|
||||
if err := c.do(ctx, http.MethodGet, "/api/tags", nil, &lr); err != nil {
|
||||
@@ -372,27 +241,6 @@ func (c *Client) List(ctx context.Context) (*ListResponse, error) {
|
||||
return &lr, nil
|
||||
}
|
||||
|
||||
// ModelRecommendationsExperimental lists model recommendations from the local
|
||||
// server's experimental recommendations endpoint.
|
||||
func (c *Client) ModelRecommendationsExperimental(ctx context.Context) (*ModelRecommendationsResponse, error) {
|
||||
var resp ModelRecommendationsResponse
|
||||
if err := c.do(ctx, http.MethodGet, "/api/experimental/model-recommendations", nil, &resp); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &resp, nil
|
||||
}
|
||||
|
||||
// ListRunning lists running models.
|
||||
func (c *Client) ListRunning(ctx context.Context) (*ProcessResponse, error) {
|
||||
var lr ProcessResponse
|
||||
if err := c.do(ctx, http.MethodGet, "/api/ps", nil, &lr); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &lr, nil
|
||||
}
|
||||
|
||||
// Copy copies a model - creating a model with another name from an existing
|
||||
// model.
|
||||
func (c *Client) Copy(ctx context.Context, req *CopyRequest) error {
|
||||
if err := c.do(ctx, http.MethodPost, "/api/copy", req, nil); err != nil {
|
||||
return err
|
||||
@@ -400,7 +248,6 @@ func (c *Client) Copy(ctx context.Context, req *CopyRequest) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Delete deletes a model and its data.
|
||||
func (c *Client) Delete(ctx context.Context, req *DeleteRequest) error {
|
||||
if err := c.do(ctx, http.MethodDelete, "/api/delete", req, nil); err != nil {
|
||||
return err
|
||||
@@ -408,7 +255,6 @@ func (c *Client) Delete(ctx context.Context, req *DeleteRequest) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Show obtains model information, including details, modelfile, license etc.
|
||||
func (c *Client) Show(ctx context.Context, req *ShowRequest) (*ShowResponse, error) {
|
||||
var resp ShowResponse
|
||||
if err := c.do(ctx, http.MethodPost, "/api/show", req, &resp); err != nil {
|
||||
@@ -417,96 +263,9 @@ func (c *Client) Show(ctx context.Context, req *ShowRequest) (*ShowResponse, err
|
||||
return &resp, nil
|
||||
}
|
||||
|
||||
// Heartbeat checks if the server has started and is responsive; if yes, it
|
||||
// returns nil, otherwise an error.
|
||||
func (c *Client) Heartbeat(ctx context.Context) error {
|
||||
if err := c.do(ctx, http.MethodHead, "/", nil, nil); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Embed generates embeddings from a model.
|
||||
func (c *Client) Embed(ctx context.Context, req *EmbedRequest) (*EmbedResponse, error) {
|
||||
var resp EmbedResponse
|
||||
if err := c.do(ctx, http.MethodPost, "/api/embed", req, &resp); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &resp, nil
|
||||
}
|
||||
|
||||
// Embeddings generates an embedding from a model.
|
||||
func (c *Client) Embeddings(ctx context.Context, req *EmbeddingRequest) (*EmbeddingResponse, error) {
|
||||
var resp EmbeddingResponse
|
||||
if err := c.do(ctx, http.MethodPost, "/api/embeddings", req, &resp); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &resp, nil
|
||||
}
|
||||
|
||||
// CreateBlob creates a blob from a file on the server. digest is the
|
||||
// expected SHA256 digest of the file, and r represents the file.
|
||||
func (c *Client) CreateBlob(ctx context.Context, digest string, r io.Reader) error {
|
||||
return c.do(ctx, http.MethodPost, fmt.Sprintf("/api/blobs/%s", digest), r, nil)
|
||||
}
|
||||
|
||||
// Version returns the Ollama server version as a string.
|
||||
func (c *Client) Version(ctx context.Context) (string, error) {
|
||||
var version struct {
|
||||
Version string `json:"version"`
|
||||
}
|
||||
|
||||
if err := c.do(ctx, http.MethodGet, "/api/version", nil, &version); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
return version.Version, nil
|
||||
}
|
||||
|
||||
// CloudStatusExperimental returns whether cloud features are disabled on the server.
|
||||
func (c *Client) CloudStatusExperimental(ctx context.Context) (*StatusResponse, error) {
|
||||
var status StatusResponse
|
||||
if err := c.do(ctx, http.MethodGet, "/api/status", nil, &status); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
// Disconnect will disconnect an ollama instance from ollama.com.
|
||||
func (c *Client) Disconnect(ctx context.Context, encodedKey string) error {
|
||||
return c.do(ctx, http.MethodDelete, fmt.Sprintf("/api/user/keys/%s", encodedKey), nil, nil)
|
||||
}
|
||||
|
||||
func (c *Client) Whoami(ctx context.Context) (*UserResponse, error) {
|
||||
var resp UserResponse
|
||||
if err := c.do(ctx, http.MethodPost, "/api/me", nil, &resp); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &resp, nil
|
||||
}
|
||||
|
||||
225
api/client.py
Normal file
@@ -0,0 +1,225 @@
|
||||
import os
|
||||
import json
|
||||
import requests
|
||||
|
||||
BASE_URL = os.environ.get('OLLAMA_HOST', 'http://localhost:11434')
|
||||
|
||||
# Generate a response for a given prompt with a provided model. This is a streaming endpoint, so will be a series of responses.
|
||||
# The final response object will include statistics and additional data from the request. Use the callback function to override
|
||||
# the default handler.
|
||||
def generate(model_name, prompt, system=None, template=None, context=None, options=None, callback=None):
|
||||
try:
|
||||
url = f"{BASE_URL}/api/generate"
|
||||
payload = {
|
||||
"model": model_name,
|
||||
"prompt": prompt,
|
||||
"system": system,
|
||||
"template": template,
|
||||
"context": context,
|
||||
"options": options
|
||||
}
|
||||
|
||||
# Remove keys with None values
|
||||
payload = {k: v for k, v in payload.items() if v is not None}
|
||||
|
||||
with requests.post(url, json=payload, stream=True) as response:
|
||||
response.raise_for_status()
|
||||
|
||||
# Creating a variable to hold the context history of the final chunk
|
||||
final_context = None
|
||||
|
||||
# Variable to hold concatenated response strings if no callback is provided
|
||||
full_response = ""
|
||||
|
||||
# Iterating over the response line by line and displaying the details
|
||||
for line in response.iter_lines():
|
||||
if line:
|
||||
# Parsing each line (JSON chunk) and extracting the details
|
||||
chunk = json.loads(line)
|
||||
|
||||
# If a callback function is provided, call it with the chunk
|
||||
if callback:
|
||||
callback(chunk)
|
||||
else:
|
||||
# If this is not the last chunk, add the "response" field value to full_response and print it
|
||||
if not chunk.get("done"):
|
||||
response_piece = chunk.get("response", "")
|
||||
full_response += response_piece
|
||||
print(response_piece, end="", flush=True)
|
||||
|
||||
# Check if it's the last chunk (done is true)
|
||||
if chunk.get("done"):
|
||||
final_context = chunk.get("context")
|
||||
|
||||
# Return the full response and the final context
|
||||
return full_response, final_context
|
||||
except requests.exceptions.RequestException as e:
|
||||
print(f"An error occurred: {e}")
|
||||
return None, None
|
||||
|
||||
# Create a model from a Modelfile. Use the callback function to override the default handler.
|
||||
def create(model_name, model_path, callback=None):
|
||||
try:
|
||||
url = f"{BASE_URL}/api/create"
|
||||
payload = {"name": model_name, "path": model_path}
|
||||
|
||||
# Making a POST request with the stream parameter set to True to handle streaming responses
|
||||
with requests.post(url, json=payload, stream=True) as response:
|
||||
response.raise_for_status()
|
||||
|
||||
# Iterating over the response line by line and displaying the status
|
||||
for line in response.iter_lines():
|
||||
if line:
|
||||
# Parsing each line (JSON chunk) and extracting the status
|
||||
chunk = json.loads(line)
|
||||
|
||||
if callback:
|
||||
callback(chunk)
|
||||
else:
|
||||
print(f"Status: {chunk.get('status')}")
|
||||
except requests.exceptions.RequestException as e:
|
||||
print(f"An error occurred: {e}")
|
||||
|
||||
# Pull a model from a the model registry. Cancelled pulls are resumed from where they left off, and multiple
|
||||
# calls to will share the same download progress. Use the callback function to override the default handler.
|
||||
def pull(model_name, insecure=False, callback=None):
|
||||
try:
|
||||
url = f"{BASE_URL}/api/pull"
|
||||
payload = {
|
||||
"name": model_name,
|
||||
"insecure": insecure
|
||||
}
|
||||
|
||||
# Making a POST request with the stream parameter set to True to handle streaming responses
|
||||
with requests.post(url, json=payload, stream=True) as response:
|
||||
response.raise_for_status()
|
||||
|
||||
# Iterating over the response line by line and displaying the details
|
||||
for line in response.iter_lines():
|
||||
if line:
|
||||
# Parsing each line (JSON chunk) and extracting the details
|
||||
chunk = json.loads(line)
|
||||
|
||||
# If a callback function is provided, call it with the chunk
|
||||
if callback:
|
||||
callback(chunk)
|
||||
else:
|
||||
# Print the status message directly to the console
|
||||
print(chunk.get('status', ''), end='', flush=True)
|
||||
|
||||
# If there's layer data, you might also want to print that (adjust as necessary)
|
||||
if 'digest' in chunk:
|
||||
print(f" - Digest: {chunk['digest']}", end='', flush=True)
|
||||
print(f" - Total: {chunk['total']}", end='', flush=True)
|
||||
print(f" - Completed: {chunk['completed']}", end='\n', flush=True)
|
||||
else:
|
||||
print()
|
||||
except requests.exceptions.RequestException as e:
|
||||
print(f"An error occurred: {e}")
|
||||
|
||||
# Push a model to the model registry. Use the callback function to override the default handler.
|
||||
def push(model_name, insecure=False, callback=None):
|
||||
try:
|
||||
url = f"{BASE_URL}/api/push"
|
||||
payload = {
|
||||
"name": model_name,
|
||||
"insecure": insecure
|
||||
}
|
||||
|
||||
# Making a POST request with the stream parameter set to True to handle streaming responses
|
||||
with requests.post(url, json=payload, stream=True) as response:
|
||||
response.raise_for_status()
|
||||
|
||||
# Iterating over the response line by line and displaying the details
|
||||
for line in response.iter_lines():
|
||||
if line:
|
||||
# Parsing each line (JSON chunk) and extracting the details
|
||||
chunk = json.loads(line)
|
||||
|
||||
# If a callback function is provided, call it with the chunk
|
||||
if callback:
|
||||
callback(chunk)
|
||||
else:
|
||||
# Print the status message directly to the console
|
||||
print(chunk.get('status', ''), end='', flush=True)
|
||||
|
||||
# If there's layer data, you might also want to print that (adjust as necessary)
|
||||
if 'digest' in chunk:
|
||||
print(f" - Digest: {chunk['digest']}", end='', flush=True)
|
||||
print(f" - Total: {chunk['total']}", end='', flush=True)
|
||||
print(f" - Completed: {chunk['completed']}", end='\n', flush=True)
|
||||
else:
|
||||
print()
|
||||
except requests.exceptions.RequestException as e:
|
||||
print(f"An error occurred: {e}")
|
||||
|
||||
# List models that are available locally.
|
||||
def list():
|
||||
try:
|
||||
response = requests.get(f"{BASE_URL}/api/tags")
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
models = data.get('models', [])
|
||||
return models
|
||||
|
||||
except requests.exceptions.RequestException as e:
|
||||
print(f"An error occurred: {e}")
|
||||
return None
|
||||
|
||||
# Copy a model. Creates a model with another name from an existing model.
|
||||
def copy(source, destination):
|
||||
try:
|
||||
# Create the JSON payload
|
||||
payload = {
|
||||
"source": source,
|
||||
"destination": destination
|
||||
}
|
||||
|
||||
response = requests.post(f"{BASE_URL}/api/copy", json=payload)
|
||||
response.raise_for_status()
|
||||
|
||||
# If the request was successful, return a message indicating that the copy was successful
|
||||
return "Copy successful"
|
||||
|
||||
except requests.exceptions.RequestException as e:
|
||||
print(f"An error occurred: {e}")
|
||||
return None
|
||||
|
||||
# Delete a model and its data.
|
||||
def delete(model_name):
|
||||
try:
|
||||
url = f"{BASE_URL}/api/delete"
|
||||
payload = {"name": model_name}
|
||||
response = requests.delete(url, json=payload)
|
||||
response.raise_for_status()
|
||||
return "Delete successful"
|
||||
except requests.exceptions.RequestException as e:
|
||||
print(f"An error occurred: {e}")
|
||||
return None
|
||||
|
||||
# Show info about a model.
|
||||
def show(model_name):
|
||||
try:
|
||||
url = f"{BASE_URL}/api/show"
|
||||
payload = {"name": model_name}
|
||||
response = requests.post(url, json=payload)
|
||||
response.raise_for_status()
|
||||
|
||||
# Parse the JSON response and return it
|
||||
data = response.json()
|
||||
return data
|
||||
except requests.exceptions.RequestException as e:
|
||||
print(f"An error occurred: {e}")
|
||||
return None
|
||||
|
||||
def heartbeat():
|
||||
try:
|
||||
url = f"{BASE_URL}/"
|
||||
response = requests.head(url)
|
||||
response.raise_for_status()
|
||||
return "Ollama is running"
|
||||
except requests.exceptions.RequestException as e:
|
||||
print(f"An error occurred: {e}")
|
||||
return "Ollama is not running"
|
||||
|
||||
|
||||
@@ -1,448 +0,0 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestClientFromEnvironment(t *testing.T) {
|
||||
type testCase struct {
|
||||
value string
|
||||
expect string
|
||||
err error
|
||||
}
|
||||
|
||||
testCases := map[string]*testCase{
|
||||
"empty": {value: "", expect: "http://127.0.0.1:11434"},
|
||||
"only address": {value: "1.2.3.4", expect: "http://1.2.3.4:11434"},
|
||||
"only port": {value: ":1234", expect: "http://:1234"},
|
||||
"address and port": {value: "1.2.3.4:1234", expect: "http://1.2.3.4:1234"},
|
||||
"scheme http and address": {value: "http://1.2.3.4", expect: "http://1.2.3.4:80"},
|
||||
"scheme https and address": {value: "https://1.2.3.4", expect: "https://1.2.3.4:443"},
|
||||
"scheme, address, and port": {value: "https://1.2.3.4:1234", expect: "https://1.2.3.4:1234"},
|
||||
"hostname": {value: "example.com", expect: "http://example.com:11434"},
|
||||
"hostname and port": {value: "example.com:1234", expect: "http://example.com:1234"},
|
||||
"scheme http and hostname": {value: "http://example.com", expect: "http://example.com:80"},
|
||||
"scheme https and hostname": {value: "https://example.com", expect: "https://example.com:443"},
|
||||
"scheme, hostname, and port": {value: "https://example.com:1234", expect: "https://example.com:1234"},
|
||||
"trailing slash": {value: "example.com/", expect: "http://example.com:11434"},
|
||||
"trailing slash port": {value: "example.com:1234/", expect: "http://example.com:1234"},
|
||||
}
|
||||
|
||||
for k, v := range testCases {
|
||||
t.Run(k, func(t *testing.T) {
|
||||
t.Setenv("OLLAMA_HOST", v.value)
|
||||
|
||||
client, err := ClientFromEnvironment()
|
||||
if err != v.err {
|
||||
t.Fatalf("expected %s, got %s", v.err, err)
|
||||
}
|
||||
|
||||
if client.base.String() != v.expect {
|
||||
t.Fatalf("expected %s, got %s", v.expect, client.base.String())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// testError represents an internal error type with status code and message
|
||||
// this is used since the error response from the server is not a standard error struct
|
||||
type testError struct {
|
||||
message string
|
||||
statusCode int
|
||||
raw bool // if true, write message as-is instead of JSON encoding
|
||||
}
|
||||
|
||||
func (e testError) Error() string {
|
||||
return e.message
|
||||
}
|
||||
|
||||
func TestClientStream(t *testing.T) {
|
||||
testCases := []struct {
|
||||
name string
|
||||
responses []any
|
||||
wantErr string
|
||||
}{
|
||||
{
|
||||
name: "immediate error response",
|
||||
responses: []any{
|
||||
testError{
|
||||
message: "test error message",
|
||||
statusCode: http.StatusBadRequest,
|
||||
},
|
||||
},
|
||||
wantErr: "test error message",
|
||||
},
|
||||
{
|
||||
name: "error after successful chunks, ok response",
|
||||
responses: []any{
|
||||
ChatResponse{Message: Message{Content: "partial response 1"}},
|
||||
ChatResponse{Message: Message{Content: "partial response 2"}},
|
||||
testError{
|
||||
message: "mid-stream error",
|
||||
statusCode: http.StatusOK,
|
||||
},
|
||||
},
|
||||
wantErr: "mid-stream error",
|
||||
},
|
||||
{
|
||||
name: "http status error takes precedence over general error",
|
||||
responses: []any{
|
||||
testError{
|
||||
message: "custom error message",
|
||||
statusCode: http.StatusInternalServerError,
|
||||
},
|
||||
},
|
||||
wantErr: "500",
|
||||
},
|
||||
{
|
||||
name: "successful stream completion",
|
||||
responses: []any{
|
||||
ChatResponse{Message: Message{Content: "chunk 1"}},
|
||||
ChatResponse{Message: Message{Content: "chunk 2"}},
|
||||
ChatResponse{
|
||||
Message: Message{Content: "final chunk"},
|
||||
Done: true,
|
||||
DoneReason: "stop",
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "plain text error response",
|
||||
responses: []any{
|
||||
"internal server error",
|
||||
},
|
||||
wantErr: "internal server error",
|
||||
},
|
||||
{
|
||||
name: "HTML error page",
|
||||
responses: []any{
|
||||
"<html><body>404 Not Found</body></html>",
|
||||
},
|
||||
wantErr: "404 Not Found",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range testCases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
flusher, ok := w.(http.Flusher)
|
||||
if !ok {
|
||||
t.Fatal("expected http.Flusher")
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/x-ndjson")
|
||||
|
||||
for _, resp := range tc.responses {
|
||||
if errResp, ok := resp.(testError); ok {
|
||||
w.WriteHeader(errResp.statusCode)
|
||||
err := json.NewEncoder(w).Encode(map[string]string{
|
||||
"error": errResp.message,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal("failed to encode error response:", err)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if str, ok := resp.(string); ok {
|
||||
fmt.Fprintln(w, str)
|
||||
flusher.Flush()
|
||||
continue
|
||||
}
|
||||
|
||||
if err := json.NewEncoder(w).Encode(resp); err != nil {
|
||||
t.Fatalf("failed to encode response: %v", err)
|
||||
}
|
||||
flusher.Flush()
|
||||
}
|
||||
}))
|
||||
defer ts.Close()
|
||||
|
||||
client := NewClient(&url.URL{Scheme: "http", Host: ts.Listener.Addr().String()}, http.DefaultClient)
|
||||
|
||||
var receivedChunks []ChatResponse
|
||||
err := client.stream(t.Context(), http.MethodPost, "/v1/chat", nil, func(chunk []byte) error {
|
||||
var resp ChatResponse
|
||||
if err := json.Unmarshal(chunk, &resp); err != nil {
|
||||
return fmt.Errorf("failed to unmarshal chunk: %w", err)
|
||||
}
|
||||
receivedChunks = append(receivedChunks, resp)
|
||||
return nil
|
||||
})
|
||||
|
||||
if tc.wantErr != "" {
|
||||
if err == nil {
|
||||
t.Fatal("expected error but got nil")
|
||||
}
|
||||
if !strings.Contains(err.Error(), tc.wantErr) {
|
||||
t.Errorf("expected error containing %q, got %v", tc.wantErr, err)
|
||||
}
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
t.Errorf("unexpected error: %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
response any
|
||||
wantErr string
|
||||
wantStatusCode int
|
||||
}{
|
||||
{
|
||||
name: "immediate error response",
|
||||
response: testError{
|
||||
message: "test error message",
|
||||
statusCode: http.StatusBadRequest,
|
||||
},
|
||||
wantErr: "test error message",
|
||||
wantStatusCode: http.StatusBadRequest,
|
||||
},
|
||||
{
|
||||
name: "server error response",
|
||||
response: testError{
|
||||
message: "internal error",
|
||||
statusCode: http.StatusInternalServerError,
|
||||
},
|
||||
wantErr: "internal error",
|
||||
wantStatusCode: http.StatusInternalServerError,
|
||||
},
|
||||
{
|
||||
name: "successful response",
|
||||
response: struct {
|
||||
ID string `json:"id"`
|
||||
Success bool `json:"success"`
|
||||
}{
|
||||
ID: "msg_123",
|
||||
Success: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "plain text error response",
|
||||
response: testError{
|
||||
message: "internal server error",
|
||||
statusCode: http.StatusInternalServerError,
|
||||
raw: true,
|
||||
},
|
||||
wantErr: "internal server error",
|
||||
wantStatusCode: http.StatusInternalServerError,
|
||||
},
|
||||
{
|
||||
name: "HTML error page",
|
||||
response: testError{
|
||||
message: "<html><body>404 Not Found</body></html>",
|
||||
statusCode: http.StatusNotFound,
|
||||
raw: true,
|
||||
},
|
||||
wantErr: "<html><body>404 Not Found</body></html>",
|
||||
wantStatusCode: http.StatusNotFound,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range testCases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if errResp, ok := tc.response.(testError); ok {
|
||||
w.WriteHeader(errResp.statusCode)
|
||||
if !errResp.raw {
|
||||
err := json.NewEncoder(w).Encode(map[string]string{
|
||||
"error": errResp.message,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal("failed to encode error response:", err)
|
||||
}
|
||||
} else {
|
||||
// Write raw message (simulates non-JSON error responses)
|
||||
fmt.Fprint(w, errResp.message)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
if err := json.NewEncoder(w).Encode(tc.response); err != nil {
|
||||
t.Fatalf("failed to encode response: %v", err)
|
||||
}
|
||||
}))
|
||||
defer ts.Close()
|
||||
|
||||
client := NewClient(&url.URL{Scheme: "http", Host: ts.Listener.Addr().String()}, http.DefaultClient)
|
||||
|
||||
var resp struct {
|
||||
ID string `json:"id"`
|
||||
Success bool `json:"success"`
|
||||
}
|
||||
err := client.do(t.Context(), http.MethodPost, "/v1/messages", nil, &resp)
|
||||
|
||||
if tc.wantErr != "" {
|
||||
if err == nil {
|
||||
t.Fatalf("got nil, want error %q", tc.wantErr)
|
||||
}
|
||||
if err.Error() != tc.wantErr {
|
||||
t.Errorf("error message mismatch: got %q, want %q", err.Error(), tc.wantErr)
|
||||
}
|
||||
if tc.wantStatusCode != 0 {
|
||||
if statusErr, ok := err.(StatusError); ok {
|
||||
if statusErr.StatusCode != tc.wantStatusCode {
|
||||
t.Errorf("status code mismatch: got %d, want %d", statusErr.StatusCode, tc.wantStatusCode)
|
||||
}
|
||||
} else {
|
||||
t.Errorf("expected StatusError, got %T", err)
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
t.Fatalf("got error %q, want nil", err)
|
||||
}
|
||||
|
||||
if expectedResp, ok := tc.response.(struct {
|
||||
ID string `json:"id"`
|
||||
Success bool `json:"success"`
|
||||
}); ok {
|
||||
if resp.ID != expectedResp.ID {
|
||||
t.Errorf("response ID mismatch: got %q, want %q", resp.ID, expectedResp.ID)
|
||||
}
|
||||
if resp.Success != expectedResp.Success {
|
||||
t.Errorf("response Success mismatch: got %v, want %v", resp.Success, expectedResp.Success)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
@@ -1,18 +0,0 @@
|
||||
# Ollama API Examples
|
||||
|
||||
Run the examples in this directory with:
|
||||
|
||||
```shell
|
||||
go run example_name/main.go
|
||||
```
|
||||
|
||||
## Chat - Chat with a model
|
||||
- [chat/main.go](chat/main.go)
|
||||
|
||||
## Generate - Generate text from a model
|
||||
- [generate/main.go](generate/main.go)
|
||||
- [generate-streaming/main.go](generate-streaming/main.go)
|
||||
|
||||
## Pull - Pull a model
|
||||
- [pull-progress/main.go](pull-progress/main.go)
|
||||
|
||||
@@ -1,51 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log"
|
||||
|
||||
"github.com/ollama/ollama/api"
|
||||
)
|
||||
|
||||
func main() {
|
||||
client, err := api.ClientFromEnvironment()
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
messages := []api.Message{
|
||||
{
|
||||
Role: "system",
|
||||
Content: "Provide very brief, concise responses",
|
||||
},
|
||||
{
|
||||
Role: "user",
|
||||
Content: "Name some unusual animals",
|
||||
},
|
||||
{
|
||||
Role: "assistant",
|
||||
Content: "Monotreme, platypus, echidna",
|
||||
},
|
||||
{
|
||||
Role: "user",
|
||||
Content: "which of these is the most dangerous?",
|
||||
},
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
req := &api.ChatRequest{
|
||||
Model: "llama3.2",
|
||||
Messages: messages,
|
||||
}
|
||||
|
||||
respFunc := func(resp api.ChatResponse) error {
|
||||
fmt.Print(resp.Message.Content)
|
||||
return nil
|
||||
}
|
||||
|
||||
err = client.Chat(ctx, req, respFunc)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
}
|
||||
@@ -1,40 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log"
|
||||
|
||||
"github.com/ollama/ollama/api"
|
||||
)
|
||||
|
||||
func main() {
|
||||
client, err := api.ClientFromEnvironment()
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
// By default, GenerateRequest is streaming.
|
||||
req := &api.GenerateRequest{
|
||||
Model: "gemma2",
|
||||
Prompt: "how many planets are there?",
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
respFunc := func(resp api.GenerateResponse) error {
|
||||
// Only print the response here; GenerateResponse has a number of other
|
||||
// interesting fields you want to examine.
|
||||
|
||||
// In streaming mode, responses are partial so we call fmt.Print (and not
|
||||
// Println) in order to avoid spurious newlines being introduced. The
|
||||
// model will insert its own newlines if it wants.
|
||||
fmt.Print(resp.Response)
|
||||
return nil
|
||||
}
|
||||
|
||||
err = client.Generate(ctx, req, respFunc)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
fmt.Println()
|
||||
}
|
||||
@@ -1,37 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log"
|
||||
|
||||
"github.com/ollama/ollama/api"
|
||||
)
|
||||
|
||||
func main() {
|
||||
client, err := api.ClientFromEnvironment()
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
req := &api.GenerateRequest{
|
||||
Model: "gemma2",
|
||||
Prompt: "how many planets are there?",
|
||||
|
||||
// set streaming to false
|
||||
Stream: new(bool),
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
respFunc := func(resp api.GenerateResponse) error {
|
||||
// Only print the response here; GenerateResponse has a number of other
|
||||
// interesting fields you want to examine.
|
||||
fmt.Println(resp.Response)
|
||||
return nil
|
||||
}
|
||||
|
||||
err = client.Generate(ctx, req, respFunc)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
}
|
||||
@@ -1,47 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
|
||||
"github.com/ollama/ollama/api"
|
||||
)
|
||||
|
||||
func main() {
|
||||
if len(os.Args) <= 1 {
|
||||
log.Fatal("usage: <image name>")
|
||||
}
|
||||
|
||||
imgData, err := os.ReadFile(os.Args[1])
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
client, err := api.ClientFromEnvironment()
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
req := &api.GenerateRequest{
|
||||
Model: "llava",
|
||||
Prompt: "describe this image",
|
||||
Images: []api.ImageData{imgData},
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
respFunc := func(resp api.GenerateResponse) error {
|
||||
// In streaming mode, responses are partial so we call fmt.Print (and not
|
||||
// Println) in order to avoid spurious newlines being introduced. The
|
||||
// model will insert its own newlines if it wants.
|
||||
fmt.Print(resp.Response)
|
||||
return nil
|
||||
}
|
||||
|
||||
err = client.Generate(ctx, req, respFunc)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
fmt.Println()
|
||||
}
|
||||
@@ -1,31 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log"
|
||||
|
||||
"github.com/ollama/ollama/api"
|
||||
)
|
||||
|
||||
func main() {
|
||||
client, err := api.ClientFromEnvironment()
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
req := &api.PullRequest{
|
||||
Model: "mistral",
|
||||
}
|
||||
progressFunc := func(resp api.ProgressResponse) error {
|
||||
fmt.Printf("Progress: status=%v, total=%v, completed=%v\n", resp.Status, resp.Total, resp.Completed)
|
||||
return nil
|
||||
}
|
||||
|
||||
err = client.Pull(ctx, req, progressFunc)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
}
|
||||
1477
api/types.go
@@ -1,968 +0,0 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"math"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// testPropsMap creates a ToolPropertiesMap from a map (convenience function for tests, order not preserved)
|
||||
func testPropsMap(m map[string]ToolProperty) *ToolPropertiesMap {
|
||||
props := NewToolPropertiesMap()
|
||||
for k, v := range m {
|
||||
props.Set(k, v)
|
||||
}
|
||||
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()
|
||||
for k, v := range m {
|
||||
args.Set(k, v)
|
||||
}
|
||||
return args
|
||||
}
|
||||
|
||||
func TestKeepAliveParsingFromJSON(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
req string
|
||||
exp *Duration
|
||||
}{
|
||||
{
|
||||
name: "Unset",
|
||||
req: `{ }`,
|
||||
exp: nil,
|
||||
},
|
||||
{
|
||||
name: "Positive Integer",
|
||||
req: `{ "keep_alive": 42 }`,
|
||||
exp: &Duration{42 * time.Second},
|
||||
},
|
||||
{
|
||||
name: "Positive Float",
|
||||
req: `{ "keep_alive": 42.5 }`,
|
||||
exp: &Duration{42500 * time.Millisecond},
|
||||
},
|
||||
{
|
||||
name: "Positive Integer String",
|
||||
req: `{ "keep_alive": "42m" }`,
|
||||
exp: &Duration{42 * time.Minute},
|
||||
},
|
||||
{
|
||||
name: "Negative Integer",
|
||||
req: `{ "keep_alive": -1 }`,
|
||||
exp: &Duration{math.MaxInt64},
|
||||
},
|
||||
{
|
||||
name: "Negative Float",
|
||||
req: `{ "keep_alive": -3.14 }`,
|
||||
exp: &Duration{math.MaxInt64},
|
||||
},
|
||||
{
|
||||
name: "Negative Integer String",
|
||||
req: `{ "keep_alive": "-1m" }`,
|
||||
exp: &Duration{math.MaxInt64},
|
||||
},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
var dec ChatRequest
|
||||
err := json.Unmarshal([]byte(test.req), &dec)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Equal(t, test.exp, dec.KeepAlive)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDurationMarshalUnmarshal(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input time.Duration
|
||||
expected time.Duration
|
||||
}{
|
||||
{
|
||||
"negative duration",
|
||||
time.Duration(-1),
|
||||
time.Duration(math.MaxInt64),
|
||||
},
|
||||
{
|
||||
"positive duration",
|
||||
42 * time.Second,
|
||||
42 * time.Second,
|
||||
},
|
||||
{
|
||||
"another positive duration",
|
||||
42 * time.Minute,
|
||||
42 * time.Minute,
|
||||
},
|
||||
{
|
||||
"zero duration",
|
||||
time.Duration(0),
|
||||
time.Duration(0),
|
||||
},
|
||||
{
|
||||
"max duration",
|
||||
time.Duration(math.MaxInt64),
|
||||
time.Duration(math.MaxInt64),
|
||||
},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
b, err := json.Marshal(Duration{test.input})
|
||||
require.NoError(t, err)
|
||||
|
||||
var d Duration
|
||||
err = json.Unmarshal(b, &d)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Equal(t, test.expected, d.Duration, "input %v, marshalled %v, got %v", test.input, string(b), d.Duration)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestUseMmapParsingFromJSON(t *testing.T) {
|
||||
tr := true
|
||||
fa := false
|
||||
tests := []struct {
|
||||
name string
|
||||
req string
|
||||
exp *bool
|
||||
}{
|
||||
{
|
||||
name: "Undefined",
|
||||
req: `{ }`,
|
||||
exp: nil,
|
||||
},
|
||||
{
|
||||
name: "True",
|
||||
req: `{ "use_mmap": true }`,
|
||||
exp: &tr,
|
||||
},
|
||||
{
|
||||
name: "False",
|
||||
req: `{ "use_mmap": false }`,
|
||||
exp: &fa,
|
||||
},
|
||||
}
|
||||
|
||||
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)
|
||||
assert.Equal(t, test.exp, opts.UseMMap)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
tests := []struct {
|
||||
name string
|
||||
req map[string][]string
|
||||
exp *bool
|
||||
err error
|
||||
}{
|
||||
{
|
||||
name: "True",
|
||||
req: map[string][]string{
|
||||
"use_mmap": {"true"},
|
||||
},
|
||||
exp: &tr,
|
||||
err: nil,
|
||||
},
|
||||
{
|
||||
name: "False",
|
||||
req: map[string][]string{
|
||||
"use_mmap": {"false"},
|
||||
},
|
||||
exp: &fa,
|
||||
err: nil,
|
||||
},
|
||||
{
|
||||
name: "Numeric True",
|
||||
req: map[string][]string{
|
||||
"use_mmap": {"1"},
|
||||
},
|
||||
exp: &tr,
|
||||
err: nil,
|
||||
},
|
||||
{
|
||||
name: "Numeric False",
|
||||
req: map[string][]string{
|
||||
"use_mmap": {"0"},
|
||||
},
|
||||
exp: &fa,
|
||||
err: nil,
|
||||
},
|
||||
{
|
||||
name: "invalid string",
|
||||
req: map[string][]string{
|
||||
"use_mmap": {"foo"},
|
||||
},
|
||||
exp: nil,
|
||||
err: errors.New("invalid bool value [foo]"),
|
||||
},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
resp, err := FormatParams(test.req)
|
||||
require.Equal(t, test.err, err)
|
||||
respVal, ok := resp["use_mmap"]
|
||||
if test.exp != nil {
|
||||
assert.True(t, ok, "resp: %v", resp)
|
||||
assert.Equal(t, *test.exp, *respVal.(*bool))
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
expected string
|
||||
}{
|
||||
{`{"role": "USER", "content": "Hello!"}`, "user"},
|
||||
{`{"role": "System", "content": "Initialization complete."}`, "system"},
|
||||
{`{"role": "assistant", "content": "How can I help you?"}`, "assistant"},
|
||||
{`{"role": "TOOl", "content": "Access granted."}`, "tool"},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
var msg Message
|
||||
if err := json.Unmarshal([]byte(test.input), &msg); err != nil {
|
||||
t.Errorf("Unexpected error: %v", err)
|
||||
}
|
||||
|
||||
if msg.Role != test.expected {
|
||||
t.Errorf("role not lowercased: got %v, expected %v", msg.Role, test.expected)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestToolFunction_UnmarshalJSON(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
wantErr string
|
||||
}{
|
||||
{
|
||||
name: "valid enum with same types",
|
||||
input: `{
|
||||
"name": "test",
|
||||
"description": "test function",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"required": ["test"],
|
||||
"properties": {
|
||||
"test": {
|
||||
"type": "string",
|
||||
"description": "test prop",
|
||||
"enum": ["a", "b", "c"]
|
||||
}
|
||||
}
|
||||
}
|
||||
}`,
|
||||
wantErr: "",
|
||||
},
|
||||
{
|
||||
name: "empty enum array",
|
||||
input: `{
|
||||
"name": "test",
|
||||
"description": "test function",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"required": ["test"],
|
||||
"properties": {
|
||||
"test": {
|
||||
"type": "string",
|
||||
"description": "test prop",
|
||||
"enum": []
|
||||
}
|
||||
}
|
||||
}
|
||||
}`,
|
||||
wantErr: "",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
var tf ToolFunction
|
||||
err := json.Unmarshal([]byte(tt.input), &tf)
|
||||
|
||||
if tt.wantErr != "" {
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), tt.wantErr)
|
||||
} else {
|
||||
require.NoError(t, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestToolFunctionParameters_MarshalJSON(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input ToolFunctionParameters
|
||||
expected string
|
||||
}{
|
||||
{
|
||||
name: "simple object with string property",
|
||||
input: ToolFunctionParameters{
|
||||
Type: "object",
|
||||
Required: []string{"name"},
|
||||
Properties: testPropsMap(map[string]ToolProperty{
|
||||
"name": {Type: PropertyType{"string"}},
|
||||
}),
|
||||
},
|
||||
expected: `{"type":"object","required":["name"],"properties":{"name":{"type":"string"}}}`,
|
||||
},
|
||||
{
|
||||
name: "no required",
|
||||
input: ToolFunctionParameters{
|
||||
Type: "object",
|
||||
Properties: testPropsMap(map[string]ToolProperty{
|
||||
"name": {Type: PropertyType{"string"}},
|
||||
}),
|
||||
},
|
||||
expected: `{"type":"object","properties":{"name":{"type":"string"}}}`,
|
||||
},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
data, err := json.Marshal(test.input)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, test.expected, string(data))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestToolCallFunction_IndexAlwaysMarshals(t *testing.T) {
|
||||
fn := ToolCallFunction{
|
||||
Name: "echo",
|
||||
Arguments: testArgs(map[string]any{"message": "hi"}),
|
||||
}
|
||||
|
||||
data, err := json.Marshal(fn)
|
||||
require.NoError(t, err)
|
||||
|
||||
raw := map[string]any{}
|
||||
require.NoError(t, json.Unmarshal(data, &raw))
|
||||
require.Contains(t, raw, "index")
|
||||
assert.Equal(t, float64(0), raw["index"])
|
||||
|
||||
fn.Index = 3
|
||||
data, err = json.Marshal(fn)
|
||||
require.NoError(t, err)
|
||||
|
||||
raw = map[string]any{}
|
||||
require.NoError(t, json.Unmarshal(data, &raw))
|
||||
require.Contains(t, raw, "index")
|
||||
assert.Equal(t, float64(3), raw["index"])
|
||||
}
|
||||
|
||||
func TestPropertyType_UnmarshalJSON(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
expected PropertyType
|
||||
}{
|
||||
{
|
||||
name: "string type",
|
||||
input: `"string"`,
|
||||
expected: PropertyType{"string"},
|
||||
},
|
||||
{
|
||||
name: "array of types",
|
||||
input: `["string", "number"]`,
|
||||
expected: PropertyType{"string", "number"},
|
||||
},
|
||||
{
|
||||
name: "array with single type",
|
||||
input: `["string"]`,
|
||||
expected: PropertyType{"string"},
|
||||
},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
var pt PropertyType
|
||||
if err := json.Unmarshal([]byte(test.input), &pt); err != nil {
|
||||
t.Errorf("Unexpected error: %v", err)
|
||||
}
|
||||
|
||||
if len(pt) != len(test.expected) {
|
||||
t.Errorf("Length mismatch: got %v, expected %v", len(pt), len(test.expected))
|
||||
}
|
||||
|
||||
for i, v := range pt {
|
||||
if v != test.expected[i] {
|
||||
t.Errorf("Value mismatch at index %d: got %v, expected %v", i, v, test.expected[i])
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestPropertyType_MarshalJSON(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input PropertyType
|
||||
expected string
|
||||
}{
|
||||
{
|
||||
name: "single type",
|
||||
input: PropertyType{"string"},
|
||||
expected: `"string"`,
|
||||
},
|
||||
{
|
||||
name: "multiple types",
|
||||
input: PropertyType{"string", "number"},
|
||||
expected: `["string","number"]`,
|
||||
},
|
||||
{
|
||||
name: "empty type",
|
||||
input: PropertyType{},
|
||||
expected: `[]`,
|
||||
},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
data, err := json.Marshal(test.input)
|
||||
if err != nil {
|
||||
t.Errorf("Unexpected error: %v", err)
|
||||
}
|
||||
|
||||
if string(data) != test.expected {
|
||||
t.Errorf("Marshaled data mismatch: got %v, expected %v", string(data), test.expected)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestThinking_UnmarshalJSON(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
expectedThinking *ThinkValue
|
||||
expectedError bool
|
||||
}{
|
||||
{
|
||||
name: "true",
|
||||
input: `{ "think": true }`,
|
||||
expectedThinking: &ThinkValue{Value: true},
|
||||
},
|
||||
{
|
||||
name: "false",
|
||||
input: `{ "think": false }`,
|
||||
expectedThinking: &ThinkValue{Value: false},
|
||||
},
|
||||
{
|
||||
name: "unset",
|
||||
input: `{ }`,
|
||||
expectedThinking: nil,
|
||||
},
|
||||
{
|
||||
name: "string_high",
|
||||
input: `{ "think": "high" }`,
|
||||
expectedThinking: &ThinkValue{Value: "high"},
|
||||
},
|
||||
{
|
||||
name: "string_medium",
|
||||
input: `{ "think": "medium" }`,
|
||||
expectedThinking: &ThinkValue{Value: "medium"},
|
||||
},
|
||||
{
|
||||
name: "string_low",
|
||||
input: `{ "think": "low" }`,
|
||||
expectedThinking: &ThinkValue{Value: "low"},
|
||||
},
|
||||
{
|
||||
name: "string_max",
|
||||
input: `{ "think": "max" }`,
|
||||
expectedThinking: &ThinkValue{Value: "max"},
|
||||
},
|
||||
{
|
||||
name: "invalid_string",
|
||||
input: `{ "think": "invalid" }`,
|
||||
expectedThinking: nil,
|
||||
expectedError: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
var req GenerateRequest
|
||||
err := json.Unmarshal([]byte(test.input), &req)
|
||||
if test.expectedError {
|
||||
require.Error(t, err)
|
||||
} else {
|
||||
require.NoError(t, err)
|
||||
if test.expectedThinking == nil {
|
||||
assert.Nil(t, req.Think)
|
||||
} else {
|
||||
require.NotNil(t, req.Think)
|
||||
assert.Equal(t, test.expectedThinking.Value, req.Think.Value)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestToolPropertyNestedProperties(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
expected ToolProperty
|
||||
}{
|
||||
{
|
||||
name: "nested object properties",
|
||||
input: `{
|
||||
"type": "object",
|
||||
"description": "Location details",
|
||||
"properties": {
|
||||
"address": {
|
||||
"type": "string",
|
||||
"description": "Street address"
|
||||
},
|
||||
"city": {
|
||||
"type": "string",
|
||||
"description": "City name"
|
||||
}
|
||||
}
|
||||
}`,
|
||||
expected: ToolProperty{
|
||||
Type: PropertyType{"object"},
|
||||
Description: "Location details",
|
||||
Properties: testPropsMap(map[string]ToolProperty{
|
||||
"address": {
|
||||
Type: PropertyType{"string"},
|
||||
Description: "Street address",
|
||||
},
|
||||
"city": {
|
||||
Type: PropertyType{"string"},
|
||||
Description: "City name",
|
||||
},
|
||||
}),
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "deeply nested properties",
|
||||
input: `{
|
||||
"type": "object",
|
||||
"description": "Event",
|
||||
"properties": {
|
||||
"location": {
|
||||
"type": "object",
|
||||
"description": "Location",
|
||||
"properties": {
|
||||
"coordinates": {
|
||||
"type": "object",
|
||||
"description": "GPS coordinates",
|
||||
"properties": {
|
||||
"lat": {"type": "number", "description": "Latitude"},
|
||||
"lng": {"type": "number", "description": "Longitude"}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}`,
|
||||
expected: ToolProperty{
|
||||
Type: PropertyType{"object"},
|
||||
Description: "Event",
|
||||
Properties: testPropsMap(map[string]ToolProperty{
|
||||
"location": {
|
||||
Type: PropertyType{"object"},
|
||||
Description: "Location",
|
||||
Properties: testPropsMap(map[string]ToolProperty{
|
||||
"coordinates": {
|
||||
Type: PropertyType{"object"},
|
||||
Description: "GPS coordinates",
|
||||
Properties: testPropsMap(map[string]ToolProperty{
|
||||
"lat": {Type: PropertyType{"number"}, Description: "Latitude"},
|
||||
"lng": {Type: PropertyType{"number"}, Description: "Longitude"},
|
||||
}),
|
||||
},
|
||||
}),
|
||||
},
|
||||
}),
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
var prop ToolProperty
|
||||
err := json.Unmarshal([]byte(tt.input), &prop)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Compare JSON representations since pointer comparison doesn't work
|
||||
expectedJSON, err := json.Marshal(tt.expected)
|
||||
require.NoError(t, err)
|
||||
actualJSON, err := json.Marshal(prop)
|
||||
require.NoError(t, err)
|
||||
assert.JSONEq(t, string(expectedJSON), string(actualJSON))
|
||||
|
||||
// Round-trip test: marshal and unmarshal again
|
||||
data, err := json.Marshal(prop)
|
||||
require.NoError(t, err)
|
||||
|
||||
var prop2 ToolProperty
|
||||
err = json.Unmarshal(data, &prop2)
|
||||
require.NoError(t, err)
|
||||
|
||||
prop2JSON, err := json.Marshal(prop2)
|
||||
require.NoError(t, err)
|
||||
assert.JSONEq(t, string(expectedJSON), string(prop2JSON))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestToolFunctionParameters_String(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
params ToolFunctionParameters
|
||||
expected string
|
||||
}{
|
||||
{
|
||||
name: "simple object with string property",
|
||||
params: ToolFunctionParameters{
|
||||
Type: "object",
|
||||
Required: []string{"name"},
|
||||
Properties: testPropsMap(map[string]ToolProperty{
|
||||
"name": {
|
||||
Type: PropertyType{"string"},
|
||||
Description: "The name of the person",
|
||||
},
|
||||
}),
|
||||
},
|
||||
expected: `{"type":"object","required":["name"],"properties":{"name":{"type":"string","description":"The name of the person"}}}`,
|
||||
},
|
||||
{
|
||||
name: "marshal failure returns empty string",
|
||||
params: ToolFunctionParameters{
|
||||
Type: "object",
|
||||
Defs: func() any {
|
||||
// Create a cycle that will cause json.Marshal to fail
|
||||
type selfRef struct {
|
||||
Self *selfRef
|
||||
}
|
||||
s := &selfRef{}
|
||||
s.Self = s
|
||||
return s
|
||||
}(),
|
||||
Properties: testPropsMap(map[string]ToolProperty{}),
|
||||
},
|
||||
expected: "",
|
||||
},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
result := test.params.String()
|
||||
assert.Equal(t, test.expected, result)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestToolCallFunctionArguments_OrderPreservation(t *testing.T) {
|
||||
t.Run("marshal preserves insertion order", func(t *testing.T) {
|
||||
args := NewToolCallFunctionArguments()
|
||||
args.Set("zebra", "z")
|
||||
args.Set("apple", "a")
|
||||
args.Set("mango", "m")
|
||||
|
||||
data, err := json.Marshal(args)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Should preserve insertion order, not alphabetical
|
||||
assert.Equal(t, `{"zebra":"z","apple":"a","mango":"m"}`, string(data))
|
||||
})
|
||||
|
||||
t.Run("unmarshal preserves JSON order", func(t *testing.T) {
|
||||
jsonData := `{"zebra":"z","apple":"a","mango":"m"}`
|
||||
|
||||
var args ToolCallFunctionArguments
|
||||
err := json.Unmarshal([]byte(jsonData), &args)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Verify iteration order matches JSON order
|
||||
var keys []string
|
||||
for k := range args.All() {
|
||||
keys = append(keys, k)
|
||||
}
|
||||
assert.Equal(t, []string{"zebra", "apple", "mango"}, keys)
|
||||
})
|
||||
|
||||
t.Run("round trip preserves order", func(t *testing.T) {
|
||||
original := `{"z":1,"a":2,"m":3,"b":4}`
|
||||
|
||||
var args ToolCallFunctionArguments
|
||||
err := json.Unmarshal([]byte(original), &args)
|
||||
require.NoError(t, err)
|
||||
|
||||
data, err := json.Marshal(args)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Equal(t, original, string(data))
|
||||
})
|
||||
|
||||
t.Run("String method returns ordered JSON", func(t *testing.T) {
|
||||
args := NewToolCallFunctionArguments()
|
||||
args.Set("c", 3)
|
||||
args.Set("a", 1)
|
||||
args.Set("b", 2)
|
||||
|
||||
assert.Equal(t, `{"c":3,"a":1,"b":2}`, args.String())
|
||||
})
|
||||
|
||||
t.Run("Get retrieves correct values", func(t *testing.T) {
|
||||
args := NewToolCallFunctionArguments()
|
||||
args.Set("key1", "value1")
|
||||
args.Set("key2", 42)
|
||||
|
||||
v, ok := args.Get("key1")
|
||||
assert.True(t, ok)
|
||||
assert.Equal(t, "value1", v)
|
||||
|
||||
v, ok = args.Get("key2")
|
||||
assert.True(t, ok)
|
||||
assert.Equal(t, 42, v)
|
||||
|
||||
_, ok = args.Get("nonexistent")
|
||||
assert.False(t, ok)
|
||||
})
|
||||
|
||||
t.Run("Len returns correct count", func(t *testing.T) {
|
||||
args := NewToolCallFunctionArguments()
|
||||
assert.Equal(t, 0, args.Len())
|
||||
|
||||
args.Set("a", 1)
|
||||
assert.Equal(t, 1, args.Len())
|
||||
|
||||
args.Set("b", 2)
|
||||
assert.Equal(t, 2, args.Len())
|
||||
})
|
||||
|
||||
t.Run("empty args marshal to empty object", func(t *testing.T) {
|
||||
args := NewToolCallFunctionArguments()
|
||||
data, err := json.Marshal(args)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, `{}`, string(data))
|
||||
})
|
||||
|
||||
t.Run("zero value args marshal to empty object", func(t *testing.T) {
|
||||
var args ToolCallFunctionArguments
|
||||
assert.Equal(t, "{}", args.String())
|
||||
})
|
||||
}
|
||||
|
||||
func TestToolPropertiesMap_OrderPreservation(t *testing.T) {
|
||||
t.Run("marshal preserves insertion order", func(t *testing.T) {
|
||||
props := NewToolPropertiesMap()
|
||||
props.Set("zebra", ToolProperty{Type: PropertyType{"string"}})
|
||||
props.Set("apple", ToolProperty{Type: PropertyType{"number"}})
|
||||
props.Set("mango", ToolProperty{Type: PropertyType{"boolean"}})
|
||||
|
||||
data, err := json.Marshal(props)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Should preserve insertion order, not alphabetical
|
||||
expected := `{"zebra":{"type":"string"},"apple":{"type":"number"},"mango":{"type":"boolean"}}`
|
||||
assert.Equal(t, expected, string(data))
|
||||
})
|
||||
|
||||
t.Run("unmarshal preserves JSON order", func(t *testing.T) {
|
||||
jsonData := `{"zebra":{"type":"string"},"apple":{"type":"number"},"mango":{"type":"boolean"}}`
|
||||
|
||||
var props ToolPropertiesMap
|
||||
err := json.Unmarshal([]byte(jsonData), &props)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Verify iteration order matches JSON order
|
||||
var keys []string
|
||||
for k := range props.All() {
|
||||
keys = append(keys, k)
|
||||
}
|
||||
assert.Equal(t, []string{"zebra", "apple", "mango"}, keys)
|
||||
})
|
||||
|
||||
t.Run("round trip preserves order", func(t *testing.T) {
|
||||
original := `{"z":{"type":"string"},"a":{"type":"number"},"m":{"type":"boolean"}}`
|
||||
|
||||
var props ToolPropertiesMap
|
||||
err := json.Unmarshal([]byte(original), &props)
|
||||
require.NoError(t, err)
|
||||
|
||||
data, err := json.Marshal(props)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Equal(t, original, string(data))
|
||||
})
|
||||
|
||||
t.Run("Get retrieves correct values", func(t *testing.T) {
|
||||
props := NewToolPropertiesMap()
|
||||
props.Set("name", ToolProperty{Type: PropertyType{"string"}, Description: "The name"})
|
||||
props.Set("age", ToolProperty{Type: PropertyType{"integer"}, Description: "The age"})
|
||||
|
||||
v, ok := props.Get("name")
|
||||
assert.True(t, ok)
|
||||
assert.Equal(t, "The name", v.Description)
|
||||
|
||||
v, ok = props.Get("age")
|
||||
assert.True(t, ok)
|
||||
assert.Equal(t, "The age", v.Description)
|
||||
|
||||
_, ok = props.Get("nonexistent")
|
||||
assert.False(t, ok)
|
||||
})
|
||||
|
||||
t.Run("Len returns correct count", func(t *testing.T) {
|
||||
props := NewToolPropertiesMap()
|
||||
assert.Equal(t, 0, props.Len())
|
||||
|
||||
props.Set("a", ToolProperty{})
|
||||
assert.Equal(t, 1, props.Len())
|
||||
|
||||
props.Set("b", ToolProperty{})
|
||||
assert.Equal(t, 2, props.Len())
|
||||
})
|
||||
|
||||
t.Run("nil props marshal to null", func(t *testing.T) {
|
||||
var props *ToolPropertiesMap
|
||||
data, err := json.Marshal(props)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, `null`, string(data))
|
||||
})
|
||||
|
||||
t.Run("ToMap returns regular map", func(t *testing.T) {
|
||||
props := NewToolPropertiesMap()
|
||||
props.Set("a", ToolProperty{Type: PropertyType{"string"}})
|
||||
props.Set("b", ToolProperty{Type: PropertyType{"number"}})
|
||||
|
||||
m := props.ToMap()
|
||||
assert.Equal(t, 2, len(m))
|
||||
assert.Equal(t, PropertyType{"string"}, m["a"].Type)
|
||||
assert.Equal(t, PropertyType{"number"}, m["b"].Type)
|
||||
})
|
||||
}
|
||||
|
||||
func TestToolCallFunctionArguments_ComplexValues(t *testing.T) {
|
||||
t.Run("nested objects preserve order", func(t *testing.T) {
|
||||
jsonData := `{"outer":{"z":1,"a":2},"simple":"value"}`
|
||||
|
||||
var args ToolCallFunctionArguments
|
||||
err := json.Unmarshal([]byte(jsonData), &args)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Outer keys should be in order
|
||||
var keys []string
|
||||
for k := range args.All() {
|
||||
keys = append(keys, k)
|
||||
}
|
||||
assert.Equal(t, []string{"outer", "simple"}, keys)
|
||||
})
|
||||
|
||||
t.Run("arrays as values", func(t *testing.T) {
|
||||
args := NewToolCallFunctionArguments()
|
||||
args.Set("items", []string{"a", "b", "c"})
|
||||
args.Set("numbers", []int{1, 2, 3})
|
||||
|
||||
data, err := json.Marshal(args)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Equal(t, `{"items":["a","b","c"],"numbers":[1,2,3]}`, string(data))
|
||||
})
|
||||
}
|
||||
|
||||
func TestToolPropertiesMap_NestedProperties(t *testing.T) {
|
||||
t.Run("nested properties preserve order", func(t *testing.T) {
|
||||
props := NewToolPropertiesMap()
|
||||
|
||||
nestedProps := NewToolPropertiesMap()
|
||||
nestedProps.Set("z_field", ToolProperty{Type: PropertyType{"string"}})
|
||||
nestedProps.Set("a_field", ToolProperty{Type: PropertyType{"number"}})
|
||||
|
||||
props.Set("outer", ToolProperty{
|
||||
Type: PropertyType{"object"},
|
||||
Properties: nestedProps,
|
||||
})
|
||||
|
||||
data, err := json.Marshal(props)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Both outer and inner should preserve order
|
||||
expected := `{"outer":{"type":"object","properties":{"z_field":{"type":"string"},"a_field":{"type":"number"}}}}`
|
||||
assert.Equal(t, expected, string(data))
|
||||
})
|
||||
}
|
||||
@@ -1,142 +0,0 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestToolParameterToTypeScriptType(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
param ToolProperty
|
||||
expected string
|
||||
}{
|
||||
{
|
||||
name: "single string type",
|
||||
param: ToolProperty{
|
||||
Type: PropertyType{"string"},
|
||||
},
|
||||
expected: "string",
|
||||
},
|
||||
{
|
||||
name: "single number type",
|
||||
param: ToolProperty{
|
||||
Type: PropertyType{"number"},
|
||||
},
|
||||
expected: "number",
|
||||
},
|
||||
{
|
||||
name: "integer maps to number",
|
||||
param: ToolProperty{
|
||||
Type: PropertyType{"integer"},
|
||||
},
|
||||
expected: "number",
|
||||
},
|
||||
{
|
||||
name: "boolean type",
|
||||
param: ToolProperty{
|
||||
Type: PropertyType{"boolean"},
|
||||
},
|
||||
expected: "boolean",
|
||||
},
|
||||
{
|
||||
name: "array type",
|
||||
param: ToolProperty{
|
||||
Type: PropertyType{"array"},
|
||||
},
|
||||
expected: "any[]",
|
||||
},
|
||||
{
|
||||
name: "object type",
|
||||
param: ToolProperty{
|
||||
Type: PropertyType{"object"},
|
||||
},
|
||||
expected: "Record<string, any>",
|
||||
},
|
||||
{
|
||||
name: "null type",
|
||||
param: ToolProperty{
|
||||
Type: PropertyType{"null"},
|
||||
},
|
||||
expected: "null",
|
||||
},
|
||||
{
|
||||
name: "multiple types as union",
|
||||
param: ToolProperty{
|
||||
Type: PropertyType{"string", "number"},
|
||||
},
|
||||
expected: "string | number",
|
||||
},
|
||||
{
|
||||
name: "string or null union",
|
||||
param: ToolProperty{
|
||||
Type: PropertyType{"string", "null"},
|
||||
},
|
||||
expected: "string | null",
|
||||
},
|
||||
{
|
||||
name: "anyOf with single types",
|
||||
param: ToolProperty{
|
||||
AnyOf: []ToolProperty{
|
||||
{Type: PropertyType{"string"}},
|
||||
{Type: PropertyType{"number"}},
|
||||
},
|
||||
},
|
||||
expected: "string | number",
|
||||
},
|
||||
{
|
||||
name: "anyOf with multiple types in each branch",
|
||||
param: ToolProperty{
|
||||
AnyOf: []ToolProperty{
|
||||
{Type: PropertyType{"string", "null"}},
|
||||
{Type: PropertyType{"number"}},
|
||||
},
|
||||
},
|
||||
expected: "string | null | number",
|
||||
},
|
||||
{
|
||||
name: "nested anyOf",
|
||||
param: ToolProperty{
|
||||
AnyOf: []ToolProperty{
|
||||
{Type: PropertyType{"boolean"}},
|
||||
{
|
||||
AnyOf: []ToolProperty{
|
||||
{Type: PropertyType{"string"}},
|
||||
{Type: PropertyType{"number"}},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
expected: "boolean | string | number",
|
||||
},
|
||||
{
|
||||
name: "empty type returns any",
|
||||
param: ToolProperty{
|
||||
Type: PropertyType{},
|
||||
},
|
||||
expected: "any",
|
||||
},
|
||||
{
|
||||
name: "unknown type maps to any",
|
||||
param: ToolProperty{
|
||||
Type: PropertyType{"unknown_type"},
|
||||
},
|
||||
expected: "any",
|
||||
},
|
||||
{
|
||||
name: "multiple types including array",
|
||||
param: ToolProperty{
|
||||
Type: PropertyType{"string", "array", "null"},
|
||||
},
|
||||
expected: "string | any[] | null",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result := tt.param.ToTypeScriptType()
|
||||
if result != tt.expected {
|
||||
t.Errorf("ToTypeScriptType() = %q, want %q", result, tt.expected)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
16
app/.eslintrc.json
Normal file
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"env": {
|
||||
"browser": true,
|
||||
"es6": true,
|
||||
"node": true
|
||||
},
|
||||
"extends": [
|
||||
"eslint:recommended",
|
||||
"plugin:@typescript-eslint/eslint-recommended",
|
||||
"plugin:@typescript-eslint/recommended",
|
||||
"plugin:import/recommended",
|
||||
"plugin:import/electron",
|
||||
"plugin:import/typescript"
|
||||
],
|
||||
"parser": "@typescript-eslint/parser"
|
||||
}
|
||||
101
app/.gitignore
vendored
@@ -1,11 +1,92 @@
|
||||
ollama.syso
|
||||
*.crt
|
||||
*.exe
|
||||
/app/app
|
||||
/app/squirrel
|
||||
ollama
|
||||
*cover*
|
||||
.vscode
|
||||
.env
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
lerna-debug.log*
|
||||
|
||||
# Diagnostic reports (https://nodejs.org/api/report.html)
|
||||
report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
|
||||
|
||||
# Runtime data
|
||||
pids
|
||||
*.pid
|
||||
*.seed
|
||||
*.pid.lock
|
||||
.DS_Store
|
||||
.claude
|
||||
|
||||
# Directory for instrumented libs generated by jscoverage/JSCover
|
||||
lib-cov
|
||||
|
||||
# Coverage directory used by tools like istanbul
|
||||
coverage
|
||||
*.lcov
|
||||
|
||||
# nyc test coverage
|
||||
.nyc_output
|
||||
|
||||
# node-waf configuration
|
||||
.lock-wscript
|
||||
|
||||
# Compiled binary addons (https://nodejs.org/api/addons.html)
|
||||
build/Release
|
||||
|
||||
# Dependency directories
|
||||
node_modules/
|
||||
jspm_packages/
|
||||
|
||||
# TypeScript v1 declaration files
|
||||
typings/
|
||||
|
||||
# TypeScript cache
|
||||
*.tsbuildinfo
|
||||
|
||||
# Optional npm cache directory
|
||||
.npm
|
||||
|
||||
# Optional eslint cache
|
||||
.eslintcache
|
||||
|
||||
# Optional REPL history
|
||||
.node_repl_history
|
||||
|
||||
# Output of 'npm pack'
|
||||
*.tgz
|
||||
|
||||
# Yarn Integrity file
|
||||
.yarn-integrity
|
||||
|
||||
# dotenv environment variables file
|
||||
.env
|
||||
.env.test
|
||||
|
||||
# parcel-bundler cache (https://parceljs.org/)
|
||||
.cache
|
||||
|
||||
# next.js build output
|
||||
.next
|
||||
|
||||
# nuxt.js build output
|
||||
.nuxt
|
||||
|
||||
# vuepress build output
|
||||
.vuepress/dist
|
||||
|
||||
# Serverless directories
|
||||
.serverless/
|
||||
|
||||
# FuseBox cache
|
||||
.fusebox/
|
||||
|
||||
# DynamoDB Local files
|
||||
.dynamodb/
|
||||
|
||||
# Webpack
|
||||
.webpack/
|
||||
|
||||
# Vite
|
||||
.vite/
|
||||
|
||||
# Electron-Forge
|
||||
out/
|
||||
|
||||
@@ -1,97 +1,21 @@
|
||||
# Ollama for macOS and Windows
|
||||
# Desktop
|
||||
|
||||
## Download
|
||||
This app builds upon Ollama to provide a desktop experience for running models.
|
||||
|
||||
- [macOS](https://github.com/ollama/app/releases/download/latest/Ollama.dmg)
|
||||
- [Windows](https://github.com/ollama/app/releases/download/latest/OllamaSetup.exe)
|
||||
## Developing
|
||||
|
||||
## Development
|
||||
First, build the `ollama` binary:
|
||||
|
||||
### Desktop App
|
||||
|
||||
```bash
|
||||
go generate ./... &&
|
||||
go run ./cmd/app
|
||||
```
|
||||
cd ..
|
||||
go build .
|
||||
```
|
||||
|
||||
### UI Development
|
||||
Then run the desktop app with `npm start`:
|
||||
|
||||
#### Setup
|
||||
|
||||
Install required tools:
|
||||
|
||||
```bash
|
||||
go install github.com/tkrajina/typescriptify-golang-structs/tscriptify@latest
|
||||
```
|
||||
|
||||
#### Develop UI (Development Mode)
|
||||
|
||||
1. Start the React development server (with hot-reload):
|
||||
|
||||
```bash
|
||||
cd ui/app
|
||||
cd app
|
||||
npm install
|
||||
npm run dev
|
||||
npm start
|
||||
```
|
||||
|
||||
2. In a separate terminal, run the Ollama app with the `-dev` flag:
|
||||
|
||||
```bash
|
||||
go generate ./... &&
|
||||
OLLAMA_DEBUG=1 go run ./cmd/app -dev
|
||||
```
|
||||
|
||||
The `-dev` flag enables:
|
||||
|
||||
- Loading the UI from the Vite dev server at http://localhost:5173
|
||||
- Fixed UI server port at http://127.0.0.1:3001 for API requests
|
||||
- CORS headers for cross-origin requests
|
||||
- Hot-reload support for UI development
|
||||
|
||||
## Build
|
||||
|
||||
|
||||
### Windows
|
||||
|
||||
- https://jrsoftware.org/isinfo.php
|
||||
|
||||
|
||||
**Dependencies** - either build a local copy of ollama, or use a github release
|
||||
```powershell
|
||||
# Local dependencies
|
||||
.\scripts\deps_local.ps1
|
||||
|
||||
# Release dependencies
|
||||
.\scripts\deps_release.ps1 0.6.8
|
||||
```
|
||||
|
||||
**Build**
|
||||
```powershell
|
||||
.\scripts\build_windows.ps1
|
||||
```
|
||||
|
||||
### macOS
|
||||
|
||||
CI builds with Xcode 14.1 for OS compatibility prior to v13. If you want to manually build v11+ support, you can download the older Xcode [here](https://developer.apple.com/services-account/download?path=/Developer_Tools/Xcode_14.1/Xcode_14.1.xip), extract, then `mv ./Xcode.app /Applications/Xcode_14.1.0.app` then activate with:
|
||||
|
||||
```
|
||||
export CGO_CFLAGS="-O3 -mmacosx-version-min=12.0"
|
||||
export CGO_CXXFLAGS="-O3 -mmacosx-version-min=12.0"
|
||||
export CGO_LDFLAGS="-mmacosx-version-min=12.0"
|
||||
export SDKROOT=/Applications/Xcode_14.1.0.app/Contents/Developer/Platforms/MacOSX.platform/Developer/SDKs/MacOSX.sdk
|
||||
export DEVELOPER_DIR=/Applications/Xcode_14.1.0.app/Contents/Developer
|
||||
```
|
||||
|
||||
**Dependencies** - either build a local copy of Ollama, or use a GitHub release:
|
||||
```sh
|
||||
# Local dependencies
|
||||
./scripts/deps_local.sh
|
||||
|
||||
# Release dependencies
|
||||
./scripts/deps_release.sh 0.6.8
|
||||
```
|
||||
|
||||
**Build**
|
||||
```sh
|
||||
./scripts/build_darwin.sh
|
||||
```
|
||||
|
||||
|
Before Width: | Height: | Size: 7.3 KiB |
@@ -1,19 +0,0 @@
|
||||
//go:build windows || darwin
|
||||
|
||||
package assets
|
||||
|
||||
import (
|
||||
"embed"
|
||||
"io/fs"
|
||||
)
|
||||
|
||||
//go:embed *.ico
|
||||
var icons embed.FS
|
||||
|
||||
func ListIcons() ([]string, error) {
|
||||
return fs.Glob(icons, "*")
|
||||
}
|
||||
|
||||
func GetIcon(filename string) ([]byte, error) {
|
||||
return icons.ReadFile(filename)
|
||||
}
|
||||
|
Before Width: | Height: | Size: 15 KiB |
BIN
app/assets/icon.icns
Normal file
BIN
app/assets/iconDarkTemplate.png
Normal file
|
After Width: | Height: | Size: 402 B |
BIN
app/assets/iconDarkTemplate@2x.png
Normal file
|
After Width: | Height: | Size: 741 B |
BIN
app/assets/iconDarkUpdateTemplate.png
Normal file
|
After Width: | Height: | Size: 440 B |
BIN
app/assets/iconDarkUpdateTemplate@2x.png
Normal file
|
After Width: | Height: | Size: 763 B |
BIN
app/assets/iconTemplate.png
Normal file
|
After Width: | Height: | Size: 447 B |
BIN
app/assets/iconTemplate@2x.png
Normal file
|
After Width: | Height: | Size: 891 B |
BIN
app/assets/iconUpdateTemplate.png
Normal file
|
After Width: | Height: | Size: 443 B |
BIN
app/assets/iconUpdateTemplate@2x.png
Normal file
|
After Width: | Height: | Size: 844 B |
|
Before Width: | Height: | Size: 76 KiB |
|
Before Width: | Height: | Size: 115 KiB |
|
Before Width: | Height: | Size: 116 KiB |
@@ -1,26 +0,0 @@
|
||||
//go:build windows || darwin
|
||||
|
||||
package auth
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"os"
|
||||
|
||||
"github.com/ollama/ollama/auth"
|
||||
)
|
||||
|
||||
// BuildConnectURL generates the connect URL with the public key and device name
|
||||
func BuildConnectURL(baseURL string) (string, error) {
|
||||
pubKey, err := auth.GetPublicKey()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to get public key: %w", err)
|
||||
}
|
||||
|
||||
encodedKey := base64.RawURLEncoding.EncodeToString([]byte(pubKey))
|
||||
hostname, _ := os.Hostname()
|
||||
encodedDevice := url.QueryEscape(hostname)
|
||||
|
||||
return fmt.Sprintf("%s/connect?name=%s&key=%s&launch=true", baseURL, encodedDevice, encodedKey), nil
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
#import <Cocoa/Cocoa.h>
|
||||
|
||||
@interface AppDelegate : NSObject <NSApplicationDelegate>
|
||||
|
||||
- (void)applicationDidFinishLaunching:(NSNotification *)aNotification;
|
||||
|
||||
@end
|
||||
@@ -1,507 +0,0 @@
|
||||
//go:build windows || darwin
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"os/exec"
|
||||
"os/signal"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/ollama/ollama/app/auth"
|
||||
"github.com/ollama/ollama/app/logrotate"
|
||||
"github.com/ollama/ollama/app/server"
|
||||
"github.com/ollama/ollama/app/store"
|
||||
"github.com/ollama/ollama/app/tools"
|
||||
"github.com/ollama/ollama/app/ui"
|
||||
"github.com/ollama/ollama/app/updater"
|
||||
"github.com/ollama/ollama/app/version"
|
||||
)
|
||||
|
||||
var (
|
||||
wv = &Webview{}
|
||||
uiServerPort int
|
||||
appStore *store.Store
|
||||
)
|
||||
|
||||
var debug = strings.EqualFold(os.Getenv("OLLAMA_DEBUG"), "true") || os.Getenv("OLLAMA_DEBUG") == "1"
|
||||
|
||||
var (
|
||||
fastStartup = false
|
||||
devMode = false
|
||||
)
|
||||
|
||||
type appMove int
|
||||
|
||||
const (
|
||||
CannotMove appMove = iota
|
||||
UserDeclinedMove
|
||||
MoveCompleted
|
||||
AlreadyMoved
|
||||
LoginSession
|
||||
PermissionDenied
|
||||
MoveError
|
||||
)
|
||||
|
||||
func main() {
|
||||
startHidden := false
|
||||
var urlSchemeRequest string
|
||||
if len(os.Args) > 1 {
|
||||
for _, arg := range os.Args {
|
||||
// Handle URL scheme requests (Windows)
|
||||
if strings.HasPrefix(arg, "ollama://") {
|
||||
urlSchemeRequest = arg
|
||||
slog.Info("received URL scheme request", "url", arg)
|
||||
continue
|
||||
}
|
||||
switch arg {
|
||||
case "serve":
|
||||
fmt.Fprintln(os.Stderr, "serve command not supported, use ollama")
|
||||
os.Exit(1)
|
||||
case "version", "-v", "--version":
|
||||
fmt.Println(version.Version)
|
||||
os.Exit(0)
|
||||
case "background":
|
||||
// When running the process in this "background" mode, we spawn a
|
||||
// child process for the main app. This is necessary so the
|
||||
// "Allow in the Background" setting in MacOS can be unchecked
|
||||
// without breaking the main app. Two copies of the app are
|
||||
// present in the bundle, one for the main app and one for the
|
||||
// background initiator.
|
||||
fmt.Fprintln(os.Stdout, "starting in background")
|
||||
runInBackground()
|
||||
os.Exit(0)
|
||||
case "hidden", "-j", "--hide":
|
||||
// startHidden suppresses the UI on startup, and can be triggered multiple ways
|
||||
// On windows, path based via login startup detection
|
||||
// On MacOS via [NSApp isHidden] from `open -j -a /Applications/Ollama.app` or equivalent
|
||||
// On both via the "hidden" command line argument
|
||||
startHidden = true
|
||||
case "--fast-startup":
|
||||
// Skip optional steps like pending updates to start quickly for immediate use
|
||||
fastStartup = true
|
||||
case "-dev", "--dev":
|
||||
// Development mode: use local dev server and enable CORS
|
||||
devMode = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
level := slog.LevelInfo
|
||||
if debug {
|
||||
level = slog.LevelDebug
|
||||
}
|
||||
|
||||
logrotate.Rotate(appLogPath)
|
||||
if _, err := os.Stat(filepath.Dir(appLogPath)); errors.Is(err, os.ErrNotExist) {
|
||||
if err := os.MkdirAll(filepath.Dir(appLogPath), 0o755); err != nil {
|
||||
slog.Error(fmt.Sprintf("failed to create server log dir %v", err))
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
var logFile io.Writer
|
||||
var err error
|
||||
logFile, err = os.OpenFile(appLogPath, os.O_APPEND|os.O_WRONLY|os.O_CREATE, 0o755)
|
||||
if err != nil {
|
||||
slog.Error(fmt.Sprintf("failed to create server log %v", err))
|
||||
return
|
||||
}
|
||||
// Detect if we're a GUI app on windows, and if not, send logs to console as well
|
||||
if os.Stderr.Fd() != 0 {
|
||||
// Console app detected
|
||||
logFile = io.MultiWriter(os.Stderr, logFile)
|
||||
}
|
||||
|
||||
handler := slog.NewTextHandler(logFile, &slog.HandlerOptions{
|
||||
Level: level,
|
||||
AddSource: true,
|
||||
ReplaceAttr: func(_ []string, attr slog.Attr) slog.Attr {
|
||||
if attr.Key == slog.SourceKey {
|
||||
source := attr.Value.Any().(*slog.Source)
|
||||
source.File = filepath.Base(source.File)
|
||||
}
|
||||
return attr
|
||||
},
|
||||
})
|
||||
|
||||
slog.SetDefault(slog.New(handler))
|
||||
logStartup()
|
||||
|
||||
// On Windows, check if another instance is running and send URL to it
|
||||
// Do this after logging is set up so we can debug issues
|
||||
if runtime.GOOS == "windows" && urlSchemeRequest != "" {
|
||||
slog.Debug("checking for existing instance", "url", urlSchemeRequest)
|
||||
if checkAndHandleExistingInstance(urlSchemeRequest) {
|
||||
// The function will exit if it successfully sends to another instance
|
||||
// If we reach here, we're the first/only instance
|
||||
} else {
|
||||
// No existing instance found, handle the URL scheme in this instance
|
||||
go func() {
|
||||
handleURLSchemeInCurrentInstance(urlSchemeRequest)
|
||||
}()
|
||||
}
|
||||
}
|
||||
|
||||
// Detect if this is a first start after an upgrade, in
|
||||
// which case we need to do some cleanup
|
||||
var skipMove bool
|
||||
if _, err := os.Stat(updater.UpgradeMarkerFile); err == nil {
|
||||
slog.Debug("first start after upgrade")
|
||||
err = updater.DoPostUpgradeCleanup()
|
||||
if err != nil {
|
||||
slog.Error("failed to cleanup prior version", "error", err)
|
||||
}
|
||||
// We never prompt to move the app after an upgrade
|
||||
skipMove = true
|
||||
// Start hidden after updates to prevent UI from opening automatically
|
||||
startHidden = true
|
||||
}
|
||||
|
||||
if !skipMove && !fastStartup {
|
||||
if maybeMoveAndRestart() == MoveCompleted {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Check if another instance is already running
|
||||
// On Windows, focus the existing instance; on other platforms, kill it
|
||||
handleExistingInstance(startHidden)
|
||||
|
||||
// on macOS, offer the user to create a symlink
|
||||
// from /usr/local/bin/ollama to the app bundle
|
||||
installSymlink()
|
||||
|
||||
var ln net.Listener
|
||||
if devMode {
|
||||
// Use a fixed port in dev mode for predictable API access
|
||||
ln, err = net.Listen("tcp", "127.0.0.1:3001")
|
||||
} else {
|
||||
ln, err = net.Listen("tcp", "127.0.0.1:0")
|
||||
}
|
||||
if err != nil {
|
||||
slog.Error("failed to find available port", "error", err)
|
||||
return
|
||||
}
|
||||
|
||||
port := ln.Addr().(*net.TCPAddr).Port
|
||||
token := uuid.NewString()
|
||||
wv.port = port
|
||||
wv.token = token
|
||||
uiServerPort = port
|
||||
|
||||
st := &store.Store{}
|
||||
appStore = st
|
||||
|
||||
// Enable CORS in development mode
|
||||
if devMode {
|
||||
os.Setenv("OLLAMA_CORS", "1")
|
||||
|
||||
// Check if Vite dev server is running on port 5173
|
||||
var conn net.Conn
|
||||
var err error
|
||||
for _, addr := range []string{"127.0.0.1:5173", "localhost:5173"} {
|
||||
conn, err = net.DialTimeout("tcp", addr, 2*time.Second)
|
||||
if err == nil {
|
||||
conn.Close()
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
slog.Error("Vite dev server not running on port 5173")
|
||||
fmt.Fprintln(os.Stderr, "Error: Vite dev server is not running on port 5173")
|
||||
fmt.Fprintln(os.Stderr, "Please run 'npm run dev' in the ui/app directory to start the UI in development mode")
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize tools registry
|
||||
toolRegistry := tools.NewRegistry()
|
||||
slog.Info("initialized tools registry", "tool_count", len(toolRegistry.List()))
|
||||
|
||||
// ctx is the app-level context that will be used to stop the app
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
|
||||
// octx is the ollama server context that will be used to stop the ollama server
|
||||
octx, ocancel := context.WithCancel(ctx)
|
||||
|
||||
// TODO (jmorganca): instead we should instantiate the
|
||||
// webview with the store instead of assigning it here, however
|
||||
// making the webview a global variable is easier for now
|
||||
wv.Store = st
|
||||
done := make(chan error, 1)
|
||||
osrv := server.New(st, devMode)
|
||||
go func() {
|
||||
slog.Info("starting ollama server")
|
||||
done <- osrv.Run(octx)
|
||||
}()
|
||||
|
||||
upd := &updater.Updater{Store: st}
|
||||
|
||||
uiServer := ui.Server{
|
||||
Token: token,
|
||||
Restart: func() {
|
||||
ocancel()
|
||||
<-done
|
||||
octx, ocancel = context.WithCancel(ctx)
|
||||
go func() {
|
||||
done <- osrv.Run(octx)
|
||||
}()
|
||||
},
|
||||
Store: st,
|
||||
ToolRegistry: toolRegistry,
|
||||
Dev: devMode,
|
||||
Logger: slog.Default(),
|
||||
Updater: upd,
|
||||
UpdateAvailableFunc: func() {
|
||||
UpdateAvailable("")
|
||||
},
|
||||
}
|
||||
|
||||
srv := &http.Server{
|
||||
Handler: uiServer.Handler(),
|
||||
}
|
||||
|
||||
// Start the UI server
|
||||
slog.Info("starting ui server", "port", port)
|
||||
go func() {
|
||||
slog.Debug("starting ui server on port", "port", port)
|
||||
err = srv.Serve(ln)
|
||||
if err != nil && !errors.Is(err, http.ErrServerClosed) {
|
||||
slog.Warn("desktop server", "error", err)
|
||||
}
|
||||
slog.Debug("background desktop server done")
|
||||
}()
|
||||
|
||||
upd.StartBackgroundUpdaterChecker(ctx, UpdateAvailable)
|
||||
|
||||
// Check for pending updates on startup (show tray notification if update is ready)
|
||||
if updater.IsUpdatePending() {
|
||||
// On Windows, the tray is initialized in osRun(). Calling UpdateAvailable
|
||||
// before that would dereference a nil tray callback.
|
||||
// TODO: refactor so the update check runs after platform init on all platforms.
|
||||
if runtime.GOOS == "windows" {
|
||||
slog.Debug("update pending on startup, deferring tray notification until tray initialization")
|
||||
} else {
|
||||
slog.Debug("update pending on startup, showing tray notification")
|
||||
UpdateAvailable("")
|
||||
}
|
||||
}
|
||||
|
||||
hasCompletedFirstRun, err := st.HasCompletedFirstRun()
|
||||
if err != nil {
|
||||
slog.Error("failed to load has completed first run", "error", err)
|
||||
}
|
||||
|
||||
if !hasCompletedFirstRun {
|
||||
err = st.SetHasCompletedFirstRun(true)
|
||||
if err != nil {
|
||||
slog.Error("failed to set has completed first run", "error", err)
|
||||
}
|
||||
}
|
||||
|
||||
// capture SIGINT and SIGTERM signals and gracefully shutdown the app
|
||||
signals := make(chan os.Signal, 1)
|
||||
signal.Notify(signals, syscall.SIGINT, syscall.SIGTERM)
|
||||
go func() {
|
||||
<-signals
|
||||
slog.Info("received SIGINT or SIGTERM signal, shutting down")
|
||||
quit()
|
||||
}()
|
||||
|
||||
if urlSchemeRequest != "" {
|
||||
go func() {
|
||||
handleURLSchemeInCurrentInstance(urlSchemeRequest)
|
||||
}()
|
||||
} else {
|
||||
slog.Debug("no URL scheme request to handle")
|
||||
}
|
||||
|
||||
go func() {
|
||||
slog.Debug("waiting for ollama server to be ready")
|
||||
if err := ui.WaitForServer(ctx, 10*time.Second); err != nil {
|
||||
slog.Warn("ollama server not ready, continuing anyway", "error", err)
|
||||
}
|
||||
|
||||
if _, err := uiServer.UserData(ctx); err != nil {
|
||||
slog.Warn("failed to load user data", "error", err)
|
||||
}
|
||||
}()
|
||||
|
||||
osRun(cancel, hasCompletedFirstRun, startHidden)
|
||||
|
||||
slog.Info("shutting down desktop server")
|
||||
if err := srv.Close(); err != nil {
|
||||
slog.Warn("error shutting down desktop server", "error", err)
|
||||
}
|
||||
|
||||
slog.Info("shutting down ollama server")
|
||||
cancel()
|
||||
<-done
|
||||
}
|
||||
|
||||
func startHiddenTasks() {
|
||||
// If an upgrade is ready and we're in hidden mode, perform it at startup.
|
||||
// If we're not in hidden mode, we want to start as fast as possible and not
|
||||
// slow the user down with an upgrade.
|
||||
if updater.IsUpdatePending() {
|
||||
if fastStartup {
|
||||
// CLI triggered app startup use-case
|
||||
slog.Info("deferring pending update for fast startup")
|
||||
} else {
|
||||
// Check if auto-update is enabled before automatically upgrading
|
||||
settings, err := appStore.Settings()
|
||||
if err != nil {
|
||||
slog.Warn("failed to load settings for upgrade check", "error", err)
|
||||
} else if !settings.AutoUpdateEnabled {
|
||||
slog.Info("auto-update disabled, skipping automatic upgrade at startup")
|
||||
// Still show tray notification so user knows update is ready
|
||||
UpdateAvailable("")
|
||||
return
|
||||
}
|
||||
|
||||
if err := updater.DoUpgradeAtStartup(); err != nil {
|
||||
slog.Info("unable to perform upgrade at startup", "error", err)
|
||||
// Make sure the restart to upgrade menu shows so we can attempt an interactive upgrade to get authorization
|
||||
UpdateAvailable("")
|
||||
} else {
|
||||
slog.Debug("launching new version...")
|
||||
// TODO - consider a timer that aborts if this takes too long and we haven't been killed yet...
|
||||
LaunchNewApp()
|
||||
os.Exit(0)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func checkUserLoggedIn(uiServerPort int) bool {
|
||||
if uiServerPort == 0 {
|
||||
slog.Debug("UI server not ready yet, skipping auth check")
|
||||
return false
|
||||
}
|
||||
|
||||
resp, err := http.Post(fmt.Sprintf("http://127.0.0.1:%d/api/me", uiServerPort), "application/json", nil)
|
||||
if err != nil {
|
||||
slog.Debug("failed to call local auth endpoint", "error", err)
|
||||
return false
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
// Check if the response is successful
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
slog.Debug("auth endpoint returned non-OK status", "status", resp.StatusCode)
|
||||
return false
|
||||
}
|
||||
|
||||
var user struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
}
|
||||
|
||||
if err := json.NewDecoder(resp.Body).Decode(&user); err != nil {
|
||||
slog.Debug("failed to parse user response", "error", err)
|
||||
return false
|
||||
}
|
||||
|
||||
// Verify we have a valid user with an ID and name
|
||||
if user.ID == "" || user.Name == "" {
|
||||
slog.Debug("user response missing required fields", "id", user.ID, "name", user.Name)
|
||||
return false
|
||||
}
|
||||
|
||||
slog.Debug("user is logged in", "user_id", user.ID, "user_name", user.Name)
|
||||
return true
|
||||
}
|
||||
|
||||
// handleConnectURLScheme fetches the connect URL and opens it in the browser
|
||||
func handleConnectURLScheme() {
|
||||
if checkUserLoggedIn(uiServerPort) {
|
||||
slog.Info("user is already logged in, opening app instead")
|
||||
showWindow(wv.webview.Window())
|
||||
return
|
||||
}
|
||||
|
||||
connectURL, err := auth.BuildConnectURL("https://ollama.com")
|
||||
if err != nil {
|
||||
slog.Error("failed to build connect URL", "error", err)
|
||||
openInBrowser("https://ollama.com/connect")
|
||||
return
|
||||
}
|
||||
|
||||
openInBrowser(connectURL)
|
||||
}
|
||||
|
||||
// openInBrowser opens the specified URL in the default browser
|
||||
func openInBrowser(url string) {
|
||||
var cmd string
|
||||
var args []string
|
||||
|
||||
switch runtime.GOOS {
|
||||
case "windows":
|
||||
cmd = "rundll32"
|
||||
args = []string{"url.dll,FileProtocolHandler", url}
|
||||
case "darwin":
|
||||
cmd = "open"
|
||||
args = []string{url}
|
||||
default: // "linux", "freebsd", "openbsd", "netbsd"... should not reach here
|
||||
slog.Warn("unsupported OS for openInBrowser", "os", runtime.GOOS)
|
||||
}
|
||||
|
||||
slog.Info("executing browser command", "cmd", cmd, "args", args)
|
||||
if err := exec.Command(cmd, args...).Start(); err != nil {
|
||||
slog.Error("failed to open URL in browser", "url", url, "cmd", cmd, "args", args, "error", err)
|
||||
}
|
||||
}
|
||||
|
||||
// parseURLScheme parses an ollama:// URL and validates it
|
||||
// Supports: ollama:// (open app) and ollama://connect (OAuth)
|
||||
func parseURLScheme(urlSchemeRequest string) (isConnect bool, err error) {
|
||||
parsedURL, err := url.Parse(urlSchemeRequest)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("invalid URL: %w", err)
|
||||
}
|
||||
|
||||
// Check if this is a connect URL
|
||||
if parsedURL.Host == "connect" || strings.TrimPrefix(parsedURL.Path, "/") == "connect" {
|
||||
return true, nil
|
||||
}
|
||||
|
||||
// Allow bare ollama:// or ollama:/// to open the app
|
||||
if (parsedURL.Host == "" && parsedURL.Path == "") || parsedURL.Path == "/" {
|
||||
return false, nil
|
||||
}
|
||||
|
||||
return false, fmt.Errorf("unsupported ollama:// URL path: %s", urlSchemeRequest)
|
||||
}
|
||||
|
||||
// handleURLSchemeInCurrentInstance processes URL scheme requests in the current instance
|
||||
func handleURLSchemeInCurrentInstance(urlSchemeRequest string) {
|
||||
isConnect, err := parseURLScheme(urlSchemeRequest)
|
||||
if err != nil {
|
||||
slog.Error("failed to parse URL scheme request", "url", urlSchemeRequest, "error", err)
|
||||
return
|
||||
}
|
||||
|
||||
if isConnect {
|
||||
handleConnectURLScheme()
|
||||
} else {
|
||||
if wv.webview != nil {
|
||||
showWindow(wv.webview.Window())
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,262 +0,0 @@
|
||||
//go:build windows || darwin
|
||||
|
||||
package main
|
||||
|
||||
// #cgo CFLAGS: -x objective-c
|
||||
// #cgo LDFLAGS: -framework Webkit -framework Cocoa -framework LocalAuthentication -framework ServiceManagement
|
||||
// #include "app_darwin.h"
|
||||
// #include "../../updater/updater_darwin.h"
|
||||
// typedef const char cchar_t;
|
||||
import "C"
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
"unsafe"
|
||||
|
||||
"github.com/ollama/ollama/app/updater"
|
||||
"github.com/ollama/ollama/app/version"
|
||||
)
|
||||
|
||||
var ollamaPath = func() string {
|
||||
if updater.BundlePath != "" {
|
||||
return filepath.Join(updater.BundlePath, "Contents", "Resources", "ollama")
|
||||
}
|
||||
|
||||
pwd, err := os.Getwd()
|
||||
if err != nil {
|
||||
slog.Warn("failed to get pwd", "error", err)
|
||||
return ""
|
||||
}
|
||||
return filepath.Join(pwd, "ollama")
|
||||
}()
|
||||
|
||||
var (
|
||||
isApp = updater.BundlePath != ""
|
||||
appLogPath = filepath.Join(os.Getenv("HOME"), ".ollama", "logs", "app.log")
|
||||
launchAgentPath = filepath.Join(os.Getenv("HOME"), "Library", "LaunchAgents", "com.ollama.ollama.plist")
|
||||
)
|
||||
|
||||
// TODO(jmorganca): pre-create the window and pass
|
||||
// it to the webview instead of using the internal one
|
||||
//
|
||||
//export StartUI
|
||||
func StartUI(path *C.cchar_t) {
|
||||
p := C.GoString(path)
|
||||
wv.Run(p)
|
||||
styleWindow(wv.webview.Window())
|
||||
C.setWindowDelegate(wv.webview.Window())
|
||||
}
|
||||
|
||||
//export ShowUI
|
||||
func ShowUI() {
|
||||
// If webview is already running, just show the window
|
||||
if wv.IsRunning() && wv.webview != nil {
|
||||
showWindow(wv.webview.Window())
|
||||
} else {
|
||||
root := C.CString("/")
|
||||
defer C.free(unsafe.Pointer(root))
|
||||
StartUI(root)
|
||||
}
|
||||
}
|
||||
|
||||
//export StopUI
|
||||
func StopUI() {
|
||||
wv.Terminate()
|
||||
}
|
||||
|
||||
//export StartUpdate
|
||||
func StartUpdate() {
|
||||
if err := updater.DoUpgrade(true); err != nil {
|
||||
slog.Error("upgrade failed", "error", err)
|
||||
return
|
||||
}
|
||||
slog.Debug("launching new version...")
|
||||
// TODO - consider a timer that aborts if this takes too long and we haven't been killed yet...
|
||||
LaunchNewApp()
|
||||
// not reached if upgrade works, the new app will kill this process
|
||||
}
|
||||
|
||||
//export darwinStartHiddenTasks
|
||||
func darwinStartHiddenTasks() {
|
||||
startHiddenTasks()
|
||||
}
|
||||
|
||||
func init() {
|
||||
// Temporary code to mimic Squirrel ShipIt behavior
|
||||
if len(os.Args) > 2 {
|
||||
if os.Args[1] == "___launch___" {
|
||||
path := strings.TrimPrefix(os.Args[2], "file://")
|
||||
slog.Info("Ollama binary called as ShipIt - launching", "app", path)
|
||||
appName := C.CString(path)
|
||||
defer C.free(unsafe.Pointer(appName))
|
||||
C.launchApp(appName)
|
||||
slog.Info("other instance has been launched")
|
||||
time.Sleep(5 * time.Second)
|
||||
slog.Info("exiting with zero status")
|
||||
os.Exit(0)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// maybeMoveAndRestart checks if we should relocate
|
||||
// and returns true if we did and should immediately exit
|
||||
func maybeMoveAndRestart() appMove {
|
||||
if updater.BundlePath == "" {
|
||||
// Typically developer mode with 'go run ./cmd/app'
|
||||
return CannotMove
|
||||
}
|
||||
// Respect users intent if they chose "keep" vs. "replace" when dragging to Applications
|
||||
if strings.HasPrefix(updater.BundlePath, strings.TrimSuffix(updater.SystemWidePath, filepath.Ext(updater.SystemWidePath))) {
|
||||
return AlreadyMoved
|
||||
}
|
||||
|
||||
// Ask to move to applications directory
|
||||
status := (appMove)(C.askToMoveToApplications())
|
||||
if status == MoveCompleted {
|
||||
// Double check
|
||||
if _, err := os.Stat(updater.SystemWidePath); err != nil {
|
||||
slog.Warn("stat failure after move", "path", updater.SystemWidePath, "error", err)
|
||||
return MoveError
|
||||
}
|
||||
}
|
||||
return status
|
||||
}
|
||||
|
||||
// handleExistingInstance handles existing instances on macOS
|
||||
func handleExistingInstance(_ bool) {
|
||||
C.killOtherInstances()
|
||||
}
|
||||
|
||||
func installSymlink() {
|
||||
if !isApp {
|
||||
return
|
||||
}
|
||||
cliPath := C.CString(ollamaPath)
|
||||
defer C.free(unsafe.Pointer(cliPath))
|
||||
|
||||
// Check the users path first
|
||||
cmd, _ := exec.LookPath("ollama")
|
||||
if cmd != "" {
|
||||
resolved, err := os.Readlink(cmd)
|
||||
if err == nil {
|
||||
tmp, err := filepath.Abs(resolved)
|
||||
if err == nil {
|
||||
resolved = tmp
|
||||
}
|
||||
} else {
|
||||
resolved = cmd
|
||||
}
|
||||
if resolved == ollamaPath {
|
||||
slog.Info("ollama already in users PATH", "cli", cmd)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
code := C.installSymlink(cliPath)
|
||||
if code != 0 {
|
||||
slog.Error("Failed to install symlink")
|
||||
}
|
||||
}
|
||||
|
||||
func UpdateAvailable(ver string) error {
|
||||
slog.Debug("update detected, adjusting menu")
|
||||
// TODO (jmorganca): find a better check for development mode than checking the bundle path
|
||||
if updater.BundlePath != "" {
|
||||
C.updateAvailable()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func osRun(_ func(), hasCompletedFirstRun, startHidden bool) {
|
||||
registerLaunchAgent(hasCompletedFirstRun)
|
||||
|
||||
// Run the native macOS app
|
||||
// Note: this will block until the app is closed
|
||||
slog.Debug("starting native darwin event loop")
|
||||
C.run(C._Bool(hasCompletedFirstRun), C._Bool(startHidden))
|
||||
}
|
||||
|
||||
func quit() {
|
||||
C.quit()
|
||||
}
|
||||
|
||||
func LaunchNewApp() {
|
||||
appName := C.CString(updater.BundlePath)
|
||||
defer C.free(unsafe.Pointer(appName))
|
||||
C.launchApp(appName)
|
||||
}
|
||||
|
||||
func registerLaunchAgent(hasCompletedFirstRun bool) {
|
||||
// Remove any stale Login Item registrations
|
||||
C.unregisterSelfFromLoginItem()
|
||||
|
||||
C.registerSelfAsLoginItem(C._Bool(hasCompletedFirstRun))
|
||||
}
|
||||
|
||||
func logStartup() {
|
||||
appPath := updater.BundlePath
|
||||
if appPath == updater.SystemWidePath {
|
||||
// Detect sandboxed scenario
|
||||
exe, err := os.Executable()
|
||||
if err == nil {
|
||||
p := filepath.Dir(exe)
|
||||
if filepath.Base(p) == "MacOS" {
|
||||
p = filepath.Dir(filepath.Dir(p))
|
||||
if p != appPath {
|
||||
slog.Info("starting sandboxed Ollama", "app", appPath, "sandbox", p)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
slog.Info("starting Ollama", "app", appPath, "version", version.Version, "OS", updater.UserAgentOS)
|
||||
}
|
||||
|
||||
func hideWindow(ptr unsafe.Pointer) {
|
||||
C.hideWindow(C.uintptr_t(uintptr(ptr)))
|
||||
}
|
||||
|
||||
func showWindow(ptr unsafe.Pointer) {
|
||||
C.showWindow(C.uintptr_t(uintptr(ptr)))
|
||||
}
|
||||
|
||||
func styleWindow(ptr unsafe.Pointer) {
|
||||
C.styleWindow(C.uintptr_t(uintptr(ptr)))
|
||||
}
|
||||
|
||||
func runInBackground() {
|
||||
cmd := exec.Command(filepath.Join(updater.BundlePath, "Contents", "MacOS", "Ollama"), "hidden")
|
||||
if cmd != nil {
|
||||
err := cmd.Run()
|
||||
if err != nil {
|
||||
slog.Error("failed to run Ollama", "bundlePath", updater.BundlePath, "error", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
} else {
|
||||
slog.Error("failed to start Ollama in background", "bundlePath", updater.BundlePath)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
func drag(ptr unsafe.Pointer) {
|
||||
C.drag(C.uintptr_t(uintptr(ptr)))
|
||||
}
|
||||
|
||||
func doubleClick(ptr unsafe.Pointer) {
|
||||
C.doubleClick(C.uintptr_t(uintptr(ptr)))
|
||||
}
|
||||
|
||||
//export handleConnectURL
|
||||
func handleConnectURL() {
|
||||
handleConnectURLScheme()
|
||||
}
|
||||
|
||||
// checkAndHandleExistingInstance is not needed on non-Windows platforms
|
||||
func checkAndHandleExistingInstance(_ string) bool {
|
||||
return false
|
||||
}
|
||||
@@ -1,43 +0,0 @@
|
||||
#import <Cocoa/Cocoa.h>
|
||||
#import <Security/Security.h>
|
||||
|
||||
@interface AppDelegate : NSObject <NSApplicationDelegate>
|
||||
- (void)applicationDidFinishLaunching:(NSNotification *)aNotification;
|
||||
@end
|
||||
|
||||
enum AppMove
|
||||
{
|
||||
CannotMove,
|
||||
UserDeclinedMove,
|
||||
MoveCompleted,
|
||||
AlreadyMoved,
|
||||
LoginSession,
|
||||
PermissionDenied,
|
||||
MoveError,
|
||||
};
|
||||
|
||||
void run(bool firstTimeRun, bool startHidden);
|
||||
void killOtherInstances();
|
||||
enum AppMove askToMoveToApplications();
|
||||
int createSymlinkWithAuthorization();
|
||||
int installSymlink(const char *cliPath);
|
||||
extern void Restart();
|
||||
// extern void Quit();
|
||||
void StartUI(const char *path);
|
||||
void ShowUI();
|
||||
void StopUI();
|
||||
void StartUpdate();
|
||||
void darwinStartHiddenTasks();
|
||||
void launchApp(const char *appPath);
|
||||
void updateAvailable();
|
||||
void quit();
|
||||
void uiRequest(char *path);
|
||||
void registerSelfAsLoginItem(bool firstTimeRun);
|
||||
void unregisterSelfFromLoginItem();
|
||||
void setWindowDelegate(void *window);
|
||||
void showWindow(uintptr_t wndPtr);
|
||||
void hideWindow(uintptr_t wndPtr);
|
||||
void styleWindow(uintptr_t wndPtr);
|
||||
void drag(uintptr_t wndPtr);
|
||||
void doubleClick(uintptr_t wndPtr);
|
||||
void handleConnectURL();
|
||||
@@ -1,448 +0,0 @@
|
||||
//go:build windows || darwin
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"log/slog"
|
||||
"os"
|
||||
"os/exec"
|
||||
"os/signal"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"syscall"
|
||||
"unsafe"
|
||||
|
||||
"github.com/ollama/ollama/app/updater"
|
||||
"github.com/ollama/ollama/app/version"
|
||||
"github.com/ollama/ollama/app/wintray"
|
||||
"golang.org/x/sys/windows"
|
||||
)
|
||||
|
||||
var (
|
||||
u32 = windows.NewLazySystemDLL("User32.dll")
|
||||
pBringWindowToTop = u32.NewProc("BringWindowToTop")
|
||||
pShowWindow = u32.NewProc("ShowWindow")
|
||||
pSendMessage = u32.NewProc("SendMessageA")
|
||||
pGetSystemMetrics = u32.NewProc("GetSystemMetrics")
|
||||
pGetWindowRect = u32.NewProc("GetWindowRect")
|
||||
pSetWindowPos = u32.NewProc("SetWindowPos")
|
||||
pSetForegroundWindow = u32.NewProc("SetForegroundWindow")
|
||||
pSetActiveWindow = u32.NewProc("SetActiveWindow")
|
||||
pIsIconic = u32.NewProc("IsIconic")
|
||||
|
||||
appPath = filepath.Join(os.Getenv("LOCALAPPDATA"), "Programs", "Ollama")
|
||||
appLogPath = filepath.Join(os.Getenv("LOCALAPPDATA"), "Ollama", "app.log")
|
||||
startupShortcut = filepath.Join(os.Getenv("APPDATA"), "Microsoft", "Windows", "Start Menu", "Programs", "Startup", "Ollama.lnk")
|
||||
ollamaPath string
|
||||
DesktopAppName = "ollama app.exe"
|
||||
)
|
||||
|
||||
func init() {
|
||||
// With alternate install location use executable location
|
||||
exe, err := os.Executable()
|
||||
if err != nil {
|
||||
slog.Warn("error discovering executable directory", "error", err)
|
||||
} else {
|
||||
appPath = filepath.Dir(exe)
|
||||
}
|
||||
ollamaPath = filepath.Join(appPath, "ollama.exe")
|
||||
|
||||
// Handle developer mode (go run ./cmd/app)
|
||||
if _, err := os.Stat(ollamaPath); err != nil {
|
||||
pwd, err := os.Getwd()
|
||||
if err != nil {
|
||||
slog.Warn("missing ollama.exe and failed to get pwd", "error", err)
|
||||
return
|
||||
}
|
||||
distAppPath := filepath.Join(pwd, "dist", "windows-"+runtime.GOARCH)
|
||||
distOllamaPath := filepath.Join(distAppPath, "ollama.exe")
|
||||
if _, err := os.Stat(distOllamaPath); err == nil {
|
||||
slog.Info("detected developer mode")
|
||||
appPath = distAppPath
|
||||
ollamaPath = distOllamaPath
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func maybeMoveAndRestart() appMove {
|
||||
return 0
|
||||
}
|
||||
|
||||
// handleExistingInstance checks for existing instances and optionally focuses them
|
||||
func handleExistingInstance(startHidden bool) {
|
||||
if wintray.CheckAndFocusExistingInstance(!startHidden) {
|
||||
slog.Info("existing instance found, exiting")
|
||||
os.Exit(0)
|
||||
}
|
||||
}
|
||||
|
||||
func installSymlink() {}
|
||||
|
||||
type appCallbacks struct {
|
||||
t wintray.TrayCallbacks
|
||||
shutdown func()
|
||||
}
|
||||
|
||||
var app = &appCallbacks{}
|
||||
|
||||
func (ac *appCallbacks) UIRun(path string) {
|
||||
wv.Run(path)
|
||||
}
|
||||
|
||||
func (*appCallbacks) UIShow() {
|
||||
if wv.webview != nil {
|
||||
showWindow(wv.webview.Window())
|
||||
} else {
|
||||
wv.Run("/")
|
||||
}
|
||||
}
|
||||
|
||||
func (*appCallbacks) UITerminate() {
|
||||
wv.Terminate()
|
||||
}
|
||||
|
||||
func (*appCallbacks) UIRunning() bool {
|
||||
return wv.IsRunning()
|
||||
}
|
||||
|
||||
func (app *appCallbacks) Quit() {
|
||||
app.t.Quit()
|
||||
wv.Terminate()
|
||||
}
|
||||
|
||||
// TODO - reconcile with above for consistency between mac/windows
|
||||
func quit() {
|
||||
wv.Terminate()
|
||||
}
|
||||
|
||||
func (app *appCallbacks) DoUpdate() {
|
||||
// Safeguard in case we have requests in flight that need to drain...
|
||||
slog.Info("Waiting for server to shutdown")
|
||||
|
||||
app.shutdown()
|
||||
|
||||
if err := updater.DoUpgrade(true); err != nil {
|
||||
slog.Warn(fmt.Sprintf("upgrade attempt failed: %s", err))
|
||||
}
|
||||
}
|
||||
|
||||
// HandleURLScheme implements the URLSchemeHandler interface
|
||||
func (app *appCallbacks) HandleURLScheme(urlScheme string) {
|
||||
handleURLSchemeRequest(urlScheme)
|
||||
}
|
||||
|
||||
// handleURLSchemeRequest processes URL scheme requests from other instances
|
||||
func handleURLSchemeRequest(urlScheme string) {
|
||||
isConnect, err := parseURLScheme(urlScheme)
|
||||
if err != nil {
|
||||
slog.Error("failed to parse URL scheme request", "url", urlScheme, "error", err)
|
||||
return
|
||||
}
|
||||
|
||||
if isConnect {
|
||||
handleConnectURLScheme()
|
||||
} else {
|
||||
if wv.webview != nil {
|
||||
showWindow(wv.webview.Window())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func UpdateAvailable(ver string) error {
|
||||
if app.t == nil {
|
||||
slog.Debug("tray not yet initialized, skipping update notification")
|
||||
return nil
|
||||
}
|
||||
return app.t.UpdateAvailable(ver)
|
||||
}
|
||||
|
||||
func osRun(shutdown func(), hasCompletedFirstRun, startHidden bool) {
|
||||
var err error
|
||||
app.shutdown = shutdown
|
||||
app.t, err = wintray.NewTray(app)
|
||||
if err != nil {
|
||||
log.Fatalf("Failed to start: %s", err)
|
||||
}
|
||||
|
||||
// Check for pending updates now that the tray is initialized.
|
||||
// The platform-independent check in app.go fires before osRun,
|
||||
// when app.t is still nil, so we must re-check here.
|
||||
if updater.IsUpdatePending() {
|
||||
slog.Debug("update pending on startup, showing tray notification")
|
||||
UpdateAvailable("")
|
||||
}
|
||||
|
||||
signals := make(chan os.Signal, 1)
|
||||
signal.Notify(signals, syscall.SIGINT, syscall.SIGTERM)
|
||||
|
||||
// TODO - can this be generalized?
|
||||
go func() {
|
||||
<-signals
|
||||
slog.Debug("shutting down due to signal")
|
||||
app.t.Quit()
|
||||
wv.Terminate()
|
||||
}()
|
||||
|
||||
// On windows, we run the final tasks in the main thread
|
||||
// before starting the tray event loop. These final tasks
|
||||
// may trigger the UI, and must do that from the main thread.
|
||||
if !startHidden {
|
||||
// Determine if the process was started from a shortcut
|
||||
// ~\AppData\Roaming\Microsoft\Windows\Start Menu\Programs\Startup\Ollama
|
||||
const STARTF_TITLEISLINKNAME = 0x00000800
|
||||
var info windows.StartupInfo
|
||||
if err := windows.GetStartupInfo(&info); err != nil {
|
||||
slog.Debug("unable to retrieve startup info", "error", err)
|
||||
} else if info.Flags&STARTF_TITLEISLINKNAME == STARTF_TITLEISLINKNAME {
|
||||
linkPath := windows.UTF16PtrToString(info.Title)
|
||||
if strings.Contains(linkPath, "Startup") {
|
||||
startHidden = true
|
||||
}
|
||||
}
|
||||
}
|
||||
if startHidden {
|
||||
startHiddenTasks()
|
||||
} else {
|
||||
ptr := wv.Run("/")
|
||||
|
||||
// Set the window icon using the tray icon
|
||||
if ptr != nil {
|
||||
iconHandle := app.t.GetIconHandle()
|
||||
if iconHandle != 0 {
|
||||
hwnd := uintptr(ptr)
|
||||
const ICON_SMALL = 0
|
||||
const ICON_BIG = 1
|
||||
const WM_SETICON = 0x0080
|
||||
|
||||
pSendMessage.Call(hwnd, uintptr(WM_SETICON), uintptr(ICON_SMALL), uintptr(iconHandle))
|
||||
pSendMessage.Call(hwnd, uintptr(WM_SETICON), uintptr(ICON_BIG), uintptr(iconHandle))
|
||||
}
|
||||
}
|
||||
|
||||
centerWindow(ptr)
|
||||
}
|
||||
|
||||
if !hasCompletedFirstRun {
|
||||
// Only create the login shortcut on first start
|
||||
// so we can respect users deletion of the link
|
||||
err = createLoginShortcut()
|
||||
if err != nil {
|
||||
slog.Warn("unable to create login shortcut", "error", err)
|
||||
}
|
||||
}
|
||||
|
||||
app.t.TrayRun() // This will block the main thread
|
||||
}
|
||||
|
||||
func createLoginShortcut() error {
|
||||
// The installer lays down a shortcut for us so we can copy it without
|
||||
// having to resort to calling COM APIs to establish the shortcut
|
||||
shortcutOrigin := filepath.Join(appPath, "lib", "Ollama.lnk")
|
||||
|
||||
_, err := os.Stat(startupShortcut)
|
||||
if err != nil {
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
in, err := os.Open(shortcutOrigin)
|
||||
if err != nil {
|
||||
return fmt.Errorf("unable to open shortcut %s : %w", shortcutOrigin, err)
|
||||
}
|
||||
defer in.Close()
|
||||
out, err := os.Create(startupShortcut)
|
||||
if err != nil {
|
||||
return fmt.Errorf("unable to open startup link %s : %w", startupShortcut, err)
|
||||
}
|
||||
defer out.Close()
|
||||
_, err = io.Copy(out, in)
|
||||
if err != nil {
|
||||
return fmt.Errorf("unable to copy shortcut %s : %w", startupShortcut, err)
|
||||
}
|
||||
err = out.Sync()
|
||||
if err != nil {
|
||||
return fmt.Errorf("unable to sync shortcut %s : %w", startupShortcut, err)
|
||||
}
|
||||
slog.Info("Created Startup shortcut", "shortcut", startupShortcut)
|
||||
} else {
|
||||
slog.Warn("unexpected error looking up Startup shortcut", "error", err)
|
||||
}
|
||||
} else {
|
||||
slog.Debug("Startup link already exists", "shortcut", startupShortcut)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func LaunchNewApp() {
|
||||
}
|
||||
|
||||
func logStartup() {
|
||||
slog.Info("starting Ollama", "app", appPath, "version", version.Version, "OS", updater.UserAgentOS)
|
||||
}
|
||||
|
||||
const (
|
||||
SW_HIDE = 0 // Hides the window
|
||||
SW_SHOW = 5 // Shows window in its current size/position
|
||||
SW_SHOWNA = 8 // Shows without activating
|
||||
SW_MINIMIZE = 6 // Minimizes the window
|
||||
SW_RESTORE = 9 // Restores to previous size/position
|
||||
SW_SHOWDEFAULT = 10 // Sets show state based on program state
|
||||
SM_CXSCREEN = 0
|
||||
SM_CYSCREEN = 1
|
||||
HWND_TOP = 0
|
||||
SWP_NOSIZE = 0x0001
|
||||
SWP_NOMOVE = 0x0002
|
||||
SWP_NOZORDER = 0x0004
|
||||
SWP_SHOWWINDOW = 0x0040
|
||||
|
||||
// Menu constants
|
||||
MF_STRING = 0x00000000
|
||||
MF_SEPARATOR = 0x00000800
|
||||
MF_GRAYED = 0x00000001
|
||||
TPM_RETURNCMD = 0x0100
|
||||
)
|
||||
|
||||
// POINT structure for cursor position
|
||||
type POINT struct {
|
||||
X int32
|
||||
Y int32
|
||||
}
|
||||
|
||||
// Rect structure for GetWindowRect
|
||||
type Rect struct {
|
||||
Left int32
|
||||
Top int32
|
||||
Right int32
|
||||
Bottom int32
|
||||
}
|
||||
|
||||
func centerWindow(ptr unsafe.Pointer) {
|
||||
hwnd := uintptr(ptr)
|
||||
if hwnd == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
var rect Rect
|
||||
pGetWindowRect.Call(hwnd, uintptr(unsafe.Pointer(&rect)))
|
||||
|
||||
screenWidth, _, _ := pGetSystemMetrics.Call(uintptr(SM_CXSCREEN))
|
||||
screenHeight, _, _ := pGetSystemMetrics.Call(uintptr(SM_CYSCREEN))
|
||||
|
||||
windowWidth := rect.Right - rect.Left
|
||||
windowHeight := rect.Bottom - rect.Top
|
||||
|
||||
x := (int32(screenWidth) - windowWidth) / 2
|
||||
y := (int32(screenHeight) - windowHeight) / 2
|
||||
|
||||
// Ensure the window is not positioned off-screen
|
||||
if x < 0 {
|
||||
x = 0
|
||||
}
|
||||
if y < 0 {
|
||||
y = 0
|
||||
}
|
||||
|
||||
pSetWindowPos.Call(
|
||||
hwnd,
|
||||
uintptr(HWND_TOP),
|
||||
uintptr(x),
|
||||
uintptr(y),
|
||||
uintptr(windowWidth), // Keep original width
|
||||
uintptr(windowHeight), // Keep original height
|
||||
uintptr(SWP_SHOWWINDOW),
|
||||
)
|
||||
}
|
||||
|
||||
func showWindow(ptr unsafe.Pointer) {
|
||||
hwnd := uintptr(ptr)
|
||||
if hwnd != 0 {
|
||||
iconHandle := app.t.GetIconHandle()
|
||||
if iconHandle != 0 {
|
||||
const ICON_SMALL = 0
|
||||
const ICON_BIG = 1
|
||||
const WM_SETICON = 0x0080
|
||||
|
||||
pSendMessage.Call(hwnd, uintptr(WM_SETICON), uintptr(ICON_SMALL), uintptr(iconHandle))
|
||||
pSendMessage.Call(hwnd, uintptr(WM_SETICON), uintptr(ICON_BIG), uintptr(iconHandle))
|
||||
}
|
||||
|
||||
// Check if window is minimized
|
||||
isMinimized, _, _ := pIsIconic.Call(hwnd)
|
||||
if isMinimized != 0 {
|
||||
// Restore the window if it's minimized
|
||||
pShowWindow.Call(hwnd, uintptr(SW_RESTORE))
|
||||
}
|
||||
|
||||
// Show the window
|
||||
pShowWindow.Call(hwnd, uintptr(SW_SHOW))
|
||||
|
||||
// Bring window to top
|
||||
pBringWindowToTop.Call(hwnd)
|
||||
|
||||
// Force window to foreground
|
||||
pSetForegroundWindow.Call(hwnd)
|
||||
|
||||
// Make it the active window
|
||||
pSetActiveWindow.Call(hwnd)
|
||||
|
||||
// Ensure window is positioned on top
|
||||
pSetWindowPos.Call(
|
||||
hwnd,
|
||||
uintptr(HWND_TOP),
|
||||
0, 0, 0, 0,
|
||||
uintptr(SWP_NOSIZE|SWP_NOMOVE|SWP_SHOWWINDOW),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// HideWindow hides the application window
|
||||
func hideWindow(ptr unsafe.Pointer) {
|
||||
hwnd := uintptr(ptr)
|
||||
if hwnd != 0 {
|
||||
pShowWindow.Call(
|
||||
hwnd,
|
||||
uintptr(SW_HIDE),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
func runInBackground() {
|
||||
exe, err := os.Executable()
|
||||
if err != nil {
|
||||
slog.Error("failed to get executable path", "error", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
cmd := exec.Command(exe, "hidden")
|
||||
if cmd != nil {
|
||||
err = cmd.Run()
|
||||
if err != nil {
|
||||
slog.Error("failed to run Ollama", "exe", exe, "error", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
} else {
|
||||
slog.Error("failed to start Ollama", "exe", exe)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
func drag(ptr unsafe.Pointer) {}
|
||||
|
||||
func doubleClick(ptr unsafe.Pointer) {}
|
||||
|
||||
// checkAndHandleExistingInstance checks if another instance is running and sends the URL to it
|
||||
func checkAndHandleExistingInstance(urlSchemeRequest string) bool {
|
||||
if urlSchemeRequest == "" {
|
||||
return false
|
||||
}
|
||||
|
||||
// Try to send URL to existing instance using wintray messaging
|
||||
if wintray.CheckAndSendToExistingInstance(urlSchemeRequest) {
|
||||
os.Exit(0)
|
||||
return true
|
||||
}
|
||||
|
||||
// No existing instance, we'll handle it ourselves
|
||||
return false
|
||||
}
|
||||
@@ -1,27 +0,0 @@
|
||||
#ifndef MENU_H
|
||||
#define MENU_H
|
||||
|
||||
#ifdef __cplusplus
|
||||
extern "C" {
|
||||
#endif
|
||||
|
||||
typedef struct
|
||||
{
|
||||
char *label;
|
||||
int enabled;
|
||||
int separator;
|
||||
} menuItem;
|
||||
|
||||
// TODO (jmorganca): these need to be forward declared in the webview.h file
|
||||
// for now but ideally they should be in this header file on windows too
|
||||
#ifndef WIN32
|
||||
int menu_get_item_count();
|
||||
void *menu_get_items();
|
||||
void menu_handle_selection(char *item);
|
||||
#endif
|
||||
|
||||
#ifdef __cplusplus
|
||||
}
|
||||
#endif
|
||||
|
||||
#endif
|
||||
@@ -1,528 +0,0 @@
|
||||
//go:build windows || darwin
|
||||
|
||||
package main
|
||||
|
||||
// #include "menu.h"
|
||||
import "C"
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
"unsafe"
|
||||
|
||||
"github.com/ollama/ollama/app/dialog"
|
||||
"github.com/ollama/ollama/app/store"
|
||||
"github.com/ollama/ollama/app/webview"
|
||||
)
|
||||
|
||||
type Webview struct {
|
||||
port int
|
||||
token string
|
||||
webview webview.WebView
|
||||
mutex sync.Mutex
|
||||
|
||||
Store *store.Store
|
||||
}
|
||||
|
||||
// Run initializes the webview and starts its event loop.
|
||||
// Note: this must be called from the primary app thread
|
||||
// This returns the OS native window handle to the caller
|
||||
func (w *Webview) Run(path string) unsafe.Pointer {
|
||||
var url string
|
||||
if devMode {
|
||||
// In development mode, use the local dev server
|
||||
url = fmt.Sprintf("http://localhost:5173%s", path)
|
||||
} else {
|
||||
url = fmt.Sprintf("http://127.0.0.1:%d%s", w.port, path)
|
||||
}
|
||||
w.mutex.Lock()
|
||||
defer w.mutex.Unlock()
|
||||
|
||||
if w.webview == nil {
|
||||
// Note: turning on debug on macos throws errors but is marginally functional for debugging
|
||||
// TODO (jmorganca): we should pre-create the window and then provide it here to
|
||||
// webview so we can hide it from the start and make other modifications
|
||||
wv := webview.New(debug)
|
||||
// start the window hidden
|
||||
hideWindow(wv.Window())
|
||||
wv.SetTitle("Ollama")
|
||||
|
||||
// TODO (jmorganca): this isn't working yet since it needs to be set
|
||||
// on the first page load, ideally in an interstitial page like `/token`
|
||||
// that exists only to set the cookie and redirect to /
|
||||
// wv.Init(fmt.Sprintf(`document.cookie = "token=%s; path=/"`, w.token))
|
||||
init := `
|
||||
// Disable reload
|
||||
document.addEventListener('keydown', function(e) {
|
||||
if ((e.ctrlKey || e.metaKey) && e.key === 'r') {
|
||||
e.preventDefault();
|
||||
return false;
|
||||
}
|
||||
});
|
||||
|
||||
// Prevent back/forward navigation
|
||||
window.addEventListener('popstate', function(e) {
|
||||
e.preventDefault();
|
||||
history.pushState(null, '', window.location.pathname);
|
||||
return false;
|
||||
});
|
||||
|
||||
// Clear history on load
|
||||
window.addEventListener('load', function() {
|
||||
history.pushState(null, '', window.location.pathname);
|
||||
window.history.replaceState(null, '', window.location.pathname);
|
||||
});
|
||||
|
||||
// Set token cookie
|
||||
document.cookie = "token=` + w.token + `; path=/";
|
||||
`
|
||||
// Windows-specific scrollbar styling
|
||||
if runtime.GOOS == "windows" {
|
||||
init += `
|
||||
// Fix scrollbar styling for Edge WebView2 on Windows only
|
||||
function updateScrollbarStyles() {
|
||||
const isDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
|
||||
const existingStyle = document.getElementById('scrollbar-style');
|
||||
if (existingStyle) existingStyle.remove();
|
||||
|
||||
const style = document.createElement('style');
|
||||
style.id = 'scrollbar-style';
|
||||
|
||||
if (isDark) {
|
||||
style.textContent = ` + "`" + `
|
||||
::-webkit-scrollbar { width: 6px !important; height: 6px !important; }
|
||||
::-webkit-scrollbar-track { background: #1a1a1a !important; }
|
||||
::-webkit-scrollbar-thumb { background: #404040 !important; border-radius: 6px !important; }
|
||||
::-webkit-scrollbar-thumb:hover { background: #505050 !important; }
|
||||
::-webkit-scrollbar-corner { background: #1a1a1a !important; }
|
||||
::-webkit-scrollbar-button {
|
||||
background: transparent !important;
|
||||
border: none !important;
|
||||
width: 0px !important;
|
||||
height: 0px !important;
|
||||
margin: 0 !important;
|
||||
padding: 0 !important;
|
||||
}
|
||||
::-webkit-scrollbar-button:vertical:start:decrement {
|
||||
background: transparent !important;
|
||||
height: 0px !important;
|
||||
}
|
||||
::-webkit-scrollbar-button:vertical:end:increment {
|
||||
background: transparent !important;
|
||||
height: 0px !important;
|
||||
}
|
||||
::-webkit-scrollbar-button:horizontal:start:decrement {
|
||||
background: transparent !important;
|
||||
width: 0px !important;
|
||||
}
|
||||
::-webkit-scrollbar-button:horizontal:end:increment {
|
||||
background: transparent !important;
|
||||
width: 0px !important;
|
||||
}
|
||||
` + "`" + `;
|
||||
} else {
|
||||
style.textContent = ` + "`" + `
|
||||
::-webkit-scrollbar { width: 6px !important; height: 6px !important; }
|
||||
::-webkit-scrollbar-track { background: #f0f0f0 !important; }
|
||||
::-webkit-scrollbar-thumb { background: #c0c0c0 !important; border-radius: 6px !important; }
|
||||
::-webkit-scrollbar-thumb:hover { background: #a0a0a0 !important; }
|
||||
::-webkit-scrollbar-corner { background: #f0f0f0 !important; }
|
||||
::-webkit-scrollbar-button {
|
||||
background: transparent !important;
|
||||
border: none !important;
|
||||
width: 0px !important;
|
||||
height: 0px !important;
|
||||
margin: 0 !important;
|
||||
padding: 0 !important;
|
||||
}
|
||||
::-webkit-scrollbar-button:vertical:start:decrement {
|
||||
background: transparent !important;
|
||||
height: 0px !important;
|
||||
}
|
||||
::-webkit-scrollbar-button:vertical:end:increment {
|
||||
background: transparent !important;
|
||||
height: 0px !important;
|
||||
}
|
||||
::-webkit-scrollbar-button:horizontal:start:decrement {
|
||||
background: transparent !important;
|
||||
width: 0px !important;
|
||||
}
|
||||
::-webkit-scrollbar-button:horizontal:end:increment {
|
||||
background: transparent !important;
|
||||
width: 0px !important;
|
||||
}
|
||||
` + "`" + `;
|
||||
}
|
||||
document.head.appendChild(style);
|
||||
}
|
||||
|
||||
window.addEventListener('load', updateScrollbarStyles);
|
||||
window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', updateScrollbarStyles);
|
||||
`
|
||||
}
|
||||
// on windows make ctrl+n open new chat
|
||||
// TODO (jmorganca): later we should use proper accelerators
|
||||
// once we introduce a native menu for the window
|
||||
// this is only used on windows since macOS uses the proper accelerators
|
||||
if runtime.GOOS == "windows" {
|
||||
init += `
|
||||
document.addEventListener('keydown', function(e) {
|
||||
if ((e.ctrlKey || e.metaKey) && e.key === 'n') {
|
||||
e.preventDefault();
|
||||
// Use the existing navigation method
|
||||
history.pushState({}, '', '/c/new');
|
||||
window.dispatchEvent(new PopStateEvent('popstate'));
|
||||
return false;
|
||||
}
|
||||
});
|
||||
`
|
||||
}
|
||||
|
||||
init += `
|
||||
window.OLLAMA_WEBSEARCH = true;
|
||||
`
|
||||
|
||||
wv.Init(init)
|
||||
|
||||
// Add keyboard handler for zoom
|
||||
wv.Init(`
|
||||
window.addEventListener('keydown', function(e) {
|
||||
// CMD/Ctrl + Plus/Equals (zoom in)
|
||||
if ((e.metaKey || e.ctrlKey) && (e.key === '+' || e.key === '=')) {
|
||||
e.preventDefault();
|
||||
window.zoomIn && window.zoomIn();
|
||||
return false;
|
||||
}
|
||||
|
||||
// CMD/Ctrl + Minus (zoom out)
|
||||
if ((e.metaKey || e.ctrlKey) && e.key === '-') {
|
||||
e.preventDefault();
|
||||
window.zoomOut && window.zoomOut();
|
||||
return false;
|
||||
}
|
||||
|
||||
// CMD/Ctrl + 0 (reset zoom)
|
||||
if ((e.metaKey || e.ctrlKey) && e.key === '0') {
|
||||
e.preventDefault();
|
||||
window.zoomReset && window.zoomReset();
|
||||
return false;
|
||||
}
|
||||
}, true);
|
||||
`)
|
||||
|
||||
wv.Bind("zoomIn", func() {
|
||||
current := wv.GetZoom()
|
||||
wv.SetZoom(current + 0.1)
|
||||
})
|
||||
|
||||
wv.Bind("zoomOut", func() {
|
||||
current := wv.GetZoom()
|
||||
wv.SetZoom(current - 0.1)
|
||||
})
|
||||
|
||||
wv.Bind("zoomReset", func() {
|
||||
wv.SetZoom(1.0)
|
||||
})
|
||||
|
||||
wv.Bind("ready", func() {
|
||||
showWindow(wv.Window())
|
||||
})
|
||||
|
||||
wv.Bind("close", func() {
|
||||
hideWindow(wv.Window())
|
||||
})
|
||||
|
||||
// Webviews do not allow access to the file system by default, so we need to
|
||||
// bind file system operations here
|
||||
wv.Bind("selectModelsDirectory", func() {
|
||||
go func() {
|
||||
// Helper function to call the JavaScript callback with data or null
|
||||
callCallback := func(data interface{}) {
|
||||
dataJSON, _ := json.Marshal(data)
|
||||
wv.Dispatch(func() {
|
||||
wv.Eval(fmt.Sprintf("window.__selectModelsDirectoryCallback && window.__selectModelsDirectoryCallback(%s)", dataJSON))
|
||||
})
|
||||
}
|
||||
|
||||
directory, err := dialog.Directory().Title("Select Model Directory").ShowHidden(true).Browse()
|
||||
if err != nil {
|
||||
slog.Debug("Directory selection cancelled or failed", "error", err)
|
||||
callCallback(nil)
|
||||
return
|
||||
}
|
||||
slog.Debug("Directory selected", "path", directory)
|
||||
callCallback(directory)
|
||||
}()
|
||||
})
|
||||
|
||||
// Bind selectFiles function for selecting multiple files at once
|
||||
wv.Bind("selectFiles", func() {
|
||||
go func() {
|
||||
// Helper function to call the JavaScript callback with data or null
|
||||
callCallback := func(data interface{}) {
|
||||
dataJSON, _ := json.Marshal(data)
|
||||
wv.Dispatch(func() {
|
||||
wv.Eval(fmt.Sprintf("window.__selectFilesCallback && window.__selectFilesCallback(%s)", dataJSON))
|
||||
})
|
||||
}
|
||||
|
||||
// Define allowed extensions for native dialog filtering
|
||||
textExts := []string{
|
||||
"pdf", "docx", "txt", "md", "csv", "json", "xml", "html", "htm",
|
||||
"js", "jsx", "ts", "tsx", "py", "java", "cpp", "c", "cc", "h", "cs", "php", "rb",
|
||||
"go", "rs", "swift", "kt", "scala", "sh", "bat", "yaml", "yml", "toml", "ini",
|
||||
"cfg", "conf", "log", "rtf",
|
||||
}
|
||||
imageExts := []string{"png", "jpg", "jpeg", "webp"}
|
||||
allowedExts := append(textExts, imageExts...)
|
||||
|
||||
// Use native multiple file selection with extension filtering
|
||||
filenames, err := dialog.File().
|
||||
Filter("Supported Files", allowedExts...).
|
||||
Title("Select Files").
|
||||
LoadMultiple()
|
||||
if err != nil {
|
||||
slog.Debug("Multiple file selection cancelled or failed", "error", err)
|
||||
callCallback(nil)
|
||||
return
|
||||
}
|
||||
|
||||
if len(filenames) == 0 {
|
||||
callCallback(nil)
|
||||
return
|
||||
}
|
||||
|
||||
var files []map[string]string
|
||||
maxFileSize := int64(10 * 1024 * 1024) // 10MB
|
||||
|
||||
for _, filename := range filenames {
|
||||
// Check file extension (double-check after native dialog filtering)
|
||||
ext := strings.ToLower(strings.TrimPrefix(filepath.Ext(filename), "."))
|
||||
validExt := false
|
||||
for _, allowedExt := range allowedExts {
|
||||
if ext == allowedExt {
|
||||
validExt = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !validExt {
|
||||
slog.Warn("file extension not allowed, skipping", "filename", filepath.Base(filename), "extension", ext)
|
||||
continue
|
||||
}
|
||||
|
||||
// Check file size before reading (pre-filter large files)
|
||||
fileStat, err := os.Stat(filename)
|
||||
if err != nil {
|
||||
slog.Error("failed to get file info", "error", err, "filename", filename)
|
||||
continue
|
||||
}
|
||||
|
||||
if fileStat.Size() > maxFileSize {
|
||||
slog.Warn("file too large, skipping", "filename", filepath.Base(filename), "size", fileStat.Size())
|
||||
continue
|
||||
}
|
||||
|
||||
fileBytes, err := os.ReadFile(filename)
|
||||
if err != nil {
|
||||
slog.Error("failed to read file", "error", err, "filename", filename)
|
||||
continue
|
||||
}
|
||||
|
||||
mimeType := http.DetectContentType(fileBytes)
|
||||
dataURL := fmt.Sprintf("data:%s;base64,%s", mimeType, base64.StdEncoding.EncodeToString(fileBytes))
|
||||
|
||||
fileResult := map[string]string{
|
||||
"filename": filepath.Base(filename),
|
||||
"path": filename,
|
||||
"dataURL": dataURL,
|
||||
}
|
||||
|
||||
files = append(files, fileResult)
|
||||
}
|
||||
|
||||
if len(files) == 0 {
|
||||
callCallback(nil)
|
||||
} else {
|
||||
callCallback(files)
|
||||
}
|
||||
}()
|
||||
})
|
||||
|
||||
wv.Bind("drag", func() {
|
||||
wv.Dispatch(func() {
|
||||
drag(wv.Window())
|
||||
})
|
||||
})
|
||||
|
||||
wv.Bind("doubleClick", func() {
|
||||
wv.Dispatch(func() {
|
||||
doubleClick(wv.Window())
|
||||
})
|
||||
})
|
||||
|
||||
// Add binding for working directory selection
|
||||
wv.Bind("selectWorkingDirectory", func() {
|
||||
go func() {
|
||||
// Helper function to call the JavaScript callback with data or null
|
||||
callCallback := func(data interface{}) {
|
||||
dataJSON, _ := json.Marshal(data)
|
||||
wv.Dispatch(func() {
|
||||
wv.Eval(fmt.Sprintf("window.__selectWorkingDirectoryCallback && window.__selectWorkingDirectoryCallback(%s)", dataJSON))
|
||||
})
|
||||
}
|
||||
|
||||
directory, err := dialog.Directory().Title("Select Working Directory").ShowHidden(true).Browse()
|
||||
if err != nil {
|
||||
slog.Debug("Directory selection cancelled or failed", "error", err)
|
||||
callCallback(nil)
|
||||
return
|
||||
}
|
||||
slog.Debug("Directory selected", "path", directory)
|
||||
callCallback(directory)
|
||||
}()
|
||||
})
|
||||
|
||||
wv.Bind("setContextMenuItems", func(items []map[string]interface{}) error {
|
||||
menuMutex.Lock()
|
||||
defer menuMutex.Unlock()
|
||||
|
||||
if len(menuItems) > 0 {
|
||||
pinner.Unpin()
|
||||
}
|
||||
|
||||
menuItems = nil
|
||||
for _, item := range items {
|
||||
menuItem := C.menuItem{
|
||||
label: C.CString(item["label"].(string)),
|
||||
enabled: 0,
|
||||
separator: 0,
|
||||
}
|
||||
|
||||
if item["enabled"] != nil {
|
||||
menuItem.enabled = 1
|
||||
}
|
||||
|
||||
if item["separator"] != nil {
|
||||
menuItem.separator = 1
|
||||
}
|
||||
menuItems = append(menuItems, menuItem)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
|
||||
// Debounce resize events
|
||||
var resizeTimer *time.Timer
|
||||
var resizeMutex sync.Mutex
|
||||
|
||||
wv.Bind("resize", func(width, height int) {
|
||||
if w.Store != nil {
|
||||
resizeMutex.Lock()
|
||||
if resizeTimer != nil {
|
||||
resizeTimer.Stop()
|
||||
}
|
||||
resizeTimer = time.AfterFunc(100*time.Millisecond, func() {
|
||||
err := w.Store.SetWindowSize(width, height)
|
||||
if err != nil {
|
||||
slog.Error("failed to set window size", "error", err)
|
||||
}
|
||||
})
|
||||
resizeMutex.Unlock()
|
||||
}
|
||||
})
|
||||
|
||||
// On Darwin, we can't have 2 threads both running global event loops
|
||||
// but on Windows, the event loops are tied to the window, so we're
|
||||
// able to run in both the tray and webview
|
||||
if runtime.GOOS != "darwin" {
|
||||
slog.Debug("starting webview event loop")
|
||||
go func() {
|
||||
wv.Run()
|
||||
slog.Debug("webview event loop exited")
|
||||
}()
|
||||
}
|
||||
|
||||
if w.Store != nil {
|
||||
width, height, err := w.Store.WindowSize()
|
||||
if err != nil {
|
||||
slog.Error("failed to get window size", "error", err)
|
||||
}
|
||||
if width > 0 && height > 0 {
|
||||
wv.SetSize(width, height, webview.HintNone)
|
||||
} else {
|
||||
wv.SetSize(800, 600, webview.HintNone)
|
||||
}
|
||||
}
|
||||
wv.SetSize(800, 600, webview.HintMin)
|
||||
|
||||
w.webview = wv
|
||||
w.webview.Navigate(url)
|
||||
} else {
|
||||
w.webview.Eval(fmt.Sprintf(`
|
||||
history.pushState({}, '', '%s');
|
||||
`, path))
|
||||
showWindow(w.webview.Window())
|
||||
}
|
||||
|
||||
return w.webview.Window()
|
||||
}
|
||||
|
||||
func (w *Webview) Terminate() {
|
||||
w.mutex.Lock()
|
||||
if w.webview == nil {
|
||||
w.mutex.Unlock()
|
||||
return
|
||||
}
|
||||
|
||||
wv := w.webview
|
||||
w.webview = nil
|
||||
w.mutex.Unlock()
|
||||
wv.Terminate()
|
||||
wv.Destroy()
|
||||
}
|
||||
|
||||
func (w *Webview) IsRunning() bool {
|
||||
w.mutex.Lock()
|
||||
defer w.mutex.Unlock()
|
||||
return w.webview != nil
|
||||
}
|
||||
|
||||
var (
|
||||
menuItems []C.menuItem
|
||||
menuMutex sync.RWMutex
|
||||
pinner runtime.Pinner
|
||||
)
|
||||
|
||||
//export menu_get_item_count
|
||||
func menu_get_item_count() C.int {
|
||||
menuMutex.RLock()
|
||||
defer menuMutex.RUnlock()
|
||||
return C.int(len(menuItems))
|
||||
}
|
||||
|
||||
//export menu_get_items
|
||||
func menu_get_items() unsafe.Pointer {
|
||||
menuMutex.RLock()
|
||||
defer menuMutex.RUnlock()
|
||||
|
||||
if len(menuItems) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Return pointer to the slice data
|
||||
pinner.Pin(&menuItems[0])
|
||||
return unsafe.Pointer(&menuItems[0])
|
||||
}
|
||||
|
||||
//export menu_handle_selection
|
||||
func menu_handle_selection(item *C.char) {
|
||||
wv.webview.Eval(fmt.Sprintf("window.handleContextMenuResult('%s')", C.GoString(item)))
|
||||
}
|
||||
@@ -1,40 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>CFBundleDevelopmentRegion</key>
|
||||
<string>English</string>
|
||||
<key>CFBundleExecutable</key>
|
||||
<string>Squirrel</string>
|
||||
<key>CFBundleIconFile</key>
|
||||
<string/>
|
||||
<key>CFBundleIdentifier</key>
|
||||
<string>com.github.Squirrel</string>
|
||||
<key>CFBundleInfoDictionaryVersion</key>
|
||||
<string>6.0</string>
|
||||
<key>CFBundleName</key>
|
||||
<string>Squirrel</string>
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>FMWK</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>1.0</string>
|
||||
<key>CFBundleSignature</key>
|
||||
<string>????</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>1</string>
|
||||
<key>DTCompiler</key>
|
||||
<string>com.apple.compilers.llvm.clang.1_0</string>
|
||||
<key>DTSDKBuild</key>
|
||||
<string>22E245</string>
|
||||
<key>DTSDKName</key>
|
||||
<string>macosx13.3</string>
|
||||
<key>DTXcode</key>
|
||||
<string>1431</string>
|
||||
<key>DTXcodeBuild</key>
|
||||
<string>14E300c</string>
|
||||
<key>NSHumanReadableCopyright</key>
|
||||
<string>Copyright © 2013 GitHub. All rights reserved.</string>
|
||||
<key>NSPrincipalClass</key>
|
||||
<string/>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -1,51 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>CFBundleDisplayName</key>
|
||||
<string>Ollama</string>
|
||||
<key>CFBundleExecutable</key>
|
||||
<string>Ollama</string>
|
||||
<key>CFBundleIconFile</key>
|
||||
<string>icon.icns</string>
|
||||
<key>CFBundleIdentifier</key>
|
||||
<string>com.electron.ollama</string>
|
||||
<key>CFBundleInfoDictionaryVersion</key>
|
||||
<string>6.0</string>
|
||||
<key>CFBundleName</key>
|
||||
<string>Ollama</string>
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>APPL</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>0.0.0</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>0.0.0</string>
|
||||
<key>DTCompiler</key>
|
||||
<string>com.apple.compilers.llvm.clang.1_0</string>
|
||||
<key>DTSDKBuild</key>
|
||||
<string>22E245</string>
|
||||
<key>DTSDKName</key>
|
||||
<string>macosx14.0</string>
|
||||
<key>DTXcode</key>
|
||||
<string>1431</string>
|
||||
<key>DTXcodeBuild</key>
|
||||
<string>14E300c</string>
|
||||
<key>LSApplicationCategoryType</key>
|
||||
<string>public.app-category.developer-tools</string>
|
||||
<key>LSMinimumSystemVersion</key>
|
||||
<string>14.0</string>
|
||||
<key>LSUIElement</key>
|
||||
<true/>
|
||||
<key>CFBundleURLTypes</key>
|
||||
<array>
|
||||
<dict>
|
||||
<key>CFBundleURLName</key>
|
||||
<string>Ollama URL</string>
|
||||
<key>CFBundleURLSchemes</key>
|
||||
<array>
|
||||
<string>ollama</string>
|
||||
</array>
|
||||
</dict>
|
||||
</array>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -1,25 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>Label</key>
|
||||
<string>com.ollama.ollama</string>
|
||||
<key>BundleProgram</key>
|
||||
<string>Contents/Frameworks/Squirrel.framework/Versions/A/Squirrel</string>
|
||||
<key>ProgramArguments</key>
|
||||
<array>
|
||||
<string>Contents/Frameworks/Squirrel.framework/Versions/A/Squirrel</string>
|
||||
<string>background</string>
|
||||
</array>
|
||||
<key>RunAtLoad</key>
|
||||
<true/>
|
||||
<key>LimitLoadToSessionType</key>
|
||||
<string>Aqua</string>
|
||||
<key>POSIXSpawnType</key>
|
||||
<string>Interactive</string>
|
||||
<key>LSUIElement</key>
|
||||
<true/>
|
||||
<key>LSBackgroundOnly</key>
|
||||
<false/>
|
||||
</dict>
|
||||
</plist>
|
||||
|
Before Width: | Height: | Size: 374 B |