Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
83ed7d9965 | ||
|
|
b043d891c2 | ||
|
|
43e667004a | ||
|
|
d3efc63263 | ||
|
|
87b9f9e95a | ||
|
|
3f77cb6dfb | ||
|
|
cf8b605b06 | ||
|
|
b68365a0a4 | ||
|
|
4986e92379 | ||
|
|
59fe23d85c | ||
|
|
b79067b0db | ||
|
|
3ffc9a682a | ||
|
|
ba064c3662 | ||
|
|
c36adebc20 | ||
|
|
882387a57b | ||
|
|
b1d1ccc957 | ||
|
|
855f4bf989 | ||
|
|
e5e4377115 | ||
|
|
5ec5804360 | ||
|
|
3ba380d0be | ||
|
|
f348c7e3f5 | ||
|
|
205a042690 | ||
|
|
ef117cfcc0 | ||
|
|
e37a00a8fa | ||
|
|
f96e7aa051 | ||
|
|
68793119df | ||
|
|
f4025ed1fe | ||
|
|
39f7f91563 | ||
|
|
d366f4868a | ||
|
|
a67fe8c537 | ||
|
|
13f2fb8c99 | ||
|
|
3b96a8972a | ||
|
|
91cf995996 | ||
|
|
a773b7affa | ||
|
|
d3a8af4d9b | ||
|
|
18ea9de0fe | ||
|
|
e2c6c7e894 | ||
|
|
9dbf139133 | ||
|
|
77e3b0ac7a | ||
|
|
147509c0c5 | ||
|
|
7623501fc2 | ||
|
|
7027546ccf | ||
|
|
3d86a552b8 | ||
|
|
d465dc7ca1 | ||
|
|
ad94d52965 | ||
|
|
ebf200f952 | ||
|
|
075aa7e147 | ||
|
|
377ef091dc | ||
|
|
6e19e916c7 | ||
|
|
f6c59d8703 | ||
|
|
82ad9fa38b | ||
|
|
60d83f8b0e | ||
|
|
e2e82903fa | ||
|
|
939425152e | ||
|
|
02dc3ea4c3 | ||
|
|
fb30760996 | ||
|
|
add1f92bdd | ||
|
|
124e9af9d2 | ||
|
|
2d9622a4d4 | ||
|
|
30019c87c4 | ||
|
|
c44575ef14 | ||
|
|
81f9a394e9 | ||
|
|
30e2891808 | ||
|
|
b315b3ee97 | ||
|
|
c01eafa552 | ||
|
|
8f912415e8 | ||
|
|
5ad1681cf1 | ||
|
|
30546d1fd4 | ||
|
|
6bba484f1a | ||
|
|
e92b7855f6 | ||
|
|
4e13421378 | ||
|
|
b7871fc0d1 | ||
|
|
e0c95a5ffd | ||
|
|
b8a6272440 | ||
|
|
d1bd15ccce | ||
|
|
0bb0925920 | ||
|
|
a5165c53ac | ||
|
|
cd37044093 | ||
|
|
d67ad83426 | ||
|
|
e5a81899d0 | ||
|
|
78e818e3ce | ||
|
|
87abaa019e | ||
|
|
f427fa0753 | ||
|
|
0f25c31bd5 | ||
|
|
5512797527 | ||
|
|
39df91c982 | ||
|
|
7ce88bd686 | ||
|
|
01d04d50f8 | ||
|
|
9a56a0e845 | ||
|
|
88313499e0 | ||
|
|
2b4a99376c | ||
|
|
e922bc7125 | ||
|
|
950dd9ac67 | ||
|
|
b6b1b258c3 | ||
|
|
4138e853d5 | ||
|
|
641df5e5ad | ||
|
|
6a261db7d8 | ||
|
|
948f69330a | ||
|
|
96fb6d2fa9 | ||
|
|
400164d47c | ||
|
|
bb7bba885e | ||
|
|
4f066a6fb0 | ||
|
|
a836eb8c3c | ||
|
|
1a9e4235ac | ||
|
|
43f4eda808 | ||
|
|
acdf81510d | ||
|
|
1e85fe8e9a | ||
|
|
5fcf71b8b8 | ||
|
|
60bdc23467 | ||
|
|
694487c65b | ||
|
|
af5b627672 | ||
|
|
8713570d3c | ||
|
|
5a173edb63 | ||
|
|
b880b76c43 | ||
|
|
cf129bbb11 | ||
|
|
c1bf60d7b1 | ||
|
|
0fcfc99ea0 | ||
|
|
e7fbd528f7 | ||
|
|
2f84872ce0 | ||
|
|
f91cb0d6a7 | ||
|
|
8dd34b77d1 | ||
|
|
35f71382de | ||
|
|
144893850f | ||
|
|
26936bea45 | ||
|
|
43983edf18 | ||
|
|
c82ebbd5bf | ||
|
|
8edecb5c69 | ||
|
|
8d8c701d6a | ||
|
|
b63eed94b6 | ||
|
|
4f9d09ef52 | ||
|
|
ba8f2a324d | ||
|
|
721f05049d | ||
|
|
accd6d656a | ||
|
|
bd3f22e2f7 | ||
|
|
5db07cad71 | ||
|
|
acf96e7ab7 | ||
|
|
a199313eb3 | ||
|
|
b205993ed4 | ||
|
|
9ea503f505 | ||
|
|
3ff2dcb649 | ||
|
|
4713800b08 | ||
|
|
0e2e34aa86 | ||
|
|
76929b0a8a | ||
|
|
bf7be180e3 | ||
|
|
eec8e0b945 | ||
|
|
be7572e2cf | ||
|
|
64ee2f9847 | ||
|
|
132e0ca25d | ||
|
|
9eef4a7195 | ||
|
|
3f07e022ac | ||
|
|
551809688b | ||
|
|
08edcb8f2c | ||
|
|
d6f69da04d | ||
|
|
6cd40001a9 | ||
|
|
a84b315e7b | ||
|
|
83d4311ffe | ||
|
|
fce745fe5e | ||
|
|
1fd1ccf7ad | ||
|
|
efb7e3c55e | ||
|
|
b517b9bd01 | ||
|
|
479664e7aa | ||
|
|
a51df81573 | ||
|
|
a18c230189 | ||
|
|
e21d5327b0 | ||
|
|
4d1b53e6fb | ||
|
|
6100aca085 | ||
|
|
72116bafb3 | ||
|
|
e2c2edcc27 | ||
|
|
de1ce45913 | ||
|
|
51fc00122b | ||
|
|
445284b428 | ||
|
|
e8f7c93a0b | ||
|
|
0de38190d7 | ||
|
|
681dfaedcc | ||
|
|
9893d39218 | ||
|
|
5ba17e6fdf | ||
|
|
6f3b997dec | ||
|
|
cc62676656 | ||
|
|
573386c35e | ||
|
|
794a254111 | ||
|
|
714b6fc2a4 | ||
|
|
61e1b1ba5e | ||
|
|
5865a01e48 | ||
|
|
e61c1c73fe | ||
|
|
03d61e1925 | ||
|
|
30c390384e | ||
|
|
d590830091 | ||
|
|
fdcf9efafd | ||
|
|
76188f60cd | ||
|
|
8a0016f826 | ||
|
|
d49b96d9ab | ||
|
|
3bd506bd1c | ||
|
|
123b1f2479 | ||
|
|
556245843a | ||
|
|
c963822dca | ||
|
|
dd49563d55 | ||
|
|
4e96f4dbf2 | ||
|
|
d573a2367b | ||
|
|
4f7786d0ba | ||
|
|
f1a0ffd621 | ||
|
|
cd600e19a3 | ||
|
|
59bd0b49bb | ||
|
|
82f905cd9c | ||
|
|
cb3d98ccb2 | ||
|
|
d47859ce49 | ||
|
|
a6293eb516 | ||
|
|
892e7f6be6 | ||
|
|
f3d69a3dee | ||
|
|
67b6a1c2d4 | ||
|
|
87b64213b4 | ||
|
|
f2d069f6df | ||
|
|
5208ae7500 | ||
|
|
9d779572a7 | ||
|
|
964ea42c09 | ||
|
|
dba1e27fa8 | ||
|
|
e436db25ff | ||
|
|
26acfa42b5 | ||
|
|
7b22ac9683 | ||
|
|
a2b3a5e9a3 | ||
|
|
624cada952 | ||
|
|
cecd265d3a | ||
|
|
2ea95fb059 | ||
|
|
8e7be3aed1 | ||
|
|
710292ff4f | ||
|
|
ada1eb5163 | ||
|
|
1c5ebbf5f4 | ||
|
|
7926b99e0e | ||
|
|
32a97b7493 | ||
|
|
d26a58557d | ||
|
|
2e474c98f9 | ||
|
|
2cb2c5381f | ||
|
|
2a6b50421a | ||
|
|
f22ec2ec49 | ||
|
|
d9075caf1a | ||
|
|
e11eeb3ba0 | ||
|
|
0a408b2225 | ||
|
|
16739dee60 | ||
|
|
d48d790baf | ||
|
|
0463940334 | ||
|
|
570679c9e0 | ||
|
|
89a171cc70 | ||
|
|
33878e671a | ||
|
|
c191a145bb | ||
|
|
479e1cf94e | ||
|
|
836507378b | ||
|
|
46bc1bcb4c | ||
|
|
2a8b31531e | ||
|
|
505e35f2b9 | ||
|
|
114875133b | ||
|
|
42c330283b | ||
|
|
f93efe2809 | ||
|
|
28fbbb06d5 | ||
|
|
340c51bbb7 | ||
|
|
2e9d68dc38 | ||
|
|
fc58544422 | ||
|
|
e434a93884 | ||
|
|
9c02d8e69d | ||
|
|
07ed752353 | ||
|
|
e1f7f9cbdb | ||
|
|
8c432fc88a | ||
|
|
acfb50d9af | ||
|
|
0f047feef5 | ||
|
|
9e4ed74efe | ||
|
|
bbb40a0a6c | ||
|
|
993acc7504 | ||
|
|
7ea692cb2b | ||
|
|
12e04379cd | ||
|
|
f8a48df24d | ||
|
|
82e0ddb6fe | ||
|
|
1abd56b6e6 | ||
|
|
ded2db7d86 | ||
|
|
d00622060f | ||
|
|
177aefb8a9 | ||
|
|
07588c64ee | ||
|
|
4c97a940ca | ||
|
|
74cbf1d2c2 | ||
|
|
5c1e37eb67 | ||
|
|
f0078ae476 | ||
|
|
96201a623a | ||
|
|
9c94c2b11e | ||
|
|
e09b3f9fb5 | ||
|
|
a0099da2d1 | ||
|
|
25e0e81e12 | ||
|
|
87cff95af8 | ||
|
|
3ef69ef784 | ||
|
|
1a7786be14 | ||
|
|
3370ff8b1c | ||
|
|
455f57457d | ||
|
|
1d955ed990 | ||
|
|
d071237131 | ||
|
|
229a1303fb | ||
|
|
ac3d0657a2 | ||
|
|
01557ff313 | ||
|
|
e5a38739b4 | ||
|
|
5f56a289b3 | ||
|
|
ad8cda255d | ||
|
|
3e1b4fe39d | ||
|
|
52196f1a97 | ||
|
|
50bbda5660 | ||
|
|
4b5bdd3b25 | ||
|
|
e828061b6e | ||
|
|
7a2073d17b | ||
|
|
c952708169 | ||
|
|
f57d111754 | ||
|
|
c34a79a373 | ||
|
|
b051c9cf83 | ||
|
|
b7b7fa0454 | ||
|
|
4c076813be | ||
|
|
6780f0416a | ||
|
|
35fa277fa9 | ||
|
|
05747b02ab | ||
|
|
4e807fdedd | ||
|
|
7d3a6c3ae5 | ||
|
|
06ff728246 | ||
|
|
2c71d8d7ca | ||
|
|
00381496a3 | ||
|
|
5e9636fa05 | ||
|
|
630882621b | ||
|
|
0e93ccc2cd | ||
|
|
e7766a4a47 | ||
|
|
be7de10c41 | ||
|
|
11be8f6ac8 | ||
|
|
9db4bdbad6 |
No files matched your search
@@ -0,0 +1,366 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
# Prepare MLX runtime libraries for macOS CI unit tests.
|
||||
#
|
||||
# Building MLX is expensive, so to enable the MLX-specific unit tests this
|
||||
# helper finds the newest Ollama release whose MLX_VERSION and MLX_C_VERSION
|
||||
# match the current checkout, downloads that release's ollama-darwin.tgz, and
|
||||
# extracts only mlx_metal_v* into build/lib/ollama.
|
||||
#
|
||||
# The payload also depends on Ollama's payload build rules (cmake glue and
|
||||
# carried mlx/compat patches) and the xgrammar native wrapper
|
||||
# (x/mlxrunner/xgrammar/native). Rule drift rebuilds the whole payload from
|
||||
# source; wrapper-only drift rebuilds just libollama_xgrammar.dylib.
|
||||
#
|
||||
# If no release matches the MLX pins (e.g. right after a pin bump), the
|
||||
# helper builds the minimal MLX payload for this platform: a single Metal
|
||||
# variant using the superbuild's platform default (metal_v4 on macOS 26.2+
|
||||
# SDKs, otherwise metal_v3), including a fresh libollama_xgrammar.dylib.
|
||||
# Built payloads are cached in the release tarball's layout for later runs.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
repo="${OLLAMA_MLX_RELEASE_REPO:-ollama/ollama}"
|
||||
scan_limit="${OLLAMA_MLX_RELEASE_SCAN_LIMIT:-50}"
|
||||
cache_dir="${OLLAMA_MLX_DARWIN_CACHE:-.cache/mlx-darwin-release}"
|
||||
target_dir="${OLLAMA_MLX_DARWIN_TARGET:-build/lib/ollama}"
|
||||
ci_build_dir="${OLLAMA_MLX_CI_BUILD_DIR:-build/mlx-ci}"
|
||||
tarball="${cache_dir}/ollama-darwin.tgz"
|
||||
tag_file="${cache_dir}/matched-tag"
|
||||
pins_file="${cache_dir}/matched-pins"
|
||||
target_pins_file="${target_dir}/.mlx-release-pins"
|
||||
# ${tag_file} value for a cached local build.
|
||||
local_tag="local-build"
|
||||
tmpdir=""
|
||||
tmp_tarball=""
|
||||
|
||||
cleanup() {
|
||||
[ -z "${tmpdir}" ] || rm -rf "${tmpdir}"
|
||||
[ -z "${tmp_tarball}" ] || rm -f "${tmp_tarball}"
|
||||
}
|
||||
trap cleanup EXIT
|
||||
|
||||
warn() {
|
||||
if [ -n "${GITHUB_ACTIONS:-}" ]; then
|
||||
echo "::warning::$*"
|
||||
else
|
||||
echo "warning: $*" >&2
|
||||
fi
|
||||
}
|
||||
|
||||
read_pin() {
|
||||
tr -d '[:space:]' <"$1"
|
||||
}
|
||||
|
||||
# Native wrapper sources compiled into libollama_xgrammar.dylib — keep in
|
||||
# sync with the ollama_xgrammar target in cmake/mlx/CMakeLists.txt.
|
||||
xgrammar_native_dir=x/mlxrunner/xgrammar/native
|
||||
|
||||
# Payload build rules beyond the MLX_VERSION/MLX_C_VERSION pins.
|
||||
payload_rule_files=(
|
||||
"cmake/local.cmake"
|
||||
"cmake/apply-git-patches.cmake"
|
||||
"cmake/mlx/CMakeLists.txt"
|
||||
"cmake/mlx/CMakePresets.json"
|
||||
"x/mlxrunner/mlx/CMakeLists.txt"
|
||||
)
|
||||
|
||||
# Build-rule inputs: the rule files plus carried MLX/MLX-C patch content.
|
||||
rule_inputs() {
|
||||
local file
|
||||
for file in "${payload_rule_files[@]}"; do
|
||||
printf '%s\n' "${file}"
|
||||
done
|
||||
if [ -d mlx/compat ]; then
|
||||
find mlx/compat -type f | sort
|
||||
fi
|
||||
}
|
||||
|
||||
wrapper_inputs() {
|
||||
find "${xgrammar_native_dir}" -type f | sort
|
||||
}
|
||||
|
||||
payload_inputs() {
|
||||
rule_inputs
|
||||
wrapper_inputs
|
||||
}
|
||||
|
||||
payload_fingerprint() {
|
||||
local file
|
||||
{
|
||||
payload_inputs
|
||||
while IFS= read -r file; do
|
||||
cat "${file}" 2>/dev/null || true
|
||||
done < <(payload_inputs)
|
||||
} | shasum -a 256 | awk '{print $1}'
|
||||
}
|
||||
|
||||
# True when the tag matches the checkout on all payload inputs.
|
||||
tag_matches_payload() {
|
||||
local tag="$1" file
|
||||
while IFS= read -r file; do
|
||||
if ! curl -fsSL "https://raw.githubusercontent.com/${repo}/${tag}/${file}" 2>/dev/null | cmp -s - "${file}"; then
|
||||
return 1
|
||||
fi
|
||||
done < <(payload_inputs)
|
||||
return 0
|
||||
}
|
||||
|
||||
# True when the tag matches the checkout on the build rules.
|
||||
tag_matches_rules() {
|
||||
local tag="$1" file
|
||||
while IFS= read -r file; do
|
||||
if ! curl -fsSL "https://raw.githubusercontent.com/${repo}/${tag}/${file}" 2>/dev/null | cmp -s - "${file}"; then
|
||||
return 1
|
||||
fi
|
||||
done < <(rule_inputs)
|
||||
return 0
|
||||
}
|
||||
|
||||
has_payload() {
|
||||
local variant
|
||||
for variant in "${target_dir}"/mlx_metal_v*; do
|
||||
[ -d "${variant}" ] || continue
|
||||
[ -f "${variant}/libmlx.dylib" ] && [ -f "${variant}/libmlxc.dylib" ] && return 0
|
||||
done
|
||||
return 1
|
||||
}
|
||||
|
||||
has_matching_payload() {
|
||||
[ -f "${target_pins_file}" ] || return 1
|
||||
[ "$(cat "${target_pins_file}")" = "${current_pins}" ] || return 1
|
||||
has_payload || return 1
|
||||
# Every payload variant must carry libollama_xgrammar.dylib.
|
||||
local variant
|
||||
for variant in "${target_dir}"/mlx_metal_v*; do
|
||||
[ -d "${variant}" ] || continue
|
||||
[ -f "${variant}/libollama_xgrammar.dylib" ] || return 1
|
||||
done
|
||||
return 0
|
||||
}
|
||||
|
||||
extract_payload() {
|
||||
local tag="$1"
|
||||
tmpdir="$(mktemp -d)"
|
||||
|
||||
tar -xzf "${tarball}" -C "${tmpdir}"
|
||||
mkdir -p "${target_dir}"
|
||||
|
||||
rm -rf "${target_dir}"/mlx_metal_v*
|
||||
|
||||
local found=false
|
||||
local src dest
|
||||
for src in "${tmpdir}"/mlx_metal_v*; do
|
||||
[ -d "${src}" ] || continue
|
||||
found=true
|
||||
dest="${target_dir}/$(basename "${src}")"
|
||||
rm -rf "${dest}"
|
||||
cp -R "${src}" "${dest}"
|
||||
done
|
||||
|
||||
if [ "${found}" != true ] || ! has_payload; then
|
||||
echo "Downloaded ${tarball} did not contain a usable MLX Metal payload" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "${current_pins}" >"${target_pins_file}"
|
||||
echo "Prepared MLX Darwin payload from ${repo} ${tag}:"
|
||||
find "${target_dir}" -maxdepth 2 -type f \( -name 'libmlx.dylib' -o -name 'libmlxc.dylib' -o -name '*.metallib' \) -print
|
||||
|
||||
rm -rf "${tmpdir}"
|
||||
tmpdir=""
|
||||
}
|
||||
|
||||
# Cache the built payload in the release tarball's layout.
|
||||
save_built_payload() {
|
||||
local variant
|
||||
local -a variants=()
|
||||
for variant in "${target_dir}"/mlx_metal_v*; do
|
||||
[ -d "${variant}" ] || continue
|
||||
variants+=("$(basename "${variant}")")
|
||||
done
|
||||
tmp_tarball="${tarball}.tmp"
|
||||
tar -czf "${tmp_tarball}" -C "${target_dir}" "${variants[@]}"
|
||||
mv "${tmp_tarball}" "${tarball}"
|
||||
tmp_tarball=""
|
||||
echo "${local_tag}" >"${tag_file}"
|
||||
echo "${current_pins}" >"${pins_file}"
|
||||
echo "Cached the built payload in ${cache_dir}"
|
||||
}
|
||||
|
||||
# Resolve the superbuild's platform-default MLX backend (metal_v3/metal_v4 on
|
||||
# arm64; empty when the platform has no MLX backend, e.g. x86_64 macOS).
|
||||
ci_mlx_backend() {
|
||||
[ -f "${ci_build_dir}/CMakeCache.txt" ] || return 1
|
||||
sed -n 's/^OLLAMA_MLX_BACKENDS:STRING=//p' "${ci_build_dir}/CMakeCache.txt"
|
||||
}
|
||||
|
||||
# Configure the repo-root superbuild and fetch MLX/MLX-C sources at the
|
||||
# pinned revisions (only the full payload build needs this).
|
||||
build_ci_sources() {
|
||||
cmake -S . -B "${ci_build_dir}" \
|
||||
-DOLLAMA_LLAMA_BACKENDS= \
|
||||
-DOLLAMA_PAYLOAD_INSTALL_PREFIX="$(dirname "$(dirname "${target_dir}")")"
|
||||
cmake --build "${ci_build_dir}" --target ollama-mlx-sources
|
||||
}
|
||||
|
||||
# Rebuild only libollama_xgrammar.dylib into the extracted payload. The
|
||||
# target depends only on the pinned XGrammar sources and the native wrapper;
|
||||
# the Metal toolchain and the superbuild are not involved. MLX is fetched
|
||||
# only because the cmake/mlx project defines it — nothing from it is built.
|
||||
build_ci_xgrammar() {
|
||||
local lib variant
|
||||
local xg_build_dir="${ci_build_dir}/xgrammar"
|
||||
local -a configure_args=(-S cmake/mlx -B "${xg_build_dir}" -DOLLAMA_SOURCE_DIR="$(pwd)" -DMLX_BUILD_METAL=OFF)
|
||||
if [ -n "${OLLAMA_XGRAMMAR_SOURCE:-}" ]; then
|
||||
configure_args+=("-DFETCHCONTENT_SOURCE_DIR_XGRAMMAR=${OLLAMA_XGRAMMAR_SOURCE}")
|
||||
fi
|
||||
cmake "${configure_args[@]}"
|
||||
cmake --build "${xg_build_dir}" --target ollama_xgrammar
|
||||
lib="${xg_build_dir}/lib/ollama/libollama_xgrammar.dylib"
|
||||
[ -f "${lib}" ] || {
|
||||
echo "ollama_xgrammar build produced no library at ${lib}" >&2
|
||||
exit 1
|
||||
}
|
||||
for variant in "${target_dir}"/mlx_metal_v*; do
|
||||
[ -d "${variant}" ] || continue
|
||||
cp -f "${lib}" "${variant}/libollama_xgrammar.dylib"
|
||||
[ -f "${variant}/libollama_xgrammar.dylib" ] || {
|
||||
echo "failed to install ${variant}/libollama_xgrammar.dylib" >&2
|
||||
exit 1
|
||||
}
|
||||
done
|
||||
echo "Rebuilt libollama_xgrammar.dylib from source into ${target_dir}"
|
||||
}
|
||||
|
||||
# Build the minimal MLX payload for this platform: one Metal variant,
|
||||
# whatever the superbuild defaults to here.
|
||||
build_ci_payload() {
|
||||
local backend variant
|
||||
build_ci_sources
|
||||
backend="$(ci_mlx_backend)"
|
||||
case "${backend}" in
|
||||
metal_v3 | metal_v4) ;;
|
||||
*)
|
||||
warn "no MLX backend applicable to this platform; MLX unit tests will be skipped"
|
||||
exit 0
|
||||
;;
|
||||
esac
|
||||
echo "Building the ${backend} payload for unit tests"
|
||||
rm -rf "${target_dir}"/mlx_metal_v*
|
||||
cmake --build "${ci_build_dir}" --target "ollama-mlx-${backend}"
|
||||
for variant in "${target_dir}"/mlx_metal_v*; do
|
||||
[ -d "${variant}" ] || continue
|
||||
for lib in libmlx.dylib libmlxc.dylib libollama_xgrammar.dylib; do
|
||||
[ -f "${variant}/${lib}" ] || {
|
||||
echo "built payload is missing ${variant}/${lib}" >&2
|
||||
exit 1
|
||||
}
|
||||
done
|
||||
done
|
||||
has_payload || {
|
||||
echo "built payload is incomplete in ${target_dir}" >&2
|
||||
exit 1
|
||||
}
|
||||
echo "${current_pins}" >"${target_pins_file}"
|
||||
echo "Built MLX payload for unit tests:"
|
||||
find "${target_dir}" -maxdepth 2 -type f \( -name 'libmlx.dylib' -o -name 'libmlxc.dylib' -o -name 'libollama_xgrammar.dylib' -o -name '*.metallib' \) -print
|
||||
save_built_payload
|
||||
}
|
||||
|
||||
if [ "$(uname -s)" != "Darwin" ]; then
|
||||
warn "MLX Darwin payload setup is only supported on macOS"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
export CMAKE_BUILD_PARALLEL_LEVEL="${CMAKE_BUILD_PARALLEL_LEVEL:-$(sysctl -n hw.ncpu)}"
|
||||
|
||||
current_mlx="$(read_pin MLX_VERSION)"
|
||||
current_mlxc="$(read_pin MLX_C_VERSION)"
|
||||
# The release tarball only depends on the MLX pins; the extracted payload's
|
||||
# xgrammar library additionally depends on the tree's XGrammar inputs.
|
||||
component_pins="${current_mlx} ${current_mlxc}"
|
||||
current_pins="${component_pins} $(payload_fingerprint)"
|
||||
|
||||
if has_matching_payload; then
|
||||
echo "MLX payload already present in ${target_dir}"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
mkdir -p "${cache_dir}"
|
||||
|
||||
# Release tarballs are keyed on the MLX pins; local builds on the full fingerprint.
|
||||
cached_pins="$(cat "${pins_file}" 2>/dev/null || true)"
|
||||
if [ -s "${tarball}" ] && [ -f "${tag_file}" ] && { [ "${cached_pins}" = "${component_pins}" ] || [ "${cached_pins}" = "${current_pins}" ]; }; then
|
||||
extract_payload "$(cat "${tag_file}")"
|
||||
else
|
||||
matched_tag=""
|
||||
matched_url=""
|
||||
|
||||
while read -r tag; do
|
||||
[ -n "${tag}" ] || continue
|
||||
|
||||
if ! tag_mlx="$(curl -fsSL "https://raw.githubusercontent.com/${repo}/${tag}/MLX_VERSION" | tr -d '[:space:]')"; then
|
||||
continue
|
||||
fi
|
||||
if [ "${tag_mlx}" != "${current_mlx}" ]; then
|
||||
continue
|
||||
fi
|
||||
|
||||
if ! tag_mlxc="$(curl -fsSL "https://raw.githubusercontent.com/${repo}/${tag}/MLX_C_VERSION" | tr -d '[:space:]')"; then
|
||||
continue
|
||||
fi
|
||||
if [ "${tag_mlxc}" != "${current_mlxc}" ]; then
|
||||
continue
|
||||
fi
|
||||
|
||||
url="https://github.com/${repo}/releases/download/${tag}/ollama-darwin.tgz"
|
||||
if curl -fsIL "${url}" >/dev/null; then
|
||||
matched_tag="${tag}"
|
||||
matched_url="${url}"
|
||||
break
|
||||
fi
|
||||
|
||||
echo "MLX pins match ${tag}, but ${url} is not available"
|
||||
done < <(
|
||||
git ls-remote --tags --refs --sort=-version:refname "https://github.com/${repo}.git" 'v*' |
|
||||
awk -v limit="${scan_limit}" '{ sub("refs/tags/", "", $2); print $2; if (limit > 0 && NR >= limit) exit }'
|
||||
)
|
||||
|
||||
if [ -z "${matched_tag}" ]; then
|
||||
echo "No release carries MLX_VERSION=${current_mlx} MLX_C_VERSION=${current_mlxc}"
|
||||
build_ci_payload
|
||||
exit 0
|
||||
fi
|
||||
|
||||
tmp_tarball="${tarball}.tmp"
|
||||
rm -f "${tmp_tarball}"
|
||||
curl -fL --retry 3 --retry-delay 2 -o "${tmp_tarball}" "${matched_url}"
|
||||
mv "${tmp_tarball}" "${tarball}"
|
||||
tmp_tarball=""
|
||||
echo "${matched_tag}" >"${tag_file}"
|
||||
echo "${component_pins}" >"${pins_file}"
|
||||
|
||||
extract_payload "${matched_tag}"
|
||||
fi
|
||||
|
||||
tag="$(cat "${tag_file}")"
|
||||
if [ "${tag}" = "${local_tag}" ] || tag_matches_payload "${tag}"; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if [ "$(uname -m)" != "arm64" ]; then
|
||||
warn "MLX payload builds are only supported on arm64 macOS; MLX unit tests will be skipped"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if tag_matches_rules "$(cat "${tag_file}")"; then
|
||||
# Only the xgrammar wrapper drifted; keep the rest of the release payload.
|
||||
echo "Rebuilding libollama_xgrammar.dylib from source into ${target_dir}"
|
||||
rm -f "${target_dir}"/mlx_metal_v*/libollama_xgrammar.dylib
|
||||
build_ci_xgrammar
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo "Release payload build rules do not match this checkout"
|
||||
build_ci_payload
|
||||
@@ -16,7 +16,7 @@ jobs:
|
||||
outputs:
|
||||
GOFLAGS: ${{ steps.goflags.outputs.GOFLAGS }}
|
||||
VERSION: ${{ steps.goflags.outputs.VERSION }}
|
||||
vendorsha: ${{ steps.changes.outputs.vendorsha }}
|
||||
vendorsha: ${{ steps.goflags.outputs.vendorsha }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Set environment
|
||||
@@ -24,7 +24,7 @@ jobs:
|
||||
run: |
|
||||
echo GOFLAGS="'-ldflags=-w -s \"-X=github.com/ollama/ollama/version.Version=${GITHUB_REF_NAME#v}\" \"-X=github.com/ollama/ollama/server.mode=release\"'" | tee -a $GITHUB_OUTPUT
|
||||
echo VERSION="${GITHUB_REF_NAME#v}" | tee -a $GITHUB_OUTPUT
|
||||
echo vendorsha=$(make -f Makefile.sync print-base) | tee -a $GITHUB_OUTPUT
|
||||
echo vendorsha=$(cat LLAMA_CPP_VERSION)-$(cat MLX_VERSION)-$(cat MLX_C_VERSION) | tee -a $GITHUB_OUTPUT
|
||||
|
||||
darwin-build:
|
||||
runs-on: macos-26-xlarge
|
||||
@@ -39,11 +39,27 @@ jobs:
|
||||
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
|
||||
@@ -57,8 +73,12 @@ jobs:
|
||||
go-version-file: go.mod
|
||||
cache-dependency-path: |
|
||||
go.sum
|
||||
Makefile.sync
|
||||
LLAMA_CPP_VERSION
|
||||
MLX_VERSION
|
||||
MLX_C_VERSION
|
||||
- run: |
|
||||
cmake -S . -B build/go-license -DOLLAMA_LLAMA_BACKENDS= -DOLLAMA_MLX_BACKENDS= -DOLLAMA_PAYLOAD_INSTALL_PREFIX=dist/darwin-arm64 "-DOLLAMA_GO_LICENSE_TARGETS=darwin/amd64;darwin/arm64"
|
||||
cmake --build build/go-license --target ollama-go-license
|
||||
./scripts/build_darwin.sh
|
||||
- name: Log build results
|
||||
run: |
|
||||
@@ -73,15 +93,19 @@ jobs:
|
||||
dist/*.dmg
|
||||
|
||||
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"'
|
||||
@@ -89,10 +113,10 @@ jobs:
|
||||
- '"cublas"'
|
||||
- '"cublas_dev"'
|
||||
cuda-version: '12.8'
|
||||
flags: ''
|
||||
- os: windows
|
||||
arch: amd64
|
||||
preset: 'CUDA 13'
|
||||
build-steps: cuda13
|
||||
install: https://developer.download.nvidia.com/compute/cuda/13.0.0/local_installers/cuda_13.0.0_windows.exe
|
||||
cuda-components:
|
||||
- '"cudart"'
|
||||
@@ -103,23 +127,39 @@ jobs:
|
||||
- '"nvvm"'
|
||||
- '"nvptxcompiler"'
|
||||
cuda-version: '13.0'
|
||||
flags: ''
|
||||
- os: windows
|
||||
arch: amd64
|
||||
preset: 'ROCm 6'
|
||||
install: https://download.amd.com/developer/eula/rocm-hub/AMD-Software-PRO-Edition-24.Q4-WinSvr2022-For-HIP.exe
|
||||
rocm-version: '6.2'
|
||||
flags: '-DCMAKE_C_COMPILER=clang -DCMAKE_CXX_COMPILER=clang++ -DCMAKE_C_FLAGS="-parallel-jobs=4 -Wno-ignored-attributes -Wno-deprecated-pragma" -DCMAKE_CXX_FLAGS="-parallel-jobs=4 -Wno-ignored-attributes -Wno-deprecated-pragma"'
|
||||
runner_dir: 'rocm'
|
||||
preset: '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
|
||||
flags: ''
|
||||
runner_dir: 'vulkan'
|
||||
- os: windows
|
||||
arch: amd64
|
||||
preset: 'MLX CUDA 13'
|
||||
build-steps: mlxCuda13
|
||||
build-parallel: '16'
|
||||
cmake-cuda-flags: '-t 6'
|
||||
install: https://developer.download.nvidia.com/compute/cuda/13.0.0/local_installers/cuda_13.0.0_windows.exe
|
||||
cudnn-install: https://developer.download.nvidia.com/compute/cudnn/redist/cudnn/windows-x86_64/cudnn-windows-x86_64-9.18.1.3_cuda13-archive.zip
|
||||
cuda-components:
|
||||
@@ -131,17 +171,21 @@ jobs:
|
||||
- '"cufft_dev"'
|
||||
- '"nvrtc"'
|
||||
- '"nvrtc_dev"'
|
||||
- '"cusolver"'
|
||||
- '"cusolver_dev"'
|
||||
- '"cusparse"'
|
||||
- '"cusparse_dev"'
|
||||
- '"nvjitlink"'
|
||||
- '"crt"'
|
||||
- '"nvvm"'
|
||||
- '"nvptxcompiler"'
|
||||
cuda-version: '13.0'
|
||||
flags: ''
|
||||
runs-on: ${{ matrix.arch == 'arm64' && format('{0}-{1}', matrix.os, matrix.arch) || matrix.os }}
|
||||
environment: release
|
||||
env:
|
||||
GOFLAGS: ${{ needs.setup-environment.outputs.GOFLAGS }}
|
||||
VERSION: ${{ needs.setup-environment.outputs.VERSION }}
|
||||
steps:
|
||||
# Increase pagefile to handle momentary spikes in RAM from NVCC compiles
|
||||
- if: startsWith(matrix.preset, 'MLX ')
|
||||
name: Increase pagefile to 200 GB
|
||||
uses: al-cheb/configure-pagefile-action@v1.5
|
||||
@@ -155,6 +199,15 @@ jobs:
|
||||
if (Get-Command ccache -ErrorAction SilentlyContinue) {
|
||||
ccache -o cache_dir=${{ github.workspace }}\.ccache
|
||||
}
|
||||
- if: matrix.preset == 'CPU'
|
||||
name: Install Windows ARM64 cross compiler
|
||||
run: |
|
||||
Invoke-WebRequest -Uri "https://github.com/mstorsjo/llvm-mingw/releases/download/20240619/llvm-mingw-20240619-ucrt-x86_64.zip" -OutFile "${{ runner.temp }}\llvm-mingw-ucrt.zip"
|
||||
Expand-Archive -Path ${{ runner.temp }}\llvm-mingw-ucrt.zip -DestinationPath "C:\Program Files\"
|
||||
$installPath=(Resolve-Path -Path "C:\Program Files\llvm-mingw-*-ucrt-x86_64").path
|
||||
if (!(Test-Path "$installPath\bin\aarch64-w64-mingw32-gcc.exe")) {
|
||||
throw "llvm-mingw x86_64 package is missing the aarch64 cross compiler"
|
||||
}
|
||||
- if: startsWith(matrix.preset, 'CUDA ') || startsWith(matrix.preset, 'ROCm ') || startsWith(matrix.preset, 'Vulkan') || startsWith(matrix.preset, 'MLX ')
|
||||
id: cache-install
|
||||
uses: actions/cache/restore@v4
|
||||
@@ -169,8 +222,18 @@ jobs:
|
||||
name: Install CUDA ${{ matrix.cuda-version }}
|
||||
run: |
|
||||
$ErrorActionPreference = "Stop"
|
||||
$ProgressPreference = 'SilentlyContinue'
|
||||
if ("${{ steps.cache-install.outputs.cache-hit }}" -ne 'true') {
|
||||
Invoke-WebRequest -Uri "${{ matrix.install }}" -OutFile "install.exe"
|
||||
for ($attempt = 1; $attempt -le 3; $attempt++) {
|
||||
try {
|
||||
Invoke-WebRequest -Uri "${{ matrix.install }}" -OutFile "install.exe"
|
||||
break
|
||||
} catch {
|
||||
if ($attempt -eq 3) { throw }
|
||||
Write-Host "CUDA installer download attempt $attempt failed: $($_.Exception.Message); retrying in 15s"
|
||||
Start-Sleep -Seconds 15
|
||||
}
|
||||
}
|
||||
$subpackages = @(${{ join(matrix.cuda-components, ', ') }}) | Foreach-Object {"${_}_${{ matrix.cuda-version }}"}
|
||||
Start-Process -FilePath .\install.exe -ArgumentList (@("-s") + $subpackages) -NoNewWindow -Wait
|
||||
}
|
||||
@@ -203,12 +266,12 @@ jobs:
|
||||
}
|
||||
|
||||
$vulkanPath = (Resolve-Path "C:\VulkanSDK\*").path
|
||||
$vulkanRuntime = Join-Path $vulkanPath "Helpers\VulkanRT.exe"
|
||||
if (Test-Path $vulkanRuntime) {
|
||||
Start-Process -FilePath $vulkanRuntime -ArgumentList "/s" -NoNewWindow -Wait
|
||||
}
|
||||
echo "$vulkanPath\bin" | Out-File -FilePath $env:GITHUB_PATH -Encoding utf8 -Append
|
||||
echo "VULKAN_SDK=$vulkanPath" >> $env:GITHUB_ENV
|
||||
- if: matrix.preset == 'CPU'
|
||||
run: |
|
||||
echo "CC=clang.exe" | Out-File -FilePath $env:GITHUB_ENV -Append
|
||||
echo "CXX=clang++.exe" | Out-File -FilePath $env:GITHUB_ENV -Append
|
||||
- if: startsWith(matrix.preset, 'MLX ')
|
||||
name: Install cuDNN for MLX
|
||||
run: |
|
||||
@@ -240,73 +303,63 @@ jobs:
|
||||
with:
|
||||
path: ${{ github.workspace }}\.ccache
|
||||
key: ccache-${{ matrix.os }}-${{ matrix.arch }}-${{ matrix.preset }}-${{ needs.setup-environment.outputs.vendorsha }}
|
||||
- name: Build target "${{ matrix.preset }}"
|
||||
- name: Build Windows dependencies
|
||||
run: |
|
||||
Import-Module 'C:\Program Files\Microsoft Visual Studio\2022\Enterprise\Common7\Tools\Microsoft.VisualStudio.DevShell.dll'
|
||||
Enter-VsDevShell -VsInstallPath 'C:\Program Files\Microsoft Visual Studio\2022\Enterprise' -SkipAutomaticLocation -DevCmdArguments '-arch=x64 -no_logo'
|
||||
cmake --preset "${{ matrix.preset }}" ${{ matrix.flags }} --install-prefix "$((pwd).Path)\dist\${{ matrix.os }}-${{ matrix.arch }}"
|
||||
cmake --build --preset "${{ matrix.preset }}" -- -l $([Environment]::ProcessorCount)
|
||||
cmake --install build --component "${{ startsWith(matrix.preset, 'MLX ') && 'MLX' || startsWith(matrix.preset, 'CUDA ') && 'CUDA' || startsWith(matrix.preset, 'ROCm ') && 'HIP' || startsWith(matrix.preset, 'Vulkan') && 'Vulkan' || 'CPU' }}" --strip
|
||||
if ('${{ matrix.preset }}'.StartsWith('MLX ')) { cmake --install build --component MLX_VENDOR }
|
||||
Remove-Item -Path dist\lib\ollama\rocm\rocblas\library\*gfx906* -ErrorAction SilentlyContinue
|
||||
$steps = "${{ matrix.build-steps }}".Split(' ', [System.StringSplitOptions]::RemoveEmptyEntries)
|
||||
./scripts/build_windows.ps1 @steps
|
||||
env:
|
||||
CMAKE_GENERATOR: Ninja
|
||||
OLLAMA_BUILD_PARALLEL: ${{ matrix.build-parallel || '' }}
|
||||
OLLAMA_CMAKE_CUDA_FLAGS: ${{ matrix.cmake-cuda-flags || '' }}
|
||||
- name: Log build results
|
||||
run: |
|
||||
gci -path .\dist -Recurse -File | ForEach-Object { get-filehash -path $_.FullName -Algorithm SHA256 } | format-list
|
||||
- if: matrix.preset == 'CPU'
|
||||
name: Verify Windows CPU payloads
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
for payload in \
|
||||
dist/windows-amd64/lib/ollama/llama-server.exe \
|
||||
dist/windows-arm64/lib/ollama/llama-server.exe
|
||||
do
|
||||
[ -f "$payload" ] || { echo "missing $payload"; exit 1; }
|
||||
done
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: depends-${{ matrix.os }}-${{ matrix.arch }}-${{ matrix.preset }}
|
||||
path: dist\*
|
||||
|
||||
windows-build:
|
||||
strategy:
|
||||
matrix:
|
||||
os: [windows]
|
||||
arch: [amd64, arm64]
|
||||
include:
|
||||
- os: windows
|
||||
arch: amd64
|
||||
llvmarch: x86_64
|
||||
- os: windows
|
||||
arch: arm64
|
||||
llvmarch: aarch64
|
||||
runs-on: ${{ matrix.arch == 'arm64' && format('{0}-{1}', matrix.os, matrix.arch) || matrix.os }}
|
||||
runs-on: windows
|
||||
environment: release
|
||||
needs: [setup-environment]
|
||||
env:
|
||||
GOFLAGS: ${{ needs.setup-environment.outputs.GOFLAGS }}
|
||||
VERSION: ${{ needs.setup-environment.outputs.VERSION }}
|
||||
steps:
|
||||
- name: Install ARM64 system dependencies
|
||||
if: matrix.arch == 'arm64'
|
||||
run: |
|
||||
$ErrorActionPreference = "Stop"
|
||||
Set-ExecutionPolicy Bypass -Scope Process -Force
|
||||
[System.Net.ServicePointManager]::SecurityProtocol = [System.Net.ServicePointManager]::SecurityProtocol -bor 3072
|
||||
iex ((New-Object System.Net.WebClient).DownloadString('https://community.chocolatey.org/install.ps1'))
|
||||
echo "C:\ProgramData\chocolatey\bin" | Out-File -FilePath $env:GITHUB_PATH -Encoding utf8 -Append
|
||||
|
||||
Invoke-WebRequest -Uri https://aka.ms/vs/17/release/vc_redist.arm64.exe -OutFile "${{ runner.temp }}\vc_redist.arm64.exe"
|
||||
Start-Process -FilePath "${{ runner.temp }}\vc_redist.arm64.exe" -ArgumentList @("/install", "/quiet", "/norestart") -NoNewWindow -Wait
|
||||
|
||||
choco install -y --no-progress git gzip
|
||||
echo "C:\Program Files\Git\cmd" | Out-File -FilePath $env:GITHUB_PATH -Encoding utf8 -Append
|
||||
- name: Install clang and gcc-compat
|
||||
run: |
|
||||
$ErrorActionPreference = "Stop"
|
||||
Set-ExecutionPolicy Bypass -Scope Process -Force
|
||||
Invoke-WebRequest -Uri "https://github.com/mstorsjo/llvm-mingw/releases/download/20240619/llvm-mingw-20240619-ucrt-${{ matrix.llvmarch }}.zip" -OutFile "${{ runner.temp }}\llvm-mingw-ucrt.zip"
|
||||
Invoke-WebRequest -Uri "https://github.com/mstorsjo/llvm-mingw/releases/download/20240619/llvm-mingw-20240619-ucrt-x86_64.zip" -OutFile "${{ runner.temp }}\llvm-mingw-ucrt.zip"
|
||||
Expand-Archive -Path ${{ runner.temp }}\llvm-mingw-ucrt.zip -DestinationPath "C:\Program Files\"
|
||||
$installPath=(Resolve-Path -Path "C:\Program Files\llvm-mingw-*-ucrt*").path
|
||||
$installPath=(Resolve-Path -Path "C:\Program Files\llvm-mingw-*-ucrt-x86_64").path
|
||||
echo "$installPath\bin" | Out-File -FilePath $env:GITHUB_PATH -Encoding utf8 -Append
|
||||
if (!(Test-Path "$installPath\bin\aarch64-w64-mingw32-gcc.exe")) {
|
||||
throw "llvm-mingw x86_64 package is missing the aarch64 cross compiler"
|
||||
}
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
cache-dependency-path: |
|
||||
go.sum
|
||||
Makefile.sync
|
||||
LLAMA_CPP_VERSION
|
||||
MLX_VERSION
|
||||
MLX_C_VERSION
|
||||
- name: Verify gcc is actually clang
|
||||
run: |
|
||||
$ErrorActionPreference='Continue'
|
||||
@@ -323,20 +376,30 @@ jobs:
|
||||
with:
|
||||
node-version: "20"
|
||||
- run: |
|
||||
./scripts/build_windows ollama app
|
||||
./scripts/build_windows ollama ollamaArm64 app appArm64
|
||||
- name: Verify Windows build payloads
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
for payload in \
|
||||
dist/windows-amd64/ollama.exe \
|
||||
dist/windows-arm64/ollama.exe
|
||||
do
|
||||
[ -f "$payload" ] || { echo "missing $payload"; exit 1; }
|
||||
done
|
||||
- name: Log build results
|
||||
run: |
|
||||
gci -path .\dist -Recurse -File | ForEach-Object { get-filehash -path $_.FullName -Algorithm SHA256 } | format-list
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: build-${{ matrix.os }}-${{ matrix.arch }}
|
||||
name: build-windows-amd64
|
||||
path: |
|
||||
dist\*
|
||||
|
||||
windows-app:
|
||||
runs-on: windows
|
||||
environment: release
|
||||
needs: [windows-build, windows-depends]
|
||||
needs: [setup-environment, windows-build, windows-depends]
|
||||
env:
|
||||
GOFLAGS: ${{ needs.setup-environment.outputs.GOFLAGS }}
|
||||
VERSION: ${{ needs.setup-environment.outputs.VERSION }}
|
||||
@@ -362,7 +425,9 @@ jobs:
|
||||
go-version-file: go.mod
|
||||
cache-dependency-path: |
|
||||
go.sum
|
||||
Makefile.sync
|
||||
LLAMA_CPP_VERSION
|
||||
MLX_VERSION
|
||||
MLX_C_VERSION
|
||||
- uses: actions/download-artifact@v4
|
||||
with:
|
||||
pattern: depends-windows*
|
||||
@@ -376,6 +441,28 @@ jobs:
|
||||
- name: Log dist contents after download
|
||||
run: |
|
||||
gci -path .\dist -recurse
|
||||
- name: Verify Windows package inputs
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
for payload in \
|
||||
dist/windows-amd64/ollama.exe \
|
||||
dist/windows-amd64/lib/ollama/llama-server.exe \
|
||||
dist/windows-arm64/ollama.exe \
|
||||
dist/windows-arm64/lib/ollama/llama-server.exe
|
||||
do
|
||||
[ -f "$payload" ] || { echo "missing $payload"; exit 1; }
|
||||
done
|
||||
- name: Collect Go licenses
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
for arch in amd64 arm64; do
|
||||
cmake -S . -B build/go-license -DOLLAMA_LLAMA_BACKENDS= -DOLLAMA_MLX_BACKENDS= \
|
||||
"-DOLLAMA_PAYLOAD_INSTALL_PREFIX=dist/windows-${arch}" \
|
||||
"-DOLLAMA_GO_LICENSE_TARGETS=windows/${arch}"
|
||||
cmake --build build/go-license --target ollama-go-license
|
||||
done
|
||||
- run: |
|
||||
./scripts/build_windows.ps1 deps sign installer zip
|
||||
- name: Log contents after build
|
||||
@@ -389,31 +476,29 @@ jobs:
|
||||
dist/*.ps1
|
||||
dist/OllamaSetup.exe
|
||||
|
||||
# Pre-build each Dockerfile stage on its own runner in parallel and push the
|
||||
# resulting layers to a per-stage registry cache. The downstream
|
||||
# docker-build-push job then assembles cache-hit-only.
|
||||
linux-depends:
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- arch: amd64
|
||||
target: cpu
|
||||
target: llama-server-cpu
|
||||
- arch: amd64
|
||||
target: cuda-12
|
||||
target: llama-server-cuda_v12
|
||||
- arch: amd64
|
||||
target: cuda-13
|
||||
target: llama-server-cuda_v13
|
||||
- arch: amd64
|
||||
target: mlx
|
||||
- arch: amd64
|
||||
target: rocm-7
|
||||
target: llama-server-rocm_v7_2
|
||||
- arch: amd64
|
||||
target: vulkan
|
||||
target: llama-server-vulkan
|
||||
- arch: arm64
|
||||
target: cpu
|
||||
target: llama-server-cpu
|
||||
- arch: arm64
|
||||
target: cuda-12
|
||||
target: llama-server-cuda_v12
|
||||
- arch: arm64
|
||||
target: cuda-13
|
||||
target: llama-server-cuda_v13
|
||||
- arch: arm64
|
||||
target: jetpack-5
|
||||
- arch: arm64
|
||||
@@ -430,7 +515,6 @@ jobs:
|
||||
with:
|
||||
username: ${{ vars.DOCKER_USER }}
|
||||
password: ${{ secrets.DOCKER_ACCESS_TOKEN }}
|
||||
# Increase swap to handle momentary spikes in RAM from NVCC compiles
|
||||
- if: matrix.target == 'mlx'
|
||||
name: Increase Linux swap to 200 GB
|
||||
shell: bash
|
||||
@@ -459,12 +543,13 @@ jobs:
|
||||
provenance: false
|
||||
sbom: false
|
||||
build-args: |
|
||||
GOFLAGS=${{ env.GOFLAGS }}
|
||||
CGO_CFLAGS=${{ env.CGO_CFLAGS }}
|
||||
CGO_CXXFLAGS=${{ env.CGO_CXXFLAGS }}
|
||||
GOFLAGS=${{ env.GOFLAGS }}
|
||||
APT_MIRROR=http://azure.archive.ubuntu.com/ubuntu
|
||||
OLLAMA_MLX_BUILD_JOBS=16
|
||||
OLLAMA_MLX_NVCC_THREADS=6
|
||||
APT_MIRROR=http://azure.archive.ubuntu.com/ubuntu
|
||||
APT_PORTS_MIRROR=http://azure.ports.ubuntu.com/ubuntu-ports
|
||||
cache-from: |
|
||||
type=registry,ref=ollama/release:cache-${{ matrix.arch }}-${{ matrix.target }}
|
||||
type=registry,ref=${{ vars.DOCKER_REPO }}:latest
|
||||
@@ -472,58 +557,66 @@ jobs:
|
||||
|
||||
# Build each Docker variant (OS, arch, and flavor) separately. Using QEMU is unreliable and slower.
|
||||
# Heavy stages were pre-built by linux-depends; this job is cache-hit-only for those layers
|
||||
# and just assembles, runs the Go build, and pushes the final image.
|
||||
# and just assembles, runs the Go build, pushes the final image, and extracts release bundles.
|
||||
docker-build-push:
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- os: linux
|
||||
arch: arm64
|
||||
archive-target: archive
|
||||
build-args: |
|
||||
CGO_CFLAGS
|
||||
CGO_CXXFLAGS
|
||||
GOFLAGS
|
||||
APT_MIRROR=http://azure.archive.ubuntu.com/ubuntu
|
||||
APT_PORTS_MIRROR=http://azure.ports.ubuntu.com/ubuntu-ports
|
||||
OLLAMA_MLX_BUILD_JOBS=16
|
||||
OLLAMA_MLX_NVCC_THREADS=6
|
||||
cache-from: |
|
||||
type=registry,ref=${{ vars.DOCKER_REPO }}:latest
|
||||
type=registry,ref=ollama/release:cache-arm64-cpu
|
||||
type=registry,ref=ollama/release:cache-arm64-cuda-12
|
||||
type=registry,ref=ollama/release:cache-arm64-cuda-13
|
||||
type=registry,ref=ollama/release:cache-arm64-llama-server-cpu
|
||||
type=registry,ref=ollama/release:cache-arm64-llama-server-cuda_v12
|
||||
type=registry,ref=ollama/release:cache-arm64-llama-server-cuda_v13
|
||||
type=registry,ref=ollama/release:cache-arm64-jetpack-5
|
||||
type=registry,ref=ollama/release:cache-arm64-jetpack-6
|
||||
type=registry,ref=${{ vars.DOCKER_REPO }}:latest
|
||||
- os: linux
|
||||
arch: amd64
|
||||
archive-target: archive
|
||||
build-args: |
|
||||
CGO_CFLAGS
|
||||
CGO_CXXFLAGS
|
||||
GOFLAGS
|
||||
APT_MIRROR=http://azure.archive.ubuntu.com/ubuntu
|
||||
APT_PORTS_MIRROR=http://azure.ports.ubuntu.com/ubuntu-ports
|
||||
OLLAMA_MLX_BUILD_JOBS=16
|
||||
OLLAMA_MLX_NVCC_THREADS=6
|
||||
cache-from: |
|
||||
type=registry,ref=${{ vars.DOCKER_REPO }}:latest
|
||||
type=registry,ref=ollama/release:cache-amd64-cpu
|
||||
type=registry,ref=ollama/release:cache-amd64-cuda-12
|
||||
type=registry,ref=ollama/release:cache-amd64-cuda-13
|
||||
type=registry,ref=ollama/release:cache-amd64-llama-server-cpu
|
||||
type=registry,ref=ollama/release:cache-amd64-llama-server-cuda_v12
|
||||
type=registry,ref=ollama/release:cache-amd64-llama-server-cuda_v13
|
||||
type=registry,ref=ollama/release:cache-amd64-mlx
|
||||
type=registry,ref=ollama/release:cache-amd64-vulkan
|
||||
type=registry,ref=ollama/release:cache-amd64-llama-server-rocm_v7_2
|
||||
type=registry,ref=ollama/release:cache-amd64-llama-server-vulkan
|
||||
type=registry,ref=${{ vars.DOCKER_REPO }}:latest
|
||||
- os: linux
|
||||
arch: amd64
|
||||
suffix: '-rocm'
|
||||
archive-target: image-archive
|
||||
build-args: |
|
||||
CGO_CFLAGS
|
||||
CGO_CXXFLAGS
|
||||
GOFLAGS
|
||||
FLAVOR=rocm
|
||||
APT_MIRROR=http://azure.archive.ubuntu.com/ubuntu
|
||||
APT_PORTS_MIRROR=http://azure.ports.ubuntu.com/ubuntu-ports
|
||||
OLLAMA_MLX_BUILD_JOBS=16
|
||||
OLLAMA_MLX_NVCC_THREADS=6
|
||||
cache-from: |
|
||||
type=registry,ref=ollama/release:cache-amd64-llama-server-cpu
|
||||
type=registry,ref=ollama/release:cache-amd64-llama-server-rocm_v7_2
|
||||
type=registry,ref=${{ vars.DOCKER_REPO }}:latest
|
||||
type=registry,ref=ollama/release:cache-amd64-cpu
|
||||
type=registry,ref=ollama/release:cache-amd64-rocm-7
|
||||
runs-on: ${{ matrix.arch == 'arm64' && format('{0}-{1}', matrix.os, matrix.arch) || matrix.os }}
|
||||
environment: release
|
||||
needs: [setup-environment, linux-depends]
|
||||
@@ -556,14 +649,11 @@ jobs:
|
||||
name: digest-${{ matrix.os }}-${{ matrix.arch }}-${{ matrix.suffix }}
|
||||
path: |
|
||||
${{ runner.temp }}/${{ matrix.os }}-${{ matrix.arch }}-${{ matrix.suffix }}.txt
|
||||
# Re-run buildx with --target archive against buildkit's local cache to
|
||||
# extract the release directory layout. All upstream stages were just
|
||||
# built above, so this is a cache-hit-only pass that just writes files.
|
||||
- uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
platforms: ${{ matrix.os }}/${{ matrix.arch }}
|
||||
target: archive
|
||||
target: ${{ matrix.archive-target }}
|
||||
provenance: false
|
||||
sbom: false
|
||||
build-args: ${{ matrix.build-args }}
|
||||
@@ -572,24 +662,34 @@ jobs:
|
||||
- name: Deduplicate CUDA libraries
|
||||
run: |
|
||||
./scripts/deduplicate_cuda_libs.sh dist/${{ matrix.os }}-${{ matrix.arch }}
|
||||
- name: Verify Linux build payloads
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
base="dist/${{ matrix.os }}-${{ matrix.arch }}"
|
||||
for payload in \
|
||||
"$base/bin/ollama" \
|
||||
"$base/lib/ollama/llama-server"
|
||||
do
|
||||
[ -f "$payload" ] || { echo "missing $payload"; exit 1; }
|
||||
done
|
||||
- run: |
|
||||
for COMPONENT in bin/* lib/ollama/*; do
|
||||
case "$COMPONENT" in
|
||||
bin/ollama*) echo $COMPONENT >>ollama-${{ matrix.os }}-${{ matrix.arch }}.tar.in ;;
|
||||
lib/ollama/*.so*) echo $COMPONENT >>ollama-${{ matrix.os }}-${{ matrix.arch }}.tar.in ;;
|
||||
lib/ollama/llama-server*|lib/ollama/llama-quantize*) echo $COMPONENT >>ollama-${{ matrix.os }}-${{ matrix.arch }}.tar.in ;;
|
||||
lib/ollama/cuda_v*) echo $COMPONENT >>ollama-${{ matrix.os }}-${{ matrix.arch }}.tar.in ;;
|
||||
lib/ollama/vulkan*) echo $COMPONENT >>ollama-${{ matrix.os }}-${{ matrix.arch }}.tar.in ;;
|
||||
lib/ollama/mlx*) echo $COMPONENT >>ollama-${{ matrix.os }}-${{ matrix.arch }}-mlx.tar.in ;;
|
||||
lib/ollama/include*) echo $COMPONENT >>ollama-${{ matrix.os }}-${{ matrix.arch }}.tar.in ;;
|
||||
lib/ollama/include*) echo $COMPONENT >>ollama-${{ matrix.os }}-${{ matrix.arch }}-mlx.tar.in ;;
|
||||
lib/ollama/*_LICENSE|lib/ollama/*_NOTICE) echo $COMPONENT >>ollama-${{ matrix.os }}-${{ matrix.arch }}.tar.in ;;
|
||||
lib/ollama/cuda_jetpack5) echo $COMPONENT >>ollama-${{ matrix.os }}-${{ matrix.arch }}-jetpack5.tar.in ;;
|
||||
lib/ollama/cuda_jetpack6) echo $COMPONENT >>ollama-${{ matrix.os }}-${{ matrix.arch }}-jetpack6.tar.in ;;
|
||||
lib/ollama/rocm) echo $COMPONENT >>ollama-${{ matrix.os }}-${{ matrix.arch }}-rocm.tar.in ;;
|
||||
lib/ollama/rocm_v*) echo $COMPONENT >>ollama-${{ matrix.os }}-${{ matrix.arch }}-rocm.tar.in ;;
|
||||
esac
|
||||
done
|
||||
working-directory: dist/${{ matrix.os }}-${{ matrix.arch }}
|
||||
# rocm builds cpu + rocm libs for the container image, which
|
||||
# creates a CPU-only amd64 tarball that would collide with the full
|
||||
# bundle when the release job merges artifacts.
|
||||
- if: matrix.suffix == '-rocm'
|
||||
run: rm -f dist/${{ matrix.os }}-${{ matrix.arch }}/ollama-${{ matrix.os }}-${{ matrix.arch }}.tar.in
|
||||
- run: |
|
||||
@@ -612,6 +712,7 @@ jobs:
|
||||
# Merge Docker images for the same flavor into a single multi-arch manifest
|
||||
docker-merge-push:
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
suffix: ['', '-rocm']
|
||||
runs-on: linux
|
||||
@@ -665,6 +766,21 @@ jobs:
|
||||
- name: Copy install scripts to dist
|
||||
run: |
|
||||
cp scripts/install.sh dist/install.sh
|
||||
- name: Verify release artifacts
|
||||
run: |
|
||||
required=(
|
||||
dist/OllamaSetup.exe
|
||||
dist/install.ps1
|
||||
dist/install.sh
|
||||
dist/ollama-windows-amd64.zip
|
||||
dist/ollama-windows-arm64.zip
|
||||
)
|
||||
for payload in "${required[@]}"; do
|
||||
if [ ! -f "$payload" ]; then
|
||||
echo "::error::Missing expected release artifact: $payload"
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
- name: Generate checksum file
|
||||
run: find . -type f -not -name 'sha256sum.txt' | xargs sha256sum | tee sha256sum.txt
|
||||
working-directory: dist
|
||||
|
||||
@@ -0,0 +1,636 @@
|
||||
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: |
|
||||
cmake -S . -B build/go-license -DOLLAMA_LLAMA_BACKENDS= -DOLLAMA_MLX_BACKENDS= -DOLLAMA_PAYLOAD_INSTALL_PREFIX=dist/darwin-arm64 "-DOLLAMA_GO_LICENSE_TARGETS=darwin/amd64;darwin/arm64"
|
||||
cmake --build build/go-license --target ollama-go-license
|
||||
./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/*_LICENSE|lib/ollama/*_NOTICE) echo "${COMPONENT}" >>ollama-linux-${{ matrix.arch }}.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"
|
||||
$ProgressPreference = 'SilentlyContinue'
|
||||
if ("${{ steps.cache-install.outputs.cache-hit }}" -ne 'true') {
|
||||
for ($attempt = 1; $attempt -le 3; $attempt++) {
|
||||
try {
|
||||
Invoke-WebRequest -Uri "${{ matrix.install }}" -OutFile "install.exe"
|
||||
break
|
||||
} catch {
|
||||
if ($attempt -eq 3) { throw }
|
||||
Write-Host "CUDA installer download attempt $attempt failed: $($_.Exception.Message); retrying in 15s"
|
||||
Start-Sleep -Seconds 15
|
||||
}
|
||||
}
|
||||
$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: Collect Go licenses
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
for arch in amd64 arm64; do
|
||||
cmake -S . -B build/go-license -DOLLAMA_LLAMA_BACKENDS= -DOLLAMA_MLX_BACKENDS= \
|
||||
"-DOLLAMA_PAYLOAD_INSTALL_PREFIX=dist/windows-${arch}" \
|
||||
"-DOLLAMA_GO_LICENSE_TARGETS=windows/${arch}"
|
||||
cmake --build build/go-license --target ollama-go-license
|
||||
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
|
||||
@@ -23,7 +23,8 @@ jobs:
|
||||
outputs:
|
||||
changed: ${{ steps.changes.outputs.changed }}
|
||||
app_changed: ${{ steps.changes.outputs.app_changed }}
|
||||
vendorsha: ${{ steps.changes.outputs.vendorsha }}
|
||||
go_mod_changed: ${{ steps.changes.outputs.go_mod_changed }}
|
||||
enginehash: ${{ steps.changes.outputs.enginehash }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
@@ -38,9 +39,45 @@ jobs:
|
||||
| xargs python3 -c "import sys; from pathlib import Path; print(any(Path(x).match(glob) for x in sys.argv[1:] for glob in '$*'.split(' ')))"
|
||||
}
|
||||
|
||||
echo changed=$(changed 'llama/llama.cpp/**/*' 'ml/backend/ggml/ggml/**/*' '.github/**/*') | tee -a $GITHUB_OUTPUT
|
||||
echo changed=$(changed \
|
||||
'CMakeLists.txt' \
|
||||
'CMakePresets.json' \
|
||||
'cmake/**' \
|
||||
'cmake/**/*' \
|
||||
'llama/server/**/*' \
|
||||
'llama/compat/**/*' \
|
||||
'LLAMA_CPP_VERSION' \
|
||||
'MLX_VERSION' \
|
||||
'MLX_C_VERSION' \
|
||||
'llama/llama.cpp/**/*' \
|
||||
'ml/backend/ggml/ggml/**/*' \
|
||||
'x/imagegen/mlx/**' \
|
||||
'x/imagegen/mlx/**/*' \
|
||||
'x/mlxrunner/xgrammar/native/**' \
|
||||
'x/mlxrunner/xgrammar/native/**/*' \
|
||||
'.github/**/*') | tee -a $GITHUB_OUTPUT
|
||||
echo app_changed=$(changed 'app/**' 'app/**/*') | tee -a $GITHUB_OUTPUT
|
||||
echo vendorsha=$(make -f Makefile.sync print-base) | tee -a $GITHUB_OUTPUT
|
||||
echo go_mod_changed=$(changed 'go.mod') | tee -a $GITHUB_OUTPUT
|
||||
echo enginehash=$(cat LLAMA_CPP_VERSION)-$(cat MLX_VERSION)-$(cat MLX_C_VERSION) | tee -a $GITHUB_OUTPUT
|
||||
|
||||
patches:
|
||||
strategy:
|
||||
matrix:
|
||||
os: [ubuntu-latest, windows-latest]
|
||||
runs-on: ${{ matrix.os }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Verify patches apply cleanly
|
||||
shell: bash
|
||||
run: |
|
||||
cmake -S llama/server -B "$RUNNER_TEMP/llama-server-patch-check" \
|
||||
-DCMAKE_BUILD_TYPE=Release \
|
||||
-DBUILD_SHARED_LIBS=ON \
|
||||
-DGGML_BACKEND_DL=ON \
|
||||
-DGGML_NATIVE=OFF \
|
||||
-DGGML_OPENMP=OFF \
|
||||
-DGGML_CPU_ALL_VARIANTS=ON \
|
||||
-DOLLAMA_RUNNER_DIR=
|
||||
|
||||
linux:
|
||||
needs: [changes]
|
||||
@@ -49,23 +86,41 @@ jobs:
|
||||
matrix:
|
||||
include:
|
||||
- preset: CPU
|
||||
superbuild_target: ollama-local
|
||||
superbuild_dir: build/local-superbuild
|
||||
superbuild_args: ''
|
||||
expected_payload: lib/ollama/llama-server
|
||||
install-go: true
|
||||
- preset: CUDA
|
||||
container: nvidia/cuda:13.0.0-devel-ubuntu22.04
|
||||
flags: '-DCMAKE_CUDA_ARCHITECTURES=87'
|
||||
superbuild_target: ollama-llama-server-cuda_v13
|
||||
superbuild_dir: build/local-superbuild-cuda_v13
|
||||
superbuild_args: '-DOLLAMA_LLAMA_BACKENDS=cuda_v13 -DCMAKE_CUDA_ARCHITECTURES=87'
|
||||
expected_payload: lib/ollama/cuda_v13/libggml-cuda.so
|
||||
- preset: ROCm
|
||||
container: rocm/dev-ubuntu-22.04:7.2.1
|
||||
extra-packages: rocm-libs
|
||||
flags: '-DAMDGPU_TARGETS=gfx1010 -DCMAKE_PREFIX_PATH=/opt/rocm'
|
||||
superbuild_target: ollama-llama-server-rocm_v7_2
|
||||
superbuild_dir: build/local-superbuild-rocm_v7_2
|
||||
superbuild_args: '-DOLLAMA_LLAMA_BACKENDS=rocm_v7_2 -DAMDGPU_TARGETS=gfx1010 -DCMAKE_PREFIX_PATH=/opt/rocm'
|
||||
expected_payload: lib/ollama/rocm_v7_2/libggml-hip.so
|
||||
- preset: Vulkan
|
||||
container: ubuntu:22.04
|
||||
extra-packages: >
|
||||
mesa-vulkan-drivers vulkan-tools
|
||||
libvulkan1 libvulkan-dev
|
||||
vulkan-sdk cmake ccache g++ make
|
||||
vulkan-sdk spirv-headers cmake ccache g++ make
|
||||
superbuild_target: ollama-llama-server-vulkan
|
||||
superbuild_dir: build/local-superbuild-vulkan
|
||||
superbuild_args: '-DOLLAMA_LLAMA_BACKENDS=vulkan'
|
||||
expected_payload: lib/ollama/vulkan/libggml-vulkan.so
|
||||
- preset: 'MLX CUDA 13'
|
||||
container: nvidia/cuda:13.0.0-devel-ubuntu22.04
|
||||
extra-packages: libcudnn9-dev-cuda-13 libopenblas-dev liblapack-dev liblapacke-dev git curl
|
||||
flags: '-DCMAKE_CUDA_ARCHITECTURES=87 -DMLX_CUDA_ARCHITECTURES=80-virtual -DBLAS_INCLUDE_DIRS=/usr/include/x86_64-linux-gnu -DLAPACK_INCLUDE_DIRS=/usr/include/x86_64-linux-gnu'
|
||||
superbuild_target: ollama-mlx-cuda_v13
|
||||
superbuild_dir: build/local-superbuild-mlx-cuda_v13
|
||||
superbuild_args: '-DOLLAMA_MLX_BACKENDS=cuda_v13 -DCMAKE_CUDA_ARCHITECTURES=87 -DMLX_CUDA_ARCHITECTURES=80-virtual -DBLAS_INCLUDE_DIRS=/usr/include/x86_64-linux-gnu -DLAPACK_INCLUDE_DIRS=/usr/include/x86_64-linux-gnu'
|
||||
expected_payload: lib/ollama/mlx_cuda_v13/libmlx.so lib/ollama/mlx_cuda_v13/libollama_xgrammar.so
|
||||
install-go: true
|
||||
runs-on: linux
|
||||
container: ${{ matrix.container }}
|
||||
@@ -82,11 +137,9 @@ jobs:
|
||||
echo "deb [signed-by=/usr/share/keyrings/lunarg-archive-keyring.gpg] https://packages.lunarg.com/vulkan/1.4.313 jammy main" | $sudo tee /etc/apt/sources.list.d/lunarg-vulkan-1.4.313-jammy.list > /dev/null
|
||||
$sudo apt-get update
|
||||
fi
|
||||
$sudo apt-get install -y cmake ccache ${{ matrix.extra-packages }}
|
||||
# MLX requires CMake 3.25+, install from official releases
|
||||
if [ "${{ matrix.preset }}" = "MLX CUDA 13" ]; then
|
||||
curl -fsSL https://github.com/Kitware/CMake/releases/download/v3.31.2/cmake-3.31.2-linux-$(uname -m).tar.gz | $sudo tar xz -C /usr/local --strip-components 1
|
||||
fi
|
||||
$sudo apt-get install -y cmake ccache curl git ${{ matrix.extra-packages }}
|
||||
# Use a current CMake for upstream llama.cpp and Vulkan dependency discovery.
|
||||
curl -fsSL https://github.com/Kitware/CMake/releases/download/v3.31.2/cmake-3.31.2-linux-$(uname -m).tar.gz | $sudo tar xz -C /usr/local --strip-components 1
|
||||
# Export VULKAN_SDK if provided by LunarG package (defensive)
|
||||
if [ -d "/usr/lib/x86_64-linux-gnu/vulkan" ] && [ "${{ matrix.preset }}" = "Vulkan" ]; then
|
||||
echo "VULKAN_SDK=/usr" >> $GITHUB_ENV
|
||||
@@ -96,17 +149,32 @@ jobs:
|
||||
- if: matrix.install-go
|
||||
name: Install Go
|
||||
run: |
|
||||
[ -n "${{ matrix.container }}" ] || sudo=sudo
|
||||
GO_VERSION=$(awk '/^go / { print $2 }' go.mod)
|
||||
curl -fsSL "https://golang.org/dl/go${GO_VERSION}.linux-$(dpkg --print-architecture).tar.gz" | tar xz -C /usr/local
|
||||
curl -fsSL "https://golang.org/dl/go${GO_VERSION}.linux-$(dpkg --print-architecture).tar.gz" | $sudo tar xz -C /usr/local
|
||||
echo "/usr/local/go/bin" >> $GITHUB_PATH
|
||||
- uses: actions/cache@v4
|
||||
with:
|
||||
path: /github/home/.cache/ccache
|
||||
key: ccache-${{ runner.os }}-${{ runner.arch }}-${{ matrix.preset }}-${{ needs.changes.outputs.vendorsha }}
|
||||
- run: |
|
||||
cmake --preset "${{ matrix.preset }}" ${{ matrix.flags }}
|
||||
cmake --build --preset "${{ matrix.preset }}" -- -l $(nproc)
|
||||
|
||||
key: ccache-${{ runner.os }}-${{ runner.arch }}-${{ matrix.preset }}-${{ needs.changes.outputs.enginehash }}
|
||||
- name: Build native superbuild
|
||||
if: matrix.superbuild_target
|
||||
run: |
|
||||
cmake -S . -B "${{ matrix.superbuild_dir }}" ${{ matrix.superbuild_args }}
|
||||
CMAKE_BUILD_PARALLEL_LEVEL=$(nproc) cmake --build "${{ matrix.superbuild_dir }}" --target "${{ matrix.superbuild_target }}" -- -l $(nproc)
|
||||
for f in ${{ matrix.expected_payload }}; do
|
||||
test -e "${{ matrix.superbuild_dir }}/$f"
|
||||
done
|
||||
- name: Verify local superbuild install
|
||||
if: matrix.superbuild_target == 'ollama-local'
|
||||
run: |
|
||||
./ollama --version
|
||||
"${{ matrix.superbuild_dir }}/lib/ollama/llama-server" --version
|
||||
test -x "${{ matrix.superbuild_dir }}/lib/ollama/llama-quantize"
|
||||
cmake --install "${{ matrix.superbuild_dir }}" --component ollama-local --prefix "$RUNNER_TEMP/ollama-local"
|
||||
"$RUNNER_TEMP/ollama-local/bin/ollama" --version
|
||||
"$RUNNER_TEMP/ollama-local/lib/ollama/llama-server" --version
|
||||
test -x "$RUNNER_TEMP/ollama-local/lib/ollama/llama-quantize"
|
||||
windows:
|
||||
needs: [changes]
|
||||
if: needs.changes.outputs.changed == 'True'
|
||||
@@ -114,9 +182,16 @@ jobs:
|
||||
matrix:
|
||||
include:
|
||||
- preset: CPU
|
||||
superbuild_target: ollama-local
|
||||
superbuild_dir: build\local-superbuild
|
||||
superbuild_args: ''
|
||||
expected_payload: lib\ollama\llama-server.exe
|
||||
- preset: CUDA
|
||||
install: https://developer.download.nvidia.com/compute/cuda/13.0.0/local_installers/cuda_13.0.0_windows.exe
|
||||
flags: '-DCMAKE_CUDA_ARCHITECTURES=80'
|
||||
superbuild_target: ollama-llama-server-cuda_v13
|
||||
superbuild_dir: build\local-superbuild-cuda_v13
|
||||
superbuild_args: '-DOLLAMA_LLAMA_BACKENDS=cuda_v13 -DCMAKE_CUDA_ARCHITECTURES=80'
|
||||
expected_payload: lib\ollama\cuda_v13\ggml-cuda.dll
|
||||
cuda-components:
|
||||
- '"cudart"'
|
||||
- '"nvcc"'
|
||||
@@ -127,14 +202,26 @@ jobs:
|
||||
- '"nvptxcompiler"'
|
||||
cuda-version: '13.0'
|
||||
- preset: ROCm
|
||||
install: https://download.amd.com/developer/eula/rocm-hub/AMD-Software-PRO-Edition-24.Q4-WinSvr2022-For-HIP.exe
|
||||
flags: '-DAMDGPU_TARGETS=gfx1010 -DCMAKE_C_COMPILER=clang -DCMAKE_CXX_COMPILER=clang++ -DCMAKE_C_FLAGS="-parallel-jobs=4 -Wno-ignored-attributes -Wno-deprecated-pragma" -DCMAKE_CXX_FLAGS="-parallel-jobs=4 -Wno-ignored-attributes -Wno-deprecated-pragma"'
|
||||
install: https://download.amd.com/developer/eula/rocm-hub/AMD-Software-PRO-Edition-26.Q1-Win11-For-HIP.exe
|
||||
rocm-version: '7.1'
|
||||
superbuild_target: ollama-llama-server-rocm_v7_1
|
||||
superbuild_dir: build\local-superbuild-rocm_v7_1
|
||||
superbuild_args: '-DOLLAMA_LLAMA_BACKENDS=rocm_v7_1 -DAMDGPU_TARGETS=gfx1010'
|
||||
expected_payload: lib\ollama\rocm_v7_1\ggml-hip.dll
|
||||
- preset: Vulkan
|
||||
install: https://sdk.lunarg.com/sdk/download/1.4.321.1/windows/vulkansdk-windows-X64-1.4.321.1.exe
|
||||
superbuild_target: ollama-llama-server-vulkan
|
||||
superbuild_dir: build\local-superbuild-vulkan
|
||||
superbuild_args: '-DOLLAMA_LLAMA_BACKENDS=vulkan'
|
||||
expected_payload: lib\ollama\vulkan\ggml-vulkan.dll
|
||||
- preset: 'MLX CUDA 13'
|
||||
install: https://developer.download.nvidia.com/compute/cuda/13.0.0/local_installers/cuda_13.0.0_windows.exe
|
||||
cudnn-install: https://developer.download.nvidia.com/compute/cudnn/redist/cudnn/windows-x86_64/cudnn-windows-x86_64-9.18.1.3_cuda13-archive.zip
|
||||
flags: '-DCMAKE_CUDA_ARCHITECTURES=80 -DMLX_CUDA_ARCHITECTURES=80-virtual'
|
||||
superbuild_target: ollama-mlx-cuda_v13
|
||||
superbuild_dir: build\local-superbuild-mlx-cuda_v13
|
||||
superbuild_args: '-DOLLAMA_MLX_BACKENDS=cuda_v13 -DCMAKE_CUDA_ARCHITECTURES=80 -DMLX_CUDA_ARCHITECTURES=80-virtual'
|
||||
expected_payload: lib\ollama\mlx_cuda_v13\mlx.dll lib\ollama\mlx_cuda_v13\ollama_xgrammar.dll
|
||||
install-go: true
|
||||
cuda-components:
|
||||
- '"cudart"'
|
||||
- '"nvcc"'
|
||||
@@ -144,6 +231,11 @@ jobs:
|
||||
- '"cufft_dev"'
|
||||
- '"nvrtc"'
|
||||
- '"nvrtc_dev"'
|
||||
- '"cusolver"'
|
||||
- '"cusolver_dev"'
|
||||
- '"cusparse"'
|
||||
- '"cusparse_dev"'
|
||||
- '"nvjitlink"'
|
||||
- '"crt"'
|
||||
- '"nvvm"'
|
||||
- '"nvptxcompiler"'
|
||||
@@ -203,6 +295,10 @@ jobs:
|
||||
}
|
||||
|
||||
$vulkanPath = (Resolve-Path "C:\VulkanSDK\*").path
|
||||
$vulkanRuntime = Join-Path $vulkanPath "Helpers\VulkanRT.exe"
|
||||
if (Test-Path $vulkanRuntime) {
|
||||
Start-Process -FilePath $vulkanRuntime -ArgumentList "/s" -NoNewWindow -Wait
|
||||
}
|
||||
echo "$vulkanPath\bin" | Out-File -FilePath $env:GITHUB_PATH -Encoding utf8 -Append
|
||||
echo "VULKAN_SDK=$vulkanPath" >> $env:GITHUB_ENV
|
||||
- if: matrix.preset == 'MLX CUDA 13'
|
||||
@@ -232,18 +328,46 @@ jobs:
|
||||
C:\Program Files\NVIDIA\CUDNN
|
||||
key: ${{ matrix.install }}-${{ matrix.cudnn-install }}
|
||||
- uses: actions/checkout@v4
|
||||
- if: matrix.superbuild_target == 'ollama-local' || matrix.install-go
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version-file: 'go.mod'
|
||||
- uses: actions/cache@v4
|
||||
with:
|
||||
path: ${{ github.workspace }}\.ccache
|
||||
key: ccache-${{ runner.os }}-${{ runner.arch }}-${{ matrix.preset }}-${{ needs.changes.outputs.vendorsha }}
|
||||
- run: |
|
||||
key: ccache-${{ runner.os }}-${{ runner.arch }}-${{ matrix.preset }}-${{ needs.changes.outputs.enginehash }}
|
||||
- name: Build native superbuild
|
||||
if: matrix.superbuild_target
|
||||
run: |
|
||||
$ErrorActionPreference = "Stop"
|
||||
Import-Module 'C:\Program Files\Microsoft Visual Studio\2022\Enterprise\Common7\Tools\Microsoft.VisualStudio.DevShell.dll'
|
||||
Enter-VsDevShell -VsInstallPath 'C:\Program Files\Microsoft Visual Studio\2022\Enterprise' -SkipAutomaticLocation -DevCmdArguments '-arch=x64 -no_logo'
|
||||
cmake --preset "${{ matrix.preset }}" ${{ matrix.flags }}
|
||||
cmake --build --preset "${{ matrix.preset }}" -- -l $([Environment]::ProcessorCount)
|
||||
cmake -S . -B "${{ matrix.superbuild_dir }}" ${{ matrix.superbuild_args }}
|
||||
$env:CMAKE_BUILD_PARALLEL_LEVEL = [Environment]::ProcessorCount
|
||||
cmake --build "${{ matrix.superbuild_dir }}" --target "${{ matrix.superbuild_target }}" -- -l $([Environment]::ProcessorCount)
|
||||
foreach ($f in "${{ matrix.expected_payload }}".Split(' ')) {
|
||||
if (!(Test-Path "${{ matrix.superbuild_dir }}\$f")) {
|
||||
throw "missing $f"
|
||||
}
|
||||
}
|
||||
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:
|
||||
@@ -251,6 +375,24 @@ jobs:
|
||||
- name: check that 'go mod tidy' is clean
|
||||
run: go mod tidy --diff || (echo "Please run 'go mod tidy'." && exit 1)
|
||||
|
||||
go_license:
|
||||
needs: [changes]
|
||||
if: needs.changes.outputs.go_mod_changed == 'True'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
- name: Verify Go dependency licenses
|
||||
run: |
|
||||
# See cmake/generate_go_license.cmake for special case handling.
|
||||
cmake -S . -B build/go-license \
|
||||
-DOLLAMA_LLAMA_BACKENDS= \
|
||||
-DOLLAMA_MLX_BACKENDS= \
|
||||
"-DOLLAMA_GO_LICENSE_TARGETS=linux/amd64;linux/arm64;darwin/amd64;darwin/arm64;windows/amd64;windows/arm64"
|
||||
cmake --build build/go-license --target ollama-go-license
|
||||
|
||||
test:
|
||||
needs: [changes]
|
||||
strategy:
|
||||
@@ -266,10 +408,22 @@ jobs:
|
||||
go-version-file: 'go.mod'
|
||||
cache-dependency-path: |
|
||||
go.sum
|
||||
Makefile.sync
|
||||
LLAMA_CPP_VERSION
|
||||
MLX_VERSION
|
||||
MLX_C_VERSION
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '20'
|
||||
- name: Cache MLX Darwin release payload
|
||||
if: ${{ startsWith(matrix.os, 'macos') }}
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: .cache/mlx-darwin-release
|
||||
# Key on every payload input so a source-built payload survives pushes.
|
||||
key: mlx-darwin-${{ hashFiles('MLX_VERSION', 'MLX_C_VERSION', 'cmake/local.cmake', 'cmake/apply-git-patches.cmake', 'cmake/mlx/CMakeLists.txt', 'cmake/mlx/CMakePresets.json', 'x/mlxrunner/mlx/CMakeLists.txt', 'mlx/compat/**', 'x/mlxrunner/xgrammar/native/**') }}
|
||||
- name: Prepare MLX Darwin release payload
|
||||
if: ${{ startsWith(matrix.os, 'macos') }}
|
||||
run: .github/scripts/prepare_mlx_darwin.sh
|
||||
- name: Install UI dependencies
|
||||
working-directory: ./app/ui/app
|
||||
run: npm ci
|
||||
@@ -280,26 +434,72 @@ jobs:
|
||||
if: ${{ startsWith(matrix.os, 'ubuntu') }}
|
||||
working-directory: ./app/ui/app
|
||||
run: npm test
|
||||
- name: Verify MLX generated files are current
|
||||
if: ${{ startsWith(matrix.os, 'ubuntu') }}
|
||||
run: |
|
||||
cmake -S . -B build/mlx-generate -DOLLAMA_MLX_BACKENDS=cuda_v13
|
||||
cmake --build build/mlx-generate --target ollama-mlx-generate-wrappers
|
||||
git diff --exit-code -- \
|
||||
x/imagegen/mlx/mlx.h \
|
||||
x/imagegen/mlx/mlx.c \
|
||||
x/mlxrunner/mlx/generated.h \
|
||||
x/mlxrunner/mlx/generated.c \
|
||||
x/mlxrunner/mlx/include/mlx/c
|
||||
- name: Run go generate
|
||||
run: go generate ./...
|
||||
|
||||
- name: Verify UI generated types are current
|
||||
if: ${{ startsWith(matrix.os, 'ubuntu') }}
|
||||
run: git diff --exit-code -- app/ui/app/codegen/gotypes.gen.ts
|
||||
|
||||
- name: go test
|
||||
if: always()
|
||||
run: go test -count=1 -benchtime=1x ./...
|
||||
# Smoke-run each benchmark once to catch panics and bit rot; this does
|
||||
# not assert timings. -benchtime without -bench is inert.
|
||||
run: go test -count=1 -bench=. -benchtime=1x ./...
|
||||
|
||||
- name: go test app with live updater tag
|
||||
if: ${{ needs.changes.outputs.app_changed == 'True' && contains(fromJSON('["macos-latest","windows-latest"]'), matrix.os) }}
|
||||
run: go test -count=1 -tags updater_live ./app/...
|
||||
|
||||
- uses: golangci/golangci-lint-action@v9
|
||||
with:
|
||||
only-new-issues: true
|
||||
|
||||
patches:
|
||||
runs-on: ubuntu-latest
|
||||
race:
|
||||
strategy:
|
||||
matrix:
|
||||
os: [ubuntu-latest, macos-latest]
|
||||
runs-on: ${{ matrix.os }}
|
||||
env:
|
||||
CGO_ENABLED: '1'
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Verify patches apply cleanly and do not change files
|
||||
- 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: Cache MLX Darwin release payload
|
||||
if: ${{ startsWith(matrix.os, 'macos') }}
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: .cache/mlx-darwin-release
|
||||
# Key on every payload input so a source-built payload survives pushes.
|
||||
key: mlx-darwin-${{ hashFiles('MLX_VERSION', 'MLX_C_VERSION', 'cmake/local.cmake', 'cmake/apply-git-patches.cmake', 'cmake/mlx/CMakeLists.txt', 'cmake/mlx/CMakePresets.json', 'x/mlxrunner/mlx/CMakeLists.txt', 'mlx/compat/**', 'x/mlxrunner/xgrammar/native/**') }}
|
||||
- name: Prepare MLX Darwin release payload
|
||||
if: ${{ startsWith(matrix.os, 'macos') }}
|
||||
run: .github/scripts/prepare_mlx_darwin.sh
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '20'
|
||||
# app/ui embeds app/dist, so the UI has to be built before app/... will
|
||||
# even compile.
|
||||
- name: Build UI
|
||||
working-directory: ./app/ui/app
|
||||
run: |
|
||||
make -f Makefile.sync clean checkout apply-patches sync
|
||||
git diff --compact-summary --exit-code
|
||||
npm ci
|
||||
npm run build
|
||||
- name: go test -race
|
||||
run: go test -race -count=1 ./...
|
||||
@@ -0,0 +1,21 @@
|
||||
# AGENTS.md
|
||||
|
||||
## Building
|
||||
|
||||
For a full build from the repository root:
|
||||
|
||||
```sh
|
||||
cmake -B build .
|
||||
cmake --build build --parallel 8
|
||||
./ollama serve
|
||||
```
|
||||
|
||||
For quick Go-only iteration against an existing native payload:
|
||||
|
||||
```sh
|
||||
go build .
|
||||
go run . serve
|
||||
```
|
||||
|
||||
See `docs/development.md` for prerequisites, platform notes, GPU backends, and
|
||||
the full development workflow.
|
||||
@@ -0,0 +1,3 @@
|
||||
# CLAUDE.md
|
||||
|
||||
See `AGENTS.md` for the shared agent instructions for this repository.
|
||||
@@ -1,4 +1,4 @@
|
||||
cmake_minimum_required(VERSION 3.21)
|
||||
cmake_minimum_required(VERSION 3.24)
|
||||
|
||||
project(Ollama C CXX)
|
||||
|
||||
@@ -23,39 +23,37 @@ include(GNUInstallDirs)
|
||||
|
||||
find_package(Threads REQUIRED)
|
||||
|
||||
set(CMAKE_BUILD_TYPE Release)
|
||||
set(BUILD_SHARED_LIBS ON)
|
||||
if(NOT CMAKE_CONFIGURATION_TYPES AND NOT CMAKE_BUILD_TYPE)
|
||||
set(CMAKE_BUILD_TYPE Release CACHE STRING "Build type" FORCE)
|
||||
endif()
|
||||
|
||||
# These defaults can be overridden by presets (e.g., for static macOS llama-server builds)
|
||||
if(NOT DEFINED BUILD_SHARED_LIBS)
|
||||
set(BUILD_SHARED_LIBS ON)
|
||||
endif()
|
||||
|
||||
set(CMAKE_CXX_STANDARD 17)
|
||||
set(CMAKE_CXX_STANDARD_REQUIRED ON)
|
||||
set(CMAKE_CXX_EXTENSIONS ON) # Recent versions of MLX Requires gnu++17 extensions to compile properly
|
||||
set(CMAKE_CXX_EXTENSIONS ON) # Recent versions of MLX require gnu++17 extensions to compile properly
|
||||
|
||||
set(GGML_BUILD ON)
|
||||
set(GGML_SHARED ON)
|
||||
set(GGML_CCACHE ON)
|
||||
set(GGML_BACKEND_DL ON)
|
||||
set(GGML_BACKEND_SHARED ON)
|
||||
set(GGML_SCHED_MAX_COPIES 4)
|
||||
|
||||
set(GGML_LLAMAFILE ON)
|
||||
set(GGML_CUDA_PEER_MAX_BATCH_SIZE 128)
|
||||
set(GGML_CUDA_GRAPHS ON)
|
||||
set(GGML_CUDA_FA ON)
|
||||
set(GGML_CUDA_COMPRESSION_MODE default)
|
||||
|
||||
if((CMAKE_OSX_ARCHITECTURES AND NOT CMAKE_OSX_ARCHITECTURES MATCHES "arm64")
|
||||
OR (NOT CMAKE_OSX_ARCHITECTURES AND NOT CMAKE_SYSTEM_PROCESSOR MATCHES "arm|aarch64|ARM64|ARMv[0-9]+"))
|
||||
set(GGML_CPU_ALL_VARIANTS ON)
|
||||
endif()
|
||||
# GGML backend for inference is provided by llama-server (built separately via
|
||||
# llama/server/CMakeLists.txt using FetchContent from the pinned llama.cpp source).
|
||||
# The root CMake project is the orchestration entrypoint; backend-specific
|
||||
# build rules live in subprojects under cmake/.
|
||||
|
||||
if(APPLE)
|
||||
set(CMAKE_BUILD_RPATH "@loader_path")
|
||||
set(CMAKE_INSTALL_RPATH "@loader_path")
|
||||
set(CMAKE_BUILD_WITH_INSTALL_RPATH ON)
|
||||
elseif(UNIX)
|
||||
set(CMAKE_BUILD_RPATH "$ORIGIN")
|
||||
set(CMAKE_INSTALL_RPATH "$ORIGIN")
|
||||
set(CMAKE_BUILD_WITH_INSTALL_RPATH ON)
|
||||
endif()
|
||||
|
||||
set(OLLAMA_BUILD_DIR ${CMAKE_BINARY_DIR}/lib/ollama)
|
||||
set(OLLAMA_INSTALL_DIR ${CMAKE_INSTALL_PREFIX}/lib/ollama/${OLLAMA_RUNNER_DIR})
|
||||
set(OLLAMA_LIB_DIR "lib/ollama" CACHE STRING "Install destination for Ollama runtime payloads")
|
||||
set(OLLAMA_INSTALL_DIR ${OLLAMA_LIB_DIR}/${OLLAMA_RUNNER_DIR})
|
||||
|
||||
set(CMAKE_RUNTIME_OUTPUT_DIRECTORY ${OLLAMA_BUILD_DIR})
|
||||
set(CMAKE_RUNTIME_OUTPUT_DIRECTORY_DEBUG ${OLLAMA_BUILD_DIR})
|
||||
@@ -64,314 +62,9 @@ set(CMAKE_LIBRARY_OUTPUT_DIRECTORY ${OLLAMA_BUILD_DIR})
|
||||
set(CMAKE_LIBRARY_OUTPUT_DIRECTORY_DEBUG ${OLLAMA_BUILD_DIR})
|
||||
set(CMAKE_LIBRARY_OUTPUT_DIRECTORY_RELEASE ${OLLAMA_BUILD_DIR})
|
||||
|
||||
# Store ggml include paths for use with target_include_directories later.
|
||||
# We avoid global include_directories() to prevent polluting the include path
|
||||
# for other projects like MLX (whose openblas dependency has its own common.h).
|
||||
set(GGML_INCLUDE_DIRS
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/ml/backend/ggml/ggml/src
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/ml/backend/ggml/ggml/src/include
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/ml/backend/ggml/ggml/src/ggml-cpu
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/ml/backend/ggml/ggml/src/ggml-cpu/amx
|
||||
)
|
||||
|
||||
add_compile_definitions(NDEBUG GGML_VERSION=0x0 GGML_COMMIT=0x0)
|
||||
|
||||
# Define GGML version variables for shared library SOVERSION
|
||||
# These are required by ggml/src/CMakeLists.txt for proper library versioning
|
||||
set(GGML_VERSION_MAJOR 0)
|
||||
set(GGML_VERSION_MINOR 0)
|
||||
set(GGML_VERSION_PATCH 0)
|
||||
set(GGML_VERSION "${GGML_VERSION_MAJOR}.${GGML_VERSION_MINOR}.${GGML_VERSION_PATCH}")
|
||||
|
||||
set(GGML_CPU ON)
|
||||
add_subdirectory(${CMAKE_CURRENT_SOURCE_DIR}/ml/backend/ggml/ggml/src)
|
||||
set_property(TARGET ggml PROPERTY EXCLUDE_FROM_ALL TRUE)
|
||||
|
||||
get_target_property(CPU_VARIANTS ggml-cpu MANUALLY_ADDED_DEPENDENCIES)
|
||||
if(NOT CPU_VARIANTS)
|
||||
set(CPU_VARIANTS "ggml-cpu")
|
||||
endif()
|
||||
|
||||
# Apply ggml include directories to ggml targets only (not globally)
|
||||
target_include_directories(ggml-base PRIVATE ${GGML_INCLUDE_DIRS})
|
||||
foreach(variant ${CPU_VARIANTS})
|
||||
if(TARGET ${variant})
|
||||
target_include_directories(${variant} PRIVATE ${GGML_INCLUDE_DIRS})
|
||||
endif()
|
||||
endforeach()
|
||||
|
||||
install(TARGETS ggml-base ${CPU_VARIANTS}
|
||||
RUNTIME_DEPENDENCIES
|
||||
PRE_EXCLUDE_REGEXES ".*"
|
||||
RUNTIME DESTINATION ${OLLAMA_INSTALL_DIR} COMPONENT CPU
|
||||
LIBRARY DESTINATION ${OLLAMA_INSTALL_DIR} COMPONENT CPU
|
||||
FRAMEWORK DESTINATION ${OLLAMA_INSTALL_DIR} COMPONENT CPU
|
||||
)
|
||||
|
||||
check_language(CUDA)
|
||||
if(CMAKE_CUDA_COMPILER)
|
||||
if(CMAKE_VERSION VERSION_GREATER_EQUAL "3.24" AND NOT CMAKE_CUDA_ARCHITECTURES)
|
||||
set(CMAKE_CUDA_ARCHITECTURES "native")
|
||||
endif()
|
||||
|
||||
find_package(CUDAToolkit)
|
||||
add_subdirectory(${CMAKE_CURRENT_SOURCE_DIR}/ml/backend/ggml/ggml/src/ggml-cuda)
|
||||
target_include_directories(ggml-cuda PRIVATE ${GGML_INCLUDE_DIRS})
|
||||
install(TARGETS ggml-cuda
|
||||
RUNTIME_DEPENDENCIES
|
||||
DIRECTORIES ${CUDAToolkit_BIN_DIR} ${CUDAToolkit_BIN_DIR}/x64 ${CUDAToolkit_LIBRARY_DIR}
|
||||
PRE_INCLUDE_REGEXES cublas cublasLt cudart
|
||||
PRE_EXCLUDE_REGEXES ".*"
|
||||
RUNTIME DESTINATION ${OLLAMA_INSTALL_DIR} COMPONENT CUDA
|
||||
LIBRARY DESTINATION ${OLLAMA_INSTALL_DIR} COMPONENT CUDA
|
||||
)
|
||||
endif()
|
||||
|
||||
set(WINDOWS_AMDGPU_TARGETS_EXCLUDE_REGEX "^gfx(908|90a|1200|1201):xnack[+-]$"
|
||||
CACHE STRING
|
||||
"Regular expression describing AMDGPU_TARGETS not supported on Windows. Override to force building these targets. Default \"^gfx(908|90a|1200|1201):xnack[+-]$\"."
|
||||
)
|
||||
|
||||
check_language(HIP)
|
||||
if(CMAKE_HIP_COMPILER)
|
||||
set(HIP_PLATFORM "amd")
|
||||
|
||||
if(NOT AMDGPU_TARGETS)
|
||||
find_package(hip REQUIRED)
|
||||
list(FILTER AMDGPU_TARGETS INCLUDE REGEX "^gfx(94[012]|101[02]|1030|110[012]|120[01])$")
|
||||
endif()
|
||||
|
||||
if(WIN32 AND WINDOWS_AMDGPU_TARGETS_EXCLUDE_REGEX)
|
||||
list(FILTER AMDGPU_TARGETS EXCLUDE REGEX ${WINDOWS_AMDGPU_TARGETS_EXCLUDE_REGEX})
|
||||
endif()
|
||||
|
||||
if(AMDGPU_TARGETS)
|
||||
find_package(hip REQUIRED)
|
||||
add_subdirectory(${CMAKE_CURRENT_SOURCE_DIR}/ml/backend/ggml/ggml/src/ggml-hip)
|
||||
target_include_directories(ggml-hip PRIVATE ${GGML_INCLUDE_DIRS})
|
||||
|
||||
if (WIN32)
|
||||
target_compile_definitions(ggml-hip PRIVATE GGML_CUDA_NO_PEER_COPY)
|
||||
endif()
|
||||
|
||||
target_compile_definitions(ggml-hip PRIVATE GGML_HIP_NO_VMM)
|
||||
|
||||
install(TARGETS ggml-hip
|
||||
RUNTIME_DEPENDENCY_SET rocm
|
||||
RUNTIME DESTINATION ${OLLAMA_INSTALL_DIR} COMPONENT HIP
|
||||
LIBRARY DESTINATION ${OLLAMA_INSTALL_DIR} COMPONENT HIP
|
||||
)
|
||||
install(RUNTIME_DEPENDENCY_SET rocm
|
||||
DIRECTORIES ${HIP_BIN_INSTALL_DIR} ${HIP_LIB_INSTALL_DIR}
|
||||
PRE_INCLUDE_REGEXES hipblas rocblas amdhip64 rocsolver amd_comgr hsa-runtime64 rocsparse tinfo rocprofiler-register roctx64 rocroller drm drm_amdgpu numa elf
|
||||
PRE_EXCLUDE_REGEXES ".*"
|
||||
POST_EXCLUDE_REGEXES "system32"
|
||||
RUNTIME DESTINATION ${OLLAMA_INSTALL_DIR} COMPONENT HIP
|
||||
LIBRARY DESTINATION ${OLLAMA_INSTALL_DIR} COMPONENT HIP
|
||||
)
|
||||
|
||||
foreach(HIP_LIB_BIN_INSTALL_DIR IN ITEMS ${HIP_BIN_INSTALL_DIR} ${HIP_LIB_INSTALL_DIR})
|
||||
if(EXISTS ${HIP_LIB_BIN_INSTALL_DIR}/rocblas)
|
||||
install(DIRECTORY ${HIP_LIB_BIN_INSTALL_DIR}/rocblas DESTINATION ${OLLAMA_INSTALL_DIR} COMPONENT HIP)
|
||||
break()
|
||||
endif()
|
||||
endforeach()
|
||||
endif()
|
||||
endif()
|
||||
|
||||
if(NOT APPLE)
|
||||
find_package(Vulkan)
|
||||
if(Vulkan_FOUND)
|
||||
add_subdirectory(${CMAKE_CURRENT_SOURCE_DIR}/ml/backend/ggml/ggml/src/ggml-vulkan)
|
||||
target_include_directories(ggml-vulkan PRIVATE ${GGML_INCLUDE_DIRS})
|
||||
install(TARGETS ggml-vulkan
|
||||
RUNTIME_DEPENDENCIES
|
||||
PRE_INCLUDE_REGEXES vulkan
|
||||
PRE_EXCLUDE_REGEXES ".*"
|
||||
RUNTIME DESTINATION ${OLLAMA_INSTALL_DIR} COMPONENT Vulkan
|
||||
LIBRARY DESTINATION ${OLLAMA_INSTALL_DIR} COMPONENT Vulkan
|
||||
)
|
||||
endif()
|
||||
endif()
|
||||
|
||||
option(MLX_ENGINE "Enable MLX backend" OFF)
|
||||
if(MLX_ENGINE)
|
||||
message(STATUS "Setting up MLX (this takes a while...)")
|
||||
add_subdirectory(${CMAKE_CURRENT_SOURCE_DIR}/x/imagegen/mlx)
|
||||
|
||||
# Find CUDA toolkit if MLX is built with CUDA support
|
||||
find_package(CUDAToolkit)
|
||||
|
||||
# Build list of directories for runtime dependency resolution
|
||||
set(MLX_RUNTIME_DIRS ${CUDAToolkit_BIN_DIR} ${CUDAToolkit_BIN_DIR}/x64 ${CUDAToolkit_LIBRARY_DIR})
|
||||
# Add cuDNN bin paths for DLLs (Windows MLX CUDA builds)
|
||||
# CUDNN_ROOT_DIR is the standard CMake variable for cuDNN location
|
||||
if(DEFINED ENV{CUDNN_ROOT_DIR})
|
||||
# cuDNN 9.x has versioned subdirectories under bin/ (e.g., bin/13.0/)
|
||||
file(GLOB CUDNN_BIN_SUBDIRS "$ENV{CUDNN_ROOT_DIR}/bin/*")
|
||||
list(APPEND MLX_RUNTIME_DIRS ${CUDNN_BIN_SUBDIRS})
|
||||
endif()
|
||||
# Add build output directory and MLX dependency build directories
|
||||
list(APPEND MLX_RUNTIME_DIRS ${OLLAMA_BUILD_DIR})
|
||||
# OpenBLAS DLL location (pre-built zip extracts into openblas-src/bin/)
|
||||
list(APPEND MLX_RUNTIME_DIRS ${CMAKE_BINARY_DIR}/_deps/openblas-src/bin)
|
||||
# NCCL: on Linux, if real NCCL is found, cmake bundles libnccl.so via the
|
||||
# regex below. If NCCL is not found, MLX links a static stub (OBJECT lib)
|
||||
# so there is no runtime dependency. This path covers the stub build dir
|
||||
# for windows so we include the DLL in our dependencies.
|
||||
list(APPEND MLX_RUNTIME_DIRS ${CMAKE_BINARY_DIR}/_deps/mlx-build/mlx/distributed/nccl/nccl_stub-prefix/src/nccl_stub-build/Release)
|
||||
|
||||
# Base regexes for runtime dependencies (cross-platform)
|
||||
set(MLX_INCLUDE_REGEXES cublas cublasLt cudart cufft nvrtc nvrtc-builtins cudnn nccl openblas gfortran)
|
||||
# On Windows, also include dl.dll (dlfcn-win32 POSIX emulation layer)
|
||||
if(WIN32)
|
||||
list(APPEND MLX_INCLUDE_REGEXES "^dl\\.dll$")
|
||||
endif()
|
||||
|
||||
# Split mlx/mlxc libraries from runtime deps to avoid stripping deps
|
||||
install(TARGETS mlx mlxc
|
||||
RUNTIME_DEPENDENCY_SET mlx_runtime_deps
|
||||
RUNTIME DESTINATION ${OLLAMA_INSTALL_DIR} COMPONENT MLX
|
||||
LIBRARY DESTINATION ${OLLAMA_INSTALL_DIR} COMPONENT MLX
|
||||
FRAMEWORK DESTINATION ${OLLAMA_INSTALL_DIR} COMPONENT MLX
|
||||
)
|
||||
install(RUNTIME_DEPENDENCY_SET mlx_runtime_deps
|
||||
DIRECTORIES ${MLX_RUNTIME_DIRS}
|
||||
PRE_INCLUDE_REGEXES ${MLX_INCLUDE_REGEXES}
|
||||
PRE_EXCLUDE_REGEXES ".*"
|
||||
RUNTIME DESTINATION ${OLLAMA_INSTALL_DIR} COMPONENT MLX_VENDOR
|
||||
LIBRARY DESTINATION ${OLLAMA_INSTALL_DIR} COMPONENT MLX_VENDOR
|
||||
)
|
||||
|
||||
if(TARGET jaccl)
|
||||
install(TARGETS jaccl
|
||||
RUNTIME DESTINATION ${OLLAMA_INSTALL_DIR} COMPONENT MLX
|
||||
LIBRARY DESTINATION ${OLLAMA_INSTALL_DIR} COMPONENT MLX
|
||||
FRAMEWORK DESTINATION ${OLLAMA_INSTALL_DIR} COMPONENT MLX
|
||||
)
|
||||
endif()
|
||||
|
||||
# Install the Metal library for macOS arm64 (must be colocated with the binary)
|
||||
# Metal backend is only built for arm64, not x86_64
|
||||
if(APPLE AND CMAKE_SYSTEM_PROCESSOR STREQUAL "arm64")
|
||||
install(FILES ${CMAKE_BINARY_DIR}/_deps/mlx-build/mlx/backend/metal/kernels/mlx.metallib
|
||||
DESTINATION ${OLLAMA_INSTALL_DIR}
|
||||
COMPONENT MLX)
|
||||
endif()
|
||||
|
||||
# Install headers for NVRTC JIT compilation at runtime.
|
||||
# MLX's own install rules use the default component so they get skipped by
|
||||
# --component MLX. Headers are installed alongside libmlx in OLLAMA_INSTALL_DIR.
|
||||
#
|
||||
# Layout:
|
||||
# ${OLLAMA_INSTALL_DIR}/include/cccl/{cuda,nv}/ — CCCL headers
|
||||
# ${OLLAMA_INSTALL_DIR}/include/*.h — CUDA toolkit headers
|
||||
#
|
||||
# MLX's jit_module.cpp resolves CCCL via
|
||||
# current_binary_dir()[.parent_path()] / "include" / "cccl"
|
||||
# On Linux, MLX's jit_module.cpp resolves CCCL via
|
||||
# current_binary_dir().parent_path() / "include" / "cccl", so we create a
|
||||
# symlink from lib/ollama/include -> ${OLLAMA_RUNNER_DIR}/include
|
||||
# This will need refinement if we add multiple CUDA versions for MLX in the future.
|
||||
# CUDA runtime headers are found via CUDA_PATH env var (set by mlxrunner).
|
||||
if(EXISTS ${CMAKE_BINARY_DIR}/_deps/cccl-src/include/cuda)
|
||||
install(DIRECTORY ${CMAKE_BINARY_DIR}/_deps/cccl-src/include/cuda
|
||||
DESTINATION ${OLLAMA_INSTALL_DIR}/include/cccl
|
||||
COMPONENT MLX)
|
||||
install(DIRECTORY ${CMAKE_BINARY_DIR}/_deps/cccl-src/include/nv
|
||||
DESTINATION ${OLLAMA_INSTALL_DIR}/include/cccl
|
||||
COMPONENT MLX)
|
||||
if(NOT WIN32 AND NOT APPLE)
|
||||
install(CODE "
|
||||
set(_link \"${CMAKE_INSTALL_PREFIX}/lib/ollama/include\")
|
||||
set(_target \"${OLLAMA_RUNNER_DIR}/include\")
|
||||
if(NOT EXISTS \${_link})
|
||||
execute_process(COMMAND \${CMAKE_COMMAND} -E create_symlink \${_target} \${_link})
|
||||
endif()
|
||||
" COMPONENT MLX)
|
||||
endif()
|
||||
endif()
|
||||
|
||||
# Install minimal CUDA toolkit headers needed by MLX JIT kernels.
|
||||
# These are the transitive closure of includes from mlx/backend/cuda/device/*.cuh.
|
||||
# The Go mlxrunner sets CUDA_PATH to OLLAMA_INSTALL_DIR so MLX finds them at
|
||||
# $CUDA_PATH/include/*.h via NVRTC --include-path.
|
||||
if(CUDAToolkit_FOUND)
|
||||
# CUDAToolkit_INCLUDE_DIRS may be a semicolon-separated list
|
||||
# (e.g. ".../include;.../include/cccl"). Find the entry that
|
||||
# contains the CUDA runtime headers we need.
|
||||
set(_cuda_inc "")
|
||||
foreach(_dir ${CUDAToolkit_INCLUDE_DIRS})
|
||||
if(EXISTS "${_dir}/cuda_runtime_api.h")
|
||||
set(_cuda_inc "${_dir}")
|
||||
break()
|
||||
endif()
|
||||
endforeach()
|
||||
if(NOT _cuda_inc)
|
||||
message(WARNING "Could not find cuda_runtime_api.h in CUDAToolkit_INCLUDE_DIRS: ${CUDAToolkit_INCLUDE_DIRS}")
|
||||
else()
|
||||
set(_dst "${OLLAMA_INSTALL_DIR}/include")
|
||||
set(_MLX_JIT_CUDA_HEADERS
|
||||
builtin_types.h
|
||||
cooperative_groups.h
|
||||
cuda_bf16.h
|
||||
cuda_bf16.hpp
|
||||
cuda_device_runtime_api.h
|
||||
cuda_fp16.h
|
||||
cuda_fp16.hpp
|
||||
cuda_fp8.h
|
||||
cuda_fp8.hpp
|
||||
cuda_runtime_api.h
|
||||
device_types.h
|
||||
driver_types.h
|
||||
math_constants.h
|
||||
surface_types.h
|
||||
texture_types.h
|
||||
vector_functions.h
|
||||
vector_functions.hpp
|
||||
vector_types.h
|
||||
)
|
||||
foreach(_hdr ${_MLX_JIT_CUDA_HEADERS})
|
||||
install(FILES "${_cuda_inc}/${_hdr}"
|
||||
DESTINATION ${_dst}
|
||||
COMPONENT MLX)
|
||||
endforeach()
|
||||
# Subdirectory headers
|
||||
install(DIRECTORY "${_cuda_inc}/cooperative_groups"
|
||||
DESTINATION ${_dst}
|
||||
COMPONENT MLX
|
||||
FILES_MATCHING PATTERN "*.h")
|
||||
install(FILES "${_cuda_inc}/crt/host_defines.h"
|
||||
DESTINATION "${_dst}/crt"
|
||||
COMPONENT MLX)
|
||||
endif()
|
||||
endif()
|
||||
|
||||
# On Windows, explicitly install dl.dll (dlfcn-win32 POSIX dlopen emulation)
|
||||
# RUNTIME_DEPENDENCIES auto-excludes it via POST_EXCLUDE_FILES_STRICT because
|
||||
# dlfcn-win32 is a known CMake target with its own install rules (which install
|
||||
# to the wrong destination). We must install it explicitly here.
|
||||
if(WIN32)
|
||||
install(FILES ${OLLAMA_BUILD_DIR}/dl.dll
|
||||
DESTINATION ${OLLAMA_INSTALL_DIR}
|
||||
COMPONENT MLX)
|
||||
endif()
|
||||
|
||||
# Manually install CUDA runtime libraries that MLX loads via dlopen
|
||||
# (not detected by RUNTIME_DEPENDENCIES since they aren't link-time deps)
|
||||
if(CUDAToolkit_FOUND)
|
||||
file(GLOB MLX_CUDA_LIBS
|
||||
"${CUDAToolkit_LIBRARY_DIR}/libcudart.so*"
|
||||
"${CUDAToolkit_LIBRARY_DIR}/libcublas.so*"
|
||||
"${CUDAToolkit_LIBRARY_DIR}/libcublasLt.so*"
|
||||
"${CUDAToolkit_LIBRARY_DIR}/libnvrtc.so*"
|
||||
"${CUDAToolkit_LIBRARY_DIR}/libnvrtc-builtins.so*"
|
||||
"${CUDAToolkit_LIBRARY_DIR}/libcufft.so*"
|
||||
"${CUDAToolkit_LIBRARY_DIR}/libcudnn.so*")
|
||||
if(MLX_CUDA_LIBS)
|
||||
install(FILES ${MLX_CUDA_LIBS}
|
||||
DESTINATION ${OLLAMA_INSTALL_DIR}
|
||||
COMPONENT MLX_VENDOR)
|
||||
endif()
|
||||
endif()
|
||||
if(EXISTS "${CMAKE_CURRENT_SOURCE_DIR}/llama/server/CMakeLists.txt")
|
||||
set(OLLAMA_HAVE_LLAMA_SERVER TRUE)
|
||||
else()
|
||||
set(OLLAMA_HAVE_LLAMA_SERVER FALSE)
|
||||
endif()
|
||||
include(${CMAKE_CURRENT_SOURCE_DIR}/cmake/local.cmake)
|
||||
@@ -11,109 +11,10 @@
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "CPU",
|
||||
"inherits": [ "Default" ]
|
||||
},
|
||||
{
|
||||
"name": "CUDA",
|
||||
"inherits": [ "Default" ]
|
||||
},
|
||||
{
|
||||
"name": "CUDA 11",
|
||||
"inherits": [ "CUDA" ],
|
||||
"cacheVariables": {
|
||||
"CMAKE_CUDA_ARCHITECTURES": "50-virtual;60-virtual;61-virtual;70-virtual;75-virtual;80-virtual;86-virtual;87-virtual;89-virtual;90-virtual",
|
||||
"CMAKE_CUDA_FLAGS": "-Wno-deprecated-gpu-targets -t 2",
|
||||
"OLLAMA_RUNNER_DIR": "cuda_v11"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "CUDA 12",
|
||||
"inherits": [ "CUDA" ],
|
||||
"cacheVariables": {
|
||||
"CMAKE_CUDA_ARCHITECTURES": "50;52;60;61;70;75;80;86;89;90;90a;120",
|
||||
"CMAKE_CUDA_FLAGS": "-Wno-deprecated-gpu-targets -t 2",
|
||||
"OLLAMA_RUNNER_DIR": "cuda_v12"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "CUDA 13",
|
||||
"inherits": [ "CUDA" ],
|
||||
"cacheVariables": {
|
||||
"CMAKE_CUDA_ARCHITECTURES": "75-virtual;80-virtual;86-virtual;87-virtual;89-virtual;90-virtual;90a-virtual;100-virtual;103-virtual;110-virtual;120-virtual;121-virtual",
|
||||
"CMAKE_CUDA_FLAGS": "-t 2",
|
||||
"OLLAMA_RUNNER_DIR": "cuda_v13"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "JetPack 5",
|
||||
"inherits": [ "CUDA" ],
|
||||
"cacheVariables": {
|
||||
"CMAKE_CUDA_ARCHITECTURES": "72;87",
|
||||
"OLLAMA_RUNNER_DIR": "cuda_jetpack5"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "JetPack 6",
|
||||
"inherits": [ "CUDA" ],
|
||||
"cacheVariables": {
|
||||
"CMAKE_CUDA_ARCHITECTURES": "87",
|
||||
"OLLAMA_RUNNER_DIR": "cuda_jetpack6"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "ROCm",
|
||||
"name": "MLX Metal",
|
||||
"inherits": [ "Default" ],
|
||||
"cacheVariables": {
|
||||
"CMAKE_HIP_PLATFORM": "amd"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "ROCm 6",
|
||||
"inherits": [ "ROCm" ],
|
||||
"cacheVariables": {
|
||||
"CMAKE_HIP_FLAGS": "-parallel-jobs=4",
|
||||
"AMDGPU_TARGETS": "gfx940;gfx941;gfx942;gfx1010;gfx1012;gfx1030;gfx1100;gfx1101;gfx1102;gfx1151;gfx1200;gfx1201;gfx908:xnack-;gfx90a:xnack+;gfx90a:xnack-",
|
||||
"OLLAMA_RUNNER_DIR": "rocm"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "ROCm 7",
|
||||
"inherits": [ "ROCm" ],
|
||||
"cacheVariables": {
|
||||
"CMAKE_HIP_FLAGS": "-parallel-jobs=4",
|
||||
"AMDGPU_TARGETS": "gfx942;gfx950;gfx1010;gfx1012;gfx1030;gfx1100;gfx1101;gfx1102;gfx1103;gfx1150;gfx1151;gfx1200;gfx1201;gfx908:xnack-;gfx90a:xnack+;gfx90a:xnack-",
|
||||
"OLLAMA_RUNNER_DIR": "rocm"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "Vulkan",
|
||||
"inherits": [ "Default" ],
|
||||
"cacheVariables": {
|
||||
"OLLAMA_RUNNER_DIR": "vulkan"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "MLX",
|
||||
"inherits": [ "Default" ],
|
||||
"cacheVariables": {
|
||||
"MLX_ENGINE": "ON",
|
||||
"OLLAMA_RUNNER_DIR": "mlx"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "MLX CUDA 12",
|
||||
"inherits": [ "MLX", "CUDA 12" ],
|
||||
"cacheVariables": {
|
||||
"OLLAMA_RUNNER_DIR": "mlx_cuda_v12"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "MLX CUDA 13",
|
||||
"inherits": [ "MLX", "CUDA 13" ],
|
||||
"cacheVariables": {
|
||||
"MLX_CUDA_ARCHITECTURES": "75-virtual;80-virtual;86-virtual;89-virtual;90-virtual;90a-virtual;100-virtual;103-virtual;110-virtual;120-virtual;121-virtual",
|
||||
"OLLAMA_RUNNER_DIR": "mlx_cuda_v13"
|
||||
"OLLAMA_MLX_BACKENDS": "metal_v3;metal_v4"
|
||||
}
|
||||
}
|
||||
],
|
||||
@@ -124,74 +25,9 @@
|
||||
"configuration": "Release"
|
||||
},
|
||||
{
|
||||
"name": "CPU",
|
||||
"configurePreset": "Default",
|
||||
"targets": [ "ggml-cpu" ]
|
||||
},
|
||||
{
|
||||
"name": "CUDA",
|
||||
"configurePreset": "CUDA",
|
||||
"targets": [ "ggml-cuda" ]
|
||||
},
|
||||
{
|
||||
"name": "CUDA 11",
|
||||
"inherits": [ "CUDA" ],
|
||||
"configurePreset": "CUDA 11"
|
||||
},
|
||||
{
|
||||
"name": "CUDA 12",
|
||||
"inherits": [ "CUDA" ],
|
||||
"configurePreset": "CUDA 12"
|
||||
},
|
||||
{
|
||||
"name": "CUDA 13",
|
||||
"inherits": [ "CUDA" ],
|
||||
"configurePreset": "CUDA 13"
|
||||
},
|
||||
{
|
||||
"name": "JetPack 5",
|
||||
"inherits": [ "CUDA" ],
|
||||
"configurePreset": "JetPack 5"
|
||||
},
|
||||
{
|
||||
"name": "JetPack 6",
|
||||
"inherits": [ "CUDA" ],
|
||||
"configurePreset": "JetPack 6"
|
||||
},
|
||||
{
|
||||
"name": "ROCm",
|
||||
"configurePreset": "ROCm",
|
||||
"targets": [ "ggml-hip" ]
|
||||
},
|
||||
{
|
||||
"name": "ROCm 6",
|
||||
"inherits": [ "ROCm" ],
|
||||
"configurePreset": "ROCm 6"
|
||||
},
|
||||
{
|
||||
"name": "ROCm 7",
|
||||
"inherits": [ "ROCm" ],
|
||||
"configurePreset": "ROCm 7"
|
||||
},
|
||||
{
|
||||
"name": "Vulkan",
|
||||
"targets": [ "ggml-vulkan" ],
|
||||
"configurePreset": "Vulkan"
|
||||
},
|
||||
{
|
||||
"name": "MLX",
|
||||
"targets": [ "mlx", "mlxc" ],
|
||||
"configurePreset": "MLX"
|
||||
},
|
||||
{
|
||||
"name": "MLX CUDA 12",
|
||||
"targets": [ "mlx", "mlxc" ],
|
||||
"configurePreset": "MLX CUDA 12"
|
||||
},
|
||||
{
|
||||
"name": "MLX CUDA 13",
|
||||
"targets": [ "mlx", "mlxc" ],
|
||||
"configurePreset": "MLX CUDA 13"
|
||||
"name": "MLX Metal",
|
||||
"targets": [ "ollama-mlx-backends" ],
|
||||
"configurePreset": "MLX Metal"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -15,9 +15,9 @@ 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-11-gcc gcc-toolset-11-gcc-c++ gcc-toolset-11-binutils \
|
||||
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-11/root/usr/bin:$PATH
|
||||
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
|
||||
@@ -37,116 +37,178 @@ RUN dnf install -y unzip \
|
||||
ENV CMAKE_GENERATOR=Ninja
|
||||
ENV LDFLAGS=-s
|
||||
|
||||
FROM base AS cpu
|
||||
RUN dnf install -y gcc-toolset-11-gcc gcc-toolset-11-gcc-c++
|
||||
ENV PATH=/opt/rh/gcc-toolset-11/root/usr/bin:$PATH
|
||||
COPY CMakeLists.txt CMakePresets.json .
|
||||
COPY ml/backend/ggml/ggml ml/backend/ggml/ggml
|
||||
RUN --mount=type=cache,target=/root/.ccache \
|
||||
cmake --preset 'CPU' \
|
||||
&& cmake --build --preset 'CPU' -- -l $(nproc) \
|
||||
&& cmake --install build --component CPU --strip
|
||||
#
|
||||
# GPU toolchain stages — provide compilers for llama-server GPU builds
|
||||
#
|
||||
|
||||
FROM base AS cuda-11
|
||||
ARG CUDA11VERSION=11.8
|
||||
RUN dnf install -y cuda-toolkit-${CUDA11VERSION//./-}
|
||||
ENV PATH=/usr/local/cuda-11/bin:$PATH
|
||||
COPY CMakeLists.txt CMakePresets.json .
|
||||
COPY ml/backend/ggml/ggml ml/backend/ggml/ggml
|
||||
RUN --mount=type=cache,target=/root/.ccache \
|
||||
cmake --preset 'CUDA 11' \
|
||||
&& cmake --build --preset 'CUDA 11' -- -l $(nproc) \
|
||||
&& cmake --install build --component CUDA --strip
|
||||
FROM base AS 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
|
||||
FROM base AS cuda-12-deps
|
||||
ARG CUDA12VERSION=12.8
|
||||
RUN dnf install -y cuda-toolkit-${CUDA12VERSION//./-}
|
||||
ENV PATH=/usr/local/cuda-12/bin:$PATH
|
||||
COPY CMakeLists.txt CMakePresets.json .
|
||||
COPY ml/backend/ggml/ggml ml/backend/ggml/ggml
|
||||
RUN --mount=type=cache,target=/root/.ccache \
|
||||
cmake --preset 'CUDA 12' \
|
||||
&& cmake --build --preset 'CUDA 12' -- -l $(nproc) \
|
||||
&& cmake --install build --component CUDA --strip
|
||||
|
||||
|
||||
FROM base AS cuda-13
|
||||
FROM base AS cuda-13-deps
|
||||
ARG CUDA13VERSION=13.0
|
||||
RUN dnf install -y cuda-toolkit-${CUDA13VERSION//./-}
|
||||
ENV PATH=/usr/local/cuda-13/bin:$PATH
|
||||
COPY CMakeLists.txt CMakePresets.json .
|
||||
COPY ml/backend/ggml/ggml ml/backend/ggml/ggml
|
||||
RUN --mount=type=cache,target=/root/.ccache \
|
||||
cmake --preset 'CUDA 13' \
|
||||
&& cmake --build --preset 'CUDA 13' -- -l $(nproc) \
|
||||
&& cmake --install build --component CUDA --strip
|
||||
|
||||
FROM base AS rocm-7-deps
|
||||
ENV PATH=/opt/rocm/llvm/bin:/opt/rocm/hcc/bin:/opt/rocm/hip/bin:/opt/rocm/bin:$PATH
|
||||
|
||||
FROM base AS rocm-7
|
||||
ENV PATH=/opt/rocm/hcc/bin:/opt/rocm/hip/bin:/opt/rocm/bin:/opt/rocm/hcc/bin:$PATH
|
||||
COPY CMakeLists.txt CMakePresets.json .
|
||||
COPY ml/backend/ggml/ggml ml/backend/ggml/ggml
|
||||
RUN --mount=type=cache,target=/root/.ccache \
|
||||
cmake --preset 'ROCm 7' \
|
||||
&& cmake --build --preset 'ROCm 7' -- -l $(nproc) \
|
||||
&& cmake --install build --component HIP --strip
|
||||
RUN rm -f dist/lib/ollama/rocm/rocblas/library/*gfx90[06]*
|
||||
|
||||
FROM --platform=linux/arm64 nvcr.io/nvidia/l4t-jetpack:${JETPACK5VERSION} AS jetpack-5
|
||||
ARG CMAKEVERSION
|
||||
ARG NINJAVERSION
|
||||
RUN apt-get update && apt-get install -y curl ccache unzip \
|
||||
&& curl -fsSL https://github.com/Kitware/CMake/releases/download/v${CMAKEVERSION}/cmake-${CMAKEVERSION}-linux-$(uname -m).tar.gz | tar xz -C /usr/local --strip-components 1 \
|
||||
&& curl -fsSL -o /tmp/ninja.zip https://github.com/ninja-build/ninja/releases/download/v${NINJAVERSION}/ninja-linux-aarch64.zip \
|
||||
&& unzip /tmp/ninja.zip -d /usr/local/bin \
|
||||
&& rm /tmp/ninja.zip
|
||||
ENV CMAKE_GENERATOR=Ninja
|
||||
COPY CMakeLists.txt CMakePresets.json .
|
||||
COPY ml/backend/ggml/ggml ml/backend/ggml/ggml
|
||||
RUN --mount=type=cache,target=/root/.ccache \
|
||||
cmake --preset 'JetPack 5' \
|
||||
&& cmake --build --preset 'JetPack 5' -- -l $(nproc) \
|
||||
&& cmake --install build --component CUDA --strip
|
||||
|
||||
FROM --platform=linux/arm64 nvcr.io/nvidia/l4t-jetpack:${JETPACK6VERSION} AS jetpack-6
|
||||
ARG CMAKEVERSION
|
||||
ARG NINJAVERSION
|
||||
RUN apt-get update && apt-get install -y curl ccache unzip \
|
||||
&& curl -fsSL https://github.com/Kitware/CMake/releases/download/v${CMAKEVERSION}/cmake-${CMAKEVERSION}-linux-$(uname -m).tar.gz | tar xz -C /usr/local --strip-components 1 \
|
||||
&& curl -fsSL -o /tmp/ninja.zip https://github.com/ninja-build/ninja/releases/download/v${NINJAVERSION}/ninja-linux-aarch64.zip \
|
||||
&& unzip /tmp/ninja.zip -d /usr/local/bin \
|
||||
&& rm /tmp/ninja.zip
|
||||
ENV CMAKE_GENERATOR=Ninja
|
||||
COPY CMakeLists.txt CMakePresets.json .
|
||||
COPY ml/backend/ggml/ggml ml/backend/ggml/ggml
|
||||
RUN --mount=type=cache,target=/root/.ccache \
|
||||
cmake --preset 'JetPack 6' \
|
||||
&& cmake --build --preset 'JetPack 6' -- -l $(nproc) \
|
||||
&& cmake --install build --component CUDA --strip
|
||||
|
||||
FROM base AS vulkan
|
||||
FROM base AS vulkan-deps
|
||||
ARG VULKANVERSION
|
||||
RUN ln -s /usr/bin/python3 /usr/bin/python \
|
||||
&& wget https://sdk.lunarg.com/sdk/download/${VULKANVERSION}/linux/vulkansdk-linux-x86_64-${VULKANVERSION}.tar.xz -O /tmp/vulkansdk.tar.xz \
|
||||
&& tar xvf /tmp/vulkansdk.tar.xz -C /tmp \
|
||||
&& /tmp/${VULKANVERSION}/vulkansdk -j 8 vulkan-headers \
|
||||
&& /tmp/${VULKANVERSION}/vulkansdk -j 8 spirv-headers \
|
||||
&& /tmp/${VULKANVERSION}/vulkansdk -j 8 shaderc \
|
||||
&& cp -r /tmp/${VULKANVERSION}/x86_64/include/* /usr/local/include/ \
|
||||
&& cp -r /tmp/${VULKANVERSION}/x86_64/lib/* /usr/local/lib \
|
||||
&& cp -r /tmp/${VULKANVERSION}/x86_64/share/* /usr/local/share/ \
|
||||
&& cp -r /tmp/${VULKANVERSION}/x86_64/bin/* /usr/local/bin/ \
|
||||
&& rm -rf /tmp/${VULKANVERSION} /tmp/vulkansdk.tar.xz
|
||||
COPY CMakeLists.txt CMakePresets.json .
|
||||
COPY ml/backend/ggml/ggml ml/backend/ggml/ggml
|
||||
ENV VULKAN_SDK=/usr/local
|
||||
|
||||
#
|
||||
# llama-server stages — rebuild when LLAMA_CPP_VERSION, llama/server/, llama/compat/, or cmake/ 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
|
||||
COPY cmake cmake
|
||||
RUN --mount=type=cache,target=/root/.ccache \
|
||||
cmake --preset 'Vulkan' \
|
||||
&& cmake --build --preset 'Vulkan' -- -l $(nproc) \
|
||||
&& cmake --install build --component Vulkan --strip
|
||||
cmake -S llama/server --preset cpu \
|
||||
&& cmake --build build/llama-server-cpu -- -l $(nproc) \
|
||||
&& cmake --install build/llama-server-cpu --component llama-server --strip \
|
||||
&& for lib in \
|
||||
/usr/lib64/libgomp.so* \
|
||||
/usr/lib64/libomp.so* \
|
||||
/opt/rh/gcc-toolset-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
|
||||
COPY cmake cmake
|
||||
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
|
||||
COPY cmake cmake
|
||||
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
|
||||
COPY cmake cmake
|
||||
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
|
||||
COPY cmake cmake
|
||||
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
|
||||
COPY cmake cmake
|
||||
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
|
||||
COPY cmake cmake
|
||||
RUN --mount=type=cache,target=/root/.ccache \
|
||||
cmake -S llama/server --preset llama_cuda_jetpack6 \
|
||||
&& cmake --build build/llama-server-cuda_jetpack6 -- -l $(nproc) \
|
||||
&& cmake --install build/llama-server-cuda_jetpack6 --component llama-server --strip
|
||||
|
||||
FROM scratch AS publish-llama-server-cuda_jetpack6
|
||||
COPY --from=jetpack-6 dist/lib/ollama /lib/ollama/
|
||||
|
||||
#
|
||||
# MLX stage
|
||||
#
|
||||
|
||||
FROM base AS mlx
|
||||
ARG CUDA13VERSION=13.0
|
||||
# OLLAMA_MLX_BUILD_JOBS empty -> ninja gates by load average (-l $(nproc))
|
||||
ARG OLLAMA_MLX_BUILD_JOBS=
|
||||
ARG OLLAMA_MLX_NVCC_THREADS=2
|
||||
ARG MLX_CUDA_RAM_MB=
|
||||
RUN dnf install -y cuda-toolkit-${CUDA13VERSION//./-} \
|
||||
&& dnf install -y openblas-devel lapack-devel \
|
||||
&& dnf install -y libcudnn9-cuda-13 libcudnn9-devel-cuda-13 \
|
||||
@@ -157,8 +219,9 @@ ENV LAPACK_INCLUDE_DIRS=/usr/include/openblas
|
||||
ENV CGO_LDFLAGS="-L/usr/local/cuda-13/lib64 -L/usr/local/cuda-13/targets/x86_64-linux/lib/stubs"
|
||||
WORKDIR /go/src/github.com/ollama/ollama
|
||||
COPY CMakeLists.txt CMakePresets.json .
|
||||
COPY ml/backend/ggml/ggml ml/backend/ggml/ggml
|
||||
COPY x/imagegen/mlx x/imagegen/mlx
|
||||
COPY cmake cmake
|
||||
COPY x/mlxrunner/mlx x/mlxrunner/mlx
|
||||
COPY x/mlxrunner/xgrammar/native x/mlxrunner/xgrammar/native
|
||||
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
|
||||
@@ -173,10 +236,15 @@ RUN --mount=type=cache,target=/root/.ccache \
|
||||
&& if [ -f /tmp/local-mlx-c/CMakeLists.txt ]; then \
|
||||
export OLLAMA_MLX_C_SOURCE=/tmp/local-mlx-c; \
|
||||
fi \
|
||||
&& cmake --preset 'MLX CUDA 13' -DBLAS_INCLUDE_DIRS=/usr/include/openblas -DLAPACK_INCLUDE_DIRS=/usr/include/openblas -DCMAKE_CUDA_FLAGS="-t ${OLLAMA_MLX_NVCC_THREADS}" \
|
||||
&& cmake --build --preset 'MLX CUDA 13' -- -l $(nproc) ${OLLAMA_MLX_BUILD_JOBS:+-j ${OLLAMA_MLX_BUILD_JOBS}} \
|
||||
&& cmake --install build --component MLX --strip \
|
||||
&& cmake --install build --component MLX_VENDOR
|
||||
&& cmake -S . -B build/mlx_cuda_v13 -DOLLAMA_MLX_BACKENDS=cuda_v13 -DBLAS_INCLUDE_DIRS=/usr/include/openblas -DLAPACK_INCLUDE_DIRS=/usr/include/openblas -DCMAKE_CUDA_FLAGS="-t ${OLLAMA_MLX_NVCC_THREADS}" ${MLX_CUDA_RAM_MB:+-DMLX_CUDA_RAM_MB=${MLX_CUDA_RAM_MB}} -DOLLAMA_PAYLOAD_INSTALL_PREFIX=/go/src/github.com/ollama/ollama/dist \
|
||||
&& cmake --build build/mlx_cuda_v13 --target ollama-mlx-cuda_v13 -- -l $(nproc) ${OLLAMA_MLX_BUILD_JOBS:+-j ${OLLAMA_MLX_BUILD_JOBS}}
|
||||
|
||||
FROM scratch AS publish-mlx
|
||||
COPY --from=mlx /go/src/github.com/ollama/ollama/dist/lib/ollama /lib/ollama/
|
||||
|
||||
#
|
||||
# Go build
|
||||
#
|
||||
|
||||
FROM base AS build
|
||||
WORKDIR /go/src/github.com/ollama/ollama
|
||||
@@ -193,39 +261,71 @@ 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 .
|
||||
RUN --mount=type=cache,target=/root/.cache/go-build \
|
||||
cmake -S . -B build/go-license \
|
||||
-DOLLAMA_LLAMA_BACKENDS= \
|
||||
-DOLLAMA_MLX_BACKENDS= \
|
||||
&& cmake --build build/go-license --target ollama-go-license
|
||||
|
||||
FROM scratch AS publish-go
|
||||
COPY --from=build /bin/ollama /bin/ollama
|
||||
COPY --from=build /go/src/github.com/ollama/ollama/build/go-license/lib/ollama/GO_LICENSE /lib/ollama/GO_LICENSE
|
||||
|
||||
#
|
||||
# Assembly stages — combine llama-server variants + GPU runtime libs
|
||||
#
|
||||
|
||||
FROM --platform=linux/amd64 scratch AS amd64
|
||||
# COPY --from=cuda-11 dist/lib/ollama/ /lib/ollama/
|
||||
COPY --from=cuda-12 dist/lib/ollama /lib/ollama/
|
||||
COPY --from=cuda-13 dist/lib/ollama /lib/ollama/
|
||||
COPY --from=vulkan dist/lib/ollama /lib/ollama/
|
||||
COPY --from=llama-server-cpu dist/lib/ollama /lib/ollama/
|
||||
COPY --from=llama-server-cuda_v12 dist/lib/ollama /lib/ollama/
|
||||
COPY --from=llama-server-cuda_v13 dist/lib/ollama /lib/ollama/
|
||||
COPY --from=llama-server-vulkan dist/lib/ollama /lib/ollama/
|
||||
COPY --from=mlx /go/src/github.com/ollama/ollama/dist/lib/ollama /lib/ollama/
|
||||
|
||||
FROM --platform=linux/arm64 scratch AS arm64
|
||||
# COPY --from=cuda-11 dist/lib/ollama/ /lib/ollama/
|
||||
COPY --from=cuda-12 dist/lib/ollama /lib/ollama/
|
||||
COPY --from=cuda-13 dist/lib/ollama/ /lib/ollama/
|
||||
COPY --from=llama-server-cpu dist/lib/ollama /lib/ollama/
|
||||
COPY --from=llama-server-cuda_v12 dist/lib/ollama /lib/ollama/
|
||||
COPY --from=llama-server-cuda_v13 dist/lib/ollama /lib/ollama/
|
||||
COPY --from=jetpack-5 dist/lib/ollama/ /lib/ollama/
|
||||
COPY --from=jetpack-6 dist/lib/ollama/ /lib/ollama/
|
||||
|
||||
FROM scratch AS rocm
|
||||
COPY --from=rocm-7 dist/lib/ollama /lib/ollama
|
||||
COPY --from=llama-server-cpu dist/lib/ollama /lib/ollama
|
||||
COPY --from=llama-server-rocm_v7_2 dist/lib/ollama /lib/ollama
|
||||
|
||||
FROM ${FLAVOR} AS archive
|
||||
COPY --from=cpu dist/lib/ollama /lib/ollama
|
||||
FROM --platform=linux/amd64 scratch AS amd64-archive
|
||||
COPY --from=amd64 /lib/ollama /lib/ollama/
|
||||
COPY --from=llama-server-rocm_v7_2 dist/lib/ollama /lib/ollama/
|
||||
|
||||
FROM --platform=linux/arm64 scratch AS arm64-archive
|
||||
COPY --from=arm64 /lib/ollama /lib/ollama/
|
||||
|
||||
FROM ${TARGETARCH}-archive AS archive
|
||||
COPY --from=build /bin/ollama /bin/ollama
|
||||
COPY --from=build /go/src/github.com/ollama/ollama/build/go-license/lib/ollama/GO_LICENSE /lib/ollama/GO_LICENSE
|
||||
|
||||
FROM ${FLAVOR} AS image-archive
|
||||
COPY --from=build /bin/ollama /bin/ollama
|
||||
COPY --from=build /go/src/github.com/ollama/ollama/build/go-license/lib/ollama/GO_LICENSE /lib/ollama/GO_LICENSE
|
||||
|
||||
FROM ubuntu:24.04
|
||||
ARG APT_MIRROR=http://archive.ubuntu.com/ubuntu
|
||||
RUN sed -i "s|http://archive.ubuntu.com/ubuntu|$APT_MIRROR|g" /etc/apt/sources.list.d/ubuntu.sources \
|
||||
ARG APT_PORTS_MIRROR=http://ports.ubuntu.com/ubuntu-ports
|
||||
RUN sed -i \
|
||||
-e "s|http://archive.ubuntu.com/ubuntu|$APT_MIRROR|g" \
|
||||
-e "s|http://ports.ubuntu.com/ubuntu-ports|$APT_PORTS_MIRROR|g" \
|
||||
/etc/apt/sources.list.d/ubuntu.sources \
|
||||
&& apt-get update \
|
||||
&& apt-get install -y ca-certificates libvulkan1 libopenblas0 \
|
||||
&& sed -i "s|$APT_MIRROR|http://archive.ubuntu.com/ubuntu|g" /etc/apt/sources.list.d/ubuntu.sources \
|
||||
&& sed -i \
|
||||
-e "s|$APT_MIRROR|http://archive.ubuntu.com/ubuntu|g" \
|
||||
-e "s|$APT_PORTS_MIRROR|http://ports.ubuntu.com/ubuntu-ports|g" \
|
||||
/etc/apt/sources.list.d/ubuntu.sources \
|
||||
&& apt-get clean \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
COPY --from=archive /bin /usr/bin
|
||||
COPY --from=image-archive /bin /usr/bin
|
||||
ENV PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
|
||||
COPY --from=archive /lib/ollama /usr/lib/ollama
|
||||
COPY --from=image-archive /lib/ollama /usr/lib/ollama
|
||||
ENV LD_LIBRARY_PATH=/usr/local/nvidia/lib:/usr/local/nvidia/lib64
|
||||
ENV NVIDIA_DRIVER_CAPABILITIES=compute,utility
|
||||
ENV NVIDIA_VISIBLE_DEVICES=all
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
b10760
|
||||
@@ -1 +1 @@
|
||||
fba4470b89073180056c9ea46c443051375f7399
|
||||
c74db5307cc8ce122f48d97ef951b30578674e7f
|
||||
@@ -1 +1 @@
|
||||
e8ebdebeeb655feaa85a51f6b24ece5b6d5518d1
|
||||
37c26e5755da637255d57ea34b4879196a485301
|
||||
@@ -1,76 +0,0 @@
|
||||
UPSTREAM=https://github.com/ggml-org/llama.cpp.git
|
||||
WORKDIR=llama/vendor
|
||||
FETCH_HEAD=ec98e2002
|
||||
|
||||
.PHONY: help
|
||||
help:
|
||||
@echo "Available targets:"
|
||||
@echo " sync Sync with upstream repositories"
|
||||
@echo " checkout Checkout upstream repository"
|
||||
@echo " apply-patches Apply patches to local repository"
|
||||
@echo " format-patches Format patches from local repository"
|
||||
@echo " clean Clean local repository"
|
||||
@echo
|
||||
@echo "Example:"
|
||||
@echo " make -f $(lastword $(MAKEFILE_LIST)) clean apply-patches sync"
|
||||
|
||||
.PHONY: sync
|
||||
sync: llama/build-info.cpp ml/backend/ggml/ggml/src/ggml-metal/ggml-metal-embed.metal
|
||||
|
||||
llama/build-info.cpp: llama/build-info.cpp.in llama/llama.cpp
|
||||
sed -e 's|@FETCH_HEAD@|$(FETCH_HEAD)|' <$< >$@
|
||||
|
||||
ml/backend/ggml/ggml/src/ggml-metal/ggml-metal-embed.metal: ml/backend/ggml/ggml
|
||||
go generate ./$(@D)
|
||||
|
||||
.PHONY: llama/llama.cpp
|
||||
llama/llama.cpp: llama/vendor
|
||||
rsync -arvzc --delete -f "include LICENSE" -f "merge $@/.rsync-filter" $(addprefix $<,/LICENSE /) $@
|
||||
|
||||
.PHONY: ml/backend/ggml/ggml
|
||||
ml/backend/ggml/ggml: llama/vendor
|
||||
rsync -arvzc --delete -f "include LICENSE" -f "merge $@/.rsync-filter" $(addprefix $<,/LICENSE /ggml/) $@
|
||||
|
||||
PATCHES=$(wildcard llama/patches/*.patch)
|
||||
PATCHED=$(join $(dir $(PATCHES)), $(addsuffix ed, $(addprefix ., $(notdir $(PATCHES)))))
|
||||
|
||||
.PHONY: apply-patches
|
||||
.NOTPARALLEL:
|
||||
apply-patches: $(PATCHED)
|
||||
|
||||
llama/patches/.%.patched: llama/patches/%.patch
|
||||
@if git -c user.name=nobody -c 'user.email=<>' -C $(WORKDIR) am -3 $(realpath $<); then \
|
||||
touch $@; \
|
||||
else \
|
||||
echo "Patch failed. Resolve any conflicts then continue."; \
|
||||
echo "1. Run 'git -C $(WORKDIR) am --continue'"; \
|
||||
echo "2. Run 'make -f $(lastword $(MAKEFILE_LIST)) format-patches'"; \
|
||||
echo "3. Run 'make -f $(lastword $(MAKEFILE_LIST)) clean apply-patches'"; \
|
||||
exit 1; \
|
||||
fi
|
||||
|
||||
.PHONY: checkout
|
||||
checkout: $(WORKDIR)
|
||||
git -C $(WORKDIR) fetch
|
||||
git -C $(WORKDIR) checkout -f $(FETCH_HEAD)
|
||||
|
||||
$(WORKDIR):
|
||||
git clone $(UPSTREAM) $(WORKDIR)
|
||||
|
||||
.PHONY: format-patches
|
||||
format-patches: llama/patches
|
||||
git -C $(WORKDIR) format-patch \
|
||||
--no-signature \
|
||||
--no-numbered \
|
||||
--zero-commit \
|
||||
-o $(realpath $<) \
|
||||
$(FETCH_HEAD)
|
||||
|
||||
.PHONY: clean
|
||||
clean: checkout
|
||||
@git -C $(WORKDIR) am --abort || true
|
||||
$(RM) llama/patches/.*.patched
|
||||
|
||||
.PHONY: print-base
|
||||
print-base:
|
||||
@echo $(FETCH_HEAD)
|
||||
@@ -65,7 +65,7 @@ To launch a specific integration:
|
||||
ollama launch claude
|
||||
```
|
||||
|
||||
Supported integrations include [Claude Code](https://docs.ollama.com/integrations/claude-code), [Codex](https://docs.ollama.com/integrations/codex), [Copilot CLI](https://docs.ollama.com/integrations/copilot-cli), [Droid](https://docs.ollama.com/integrations/droid), and [OpenCode](https://docs.ollama.com/integrations/opencode).
|
||||
Supported integrations include [Claude Code](https://docs.ollama.com/integrations/claude-code), [Codex](https://docs.ollama.com/integrations/codex), [Copilot CLI](https://docs.ollama.com/integrations/copilot-cli), [DeepSeek Harness](https://docs.ollama.com/integrations/deepseek-harness), [Droid](https://docs.ollama.com/integrations/droid), and [OpenCode](https://docs.ollama.com/integrations/opencode).
|
||||
|
||||
### AI assistant
|
||||
|
||||
@@ -77,10 +77,10 @@ ollama launch openclaw
|
||||
|
||||
### Chat with a model
|
||||
|
||||
Run and chat with [Gemma 3](https://ollama.com/library/gemma3):
|
||||
Run and chat with [Gemma 4](https://ollama.com/library/gemma4):
|
||||
|
||||
```
|
||||
ollama run gemma3
|
||||
ollama run gemma4
|
||||
```
|
||||
|
||||
See [ollama.com/library](https://ollama.com/library) for the full list.
|
||||
@@ -93,7 +93,7 @@ Ollama has a REST API for running and managing models.
|
||||
|
||||
```
|
||||
curl http://localhost:11434/api/chat -d '{
|
||||
"model": "gemma3",
|
||||
"model": "gemma4",
|
||||
"messages": [{
|
||||
"role": "user",
|
||||
"content": "Why is the sky blue?"
|
||||
@@ -113,7 +113,7 @@ pip install ollama
|
||||
```python
|
||||
from ollama import chat
|
||||
|
||||
response = chat(model='gemma3', messages=[
|
||||
response = chat(model='gemma4', messages=[
|
||||
{
|
||||
'role': 'user',
|
||||
'content': 'Why is the sky blue?',
|
||||
@@ -132,7 +132,7 @@ npm i ollama
|
||||
import ollama from "ollama";
|
||||
|
||||
const response = await ollama.chat({
|
||||
model: "gemma3",
|
||||
model: "gemma4",
|
||||
messages: [{ role: "user", content: "Why is the sky blue?" }],
|
||||
});
|
||||
console.log(response.message.content);
|
||||
|
||||
@@ -0,0 +1,198 @@
|
||||
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)
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,667 @@
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,773 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,177 @@
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,813 @@
|
||||
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")
|
||||
}
|
||||
@@ -0,0 +1,516 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
---
|
||||
name: release-notes
|
||||
description: Draft concise release notes.
|
||||
---
|
||||
|
||||
# Release notes
|
||||
|
||||
Use short bullets.
|
||||
@@ -0,0 +1,450 @@
|
||||
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)
|
||||
}
|
||||
@@ -0,0 +1,258 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
//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
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
//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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
//go:build windows
|
||||
|
||||
package tools
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os/exec"
|
||||
"strings"
|
||||
"sync"
|
||||
"unsafe"
|
||||
|
||||
"golang.org/x/sys/windows"
|
||||
)
|
||||
|
||||
var bashJobHandles sync.Map
|
||||
|
||||
func shellToolName() string {
|
||||
return "powershell"
|
||||
}
|
||||
|
||||
func shellToolDescription() string {
|
||||
return "Execute a PowerShell command on the system. Use this to inspect files, run tests, and perform development tasks."
|
||||
}
|
||||
|
||||
func shellCommandDescription() string {
|
||||
return "The PowerShell command to execute."
|
||||
}
|
||||
|
||||
func newBashCommand(ctx context.Context, command, cwdPath string) *exec.Cmd {
|
||||
return exec.CommandContext(
|
||||
ctx,
|
||||
"powershell.exe",
|
||||
"-NoLogo",
|
||||
"-NoProfile",
|
||||
"-NonInteractive",
|
||||
"-ExecutionPolicy",
|
||||
"Bypass",
|
||||
"-Command",
|
||||
powerShellCommandScript(command, cwdPath),
|
||||
)
|
||||
}
|
||||
|
||||
func powerShellCommandScript(command, cwdPath string) string {
|
||||
cwdPath = powerShellSingleQuote(cwdPath)
|
||||
return strings.Join([]string{
|
||||
"$__ollama_status = 0",
|
||||
". {",
|
||||
"try {",
|
||||
command,
|
||||
" $__ollama_success = $?",
|
||||
" $__ollama_last_exit = $global:LASTEXITCODE",
|
||||
" if ($__ollama_success) {",
|
||||
" $__ollama_status = 0",
|
||||
" } elseif ($__ollama_last_exit -is [int] -and $__ollama_last_exit -ne 0) {",
|
||||
" $__ollama_status = $__ollama_last_exit",
|
||||
" } else {",
|
||||
" $__ollama_status = 1",
|
||||
" }",
|
||||
"} catch {",
|
||||
" Write-Error $_",
|
||||
" $__ollama_status = 1",
|
||||
"} finally {",
|
||||
" try { [System.IO.File]::WriteAllText(" + cwdPath + ", (Get-Location).ProviderPath, [System.Text.Encoding]::UTF8) } catch {}",
|
||||
"}",
|
||||
"} | Out-String -Stream -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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
//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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,711 @@
|
||||
package tools
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"cmp"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"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. Pass multiple edits to change separate parts of the file in one call."
|
||||
}
|
||||
|
||||
func (e *Edit) Schema() api.ToolFunction {
|
||||
editProps := api.NewToolPropertiesMap()
|
||||
editProps.Set("old_text", api.ToolProperty{
|
||||
Type: api.PropertyType{"string"},
|
||||
Description: "Exact text for one targeted replacement. Must match the original file exactly once and must not overlap with any other edit's old_text.",
|
||||
})
|
||||
editProps.Set("new_text", api.ToolProperty{
|
||||
Type: api.PropertyType{"string"},
|
||||
Description: "Replacement text for this targeted edit.",
|
||||
})
|
||||
|
||||
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("edits", api.ToolProperty{
|
||||
Type: api.PropertyType{"array"},
|
||||
Items: api.ToolProperty{
|
||||
Type: api.PropertyType{"object"},
|
||||
Properties: editProps,
|
||||
Required: []string{"old_text", "new_text"},
|
||||
},
|
||||
Description: "One or more exact-text replacements. Each is matched against the original file, not against the output of earlier edits. Keep old_text as small as possible while still unique in the file; merge changes to the same or adjacent lines into a single edit.",
|
||||
})
|
||||
props.Set("replace_all", api.ToolProperty{
|
||||
Type: api.PropertyType{"boolean"},
|
||||
Description: "Replace every occurrence. Defaults to false; only applies when a single edit is provided.",
|
||||
})
|
||||
return api.ToolFunction{
|
||||
Name: e.Name(),
|
||||
Description: e.Description(),
|
||||
Parameters: api.ToolFunctionParameters{
|
||||
Type: "object",
|
||||
Properties: props,
|
||||
Required: []string{"path", "edits"},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
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")
|
||||
}
|
||||
|
||||
edits, replaceAll, err := parseEditArgs(args)
|
||||
if err != nil {
|
||||
return agent.ToolResult{}, err
|
||||
}
|
||||
|
||||
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)
|
||||
|
||||
var updated string
|
||||
replacements := 0
|
||||
if replaceAll {
|
||||
matches := strings.Count(content, edits[0].OldText)
|
||||
if matches == 0 {
|
||||
return agent.ToolResult{}, fmt.Errorf("old_text was not found in %s", path)
|
||||
}
|
||||
updated = strings.ReplaceAll(content, edits[0].OldText, edits[0].NewText)
|
||||
replacements = matches
|
||||
} else {
|
||||
// Every edit is matched against the original file content rather
|
||||
// than the output of earlier edits, so each edit must match exactly
|
||||
// once and edits must target disjoint regions.
|
||||
matched := make([]editMatch, 0, len(edits))
|
||||
for i, edit := range edits {
|
||||
count := strings.Count(content, edit.OldText)
|
||||
if count == 0 {
|
||||
return agent.ToolResult{}, editNotFoundError(path, i, len(edits))
|
||||
}
|
||||
if count > 1 {
|
||||
return agent.ToolResult{}, editAmbiguousError(path, i, len(edits), count)
|
||||
}
|
||||
matched = append(matched, editMatch{
|
||||
editIndex: i,
|
||||
offset: strings.Index(content, edit.OldText),
|
||||
length: len(edit.OldText),
|
||||
newText: edit.NewText,
|
||||
})
|
||||
replacements++
|
||||
}
|
||||
|
||||
slices.SortFunc(matched, func(a, b editMatch) int { return cmp.Compare(a.offset, b.offset) })
|
||||
for i := 1; i < len(matched); i++ {
|
||||
prev, cur := matched[i-1], matched[i]
|
||||
if prev.offset+prev.length > cur.offset {
|
||||
return agent.ToolResult{}, fmt.Errorf("edits[%d] and edits[%d] overlap in %s; merge them into one edit or target disjoint text", prev.editIndex, cur.editIndex, path)
|
||||
}
|
||||
}
|
||||
|
||||
// Apply from the end of the file backwards so earlier offsets stay valid.
|
||||
updated = content
|
||||
for i := len(matched) - 1; i >= 0; i-- {
|
||||
m := matched[i]
|
||||
updated = updated[:m.offset] + m.newText + updated[m.offset+m.length:]
|
||||
}
|
||||
}
|
||||
|
||||
if updated == content {
|
||||
return agent.ToolResult{}, fmt.Errorf("edit produced no changes in %s; replacement text is identical to the original", path)
|
||||
}
|
||||
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 edit%s, %d replacement%s).", path, len(edits), plural(len(edits)), replacements, plural(replacements))}, nil
|
||||
}
|
||||
|
||||
// editReplacement is one targeted replacement within an edit call.
|
||||
type editReplacement struct {
|
||||
OldText string
|
||||
NewText string
|
||||
}
|
||||
|
||||
// editMatch locates one editReplacement within the original file content.
|
||||
type editMatch struct {
|
||||
editIndex int
|
||||
offset int
|
||||
length int
|
||||
newText string
|
||||
}
|
||||
|
||||
// parseEditArgs normalizes edit arguments from a tool call into a list of
|
||||
// replacements. It accepts the `edits` array form and tolerates legacy
|
||||
// top-level old_text/new_text args as well as stringified JSON, mirroring
|
||||
// the pi coding agent's argument handling.
|
||||
func parseEditArgs(args map[string]any) ([]editReplacement, bool, error) {
|
||||
replaceAll, _ := args["replace_all"].(bool)
|
||||
|
||||
var edits []editReplacement
|
||||
if raw, ok := args["edits"]; ok {
|
||||
parsed, err := parseEditArray(raw)
|
||||
if err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
edits = parsed
|
||||
}
|
||||
|
||||
// Fold a legacy top-level old_text/new_text pair into edits.
|
||||
if oldText, ok := args["old_text"].(string); ok {
|
||||
newText, ok := args["new_text"].(string)
|
||||
if !ok {
|
||||
return nil, false, fmt.Errorf("new_text parameter is required")
|
||||
}
|
||||
edits = append(edits, editReplacement{OldText: oldText, NewText: newText})
|
||||
}
|
||||
|
||||
if len(edits) == 0 {
|
||||
return nil, false, fmt.Errorf("edits parameter is required")
|
||||
}
|
||||
for i, edit := range edits {
|
||||
if edit.OldText == "" {
|
||||
if len(edits) == 1 {
|
||||
return nil, false, fmt.Errorf("old_text parameter is required")
|
||||
}
|
||||
return nil, false, fmt.Errorf("edits[%d].old_text must not be empty", i)
|
||||
}
|
||||
}
|
||||
if replaceAll && len(edits) != 1 {
|
||||
return nil, false, fmt.Errorf("replace_all only applies to a single edit")
|
||||
}
|
||||
return edits, replaceAll, nil
|
||||
}
|
||||
|
||||
func parseEditArray(raw any) ([]editReplacement, error) {
|
||||
if s, ok := raw.(string); ok {
|
||||
// Some models serialize array arguments as a JSON string.
|
||||
if err := json.Unmarshal([]byte(s), &raw); err != nil {
|
||||
return nil, fmt.Errorf("edits must be an array of {old_text, new_text} objects")
|
||||
}
|
||||
}
|
||||
items, ok := raw.([]any)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("edits must be an array of {old_text, new_text} objects")
|
||||
}
|
||||
|
||||
edits := make([]editReplacement, 0, len(items))
|
||||
for i, item := range items {
|
||||
entry, ok := item.(map[string]any)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("edits[%d] must be an object with old_text and new_text", i)
|
||||
}
|
||||
oldText, oldOK := editTextArg(entry, "old_text", "oldText")
|
||||
newText, newOK := editTextArg(entry, "new_text", "newText")
|
||||
if !oldOK || !newOK {
|
||||
return nil, fmt.Errorf("edits[%d] must be an object with old_text and new_text", i)
|
||||
}
|
||||
edits = append(edits, editReplacement{OldText: oldText, NewText: newText})
|
||||
}
|
||||
return edits, nil
|
||||
}
|
||||
|
||||
// editTextArg reads the first present string key, tolerating both snake_case
|
||||
// and camelCase spellings that models emit.
|
||||
func editTextArg(entry map[string]any, keys ...string) (string, bool) {
|
||||
for _, key := range keys {
|
||||
if value, ok := entry[key].(string); ok {
|
||||
return value, true
|
||||
}
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
|
||||
func editNotFoundError(path string, editIndex, totalEdits int) error {
|
||||
if totalEdits == 1 {
|
||||
return fmt.Errorf("old_text was not found in %s", path)
|
||||
}
|
||||
return fmt.Errorf("edits[%d].old_text was not found in %s", editIndex, path)
|
||||
}
|
||||
|
||||
func editAmbiguousError(path string, editIndex, totalEdits, occurrences int) error {
|
||||
if totalEdits == 1 {
|
||||
return fmt.Errorf("old_text matched %d times in %s; set replace_all to true to replace every match", occurrences, path)
|
||||
}
|
||||
return fmt.Errorf("edits[%d].old_text matched %d times in %s; each edit must match exactly once, so provide more surrounding context", editIndex, occurrences, path)
|
||||
}
|
||||
|
||||
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"
|
||||
}
|
||||
@@ -0,0 +1,571 @@
|
||||
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 TestEditAppliesMultipleEdits(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "note.txt")
|
||||
if err := os.WriteFile(path, []byte("alpha beta gamma delta\n"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
result, err := (&Edit{}).Execute(context.Background(), agent.ToolContext{WorkingDir: dir}, map[string]any{
|
||||
"path": "note.txt",
|
||||
"edits": []any{
|
||||
map[string]any{"old_text": "beta", "new_text": "BETA"},
|
||||
map[string]any{"old_text": "delta", "new_text": "DELTA"},
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.Contains(result.Content, "2 edits, 2 replacements") {
|
||||
t.Fatalf("result = %q", result.Content)
|
||||
}
|
||||
|
||||
content, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if string(content) != "alpha BETA gamma DELTA\n" {
|
||||
t.Fatalf("content = %q", content)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEditMatchesEditsAgainstOriginalContent(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "note.txt")
|
||||
if err := os.WriteFile(path, []byte("abc def\n"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// edits[1] must target the original "def", not the one introduced by edits[0].
|
||||
_, err := (&Edit{}).Execute(context.Background(), agent.ToolContext{WorkingDir: dir}, map[string]any{
|
||||
"path": "note.txt",
|
||||
"edits": []any{
|
||||
map[string]any{"old_text": "abc", "new_text": "def"},
|
||||
map[string]any{"old_text": "def", "new_text": "ghi"},
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
content, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if string(content) != "def ghi\n" {
|
||||
t.Fatalf("content = %q", content)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEditRejectsOverlappingEdits(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "note.txt")
|
||||
if err := os.WriteFile(path, []byte("abc\n"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
_, err := (&Edit{}).Execute(context.Background(), agent.ToolContext{WorkingDir: dir}, map[string]any{
|
||||
"path": "note.txt",
|
||||
"edits": []any{
|
||||
map[string]any{"old_text": "ab", "new_text": "x"},
|
||||
map[string]any{"old_text": "bc", "new_text": "y"},
|
||||
},
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected overlapping edits to fail")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "overlap") {
|
||||
t.Fatalf("err = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEditMultipleEditsNotFoundIndexed(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)
|
||||
}
|
||||
|
||||
_, err := (&Edit{}).Execute(context.Background(), agent.ToolContext{WorkingDir: dir}, map[string]any{
|
||||
"path": "note.txt",
|
||||
"edits": []any{
|
||||
map[string]any{"old_text": "hello", "new_text": "hi"},
|
||||
map[string]any{"old_text": "missing", "new_text": "x"},
|
||||
},
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected missing edit to fail")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "edits[1]") {
|
||||
t.Fatalf("err = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEditMultipleEditsAmbiguousIndexed(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "note.txt")
|
||||
if err := os.WriteFile(path, []byte("hello same same\n"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
_, err := (&Edit{}).Execute(context.Background(), agent.ToolContext{WorkingDir: dir}, map[string]any{
|
||||
"path": "note.txt",
|
||||
"edits": []any{
|
||||
map[string]any{"old_text": "hello", "new_text": "hi"},
|
||||
map[string]any{"old_text": "same", "new_text": "x"},
|
||||
},
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected ambiguous edit to fail")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "edits[1]") || !strings.Contains(err.Error(), "matched 2 times") {
|
||||
t.Fatalf("err = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEditRejectsEmptyEdits(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "note.txt")
|
||||
if err := os.WriteFile(path, []byte("hello\n"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
for name, args := range map[string]map[string]any{
|
||||
"missing edits": {"path": "note.txt"},
|
||||
"empty edits": {"path": "note.txt", "edits": []any{}},
|
||||
} {
|
||||
if _, err := (&Edit{}).Execute(context.Background(), agent.ToolContext{WorkingDir: dir}, args); err == nil {
|
||||
t.Fatalf("%s: expected error", name)
|
||||
} else if !strings.Contains(err.Error(), "edits parameter is required") {
|
||||
t.Fatalf("%s: err = %v", name, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestEditRejectsEmptyOldTextInArray(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "note.txt")
|
||||
if err := os.WriteFile(path, []byte("hello\n"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
_, err := (&Edit{}).Execute(context.Background(), agent.ToolContext{WorkingDir: dir}, map[string]any{
|
||||
"path": "note.txt",
|
||||
"edits": []any{
|
||||
map[string]any{"old_text": "hello", "new_text": "hi"},
|
||||
map[string]any{"old_text": "", "new_text": "x"},
|
||||
},
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected empty old_text to fail")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "edits[1].old_text must not be empty") {
|
||||
t.Fatalf("err = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEditAcceptsJSONStringEdits(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)
|
||||
}
|
||||
|
||||
// Some models serialize array arguments as a JSON string.
|
||||
_, err := (&Edit{}).Execute(context.Background(), agent.ToolContext{WorkingDir: dir}, map[string]any{
|
||||
"path": "note.txt",
|
||||
"edits": `[{"oldText": "hello", "newText": "hi"}, {"oldText": "world", "newText": "earth"}]`,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
content, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if string(content) != "hi earth\n" {
|
||||
t.Fatalf("content = %q", content)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEditRejectsReplaceAllWithMultipleEdits(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "note.txt")
|
||||
if err := os.WriteFile(path, []byte("a b c\n"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
_, err := (&Edit{}).Execute(context.Background(), agent.ToolContext{WorkingDir: dir}, map[string]any{
|
||||
"path": "note.txt",
|
||||
"replace_all": true,
|
||||
"edits": []any{
|
||||
map[string]any{"old_text": "a", "new_text": "x"},
|
||||
map[string]any{"old_text": "b", "new_text": "y"},
|
||||
},
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected replace_all with multiple edits to fail")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "replace_all") {
|
||||
t.Fatalf("err = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEditRejectsNoChange(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "note.txt")
|
||||
if err := os.WriteFile(path, []byte("hello\n"), 0o644); 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": "hello",
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected no-change edit to fail")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "no changes") {
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
//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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,186 @@
|
||||
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.",
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,214 @@
|
||||
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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -217,8 +217,31 @@ type MessagesResponse struct {
|
||||
|
||||
// Usage contains token usage information
|
||||
type Usage struct {
|
||||
InputTokens int `json:"input_tokens"`
|
||||
OutputTokens int `json:"output_tokens"`
|
||||
InputTokens int `json:"input_tokens"`
|
||||
CacheReadInputTokens *int `json:"cache_read_input_tokens,omitempty"`
|
||||
OutputTokens int `json:"output_tokens"`
|
||||
}
|
||||
|
||||
// UsageFromMetrics separates total prompt tokens into uncached and cache-read counts.
|
||||
func UsageFromMetrics(metrics api.Metrics) Usage {
|
||||
total := max(0, metrics.PromptEvalCount)
|
||||
var cached *int
|
||||
if metrics.PromptEvalCachedCount != nil {
|
||||
count := min(max(0, *metrics.PromptEvalCachedCount), total)
|
||||
cached = &count
|
||||
}
|
||||
return Usage{
|
||||
InputTokens: total - intValue(cached),
|
||||
CacheReadInputTokens: cached,
|
||||
OutputTokens: metrics.EvalCount,
|
||||
}
|
||||
}
|
||||
|
||||
func intValue(v *int) int {
|
||||
if v == nil {
|
||||
return 0
|
||||
}
|
||||
return *v
|
||||
}
|
||||
|
||||
// Streaming event types
|
||||
@@ -273,8 +296,9 @@ type MessageDelta struct {
|
||||
|
||||
// DeltaUsage contains cumulative token usage
|
||||
type DeltaUsage struct {
|
||||
InputTokens int `json:"input_tokens"`
|
||||
OutputTokens int `json:"output_tokens"`
|
||||
InputTokens int `json:"input_tokens"`
|
||||
CacheReadInputTokens *int `json:"cache_read_input_tokens,omitempty"`
|
||||
OutputTokens int `json:"output_tokens"`
|
||||
}
|
||||
|
||||
// MessageStopEvent signals the end of the message
|
||||
@@ -688,10 +712,7 @@ func ToMessagesResponse(id string, r api.ChatResponse) MessagesResponse {
|
||||
Model: r.Model,
|
||||
Content: content,
|
||||
StopReason: stopReason,
|
||||
Usage: Usage{
|
||||
InputTokens: r.Metrics.PromptEvalCount,
|
||||
OutputTokens: r.Metrics.EvalCount,
|
||||
},
|
||||
Usage: UsageFromMetrics(r.Metrics),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -721,6 +742,7 @@ type StreamConverter struct {
|
||||
firstWrite bool
|
||||
contentIndex int
|
||||
inputTokens int
|
||||
cacheReadTokens *int
|
||||
outputTokens int
|
||||
estimatedInputTokens int // Estimated tokens from request (used when actual metrics are 0)
|
||||
thinkingStarted bool
|
||||
@@ -752,8 +774,10 @@ func (c *StreamConverter) Process(r api.ChatResponse) []StreamEvent {
|
||||
if c.firstWrite {
|
||||
c.firstWrite = false
|
||||
// Use actual metrics if available, otherwise use estimate
|
||||
c.inputTokens = r.Metrics.PromptEvalCount
|
||||
if c.inputTokens == 0 && c.estimatedInputTokens > 0 {
|
||||
usage := UsageFromMetrics(r.Metrics)
|
||||
c.inputTokens = usage.InputTokens
|
||||
c.cacheReadTokens = usage.CacheReadInputTokens
|
||||
if c.inputTokens == 0 && intValue(c.cacheReadTokens) == 0 && c.estimatedInputTokens > 0 {
|
||||
c.inputTokens = c.estimatedInputTokens
|
||||
}
|
||||
|
||||
@@ -768,8 +792,9 @@ func (c *StreamConverter) Process(r api.ChatResponse) []StreamEvent {
|
||||
Model: c.Model,
|
||||
Content: []ContentBlock{},
|
||||
Usage: Usage{
|
||||
InputTokens: c.inputTokens,
|
||||
OutputTokens: 0,
|
||||
InputTokens: c.inputTokens,
|
||||
CacheReadInputTokens: c.cacheReadTokens,
|
||||
OutputTokens: 0,
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -777,6 +802,18 @@ func (c *StreamConverter) Process(r api.ChatResponse) []StreamEvent {
|
||||
}
|
||||
|
||||
if r.Message.Thinking != "" && !c.thinkingDone {
|
||||
if c.textStarted {
|
||||
events = append(events, StreamEvent{
|
||||
Event: "content_block_stop",
|
||||
Data: ContentBlockStopEvent{
|
||||
Type: "content_block_stop",
|
||||
Index: c.contentIndex,
|
||||
},
|
||||
})
|
||||
c.contentIndex++
|
||||
c.textStarted = false
|
||||
}
|
||||
|
||||
if !c.thinkingStarted {
|
||||
c.thinkingStarted = true
|
||||
events = append(events, StreamEvent{
|
||||
@@ -938,8 +975,10 @@ func (c *StreamConverter) Process(r api.ChatResponse) []StreamEvent {
|
||||
})
|
||||
}
|
||||
|
||||
c.inputTokens = r.Metrics.PromptEvalCount
|
||||
c.outputTokens = r.Metrics.EvalCount
|
||||
usage := UsageFromMetrics(r.Metrics)
|
||||
c.inputTokens = usage.InputTokens
|
||||
c.cacheReadTokens = usage.CacheReadInputTokens
|
||||
c.outputTokens = usage.OutputTokens
|
||||
stopReason := mapStopReason(r.DoneReason, len(c.toolCallsSent) > 0)
|
||||
|
||||
events = append(events, StreamEvent{
|
||||
@@ -950,8 +989,9 @@ func (c *StreamConverter) Process(r api.ChatResponse) []StreamEvent {
|
||||
StopReason: stopReason,
|
||||
},
|
||||
Usage: DeltaUsage{
|
||||
InputTokens: c.inputTokens,
|
||||
OutputTokens: c.outputTokens,
|
||||
InputTokens: c.inputTokens,
|
||||
CacheReadInputTokens: c.cacheReadTokens,
|
||||
OutputTokens: c.outputTokens,
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -1063,7 +1103,7 @@ type CountTokensRequest struct {
|
||||
|
||||
// EstimateInputTokens estimates input tokens from a MessagesRequest (reuses CountTokensRequest logic)
|
||||
func EstimateInputTokens(req MessagesRequest) int {
|
||||
return estimateTokens(CountTokensRequest{
|
||||
return EstimateCountTokens(CountTokensRequest{
|
||||
Model: req.Model,
|
||||
Messages: req.Messages,
|
||||
System: req.System,
|
||||
@@ -1077,10 +1117,10 @@ type CountTokensResponse struct {
|
||||
InputTokens int `json:"input_tokens"`
|
||||
}
|
||||
|
||||
// estimateTokens returns a rough estimate of tokens (len/4).
|
||||
// EstimateCountTokens returns a rough estimate of tokens (len/4).
|
||||
// TODO: Replace with actual tokenization via Tokenize API for accuracy.
|
||||
// Current len/4 heuristic is a rough approximation (~4 chars/token average).
|
||||
func estimateTokens(req CountTokensRequest) int {
|
||||
func EstimateCountTokens(req CountTokensRequest) int {
|
||||
var totalLen int
|
||||
|
||||
// Count system prompt
|
||||
|
||||
@@ -3,6 +3,7 @@ package anthropic
|
||||
import (
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
@@ -15,6 +16,10 @@ const (
|
||||
testImage = `iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=`
|
||||
)
|
||||
|
||||
func testIntPtr(v int) *int {
|
||||
return &v
|
||||
}
|
||||
|
||||
// textContent is a convenience for constructing []ContentBlock with a single text block in tests.
|
||||
func textContent(s string) []ContentBlock {
|
||||
return []ContentBlock{{Type: "text", Text: &s}}
|
||||
@@ -29,6 +34,61 @@ func makeArgs(kvs ...any) api.ToolCallFunctionArguments {
|
||||
return args
|
||||
}
|
||||
|
||||
func TestUsageFromMetricsBoundsCacheReads(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
metrics api.Metrics
|
||||
want Usage
|
||||
}{
|
||||
{
|
||||
name: "negative counts",
|
||||
metrics: api.Metrics{PromptEvalCount: -1, PromptEvalCachedCount: testIntPtr(-2), EvalCount: 3},
|
||||
want: Usage{CacheReadInputTokens: testIntPtr(0), OutputTokens: 3},
|
||||
},
|
||||
{
|
||||
name: "cache reads exceed prompt",
|
||||
metrics: api.Metrics{PromptEvalCount: 3, PromptEvalCachedCount: testIntPtr(5), EvalCount: 2},
|
||||
want: Usage{CacheReadInputTokens: testIntPtr(3), OutputTokens: 2},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if diff := cmp.Diff(tt.want, UsageFromMetrics(tt.metrics)); diff != "" {
|
||||
t.Errorf("usage mismatch (-want +got):\n%s", diff)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestUsageCacheReadJSON(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
count *int
|
||||
want string
|
||||
}{
|
||||
{name: "unreported", want: `{"input_tokens":10,"output_tokens":2}`},
|
||||
{name: "zero", count: testIntPtr(0), want: `{"input_tokens":10,"cache_read_input_tokens":0,"output_tokens":2}`},
|
||||
{name: "positive", count: testIntPtr(4), want: `{"input_tokens":6,"cache_read_input_tokens":4,"output_tokens":2}`},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
data, err := json.Marshal(UsageFromMetrics(api.Metrics{
|
||||
PromptEvalCount: 10,
|
||||
PromptEvalCachedCount: tt.count,
|
||||
EvalCount: 2,
|
||||
}))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := string(data); got != tt.want {
|
||||
t.Errorf("json = %s, want %s", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestFromMessagesRequest_Basic(t *testing.T) {
|
||||
req := MessagesRequest{
|
||||
Model: "test-model",
|
||||
@@ -144,6 +204,118 @@ func TestFromMessagesRequest_WithOptions(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestFromMessagesRequest_ClaudeAutoModeClassifierFixtures(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
request string
|
||||
model string
|
||||
maxTokens int
|
||||
wantStop []string
|
||||
wantSystem string
|
||||
wantUser string
|
||||
}{
|
||||
{
|
||||
name: "stage one local model",
|
||||
request: `{
|
||||
"model": "qwen3.5:latest",
|
||||
"max_tokens": 2112,
|
||||
"messages": [{
|
||||
"role": "user",
|
||||
"content": [
|
||||
{"type": "text", "text": "<transcript>\n"},
|
||||
{"type": "text", "text": "User: Run the safe test.\n"},
|
||||
{"type": "text", "text": "Bash go test ./safe\n"},
|
||||
{"type": "text", "text": "</transcript>\n"},
|
||||
{"type": "text", "text": "Return only the stage-one block verdict."}
|
||||
]
|
||||
}],
|
||||
"system": [
|
||||
{
|
||||
"type": "text",
|
||||
"text": "Synthetic policy fixture. Evaluate whether the proposed action needs further review.",
|
||||
"cache_control": {"type": "ephemeral"}
|
||||
},
|
||||
{"type": "text", "text": "Synthetic session context."}
|
||||
],
|
||||
"stop_sequences": ["</block>"]
|
||||
}`,
|
||||
model: "qwen3.5:latest",
|
||||
maxTokens: 2112,
|
||||
wantStop: []string{"</block>"},
|
||||
wantSystem: "Synthetic policy fixture. Evaluate whether the proposed action needs further review.Synthetic session context.",
|
||||
wantUser: "<transcript>\nUser: Run the safe test.\nBash go test ./safe\n</transcript>\nReturn only the stage-one block verdict.",
|
||||
},
|
||||
{
|
||||
name: "stage two cloud model",
|
||||
request: `{
|
||||
"model": "glm-5.2:cloud",
|
||||
"max_tokens": 10240,
|
||||
"messages": [{
|
||||
"role": "user",
|
||||
"content": [
|
||||
{"type": "text", "text": "<transcript>\n"},
|
||||
{"type": "text", "text": "User: Send the fixture to an external host.\n"},
|
||||
{"type": "text", "text": "Bash upload fixture.txt\n"},
|
||||
{"type": "text", "text": "</transcript>\n"},
|
||||
{"type": "text", "text": "Return the stage-two block verdict and reason."}
|
||||
]
|
||||
}],
|
||||
"system": [
|
||||
{
|
||||
"type": "text",
|
||||
"text": "Synthetic policy fixture. Evaluate whether the proposed action must be denied.",
|
||||
"cache_control": {"type": "ephemeral"}
|
||||
},
|
||||
{"type": "text", "text": "Synthetic session context."}
|
||||
]
|
||||
}`,
|
||||
model: "glm-5.2:cloud",
|
||||
maxTokens: 10240,
|
||||
wantSystem: "Synthetic policy fixture. Evaluate whether the proposed action must be denied.Synthetic session context.",
|
||||
wantUser: "<transcript>\nUser: Send the fixture to an external host.\nBash upload fixture.txt\n</transcript>\nReturn the stage-two block verdict and reason.",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
var request MessagesRequest
|
||||
if err := json.Unmarshal([]byte(tt.request), &request); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
converted, err := FromMessagesRequest(request)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if converted.Model != tt.model {
|
||||
t.Fatalf("model = %q, want exact selected model %q", converted.Model, tt.model)
|
||||
}
|
||||
if converted.Stream == nil || *converted.Stream {
|
||||
t.Fatalf("stream = %v, want explicit non-streaming conversion", converted.Stream)
|
||||
}
|
||||
if len(converted.Tools) != 0 {
|
||||
t.Fatalf("tools = %v, want tool-free classifier request", converted.Tools)
|
||||
}
|
||||
if got := converted.Options["num_predict"]; got != tt.maxTokens {
|
||||
t.Fatalf("num_predict = %v, want %d", got, tt.maxTokens)
|
||||
}
|
||||
gotStop, _ := converted.Options["stop"].([]string)
|
||||
if diff := cmp.Diff(tt.wantStop, gotStop); diff != "" {
|
||||
t.Fatalf("stop sequences mismatch (-want +got):\n%s", diff)
|
||||
}
|
||||
if len(converted.Messages) != 2 {
|
||||
t.Fatalf("messages = %+v, want system and user messages", converted.Messages)
|
||||
}
|
||||
if got := converted.Messages[0]; got.Role != "system" || got.Content != tt.wantSystem {
|
||||
t.Fatalf("system message = %+v", got)
|
||||
}
|
||||
if got := converted.Messages[1]; got.Role != "user" || got.Content != tt.wantUser {
|
||||
t.Fatalf("user message = %+v", got)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestFromMessagesRequest_WithImage(t *testing.T) {
|
||||
imgData, _ := base64.StdEncoding.DecodeString(testImage)
|
||||
|
||||
@@ -748,8 +920,9 @@ func TestToMessagesResponse_Basic(t *testing.T) {
|
||||
Done: true,
|
||||
DoneReason: "stop",
|
||||
Metrics: api.Metrics{
|
||||
PromptEvalCount: 10,
|
||||
EvalCount: 5,
|
||||
PromptEvalCount: 10,
|
||||
PromptEvalCachedCount: testIntPtr(4),
|
||||
EvalCount: 5,
|
||||
},
|
||||
}
|
||||
|
||||
@@ -773,9 +946,51 @@ func TestToMessagesResponse_Basic(t *testing.T) {
|
||||
if result.StopReason != "end_turn" {
|
||||
t.Errorf("expected stop_reason 'end_turn', got %q", result.StopReason)
|
||||
}
|
||||
if result.Usage.InputTokens != 10 || result.Usage.OutputTokens != 5 {
|
||||
if result.Usage.InputTokens != 6 || intValue(result.Usage.CacheReadInputTokens) != 4 || result.Usage.OutputTokens != 5 {
|
||||
t.Errorf("unexpected usage: %+v", result.Usage)
|
||||
}
|
||||
|
||||
data, err := json.Marshal(result.Usage)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.Contains(string(data), `"cache_read_input_tokens":4`) {
|
||||
t.Errorf("unexpected usage json: %s", data)
|
||||
}
|
||||
}
|
||||
|
||||
func TestToMessagesResponse_PreservesClaudeAutoClassifierOutput(t *testing.T) {
|
||||
for _, output := range []string{
|
||||
"<block>no",
|
||||
"<block>yes</block><category>Synthetic risk</category><reason>Denied by the synthetic fixture.</reason>",
|
||||
"malformed classifier output",
|
||||
} {
|
||||
t.Run(output, func(t *testing.T) {
|
||||
result := ToMessagesResponse("msg_classifier", api.ChatResponse{
|
||||
Model: "qwen3.5:latest",
|
||||
Message: api.Message{
|
||||
Role: "assistant",
|
||||
Content: output,
|
||||
},
|
||||
Done: true,
|
||||
DoneReason: "stop",
|
||||
Metrics: api.Metrics{
|
||||
PromptEvalCount: 24644,
|
||||
EvalCount: 300,
|
||||
},
|
||||
})
|
||||
|
||||
if result.Model != "qwen3.5:latest" || len(result.Content) != 1 || result.Content[0].Text == nil || *result.Content[0].Text != output {
|
||||
t.Fatalf("classifier response = %+v, want opaque output on the selected model", result)
|
||||
}
|
||||
if result.StopReason != "end_turn" {
|
||||
t.Fatalf("stop reason = %q, want end_turn", result.StopReason)
|
||||
}
|
||||
if result.Usage.InputTokens != 24644 || result.Usage.OutputTokens != 300 {
|
||||
t.Fatalf("usage = %+v", result.Usage)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestToMessagesResponse_WithToolCalls(t *testing.T) {
|
||||
@@ -925,7 +1140,7 @@ func TestStreamConverter_Basic(t *testing.T) {
|
||||
Role: "assistant",
|
||||
Content: "Hello",
|
||||
},
|
||||
Metrics: api.Metrics{PromptEvalCount: 10},
|
||||
Metrics: api.Metrics{PromptEvalCount: 10, PromptEvalCachedCount: testIntPtr(4)},
|
||||
}
|
||||
|
||||
events1 := conv.Process(resp1)
|
||||
@@ -953,7 +1168,7 @@ func TestStreamConverter_Basic(t *testing.T) {
|
||||
},
|
||||
Done: true,
|
||||
DoneReason: "stop",
|
||||
Metrics: api.Metrics{PromptEvalCount: 10, EvalCount: 5},
|
||||
Metrics: api.Metrics{PromptEvalCount: 10, PromptEvalCachedCount: testIntPtr(4), EvalCount: 5},
|
||||
}
|
||||
|
||||
events2 := conv.Process(resp2)
|
||||
@@ -971,7 +1186,7 @@ func TestStreamConverter_Basic(t *testing.T) {
|
||||
t.Errorf("unexpected stop reason: %+v", data.Delta.StopReason)
|
||||
}
|
||||
|
||||
if data.Usage.InputTokens != 10 || data.Usage.OutputTokens != 5 {
|
||||
if data.Usage.InputTokens != 6 || intValue(data.Usage.CacheReadInputTokens) != 4 || data.Usage.OutputTokens != 5 {
|
||||
t.Errorf("unexpected usage: %+v", data.Usage)
|
||||
}
|
||||
} else {
|
||||
@@ -1140,6 +1355,56 @@ func TestStreamConverter_ThinkingDirectlyFollowedByToolCall(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestStreamConverter_TextBeforeThinking(t *testing.T) {
|
||||
conv := NewStreamConverter("msg_123", "test-model", 0)
|
||||
|
||||
responses := []api.ChatResponse{
|
||||
{Message: api.Message{Role: "assistant", Content: "---\n"}},
|
||||
{Message: api.Message{Role: "assistant", Thinking: "Let me think."}},
|
||||
{
|
||||
Message: api.Message{Role: "assistant", Content: "The answer."},
|
||||
Done: true,
|
||||
DoneReason: "stop",
|
||||
Metrics: api.Metrics{PromptEvalCount: 10, EvalCount: 5},
|
||||
},
|
||||
}
|
||||
|
||||
var got []string
|
||||
for _, response := range responses {
|
||||
for _, event := range conv.Process(response) {
|
||||
switch data := event.Data.(type) {
|
||||
case ContentBlockStartEvent:
|
||||
got = append(got, fmt.Sprintf("%s:%s:%d", event.Event, data.ContentBlock.Type, data.Index))
|
||||
case ContentBlockDeltaEvent:
|
||||
got = append(got, fmt.Sprintf("%s:%s:%d", event.Event, data.Delta.Type, data.Index))
|
||||
case ContentBlockStopEvent:
|
||||
got = append(got, fmt.Sprintf("%s:%d", event.Event, data.Index))
|
||||
default:
|
||||
got = append(got, event.Event)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
want := []string{
|
||||
"message_start",
|
||||
"content_block_start:text:0",
|
||||
"content_block_delta:text_delta:0",
|
||||
"content_block_stop:0",
|
||||
"content_block_start:thinking:1",
|
||||
"content_block_delta:thinking_delta:1",
|
||||
"content_block_stop:1",
|
||||
"content_block_start:text:2",
|
||||
"content_block_delta:text_delta:2",
|
||||
"content_block_stop:2",
|
||||
"message_delta",
|
||||
"message_stop",
|
||||
}
|
||||
|
||||
if diff := cmp.Diff(want, got); diff != "" {
|
||||
t.Fatalf("unexpected stream events (-want +got):\n%s", diff)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStreamConverter_ToolCallWithUnmarshalableArgs(t *testing.T) {
|
||||
// Test that unmarshalable arguments (like channels) are handled gracefully
|
||||
// and don't cause a panic or corrupt stream
|
||||
@@ -1495,7 +1760,7 @@ func TestEstimateTokens_SimpleMessage(t *testing.T) {
|
||||
},
|
||||
}
|
||||
|
||||
tokens := estimateTokens(req)
|
||||
tokens := EstimateCountTokens(req)
|
||||
|
||||
// "user" (4) + "Hello, world!" (13) = 17 chars / 4 = 4 tokens
|
||||
if tokens < 1 {
|
||||
@@ -1516,7 +1781,7 @@ func TestEstimateTokens_WithSystemPrompt(t *testing.T) {
|
||||
},
|
||||
}
|
||||
|
||||
tokens := estimateTokens(req)
|
||||
tokens := EstimateCountTokens(req)
|
||||
|
||||
// System prompt adds to count
|
||||
if tokens < 5 {
|
||||
@@ -1539,7 +1804,7 @@ func TestEstimateTokens_WithTools(t *testing.T) {
|
||||
},
|
||||
}
|
||||
|
||||
tokens := estimateTokens(req)
|
||||
tokens := EstimateCountTokens(req)
|
||||
|
||||
// Tools add significant content
|
||||
if tokens < 10 {
|
||||
@@ -1568,7 +1833,7 @@ func TestEstimateTokens_WithThinking(t *testing.T) {
|
||||
},
|
||||
}
|
||||
|
||||
tokens := estimateTokens(req)
|
||||
tokens := EstimateCountTokens(req)
|
||||
|
||||
// Thinking content should be counted
|
||||
if tokens < 10 {
|
||||
@@ -1582,7 +1847,7 @@ func TestEstimateTokens_EmptyContent(t *testing.T) {
|
||||
Messages: []MessageParam{},
|
||||
}
|
||||
|
||||
tokens := estimateTokens(req)
|
||||
tokens := EstimateCountTokens(req)
|
||||
|
||||
if tokens != 0 {
|
||||
t.Errorf("expected 0 tokens for empty content, got %d", tokens)
|
||||
|
||||
@@ -259,6 +259,10 @@ func (c *Client) stream(ctx context.Context, method, path string, data any, fn f
|
||||
}
|
||||
}
|
||||
|
||||
if err := scanner.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -469,6 +473,26 @@ func (c *Client) CloudStatusExperimental(ctx context.Context) (*StatusResponse,
|
||||
return &status, nil
|
||||
}
|
||||
|
||||
// WebSearchExperimental searches the web through the local server's
|
||||
// experimental web search endpoint.
|
||||
func (c *Client) WebSearchExperimental(ctx context.Context, req *WebSearchRequest) (*WebSearchResponse, error) {
|
||||
var resp WebSearchResponse
|
||||
if err := c.do(ctx, http.MethodPost, "/api/experimental/web_search", req, &resp); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &resp, nil
|
||||
}
|
||||
|
||||
// WebFetchExperimental fetches web page content through the local server's
|
||||
// experimental web fetch endpoint.
|
||||
func (c *Client) WebFetchExperimental(ctx context.Context, req *WebFetchRequest) (*WebFetchResponse, error) {
|
||||
var resp WebFetchResponse
|
||||
if err := c.do(ctx, http.MethodPost, "/api/experimental/web_fetch", req, &resp); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &resp, nil
|
||||
}
|
||||
|
||||
// Signout will signout a client for a local ollama server.
|
||||
func (c *Client) Signout(ctx context.Context) error {
|
||||
return c.do(ctx, http.MethodPost, "/api/signout", nil, nil)
|
||||
|
||||
@@ -2,7 +2,9 @@ package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
@@ -192,6 +194,35 @@ func TestClientStream(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestClientStreamReportsReadErrors(t *testing.T) {
|
||||
client := NewClient(
|
||||
&url.URL{Scheme: "http", Host: "example.com"},
|
||||
&http.Client{Transport: roundTripFunc(func(*http.Request) (*http.Response, error) {
|
||||
body := failingReader{
|
||||
data: []byte(`{"message":{"content":"partial"}}` + "\n"),
|
||||
err: io.ErrUnexpectedEOF,
|
||||
}
|
||||
|
||||
return &http.Response{
|
||||
StatusCode: http.StatusOK,
|
||||
Status: "200 OK",
|
||||
Body: io.NopCloser(&body),
|
||||
Header: make(http.Header),
|
||||
}, nil
|
||||
})},
|
||||
)
|
||||
|
||||
err := client.stream(t.Context(), http.MethodPost, "/api/chat", nil, func([]byte) error {
|
||||
return nil
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected stream read error")
|
||||
}
|
||||
if !strings.Contains(err.Error(), io.ErrUnexpectedEOF.Error()) {
|
||||
t.Fatalf("expected unexpected EOF, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClientDo(t *testing.T) {
|
||||
testCases := []struct {
|
||||
name string
|
||||
@@ -320,3 +351,157 @@ func TestClientDo(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestClientWebSearchExperimentalUsesLocalRoute(t *testing.T) {
|
||||
var gotPath string
|
||||
var gotMethod string
|
||||
var gotRequest WebSearchRequest
|
||||
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
gotPath = r.URL.Path
|
||||
gotMethod = r.Method
|
||||
if err := json.NewDecoder(r.Body).Decode(&gotRequest); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := json.NewEncoder(w).Encode(WebSearchResponse{
|
||||
Results: []WebSearchResult{{Title: "Ollama", URL: "https://ollama.com", Content: "models"}},
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}))
|
||||
defer ts.Close()
|
||||
|
||||
client := NewClient(&url.URL{Scheme: "http", Host: ts.Listener.Addr().String()}, http.DefaultClient)
|
||||
resp, err := client.WebSearchExperimental(t.Context(), &WebSearchRequest{Query: "ollama", MaxResults: 3})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if gotMethod != http.MethodPost {
|
||||
t.Fatalf("method = %q, want POST", gotMethod)
|
||||
}
|
||||
if gotPath != "/api/experimental/web_search" {
|
||||
t.Fatalf("path = %q, want /api/experimental/web_search", gotPath)
|
||||
}
|
||||
if gotRequest.Query != "ollama" || gotRequest.MaxResults != 3 {
|
||||
t.Fatalf("request = %#v", gotRequest)
|
||||
}
|
||||
if len(resp.Results) != 1 || resp.Results[0].Title != "Ollama" {
|
||||
t.Fatalf("response = %#v", resp)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClientWebSearchExperimentalErrors(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
status int
|
||||
body string
|
||||
assertError func(*testing.T, error)
|
||||
}{
|
||||
{
|
||||
name: "unauthorized retains sign in URL",
|
||||
status: http.StatusUnauthorized,
|
||||
body: `{"error":"unauthorized","signin_url":"https://ollama.com/signin/example"}`,
|
||||
assertError: func(t *testing.T, err error) {
|
||||
t.Helper()
|
||||
var authErr AuthorizationError
|
||||
if !errors.As(err, &authErr) {
|
||||
t.Fatalf("error = %T, want AuthorizationError", err)
|
||||
}
|
||||
if authErr.StatusCode != http.StatusUnauthorized || authErr.SigninURL != "https://ollama.com/signin/example" {
|
||||
t.Fatalf("authorization error = %#v", authErr)
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "rate limit retains status",
|
||||
status: http.StatusTooManyRequests,
|
||||
body: `{"error":"rate limit exceeded"}`,
|
||||
assertError: func(t *testing.T, err error) {
|
||||
t.Helper()
|
||||
var statusErr StatusError
|
||||
if !errors.As(err, &statusErr) {
|
||||
t.Fatalf("error = %T, want StatusError", err)
|
||||
}
|
||||
if statusErr.StatusCode != http.StatusTooManyRequests || statusErr.ErrorMessage != "rate limit exceeded" {
|
||||
t.Fatalf("status error = %#v", statusErr)
|
||||
}
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(tt.status)
|
||||
_, _ = w.Write([]byte(tt.body))
|
||||
}))
|
||||
defer ts.Close()
|
||||
|
||||
client := NewClient(&url.URL{Scheme: "http", Host: ts.Listener.Addr().String()}, http.DefaultClient)
|
||||
_, err := client.WebSearchExperimental(t.Context(), &WebSearchRequest{Query: "ollama"})
|
||||
if err == nil {
|
||||
t.Fatal("expected error")
|
||||
}
|
||||
tt.assertError(t, err)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"io"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestMetricsCachedPromptJSON(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
count *int
|
||||
want string
|
||||
}{
|
||||
{name: "unreported", want: `{}`},
|
||||
{name: "zero", count: testIntPtr(0), want: `{"prompt_eval_cached_count":0}`},
|
||||
{name: "positive", count: testIntPtr(4), want: `{"prompt_eval_cached_count":4}`},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
data, err := json.Marshal(Metrics{PromptEvalCachedCount: tt.count})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := string(data); got != tt.want {
|
||||
t.Errorf("json = %s, want %s", got, tt.want)
|
||||
}
|
||||
|
||||
var metrics Metrics
|
||||
if err := json.Unmarshal(data, &metrics); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if tt.count == nil {
|
||||
if metrics.PromptEvalCachedCount != nil {
|
||||
t.Errorf("cached count = %v, want nil", metrics.PromptEvalCachedCount)
|
||||
}
|
||||
} else if metrics.PromptEvalCachedCount == nil || *metrics.PromptEvalCachedCount != *tt.count {
|
||||
t.Errorf("cached count = %v, want %d", metrics.PromptEvalCachedCount, *tt.count)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestMetricsSummaryCachedPromptTokens(t *testing.T) {
|
||||
read, write, err := os.Pipe()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
original := os.Stderr
|
||||
os.Stderr = write
|
||||
t.Cleanup(func() { os.Stderr = original })
|
||||
|
||||
(&Metrics{
|
||||
PromptEvalCount: 10,
|
||||
PromptEvalCachedCount: testIntPtr(4),
|
||||
PromptEvalDuration: time.Second,
|
||||
}).Summary()
|
||||
write.Close()
|
||||
os.Stderr = original
|
||||
|
||||
output, err := io.ReadAll(read)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, want := range []string{"prompt eval count: 10 token(s)", "prompt eval cached: 4 token(s)", "prompt eval rate: 6.00 tokens/s"} {
|
||||
if !strings.Contains(string(output), want) {
|
||||
t.Errorf("summary missing %q:\n%s", want, output)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -127,20 +127,6 @@ type GenerateRequest struct {
|
||||
// each with an associated log probability. Only applies when Logprobs is true.
|
||||
// Valid values are 0-20. Default is 0 (only return the selected token's logprob).
|
||||
TopLogprobs int `json:"top_logprobs,omitempty"`
|
||||
|
||||
// Experimental: Image generation fields (may change or be removed)
|
||||
|
||||
// Width is the width of the generated image in pixels.
|
||||
// Only used for image generation models.
|
||||
Width int32 `json:"width,omitempty"`
|
||||
|
||||
// Height is the height of the generated image in pixels.
|
||||
// Only used for image generation models.
|
||||
Height int32 `json:"height,omitempty"`
|
||||
|
||||
// Steps is the number of diffusion steps for image generation.
|
||||
// Only used for image generation models.
|
||||
Steps int32 `json:"steps,omitempty"`
|
||||
}
|
||||
|
||||
// ChatRequest describes a request sent by [Client.Chat].
|
||||
@@ -569,12 +555,13 @@ type DebugInfo struct {
|
||||
}
|
||||
|
||||
type Metrics struct {
|
||||
TotalDuration time.Duration `json:"total_duration,omitempty"`
|
||||
LoadDuration time.Duration `json:"load_duration,omitempty"`
|
||||
PromptEvalCount int `json:"prompt_eval_count,omitempty"`
|
||||
PromptEvalDuration time.Duration `json:"prompt_eval_duration,omitempty"`
|
||||
EvalCount int `json:"eval_count,omitempty"`
|
||||
EvalDuration time.Duration `json:"eval_duration,omitempty"`
|
||||
TotalDuration time.Duration `json:"total_duration,omitempty"`
|
||||
LoadDuration time.Duration `json:"load_duration,omitempty"`
|
||||
PromptEvalCount int `json:"prompt_eval_count,omitempty"`
|
||||
PromptEvalCachedCount *int `json:"prompt_eval_cached_count,omitempty"`
|
||||
PromptEvalDuration time.Duration `json:"prompt_eval_duration,omitempty"`
|
||||
EvalCount int `json:"eval_count,omitempty"`
|
||||
EvalDuration time.Duration `json:"eval_duration,omitempty"`
|
||||
}
|
||||
|
||||
// Options specified in [GenerateRequest]. If you add a new option here, also
|
||||
@@ -600,12 +587,13 @@ type Options struct {
|
||||
|
||||
// Runner options which must be set when the model is loaded into memory
|
||||
type Runner struct {
|
||||
NumCtx int `json:"num_ctx,omitempty"`
|
||||
NumBatch int `json:"num_batch,omitempty"`
|
||||
NumGPU int `json:"num_gpu,omitempty"`
|
||||
MainGPU int `json:"main_gpu,omitempty"`
|
||||
UseMMap *bool `json:"use_mmap,omitempty"`
|
||||
NumThread int `json:"num_thread,omitempty"`
|
||||
NumCtx int `json:"num_ctx,omitempty"`
|
||||
NumBatch int `json:"num_batch,omitempty"`
|
||||
NumGPU int `json:"num_gpu,omitempty"`
|
||||
MainGPU *int `json:"main_gpu,omitempty"`
|
||||
UseMMap *bool `json:"use_mmap,omitempty"`
|
||||
NumThread int `json:"num_thread,omitempty"`
|
||||
DraftNumPredict int `json:"draft_num_predict,omitempty"`
|
||||
}
|
||||
|
||||
// EmbedRequest is the request passed to [Client.Embed].
|
||||
@@ -672,6 +660,9 @@ type CreateRequest struct {
|
||||
// Quantize is the quantization format for the model; leave blank to not change the quantization level.
|
||||
Quantize string `json:"quantize,omitempty"`
|
||||
|
||||
// DraftQuantize is the quantization format for the draft model.
|
||||
DraftQuantize string `json:"draft_quantize,omitempty"`
|
||||
|
||||
// From is the name of the model or file to use as the source.
|
||||
From string `json:"from,omitempty"`
|
||||
|
||||
@@ -681,6 +672,9 @@ type CreateRequest struct {
|
||||
// Files is a map of files include when creating the model.
|
||||
Files map[string]string `json:"files,omitempty"`
|
||||
|
||||
// DraftFiles is a map of draft model files to include when creating the model.
|
||||
DraftFiles map[string]string `json:"draft_files,omitempty"`
|
||||
|
||||
// Adapters is a map of LoRA adapters to include when creating the model.
|
||||
Adapters map[string]string `json:"adapters,omitempty"`
|
||||
|
||||
@@ -699,8 +693,11 @@ type CreateRequest struct {
|
||||
// Messages is a list of messages added to the model before chat and generation requests.
|
||||
Messages []Message `json:"messages,omitempty"`
|
||||
|
||||
// Renderer is the name of the renderer used when constructing a request to the model.
|
||||
Renderer string `json:"renderer,omitempty"`
|
||||
Parser string `json:"parser,omitempty"`
|
||||
|
||||
// Parser is the name of the parser used to parse the output of the request.
|
||||
Parser string `json:"parser,omitempty"`
|
||||
|
||||
// Requires is the minimum version of Ollama required by the model.
|
||||
Requires string `json:"requires,omitempty"`
|
||||
@@ -804,17 +801,46 @@ type ListResponse struct {
|
||||
|
||||
// ModelRecommendationsResponse is the response from [Client.ModelRecommendationsExperimental].
|
||||
type ModelRecommendationsResponse struct {
|
||||
Recommendations []ModelRecommendation `json:"recommendations"`
|
||||
Recommendations []ModelRecommendation `json:"recommendations"`
|
||||
Mappings *ModelRecommendationMappings `json:"mappings,omitempty"`
|
||||
}
|
||||
|
||||
// ModelRecommendationMapping defines one app-specific route preference.
|
||||
type ModelRecommendationMapping struct {
|
||||
Model string `json:"model"`
|
||||
RequiredPlan string `json:"required_plan,omitempty"`
|
||||
}
|
||||
|
||||
// ModelRecommendationMappings defines the app-specific model routes.
|
||||
type ModelRecommendationMappings map[string]ModelRecommendationMapping
|
||||
|
||||
// ModelRecommendation is a single recommendation entry in [ModelRecommendationsResponse].
|
||||
type ModelRecommendation struct {
|
||||
Model string `json:"model"`
|
||||
Description string `json:"description"`
|
||||
ContextLength int `json:"context_length,omitempty"`
|
||||
MaxOutputTokens int `json:"max_output_tokens,omitempty"`
|
||||
VRAMBytes int64 `json:"vram_bytes,omitempty"`
|
||||
RequiredPlan string `json:"required_plan,omitempty"`
|
||||
Model string `json:"model"`
|
||||
Description string `json:"description"`
|
||||
ContextLength int `json:"context_length,omitempty"`
|
||||
MaxOutputTokens int `json:"max_output_tokens,omitempty"`
|
||||
VRAMBytes int64 `json:"vram_bytes,omitempty"`
|
||||
RequiredPlan string `json:"required_plan,omitempty"`
|
||||
Thinking *ModelRecommendationThinking `json:"thinking,omitempty"`
|
||||
}
|
||||
|
||||
// ModelRecommendationThinking advertises the exact values accepted by
|
||||
// Ollama's think field and the model's default. Values may be booleans for
|
||||
// binary thinking controls or strings for adjustable effort levels.
|
||||
type ModelRecommendationThinking struct {
|
||||
Values []any `json:"values,omitempty"`
|
||||
Default any `json:"default,omitempty"`
|
||||
}
|
||||
|
||||
// Clone returns an independent copy.
|
||||
func (t *ModelRecommendationThinking) Clone() *ModelRecommendationThinking {
|
||||
if t == nil {
|
||||
return nil
|
||||
}
|
||||
clone := *t
|
||||
clone.Values = append([]any(nil), t.Values...)
|
||||
return &clone
|
||||
}
|
||||
|
||||
// ProcessResponse is the response from [Client.Process].
|
||||
@@ -861,6 +887,36 @@ type StatusResponse struct {
|
||||
Cloud CloudStatus `json:"cloud"`
|
||||
}
|
||||
|
||||
// WebSearchRequest is the request for [Client.WebSearchExperimental].
|
||||
type WebSearchRequest struct {
|
||||
Query string `json:"query"`
|
||||
MaxResults int `json:"max_results,omitempty"`
|
||||
}
|
||||
|
||||
// WebSearchResult is a single result from [Client.WebSearchExperimental].
|
||||
type WebSearchResult struct {
|
||||
Title string `json:"title"`
|
||||
URL string `json:"url"`
|
||||
Content string `json:"content"`
|
||||
}
|
||||
|
||||
// WebSearchResponse is the response from [Client.WebSearchExperimental].
|
||||
type WebSearchResponse struct {
|
||||
Results []WebSearchResult `json:"results"`
|
||||
}
|
||||
|
||||
// WebFetchRequest is the request for [Client.WebFetchExperimental].
|
||||
type WebFetchRequest struct {
|
||||
URL string `json:"url"`
|
||||
}
|
||||
|
||||
// WebFetchResponse is the response from [Client.WebFetchExperimental].
|
||||
type WebFetchResponse struct {
|
||||
Title string `json:"title"`
|
||||
Content string `json:"content"`
|
||||
Links []string `json:"links,omitempty"`
|
||||
}
|
||||
|
||||
// GenerateResponse is the response passed into [GenerateResponseFunc].
|
||||
type GenerateResponse struct {
|
||||
// Model is the model name that generated the response.
|
||||
@@ -901,20 +957,6 @@ type GenerateResponse struct {
|
||||
// Logprobs contains log probability information for the generated tokens,
|
||||
// if requested via the Logprobs parameter.
|
||||
Logprobs []Logprob `json:"logprobs,omitempty"`
|
||||
|
||||
// Experimental: Image generation fields (may change or be removed)
|
||||
|
||||
// Image contains a base64-encoded generated image.
|
||||
// Only present for image generation models.
|
||||
Image string `json:"image,omitempty"`
|
||||
|
||||
// Completed is the number of completed steps in image generation.
|
||||
// Only present for image generation models during streaming.
|
||||
Completed int64 `json:"completed,omitempty"`
|
||||
|
||||
// Total is the total number of steps for image generation.
|
||||
// Only present for image generation models during streaming.
|
||||
Total int64 `json:"total,omitempty"`
|
||||
}
|
||||
|
||||
// ModelDetails provides details about a model.
|
||||
@@ -961,9 +1003,18 @@ func (m *Metrics) Summary() {
|
||||
fmt.Fprintf(os.Stderr, "prompt eval count: %d token(s)\n", m.PromptEvalCount)
|
||||
}
|
||||
|
||||
cached := 0
|
||||
if m.PromptEvalCachedCount != nil {
|
||||
cached = *m.PromptEvalCachedCount
|
||||
}
|
||||
if cached > 0 {
|
||||
fmt.Fprintf(os.Stderr, "prompt eval cached: %d token(s)\n", cached)
|
||||
}
|
||||
|
||||
if m.PromptEvalDuration > 0 {
|
||||
fmt.Fprintf(os.Stderr, "prompt eval duration: %s\n", m.PromptEvalDuration)
|
||||
fmt.Fprintf(os.Stderr, "prompt eval rate: %.2f tokens/s\n", float64(m.PromptEvalCount)/m.PromptEvalDuration.Seconds())
|
||||
uncached := max(0, m.PromptEvalCount-cached)
|
||||
fmt.Fprintf(os.Stderr, "prompt eval rate: %.2f tokens/s\n", float64(uncached)/m.PromptEvalDuration.Seconds())
|
||||
}
|
||||
|
||||
if m.EvalCount > 0 {
|
||||
@@ -1049,14 +1100,25 @@ func (opts *Options) FromMap(m map[string]any) error {
|
||||
}
|
||||
field.Set(reflect.ValueOf(slice))
|
||||
case reflect.Pointer:
|
||||
var b bool
|
||||
if field.Type() == reflect.TypeOf(&b) {
|
||||
switch field.Type().Elem().Kind() {
|
||||
case reflect.Bool:
|
||||
val, ok := val.(bool)
|
||||
if !ok {
|
||||
return fmt.Errorf("option %q must be of type boolean", key)
|
||||
}
|
||||
field.Set(reflect.ValueOf(&val))
|
||||
} else {
|
||||
case reflect.Int:
|
||||
var i int
|
||||
switch t := val.(type) {
|
||||
case int64:
|
||||
i = int(t)
|
||||
case float64:
|
||||
i = int(t)
|
||||
default:
|
||||
return fmt.Errorf("option %q must be of type integer", key)
|
||||
}
|
||||
field.Set(reflect.ValueOf(&i))
|
||||
default:
|
||||
return fmt.Errorf("unknown type loading config params: %v %v", field.Kind(), field.Type())
|
||||
}
|
||||
default:
|
||||
@@ -1082,18 +1144,19 @@ func DefaultOptions() Options {
|
||||
TopP: 0.9,
|
||||
TypicalP: 1.0,
|
||||
RepeatLastN: 64,
|
||||
RepeatPenalty: 1.1,
|
||||
RepeatPenalty: 1.0,
|
||||
PresencePenalty: 0.0,
|
||||
FrequencyPenalty: 0.0,
|
||||
Seed: -1,
|
||||
|
||||
Runner: Runner{
|
||||
// options set when the model is loaded
|
||||
NumCtx: int(envconfig.ContextLength()),
|
||||
NumBatch: 512,
|
||||
NumGPU: -1, // -1 here indicates that NumGPU should be set dynamically
|
||||
NumThread: 0, // let the runtime decide
|
||||
UseMMap: nil,
|
||||
NumCtx: int(envconfig.ContextLength()),
|
||||
NumBatch: 512,
|
||||
NumGPU: -1, // -1 here indicates that NumGPU should be set dynamically
|
||||
NumThread: 0, // let the runtime decide
|
||||
DraftNumPredict: 4,
|
||||
UseMMap: nil,
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -1297,14 +1360,20 @@ func FormatParams(params map[string][]string) (map[string]any, error) {
|
||||
// TODO: only string slices are supported right now
|
||||
out[key] = vals
|
||||
case reflect.Pointer:
|
||||
var b bool
|
||||
if field.Type() == reflect.TypeOf(&b) {
|
||||
switch field.Type().Elem().Kind() {
|
||||
case reflect.Bool:
|
||||
boolVal, err := strconv.ParseBool(vals[0])
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid bool value %s", vals)
|
||||
}
|
||||
out[key] = &boolVal
|
||||
} else {
|
||||
case reflect.Int:
|
||||
intVal, err := strconv.ParseInt(vals[0], 10, 64)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid int value %s", vals)
|
||||
}
|
||||
out[key] = intVal
|
||||
default:
|
||||
return nil, fmt.Errorf("unknown type %s for %s", field.Kind(), key)
|
||||
}
|
||||
default:
|
||||
|
||||
@@ -4,9 +4,12 @@ import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"math"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/ollama/ollama/types/model"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
@@ -20,6 +23,10 @@ func testPropsMap(m map[string]ToolProperty) *ToolPropertiesMap {
|
||||
return props
|
||||
}
|
||||
|
||||
func testIntPtr(v int) *int {
|
||||
return &v
|
||||
}
|
||||
|
||||
// testArgs creates ToolCallFunctionArguments from a map (convenience function for tests, order not preserved)
|
||||
func testArgs(m map[string]any) ToolCallFunctionArguments {
|
||||
args := NewToolCallFunctionArguments()
|
||||
@@ -168,6 +175,63 @@ func TestUseMmapParsingFromJSON(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestMainGPUParsingFromJSON(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
req string
|
||||
wantGPU *int
|
||||
}{
|
||||
{
|
||||
name: "Undefined",
|
||||
req: `{}`,
|
||||
},
|
||||
{
|
||||
name: "Zero",
|
||||
req: `{ "main_gpu": 0 }`,
|
||||
wantGPU: testIntPtr(0),
|
||||
},
|
||||
{
|
||||
name: "Nonzero",
|
||||
req: `{ "main_gpu": 1 }`,
|
||||
wantGPU: testIntPtr(1),
|
||||
},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
var oMap map[string]any
|
||||
err := json.Unmarshal([]byte(test.req), &oMap)
|
||||
require.NoError(t, err)
|
||||
|
||||
opts := DefaultOptions()
|
||||
err = opts.FromMap(oMap)
|
||||
require.NoError(t, err)
|
||||
|
||||
if test.wantGPU == nil {
|
||||
assert.Nil(t, opts.MainGPU)
|
||||
} else if assert.NotNil(t, opts.MainGPU) {
|
||||
assert.Equal(t, *test.wantGPU, *opts.MainGPU)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerationDefaultMappingsAreOptions(t *testing.T) {
|
||||
jsonOpts := make(map[string]struct{})
|
||||
for _, field := range reflect.VisibleFields(reflect.TypeOf(Options{})) {
|
||||
jsonTag := strings.Split(field.Tag.Get("json"), ",")[0]
|
||||
if jsonTag != "" {
|
||||
jsonOpts[jsonTag] = struct{}{}
|
||||
}
|
||||
}
|
||||
|
||||
for _, option := range model.GenerationDefaultOptions() {
|
||||
if _, ok := jsonOpts[option]; !ok {
|
||||
t.Fatalf("%s should be defined on api.Options", option)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestUseMmapFormatParams(t *testing.T) {
|
||||
tr := true
|
||||
fa := false
|
||||
@@ -232,6 +296,12 @@ func TestUseMmapFormatParams(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestMainGPUFormatParams(t *testing.T) {
|
||||
resp, err := FormatParams(map[string][]string{"main_gpu": {"0"}})
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, int64(0), resp["main_gpu"])
|
||||
}
|
||||
|
||||
func TestMessage_UnmarshalJSON(t *testing.T) {
|
||||
tests := []struct {
|
||||
input string
|
||||
|
||||
@@ -146,15 +146,10 @@ func main() {
|
||||
// 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)
|
||||
}()
|
||||
}
|
||||
// This exits after forwarding the request when another instance is
|
||||
// running. First-instance requests are handled later by osRun, after the
|
||||
// Windows UI dependencies are initialized and from the primary thread.
|
||||
checkAndHandleExistingInstance(urlSchemeRequest)
|
||||
}
|
||||
|
||||
// Detect if this is a first start after an upgrade, in
|
||||
@@ -180,7 +175,9 @@ func main() {
|
||||
|
||||
// Check if another instance is already running
|
||||
// On Windows, focus the existing instance; on other platforms, kill it
|
||||
handleExistingInstance(startHidden)
|
||||
if !handleExistingInstance(startHidden) {
|
||||
return
|
||||
}
|
||||
|
||||
// on macOS, offer the user to create a symlink
|
||||
// from /usr/local/bin/ollama to the app bundle
|
||||
@@ -205,6 +202,12 @@ func main() {
|
||||
uiServerPort = port
|
||||
|
||||
st := &store.Store{}
|
||||
if devMode {
|
||||
if dbPath := strings.TrimSpace(os.Getenv("OLLAMA_APP_DB_PATH")); dbPath != "" {
|
||||
st.DBPath = dbPath
|
||||
slog.Debug("using development app database", "path", dbPath)
|
||||
}
|
||||
}
|
||||
appStore = st
|
||||
|
||||
// Enable CORS in development mode
|
||||
@@ -324,11 +327,11 @@ func main() {
|
||||
quit()
|
||||
}()
|
||||
|
||||
if urlSchemeRequest != "" {
|
||||
if urlSchemeRequest != "" && runtime.GOOS != "windows" {
|
||||
go func() {
|
||||
handleURLSchemeInCurrentInstance(urlSchemeRequest)
|
||||
}()
|
||||
} else {
|
||||
} else if urlSchemeRequest == "" {
|
||||
slog.Debug("no URL scheme request to handle")
|
||||
}
|
||||
|
||||
@@ -343,7 +346,13 @@ func main() {
|
||||
}
|
||||
}()
|
||||
|
||||
osRun(cancel, hasCompletedFirstRun, startHidden)
|
||||
settings, settingsErr := st.Settings()
|
||||
showOnboarding := shouldShowOnboarding(settings, settingsErr)
|
||||
if settingsErr != nil {
|
||||
slog.Error("failed to load onboarding state", "error", settingsErr)
|
||||
}
|
||||
|
||||
osRun(cancel, hasCompletedFirstRun, startHidden, showOnboarding, urlSchemeRequest)
|
||||
|
||||
slog.Info("shutting down desktop server")
|
||||
if err := srv.Close(); err != nil {
|
||||
@@ -355,6 +364,33 @@ func main() {
|
||||
<-done
|
||||
}
|
||||
|
||||
func shouldShowOnboarding(settings store.Settings, err error) bool {
|
||||
return err != nil || settings.OnboardingVersion < store.CurrentOnboardingVersion
|
||||
}
|
||||
|
||||
func runInitialWindowsUI(
|
||||
startHidden bool,
|
||||
showOnboarding bool,
|
||||
urlSchemeRequest string,
|
||||
startHiddenFn func(),
|
||||
handleURLFn func(string),
|
||||
showUIFn func(string),
|
||||
) {
|
||||
if urlSchemeRequest != "" {
|
||||
handleURLFn(urlSchemeRequest)
|
||||
return
|
||||
}
|
||||
if startHidden {
|
||||
startHiddenFn()
|
||||
return
|
||||
}
|
||||
if showOnboarding {
|
||||
showUIFn("/")
|
||||
return
|
||||
}
|
||||
showUIFn("/connect")
|
||||
}
|
||||
|
||||
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
|
||||
@@ -375,7 +411,7 @@ func startHiddenTasks() {
|
||||
return
|
||||
}
|
||||
|
||||
if err := updater.DoUpgradeAtStartup(); err != nil {
|
||||
if err := updater.DoUpgradeAtStartup(); err != nil { //nolint:staticcheck,nolintlint // DoUpgradeAtStartup may always return non-nil on Windows
|
||||
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("")
|
||||
@@ -432,7 +468,7 @@ func checkUserLoggedIn(uiServerPort int) bool {
|
||||
func handleConnectURLScheme() {
|
||||
if checkUserLoggedIn(uiServerPort) {
|
||||
slog.Info("user is already logged in, opening app instead")
|
||||
showWindow(wv.webview.Window())
|
||||
openUI("/")
|
||||
return
|
||||
}
|
||||
|
||||
@@ -491,17 +527,23 @@ func parseURLScheme(urlSchemeRequest string) (isConnect bool, err error) {
|
||||
|
||||
// handleURLSchemeInCurrentInstance processes URL scheme requests in the current instance
|
||||
func handleURLSchemeInCurrentInstance(urlSchemeRequest string) {
|
||||
isConnect, err := parseURLScheme(urlSchemeRequest)
|
||||
err := dispatchURLSchemeRequest(urlSchemeRequest, handleConnectURLScheme, func() {
|
||||
openUI("/")
|
||||
})
|
||||
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())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func dispatchURLSchemeRequest(urlSchemeRequest string, connect, open func()) error {
|
||||
isConnect, err := parseURLScheme(urlSchemeRequest)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if isConnect {
|
||||
connect()
|
||||
} else {
|
||||
open()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -1,5 +1,7 @@
|
||||
#import <Cocoa/Cocoa.h>
|
||||
#import <Security/Security.h>
|
||||
#include <stddef.h>
|
||||
#include <stdint.h>
|
||||
|
||||
@interface AppDelegate : NSObject <NSApplicationDelegate>
|
||||
- (void)applicationDidFinishLaunching:(NSNotification *)aNotification;
|
||||
@@ -16,8 +18,12 @@ enum AppMove
|
||||
MoveError,
|
||||
};
|
||||
|
||||
void run(bool firstTimeRun, bool startHidden);
|
||||
void killOtherInstances();
|
||||
void run(bool showOnboarding, bool startHidden);
|
||||
typedef struct {
|
||||
int pid;
|
||||
int64_t started_at;
|
||||
} AppProcessIdentity;
|
||||
bool otherOllamaProcesses(AppProcessIdentity **processes, size_t *count);
|
||||
enum AppMove askToMoveToApplications();
|
||||
int createSymlinkWithAuthorization();
|
||||
int installSymlink(const char *cliPath);
|
||||
@@ -25,6 +31,7 @@ extern void Restart();
|
||||
// extern void Quit();
|
||||
void StartUI(const char *path);
|
||||
void ShowUI();
|
||||
bool IsOnboardingActive(void);
|
||||
void StopUI();
|
||||
void StartUpdate();
|
||||
void darwinStartHiddenTasks();
|
||||
@@ -38,6 +45,37 @@ void setWindowDelegate(void *window);
|
||||
void showWindow(uintptr_t wndPtr);
|
||||
void hideWindow(uintptr_t wndPtr);
|
||||
void styleWindow(uintptr_t wndPtr);
|
||||
void setWindowResizable(uintptr_t wndPtr, bool resizable);
|
||||
void drag(uintptr_t wndPtr);
|
||||
void doubleClick(uintptr_t wndPtr);
|
||||
void handleConnectURL();
|
||||
bool SetClaudeGatewayInstalled(bool installed, bool restartClaude);
|
||||
bool HasUsedClaudeDesktopIntegration(void);
|
||||
bool RestoreClaudeGatewayForShutdown(void);
|
||||
bool IsClaudeGatewayConfigured(void);
|
||||
bool IsClaudeDesktopInstalled(void);
|
||||
bool IsClaudeDesktopRunning(void);
|
||||
bool IsCodexDesktopInstalled(void);
|
||||
bool IsCodexDesktopConnected(void);
|
||||
bool IsCodexDesktopRunning(void);
|
||||
unsigned long long CodexDesktopRequestCount(void);
|
||||
bool SetCodexDesktopConnected(bool connected, bool restartConfirmed);
|
||||
bool ClaudeGatewayStartFailed(void);
|
||||
bool ClaudeGatewayPortConflict(void);
|
||||
char *ClaudeGatewayErrorMessage(void);
|
||||
int ClaudeGatewayPort(void);
|
||||
void RefreshClaudeProxyMenu(void);
|
||||
void updateClaudeProxyMenu(unsigned long long routed);
|
||||
bool ShowAppsInMenu(void);
|
||||
void SetShowAppsInMenu(bool visible);
|
||||
enum ClaudeInstallResult
|
||||
{
|
||||
ClaudeInstallCancelled,
|
||||
ClaudeInstallerOpened,
|
||||
ClaudeInstallFailed,
|
||||
};
|
||||
enum ClaudeInstallResult installClaudeDesktop(void);
|
||||
enum ClaudeInstallResult installCodexDesktop(void);
|
||||
char *ClaudeDesktopDownloadRequest(char **authorization);
|
||||
bool InstallClaudeDesktopArchive(const char *archivePath);
|
||||
bool InstallCodexDesktopDiskImage(const char *imagePath);
|
||||
@@ -0,0 +1,140 @@
|
||||
//go:build windows || darwin
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"github.com/ollama/ollama/app/store"
|
||||
)
|
||||
|
||||
func TestShouldShowOnboarding(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
settings store.Settings
|
||||
err error
|
||||
want bool
|
||||
}{
|
||||
{
|
||||
name: "fresh install",
|
||||
settings: store.Settings{OnboardingVersion: 0},
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "completed onboarding",
|
||||
settings: store.Settings{OnboardingVersion: store.CurrentOnboardingVersion},
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "settings failure",
|
||||
err: errors.New("settings unavailable"),
|
||||
want: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if got := shouldShowOnboarding(tt.settings, tt.err); got != tt.want {
|
||||
t.Fatalf("shouldShowOnboarding() = %v, want %v", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDispatchURLSchemeRequest(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
request string
|
||||
wantConnect bool
|
||||
wantOpen bool
|
||||
wantErr bool
|
||||
}{
|
||||
{name: "bare URL opens app", request: "ollama://", wantOpen: true},
|
||||
{name: "connect URL starts connection", request: "ollama://connect", wantConnect: true},
|
||||
{name: "unsupported URL", request: "ollama://unsupported", wantErr: true},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
connected := false
|
||||
opened := false
|
||||
err := dispatchURLSchemeRequest(
|
||||
tt.request,
|
||||
func() { connected = true },
|
||||
func() { opened = true },
|
||||
)
|
||||
if (err != nil) != tt.wantErr {
|
||||
t.Fatalf("dispatchURLSchemeRequest() error = %v, wantErr %v", err, tt.wantErr)
|
||||
}
|
||||
if connected != tt.wantConnect {
|
||||
t.Errorf("connect called = %v, want %v", connected, tt.wantConnect)
|
||||
}
|
||||
if opened != tt.wantOpen {
|
||||
t.Errorf("open called = %v, want %v", opened, tt.wantOpen)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunInitialWindowsUIWithBareURL(t *testing.T) {
|
||||
hiddenCalls := 0
|
||||
urlCalls := 0
|
||||
onboardingCalls := 0
|
||||
openCalls := 0
|
||||
|
||||
runInitialWindowsUI(
|
||||
false,
|
||||
true,
|
||||
"ollama://",
|
||||
func() { hiddenCalls++ },
|
||||
func(request string) {
|
||||
urlCalls++
|
||||
if err := dispatchURLSchemeRequest(request, func() {}, func() { openCalls++ }); err != nil {
|
||||
t.Fatalf("dispatchURLSchemeRequest() error = %v", err)
|
||||
}
|
||||
},
|
||||
func(path string) {
|
||||
onboardingCalls++
|
||||
},
|
||||
)
|
||||
|
||||
if urlCalls != 1 {
|
||||
t.Fatalf("URL handled %d times, want 1", urlCalls)
|
||||
}
|
||||
if openCalls != 1 {
|
||||
t.Errorf("app opened %d times, want 1", openCalls)
|
||||
}
|
||||
if hiddenCalls != 0 {
|
||||
t.Errorf("hidden startup called %d times, want 0", hiddenCalls)
|
||||
}
|
||||
if onboardingCalls != 0 {
|
||||
t.Errorf("onboarding opened %d times, want 0", onboardingCalls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunInitialWindowsUIRoutesInteractiveLaunch(t *testing.T) {
|
||||
for _, tt := range []struct {
|
||||
name string
|
||||
showOnboarding bool
|
||||
wantPath string
|
||||
}{
|
||||
{name: "fresh install preserves onboarding", showOnboarding: true, wantPath: "/"},
|
||||
{name: "returning launch opens apps", wantPath: "/connect"},
|
||||
} {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
var gotPath string
|
||||
runInitialWindowsUI(
|
||||
false,
|
||||
tt.showOnboarding,
|
||||
"",
|
||||
func() { t.Fatal("unexpected hidden startup") },
|
||||
func(string) { t.Fatal("unexpected URL handling") },
|
||||
func(path string) { gotPath = path },
|
||||
)
|
||||
if gotPath != tt.wantPath {
|
||||
t.Fatalf("initial UI path = %q, want %q", gotPath, tt.wantPath)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -74,11 +74,12 @@ func maybeMoveAndRestart() appMove {
|
||||
}
|
||||
|
||||
// handleExistingInstance checks for existing instances and optionally focuses them
|
||||
func handleExistingInstance(startHidden bool) {
|
||||
func handleExistingInstance(startHidden bool) bool {
|
||||
if wintray.CheckAndFocusExistingInstance(!startHidden) {
|
||||
slog.Info("existing instance found, exiting")
|
||||
os.Exit(0)
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func installSymlink() {}
|
||||
@@ -95,11 +96,15 @@ func (ac *appCallbacks) UIRun(path string) {
|
||||
}
|
||||
|
||||
func (*appCallbacks) UIShow() {
|
||||
if wv.webview != nil {
|
||||
openUI("/")
|
||||
}
|
||||
|
||||
func openUI(path string) {
|
||||
if wv.IsRunning() && wv.webview != nil {
|
||||
showWindow(wv.webview.Window())
|
||||
} else {
|
||||
wv.Run("/")
|
||||
return
|
||||
}
|
||||
wv.Run(path)
|
||||
}
|
||||
|
||||
func (*appCallbacks) UITerminate() {
|
||||
@@ -110,6 +115,10 @@ func (*appCallbacks) UIRunning() bool {
|
||||
return wv.IsRunning()
|
||||
}
|
||||
|
||||
func (*appCallbacks) UIOnboarding() bool {
|
||||
return wv.OnboardingActive()
|
||||
}
|
||||
|
||||
func (app *appCallbacks) Quit() {
|
||||
app.t.Quit()
|
||||
wv.Terminate()
|
||||
@@ -126,7 +135,7 @@ func (app *appCallbacks) DoUpdate() {
|
||||
|
||||
app.shutdown()
|
||||
|
||||
if err := updater.DoUpgrade(true); err != nil {
|
||||
if err := updater.DoUpgrade(true); err != nil { //nolint:staticcheck,nolintlint // DoUpgrade may always return non-nil on Windows
|
||||
slog.Warn(fmt.Sprintf("upgrade attempt failed: %s", err))
|
||||
}
|
||||
}
|
||||
@@ -138,19 +147,7 @@ func (app *appCallbacks) HandleURLScheme(urlScheme string) {
|
||||
|
||||
// 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())
|
||||
}
|
||||
}
|
||||
handleURLSchemeInCurrentInstance(urlScheme)
|
||||
}
|
||||
|
||||
func UpdateAvailable(ver string) error {
|
||||
@@ -161,7 +158,7 @@ func UpdateAvailable(ver string) error {
|
||||
return app.t.UpdateAvailable(ver)
|
||||
}
|
||||
|
||||
func osRun(shutdown func(), hasCompletedFirstRun, startHidden bool) {
|
||||
func osRun(shutdown func(), hasCompletedFirstRun, startHidden, showOnboarding bool, urlSchemeRequest string) {
|
||||
var err error
|
||||
app.shutdown = shutdown
|
||||
app.t, err = wintray.NewTray(app)
|
||||
@@ -205,10 +202,8 @@ func osRun(shutdown func(), hasCompletedFirstRun, startHidden bool) {
|
||||
}
|
||||
}
|
||||
}
|
||||
if startHidden {
|
||||
startHiddenTasks()
|
||||
} else {
|
||||
ptr := wv.Run("/")
|
||||
runInitialWindowsUI(startHidden, showOnboarding, urlSchemeRequest, startHiddenTasks, handleURLSchemeInCurrentInstance, func(path string) {
|
||||
ptr := wv.Run(path)
|
||||
|
||||
// Set the window icon using the tray icon
|
||||
if ptr != nil {
|
||||
@@ -225,7 +220,7 @@ func osRun(shutdown func(), hasCompletedFirstRun, startHidden bool) {
|
||||
}
|
||||
|
||||
centerWindow(ptr)
|
||||
}
|
||||
})
|
||||
|
||||
if !hasCompletedFirstRun {
|
||||
// Only create the login shortcut on first start
|
||||
@@ -408,6 +403,8 @@ func hideWindow(ptr unsafe.Pointer) {
|
||||
}
|
||||
}
|
||||
|
||||
func setOnboardingWindowStyle(_ unsafe.Pointer, _ bool) {}
|
||||
|
||||
func runInBackground() {
|
||||
exe, err := os.Executable()
|
||||
if err != nil {
|
||||
@@ -432,17 +429,13 @@ 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 {
|
||||
func checkAndHandleExistingInstance(urlSchemeRequest string) {
|
||||
if urlSchemeRequest == "" {
|
||||
return false
|
||||
return
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
//go:build darwin
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"errors"
|
||||
|
||||
"github.com/ollama/ollama/app/webview"
|
||||
"github.com/ollama/ollama/cmd/launch"
|
||||
)
|
||||
|
||||
func bindClaudeDesktop(wv webview.WebView) {
|
||||
wv.Bind("getClaudeDesktopStatus", func() claudeDesktopStatus {
|
||||
return getClaudeDesktopConnectionStatus()
|
||||
})
|
||||
wv.Bind("getClaudeDesktopConnectionSummary", func() claudeDesktopStatus {
|
||||
return getClaudeDesktopConnectionSummary()
|
||||
})
|
||||
wv.Bind("getClaudeDesktopRequestCount", func() uint64 {
|
||||
return claudeDesktopRequestCount()
|
||||
})
|
||||
|
||||
wv.Bind("setClaudeDesktopConnected", func(enabled, restartConfirmed bool) claudeDesktopActionResult {
|
||||
err := setClaudeDesktopConnection(enabled, restartConfirmed)
|
||||
result := claudeDesktopActionResult{
|
||||
Status: getClaudeDesktopConnectionSummary(),
|
||||
}
|
||||
if err != nil {
|
||||
result.Error = err.Error()
|
||||
}
|
||||
return result
|
||||
})
|
||||
|
||||
wv.Bind("prepareClaudeDesktopConnection", func() claudeDesktopActionResult {
|
||||
err := prepareClaudeDesktopConnection()
|
||||
result := claudeDesktopActionResult{
|
||||
Status: getClaudeDesktopConnectionSummary(),
|
||||
}
|
||||
if err != nil {
|
||||
result.Error = err.Error()
|
||||
}
|
||||
return result
|
||||
})
|
||||
|
||||
wv.Bind("openClaudeDesktop", func() string {
|
||||
if err := openClaudeDesktopApplication(); err != nil {
|
||||
return err.Error()
|
||||
}
|
||||
return ""
|
||||
})
|
||||
|
||||
wv.Bind("installClaudeDesktop", func() claudeDesktopInstallResult {
|
||||
return requestClaudeDesktopInstall()
|
||||
})
|
||||
|
||||
wv.Bind("applyClaudeDesktopMappings", func(mappings map[string]string, restartConfirmed bool) claudeDesktopActionResult {
|
||||
applied, err := applyClaudeDesktopMappings(mappings, restartConfirmed)
|
||||
result := claudeDesktopActionResult{
|
||||
Status: getClaudeDesktopConnectionStatus(),
|
||||
MappingsApplied: applied,
|
||||
}
|
||||
if err != nil {
|
||||
result.Error = err.Error()
|
||||
result.RestartConfirmationRequired = errors.Is(err, launch.ErrClaudeDesktopRestartConfirmationRequired)
|
||||
}
|
||||
return result
|
||||
})
|
||||
wv.Bind("resetClaudeDesktopMappings", func(restartConfirmed bool) claudeDesktopActionResult {
|
||||
applied, err := resetClaudeDesktopMappings(restartConfirmed)
|
||||
result := claudeDesktopActionResult{
|
||||
Status: getClaudeDesktopConnectionStatus(),
|
||||
MappingsApplied: applied,
|
||||
}
|
||||
if err != nil {
|
||||
result.Error = err.Error()
|
||||
result.RestartConfirmationRequired = errors.Is(err, launch.ErrClaudeDesktopRestartConfirmationRequired)
|
||||
}
|
||||
return result
|
||||
})
|
||||
|
||||
wv.Bind("setClaudeDesktopAutoMode", func(enabled, restartConfirmed bool) claudeDesktopActionResult {
|
||||
err := setClaudeDesktopAutoMode(enabled, restartConfirmed)
|
||||
result := claudeDesktopActionResult{Status: getClaudeDesktopConnectionStatus()}
|
||||
if err != nil {
|
||||
result.Error = err.Error()
|
||||
result.RestartConfirmationRequired = errors.Is(err, launch.ErrClaudeDesktopRestartConfirmationRequired)
|
||||
}
|
||||
return result
|
||||
})
|
||||
|
||||
wv.Bind("getShowAppsInMenu", func() bool {
|
||||
return getShowAppsInMenu()
|
||||
})
|
||||
|
||||
wv.Bind("setShowAppsInMenu", func(visible bool) {
|
||||
setShowAppsInMenu(visible)
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
//go:build windows
|
||||
|
||||
package main
|
||||
|
||||
import "github.com/ollama/ollama/app/webview"
|
||||
|
||||
func bindClaudeDesktop(_ webview.WebView) {}
|
||||
@@ -0,0 +1,252 @@
|
||||
//go:build darwin
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const (
|
||||
maxClaudeDesktopArchiveBytes = 1 << 30
|
||||
maxClaudeDesktopExtractBytes = 2 << 30
|
||||
maxClaudeDesktopArchiveFiles = 100_000
|
||||
claudeDesktopBundleID = "com.anthropic.claudefordesktop"
|
||||
claudeDesktopTeamID = "Q6L2SF6YDW"
|
||||
)
|
||||
|
||||
var errClaudeDesktopDestinationExists = errors.New("Claude Desktop installation destination already exists")
|
||||
|
||||
func claudeDesktopInstallDestinations() []string {
|
||||
destinations := []string{"/Applications/Claude.app"}
|
||||
if home, err := os.UserHomeDir(); err == nil {
|
||||
destinations = append(destinations, filepath.Join(home, "Applications", "Claude.app"))
|
||||
}
|
||||
return destinations
|
||||
}
|
||||
|
||||
func installClaudeDesktopZip(archivePath string, destinations []string, verify func(string) error) (string, error) {
|
||||
if len(destinations) == 0 {
|
||||
return "", errors.New("Claude Desktop installation destination is required")
|
||||
}
|
||||
if verify == nil {
|
||||
return "", errors.New("Claude Desktop bundle verifier is required")
|
||||
}
|
||||
info, err := os.Stat(archivePath)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("stat Claude Desktop archive: %w", err)
|
||||
}
|
||||
if !info.Mode().IsRegular() {
|
||||
return "", errors.New("Claude Desktop archive is not a regular file")
|
||||
}
|
||||
if info.Size() > maxClaudeDesktopArchiveBytes {
|
||||
return "", fmt.Errorf("Claude Desktop archive exceeds %d bytes", maxClaudeDesktopArchiveBytes)
|
||||
}
|
||||
|
||||
workDir, err := os.MkdirTemp("", "ollama-claude-install-")
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("create Claude Desktop installation directory: %w", err)
|
||||
}
|
||||
defer os.RemoveAll(workDir)
|
||||
|
||||
if err := extractClaudeDesktopZip(archivePath, workDir); err != nil {
|
||||
return "", err
|
||||
}
|
||||
bundlePath := filepath.Join(workDir, "Claude.app")
|
||||
if err := validateClaudeDesktopBundle(bundlePath); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if err := verify(bundlePath); err != nil {
|
||||
return "", fmt.Errorf("verify Claude Desktop signature: %w", err)
|
||||
}
|
||||
|
||||
var permissionErr error
|
||||
for _, destination := range destinations {
|
||||
if strings.TrimSpace(destination) == "" {
|
||||
continue
|
||||
}
|
||||
if _, err := os.Stat(destination); err == nil {
|
||||
return "", fmt.Errorf("%w: %s", errClaudeDesktopDestinationExists, destination)
|
||||
} else if !errors.Is(err, os.ErrNotExist) {
|
||||
return "", fmt.Errorf("check Claude Desktop destination %s: %w", destination, err)
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Dir(destination), 0o755); err != nil {
|
||||
if errors.Is(err, os.ErrPermission) {
|
||||
permissionErr = err
|
||||
continue
|
||||
}
|
||||
return "", fmt.Errorf("create Claude Desktop destination: %w", err)
|
||||
}
|
||||
if err := os.Rename(bundlePath, destination); err != nil {
|
||||
if errors.Is(err, os.ErrPermission) {
|
||||
permissionErr = err
|
||||
continue
|
||||
}
|
||||
return "", fmt.Errorf("move Claude Desktop to %s: %w", destination, err)
|
||||
}
|
||||
return destination, nil
|
||||
}
|
||||
if permissionErr != nil {
|
||||
return "", fmt.Errorf("install Claude Desktop in Applications: %w", permissionErr)
|
||||
}
|
||||
return "", errors.New("Claude Desktop installation destination is required")
|
||||
}
|
||||
|
||||
func extractClaudeDesktopZip(archivePath, destination string) error {
|
||||
reader, err := zip.OpenReader(archivePath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("open Claude Desktop archive: %w", err)
|
||||
}
|
||||
defer reader.Close()
|
||||
if len(reader.File) == 0 {
|
||||
return errors.New("Claude Desktop archive is empty")
|
||||
}
|
||||
if len(reader.File) > maxClaudeDesktopArchiveFiles {
|
||||
return fmt.Errorf("Claude Desktop archive contains more than %d files", maxClaudeDesktopArchiveFiles)
|
||||
}
|
||||
|
||||
var expanded uint64
|
||||
for _, file := range reader.File {
|
||||
clean, err := safeClaudeDesktopArchivePath(file.Name)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
expanded += file.UncompressedSize64
|
||||
if expanded > maxClaudeDesktopExtractBytes {
|
||||
return fmt.Errorf("Claude Desktop archive expands beyond %d bytes", maxClaudeDesktopExtractBytes)
|
||||
}
|
||||
path := filepath.Join(destination, filepath.FromSlash(clean))
|
||||
switch {
|
||||
case file.FileInfo().IsDir():
|
||||
if err := os.MkdirAll(path, file.Mode().Perm()); err != nil {
|
||||
return fmt.Errorf("create Claude Desktop archive directory: %w", err)
|
||||
}
|
||||
case file.Mode()&os.ModeSymlink != 0:
|
||||
target, err := readClaudeDesktopZipFile(file, 16<<10)
|
||||
if err != nil {
|
||||
return fmt.Errorf("read Claude Desktop archive symlink: %w", err)
|
||||
}
|
||||
if err := validateClaudeDesktopSymlink(clean, string(target)); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
||||
return fmt.Errorf("create Claude Desktop archive directory: %w", err)
|
||||
}
|
||||
if err := os.Symlink(string(target), path); err != nil {
|
||||
return fmt.Errorf("create Claude Desktop archive symlink: %w", err)
|
||||
}
|
||||
case file.Mode().IsRegular():
|
||||
if err := extractClaudeDesktopZipFile(file, path); err != nil {
|
||||
return err
|
||||
}
|
||||
default:
|
||||
return fmt.Errorf("Claude Desktop archive contains unsupported file %q", file.Name)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func safeClaudeDesktopArchivePath(name string) (string, error) {
|
||||
if strings.ContainsRune(name, '\x00') || filepath.IsAbs(name) {
|
||||
return "", fmt.Errorf("Claude Desktop archive contains unsafe path %q", name)
|
||||
}
|
||||
clean := filepath.ToSlash(filepath.Clean(name))
|
||||
if clean != "Claude.app" && !strings.HasPrefix(clean, "Claude.app/") {
|
||||
return "", fmt.Errorf("Claude Desktop archive contains unexpected path %q", name)
|
||||
}
|
||||
return clean, nil
|
||||
}
|
||||
|
||||
func validateClaudeDesktopSymlink(name, target string) error {
|
||||
if target == "" || filepath.IsAbs(target) {
|
||||
return fmt.Errorf("Claude Desktop archive contains unsafe symlink %q", name)
|
||||
}
|
||||
resolved := filepath.Clean(filepath.Join(filepath.Dir(name), target))
|
||||
resolved = filepath.ToSlash(resolved)
|
||||
if resolved != "Claude.app" && !strings.HasPrefix(resolved, "Claude.app/") {
|
||||
return fmt.Errorf("Claude Desktop archive symlink %q escapes Claude.app", name)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func extractClaudeDesktopZipFile(file *zip.File, path string) error {
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
||||
return fmt.Errorf("create Claude Desktop archive directory: %w", err)
|
||||
}
|
||||
input, err := file.Open()
|
||||
if err != nil {
|
||||
return fmt.Errorf("open Claude Desktop archive file: %w", err)
|
||||
}
|
||||
output, err := os.OpenFile(path, os.O_CREATE|os.O_EXCL|os.O_WRONLY, file.Mode().Perm())
|
||||
if err != nil {
|
||||
input.Close()
|
||||
return fmt.Errorf("create Claude Desktop archive file: %w", err)
|
||||
}
|
||||
_, copyErr := io.Copy(output, input)
|
||||
inputErr := input.Close()
|
||||
outputErr := output.Close()
|
||||
if copyErr != nil {
|
||||
return fmt.Errorf("extract Claude Desktop archive file: %w", copyErr)
|
||||
}
|
||||
if inputErr != nil {
|
||||
return fmt.Errorf("close Claude Desktop archive file: %w", inputErr)
|
||||
}
|
||||
if outputErr != nil {
|
||||
return fmt.Errorf("close extracted Claude Desktop file: %w", outputErr)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func readClaudeDesktopZipFile(file *zip.File, limit int64) ([]byte, error) {
|
||||
reader, err := file.Open()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer reader.Close()
|
||||
data, err := io.ReadAll(io.LimitReader(reader, limit+1))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if int64(len(data)) > limit {
|
||||
return nil, fmt.Errorf("archive entry exceeds %d bytes", limit)
|
||||
}
|
||||
return data, nil
|
||||
}
|
||||
|
||||
func validateClaudeDesktopBundle(bundlePath string) error {
|
||||
info, err := os.Stat(bundlePath)
|
||||
if err != nil || !info.IsDir() {
|
||||
return errors.New("Claude Desktop archive does not contain Claude.app")
|
||||
}
|
||||
executable := filepath.Join(bundlePath, "Contents", "MacOS", "Claude")
|
||||
info, err = os.Stat(executable)
|
||||
if err != nil {
|
||||
return fmt.Errorf("Claude Desktop executable is missing: %w", err)
|
||||
}
|
||||
if !info.Mode().IsRegular() || info.Mode()&0o111 == 0 {
|
||||
return errors.New("Claude Desktop executable is not executable")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func verifyClaudeDesktopBundle(bundlePath string) error {
|
||||
if output, err := exec.Command("/usr/bin/codesign", "--verify", "--deep", "--strict", bundlePath).CombinedOutput(); err != nil {
|
||||
return fmt.Errorf("codesign verification failed: %w: %s", err, strings.TrimSpace(string(output)))
|
||||
}
|
||||
output, err := exec.Command("/usr/bin/codesign", "-d", "--verbose=4", bundlePath).CombinedOutput()
|
||||
if err != nil {
|
||||
return fmt.Errorf("read code signature: %w: %s", err, strings.TrimSpace(string(output)))
|
||||
}
|
||||
details := string(output)
|
||||
if !strings.Contains(details, "Identifier="+claudeDesktopBundleID) ||
|
||||
!strings.Contains(details, "TeamIdentifier="+claudeDesktopTeamID) {
|
||||
return fmt.Errorf("unexpected Claude Desktop signing identity")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
//go:build darwin
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestInstallClaudeDesktopZip(t *testing.T) {
|
||||
archive := writeClaudeDesktopTestZip(t, map[string]claudeDesktopTestZipEntry{
|
||||
"Claude.app/": {directory: true},
|
||||
"Claude.app/Contents/": {directory: true},
|
||||
"Claude.app/Contents/MacOS/": {directory: true},
|
||||
"Claude.app/Contents/MacOS/Claude": {body: "binary", mode: 0o755},
|
||||
"Claude.app/Contents/Resources/": {directory: true},
|
||||
"Claude.app/Contents/Resources/link": {body: "../MacOS/Claude", mode: os.ModeSymlink | 0o777},
|
||||
})
|
||||
destination := filepath.Join(t.TempDir(), "Applications", "Claude.app")
|
||||
var verified string
|
||||
installed, err := installClaudeDesktopZip(archive, []string{destination}, func(bundle string) error {
|
||||
verified = bundle
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if installed != destination || verified == "" {
|
||||
t.Fatalf("installed = %q, verified = %q", installed, verified)
|
||||
}
|
||||
info, err := os.Stat(filepath.Join(installed, "Contents", "MacOS", "Claude"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if info.Mode()&0o111 == 0 {
|
||||
t.Fatal("installed Claude executable is not executable")
|
||||
}
|
||||
if target, err := os.Readlink(filepath.Join(installed, "Contents", "Resources", "link")); err != nil || target != "../MacOS/Claude" {
|
||||
t.Fatalf("symlink target = %q, err = %v", target, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInstallClaudeDesktopZipRejectsUnsafeArchives(t *testing.T) {
|
||||
for _, test := range []struct {
|
||||
name string
|
||||
entries map[string]claudeDesktopTestZipEntry
|
||||
}{
|
||||
{name: "path traversal", entries: map[string]claudeDesktopTestZipEntry{"../Claude.app/Contents/MacOS/Claude": {body: "binary", mode: 0o755}}},
|
||||
{name: "unexpected root", entries: map[string]claudeDesktopTestZipEntry{"README": {body: "nope", mode: 0o644}}},
|
||||
{name: "escaping symlink", entries: map[string]claudeDesktopTestZipEntry{
|
||||
"Claude.app/Contents/MacOS/Claude": {body: "binary", mode: 0o755},
|
||||
"Claude.app/escape": {body: "../../outside", mode: os.ModeSymlink | 0o777},
|
||||
}},
|
||||
} {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
archive := writeClaudeDesktopTestZip(t, test.entries)
|
||||
destination := filepath.Join(t.TempDir(), "Claude.app")
|
||||
if _, err := installClaudeDesktopZip(archive, []string{destination}, func(string) error { return nil }); err == nil {
|
||||
t.Fatal("installClaudeDesktopZip succeeded")
|
||||
}
|
||||
if _, err := os.Stat(destination); !errors.Is(err, os.ErrNotExist) {
|
||||
t.Fatalf("unsafe archive created destination: %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestInstallClaudeDesktopZipVerifiesBeforeMove(t *testing.T) {
|
||||
archive := writeClaudeDesktopTestZip(t, map[string]claudeDesktopTestZipEntry{
|
||||
"Claude.app/Contents/MacOS/Claude": {body: "binary", mode: 0o755},
|
||||
})
|
||||
destination := filepath.Join(t.TempDir(), "Claude.app")
|
||||
wantErr := errors.New("invalid signature")
|
||||
if _, err := installClaudeDesktopZip(archive, []string{destination}, func(string) error { return wantErr }); !errors.Is(err, wantErr) {
|
||||
t.Fatalf("error = %v, want %v", err, wantErr)
|
||||
}
|
||||
if _, err := os.Stat(destination); !errors.Is(err, os.ErrNotExist) {
|
||||
t.Fatalf("invalid bundle created destination: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInstallClaudeDesktopZipDoesNotOverwrite(t *testing.T) {
|
||||
archive := writeClaudeDesktopTestZip(t, map[string]claudeDesktopTestZipEntry{
|
||||
"Claude.app/Contents/MacOS/Claude": {body: "binary", mode: 0o755},
|
||||
})
|
||||
destination := filepath.Join(t.TempDir(), "Claude.app")
|
||||
if err := os.MkdirAll(destination, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := installClaudeDesktopZip(archive, []string{destination}, func(string) error { return nil }); !errors.Is(err, errClaudeDesktopDestinationExists) {
|
||||
t.Fatalf("error = %v, want destination exists", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInstallClaudeDesktopZipRealArchive(t *testing.T) {
|
||||
archive := os.Getenv("OLLAMA_TEST_CLAUDE_DESKTOP_ZIP")
|
||||
if archive == "" {
|
||||
t.Skip("set OLLAMA_TEST_CLAUDE_DESKTOP_ZIP to a downloaded Claude Desktop ZIP")
|
||||
}
|
||||
destination := filepath.Join(t.TempDir(), "Applications", "Claude.app")
|
||||
installed, err := installClaudeDesktopZip(
|
||||
archive,
|
||||
[]string{destination},
|
||||
verifyClaudeDesktopBundle,
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if installed != destination {
|
||||
t.Fatalf("installed = %q, want %q", installed, destination)
|
||||
}
|
||||
}
|
||||
|
||||
type claudeDesktopTestZipEntry struct {
|
||||
body string
|
||||
mode os.FileMode
|
||||
directory bool
|
||||
}
|
||||
|
||||
func writeClaudeDesktopTestZip(t *testing.T, entries map[string]claudeDesktopTestZipEntry) string {
|
||||
t.Helper()
|
||||
path := filepath.Join(t.TempDir(), "Claude.zip")
|
||||
file, err := os.Create(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
writer := zip.NewWriter(file)
|
||||
for name, entry := range entries {
|
||||
header := &zip.FileHeader{Name: name, Method: zip.Deflate}
|
||||
if entry.directory {
|
||||
header.SetMode(os.ModeDir | 0o755)
|
||||
} else {
|
||||
header.SetMode(entry.mode)
|
||||
}
|
||||
item, err := writer.CreateHeader(header)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := item.Write([]byte(entry.body)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if err := writer.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := file.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return path
|
||||
}
|
||||
|
||||
func TestSafeClaudeDesktopArchivePath(t *testing.T) {
|
||||
for _, name := range []string{"Claude.app", "Claude.app/Contents/MacOS/Claude"} {
|
||||
if got, err := safeClaudeDesktopArchivePath(name); err != nil || got != strings.TrimSuffix(name, "/") {
|
||||
t.Fatalf("safeClaudeDesktopArchivePath(%q) = %q, %v", name, got, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
//go:build darwin
|
||||
|
||||
package main
|
||||
|
||||
import "github.com/ollama/ollama/internal/proxy"
|
||||
|
||||
type claudeDesktopInstallResult string
|
||||
|
||||
const (
|
||||
claudeDesktopInstallCancelled claudeDesktopInstallResult = "cancelled"
|
||||
claudeDesktopInstallerOpened claudeDesktopInstallResult = "opened"
|
||||
claudeDesktopInstallFailed claudeDesktopInstallResult = "failed"
|
||||
)
|
||||
|
||||
type claudeDesktopStatus struct {
|
||||
Supported bool `json:"supported"`
|
||||
Used bool `json:"used"`
|
||||
Installed bool `json:"installed"`
|
||||
Configured bool `json:"configured"`
|
||||
Connected bool `json:"connected"`
|
||||
Running bool `json:"running"`
|
||||
StartFailed bool `json:"startFailed"`
|
||||
PortConflict bool `json:"portConflict"`
|
||||
GatewayPort int `json:"gatewayPort,omitempty"`
|
||||
RoutedRequests uint64 `json:"routedRequests"`
|
||||
Error string `json:"error,omitempty"`
|
||||
AutoMode bool `json:"autoMode"`
|
||||
ModelSource string `json:"modelSource,omitempty"`
|
||||
Models []claudeDesktopModelStatus `json:"models,omitempty"`
|
||||
Mappings []claudeDesktopMappingStatus `json:"mappings,omitempty"`
|
||||
}
|
||||
|
||||
type claudeDesktopMappingStatus struct {
|
||||
RouteID string `json:"routeId"`
|
||||
RouteName string `json:"routeName"`
|
||||
Model string `json:"model,omitempty"`
|
||||
}
|
||||
|
||||
type claudeDesktopModelStatus struct {
|
||||
Name string `json:"name"`
|
||||
DisplayName string `json:"displayName"`
|
||||
Description string `json:"description,omitempty"`
|
||||
Cloud bool `json:"cloud"`
|
||||
Selected bool `json:"selected"`
|
||||
AutoMode bool `json:"autoMode"`
|
||||
Availability proxy.ClaudeDesktopAvailability `json:"availability"`
|
||||
Reason proxy.ClaudeDesktopAccessReason `json:"reason,omitempty"`
|
||||
RequiredPlan string `json:"requiredPlan,omitempty"`
|
||||
}
|
||||
|
||||
type claudeDesktopActionResult struct {
|
||||
Status claudeDesktopStatus `json:"status"`
|
||||
Error string `json:"error,omitempty"`
|
||||
MappingsApplied bool `json:"mappingsApplied,omitempty"`
|
||||
RestartConfirmationRequired bool `json:"restartConfirmationRequired,omitempty"`
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
//go:build darwin
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"log/slog"
|
||||
|
||||
"github.com/ollama/ollama/app/webview"
|
||||
)
|
||||
|
||||
func codexDesktopModelRefreshError(settings codexDesktopModelsSettings) string {
|
||||
if len(settings.Selected) > 0 {
|
||||
return "Couldn’t refresh available models. Your saved models are unchanged."
|
||||
}
|
||||
return "Couldn’t refresh available models. Try again."
|
||||
}
|
||||
|
||||
func bindCodexDesktop(wv webview.WebView) {
|
||||
wv.Bind("getCodexDesktopStatus", func() codexDesktopStatus {
|
||||
return getCodexDesktopStatus()
|
||||
})
|
||||
wv.Bind("getCodexDesktopRequestCount", func() uint64 {
|
||||
return codexDesktop.OllamaRequestCount()
|
||||
})
|
||||
wv.Bind("setCodexDesktopConnected", func(enabled, restartConfirmed bool) codexDesktopActionResult {
|
||||
err := setCodexDesktopConnection(enabled, restartConfirmed)
|
||||
result := codexDesktopActionResult{Status: getCodexDesktopStatus()}
|
||||
if errors.Is(err, errCodexDesktopRestartConfirmationRequired) {
|
||||
result.RestartConfirmationRequired = true
|
||||
} else if err != nil {
|
||||
result.Error = err.Error()
|
||||
slog.Warn("failed to change ChatGPT integration from Settings", "connected", enabled, "error", err)
|
||||
}
|
||||
return result
|
||||
})
|
||||
wv.Bind("installCodexDesktop", func() codexDesktopInstallResult {
|
||||
return requestCodexDesktopInstall()
|
||||
})
|
||||
wv.Bind("getCodexDesktopModelsSettings", func() codexDesktopModelsSettingsResult {
|
||||
settings, err := getCodexDesktopModelsSettings()
|
||||
result := codexDesktopModelsSettingsResult{Settings: settings}
|
||||
if err != nil {
|
||||
result.Warning = codexDesktopModelRefreshError(settings)
|
||||
slog.Warn("failed to refresh available ChatGPT models", "error", err)
|
||||
}
|
||||
return result
|
||||
})
|
||||
wv.Bind("applyCodexDesktopModels", func(models []string, restartConfirmed bool) codexDesktopModelsSettingsResult {
|
||||
err := applyCodexDesktopModels(models, restartConfirmed)
|
||||
settings, statusErr := getCodexDesktopModelsSettings()
|
||||
result := codexDesktopModelsSettingsResult{Settings: settings}
|
||||
if errors.Is(err, errCodexDesktopRestartConfirmationRequired) {
|
||||
result.RestartConfirmationRequired = true
|
||||
} else if err != nil {
|
||||
result.Error = err.Error()
|
||||
} else if statusErr != nil {
|
||||
result.Warning = codexDesktopModelRefreshError(settings)
|
||||
slog.Warn("failed to refresh available ChatGPT models after applying settings", "error", statusErr)
|
||||
}
|
||||
return result
|
||||
})
|
||||
wv.Bind("resetCodexDesktopModels", func() codexDesktopModelsSettingsResult {
|
||||
err := resetCodexDesktopModels()
|
||||
settings, statusErr := getCodexDesktopModelsSettings()
|
||||
result := codexDesktopModelsSettingsResult{Settings: settings}
|
||||
if err != nil {
|
||||
result.Error = err.Error()
|
||||
} else if statusErr != nil {
|
||||
result.Warning = codexDesktopModelRefreshError(settings)
|
||||
slog.Warn("failed to refresh available ChatGPT models after resetting settings", "error", statusErr)
|
||||
}
|
||||
return result
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
//go:build windows
|
||||
|
||||
package main
|
||||
|
||||
import "github.com/ollama/ollama/app/webview"
|
||||
|
||||
func bindCodexDesktop(_ webview.WebView) {}
|
||||
@@ -0,0 +1,979 @@
|
||||
//go:build darwin
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"slices"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/ollama/ollama/api"
|
||||
appui "github.com/ollama/ollama/app/ui"
|
||||
"github.com/ollama/ollama/cmd/config"
|
||||
"github.com/ollama/ollama/cmd/launch"
|
||||
"github.com/ollama/ollama/internal/modelref"
|
||||
"github.com/ollama/ollama/internal/proxy"
|
||||
modelpkg "github.com/ollama/ollama/types/model"
|
||||
)
|
||||
|
||||
const (
|
||||
codexDesktopIntegrationName = "chatgpt"
|
||||
codexDesktopMaxModels = 5
|
||||
codexDesktopRecommendationsMaxBody = 1 << 20
|
||||
)
|
||||
|
||||
var errCodexDesktopRestartConfirmationRequired = launch.ErrCodexAppRestartConfirmationRequired
|
||||
|
||||
type codexDesktopController interface {
|
||||
Installed() bool
|
||||
OllamaConfigured() bool
|
||||
Running() bool
|
||||
OllamaRequestCount() uint64
|
||||
UseOllamaFromDesktop(string, []launch.LaunchModel, bool) error
|
||||
UpdateOllamaModelsFromDesktop(string, []launch.LaunchModel, bool) error
|
||||
RestoreFromDesktop(bool) error
|
||||
RestartFromDesktop(bool) error
|
||||
Onboard() error
|
||||
}
|
||||
|
||||
var (
|
||||
codexDesktop codexDesktopController = &launch.CodexApp{}
|
||||
codexDesktopClientFactory = api.ClientFromEnvironment
|
||||
codexDesktopLoadModels = loadCodexDesktopModels
|
||||
codexDesktopLoadConnectionModels = loadCodexDesktopConnectionModels
|
||||
codexDesktopCloudModels = loadCodexDesktopAccountCloudModels
|
||||
codexDesktopRecommendations = loadCodexDesktopRecommendations
|
||||
codexDesktopAccessState = currentClaudeDesktopAccessState
|
||||
codexDesktopRecommendationsClient = &http.Client{Timeout: 3 * time.Second}
|
||||
codexDesktopRecommendationsEndpoint = func() string {
|
||||
return strings.TrimRight(appui.OllamaDotCom, "/") + "/api/experimental/model-recommendations?app=codex-desktop"
|
||||
}
|
||||
codexDesktopModelLoadAttempts = 20
|
||||
codexDesktopModelRetryWait = 250 * time.Millisecond
|
||||
codexDesktopMu sync.Mutex
|
||||
)
|
||||
|
||||
type codexDesktopStatus struct {
|
||||
Supported bool `json:"supported"`
|
||||
Installed bool `json:"installed"`
|
||||
Connected bool `json:"connected"`
|
||||
Running bool `json:"running"`
|
||||
Model string `json:"model,omitempty"`
|
||||
Models []string `json:"models,omitempty"`
|
||||
MaxModels int `json:"maxModels"`
|
||||
Requests uint64 `json:"requests"`
|
||||
}
|
||||
|
||||
type codexDesktopActionResult struct {
|
||||
Status codexDesktopStatus `json:"status"`
|
||||
Error string `json:"error,omitempty"`
|
||||
RestartConfirmationRequired bool `json:"restartConfirmationRequired,omitempty"`
|
||||
}
|
||||
|
||||
type codexDesktopInstallResult string
|
||||
|
||||
const (
|
||||
codexDesktopInstallCancelled codexDesktopInstallResult = "cancelled"
|
||||
codexDesktopInstallerOpened codexDesktopInstallResult = "opened"
|
||||
codexDesktopInstallFailed codexDesktopInstallResult = "failed"
|
||||
)
|
||||
|
||||
type codexDesktopModelsSettings struct {
|
||||
Supported bool `json:"supported"`
|
||||
Installed bool `json:"installed"`
|
||||
Connected bool `json:"connected"`
|
||||
Running bool `json:"running"`
|
||||
// UsesDefaults keeps recommendations implicit without overwriting saved choices.
|
||||
UsesDefaults bool `json:"usesDefaults"`
|
||||
Selected []string `json:"selected"`
|
||||
Available []string `json:"available"`
|
||||
Models []codexDesktopModelStatus `json:"models"`
|
||||
MaxModels int `json:"maxModels"`
|
||||
}
|
||||
|
||||
type codexDesktopModelStatus struct {
|
||||
Name string `json:"name"`
|
||||
DisplayName string `json:"displayName"`
|
||||
Description string `json:"description,omitempty"`
|
||||
Recommended bool `json:"recommended,omitempty"`
|
||||
Selected bool `json:"selected"`
|
||||
Availability string `json:"availability"`
|
||||
Reason string `json:"reason,omitempty"`
|
||||
RequiredPlan string `json:"requiredPlan,omitempty"`
|
||||
}
|
||||
|
||||
type codexDesktopModelsSettingsResult struct {
|
||||
Settings codexDesktopModelsSettings `json:"settings"`
|
||||
Error string `json:"error,omitempty"`
|
||||
Warning string `json:"warning,omitempty"`
|
||||
RestartConfirmationRequired bool `json:"restartConfirmationRequired,omitempty"`
|
||||
}
|
||||
|
||||
type codexDesktopModelInventory struct {
|
||||
Available []launch.LaunchModel
|
||||
Catalog []codexDesktopCatalogModel
|
||||
Defaults []launch.LaunchModel
|
||||
DefaultPrimary string
|
||||
}
|
||||
|
||||
type codexDesktopCatalogModel struct {
|
||||
Model launch.LaunchModel
|
||||
DisplayName string
|
||||
Description string
|
||||
Recommended bool
|
||||
Availability proxy.ClaudeDesktopAvailability
|
||||
Reason proxy.ClaudeDesktopAccessReason
|
||||
RequiredPlan string
|
||||
}
|
||||
|
||||
func getCodexDesktopStatus() codexDesktopStatus {
|
||||
connected := codexDesktop.OllamaConfigured()
|
||||
requests := uint64(0)
|
||||
if connected {
|
||||
requests = codexDesktop.OllamaRequestCount()
|
||||
}
|
||||
var models []string
|
||||
if saved, err := config.LoadIntegration(codexDesktopIntegrationName); err == nil && len(saved.Models) > 0 {
|
||||
models = append([]string(nil), saved.Models...)
|
||||
}
|
||||
model := ""
|
||||
if len(models) > 0 {
|
||||
model = models[0]
|
||||
}
|
||||
return codexDesktopStatus{
|
||||
Supported: true,
|
||||
Installed: codexDesktop.Installed(),
|
||||
Connected: connected,
|
||||
Running: codexDesktop.Running(),
|
||||
Model: model,
|
||||
Models: models,
|
||||
MaxModels: codexDesktopMaxModels,
|
||||
Requests: requests,
|
||||
}
|
||||
}
|
||||
|
||||
func setCodexDesktopConnection(enabled, restartConfirmed bool) error {
|
||||
codexDesktopMu.Lock()
|
||||
defer codexDesktopMu.Unlock()
|
||||
|
||||
if enabled == codexDesktop.OllamaConfigured() {
|
||||
return nil
|
||||
}
|
||||
if !enabled {
|
||||
if codexDesktop.Running() && !restartConfirmed {
|
||||
return errCodexDesktopRestartConfirmationRequired
|
||||
}
|
||||
return codexDesktop.RestoreFromDesktop(restartConfirmed)
|
||||
}
|
||||
if !codexDesktop.Installed() {
|
||||
return errors.New("ChatGPT is not installed")
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
savedSelection := config.IntegrationModels(codexDesktopIntegrationName)
|
||||
primary, models, err := codexDesktopLoadConnectionModels(ctx, savedSelection)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// Validate before requesting a restart; do not change the profile without consent.
|
||||
if codexDesktop.Running() && !restartConfirmed {
|
||||
return errCodexDesktopRestartConfirmationRequired
|
||||
}
|
||||
previous := config.IntegrationModels(codexDesktopIntegrationName)
|
||||
if err := config.SaveIntegration(codexDesktopIntegrationName, savedSelection); err != nil {
|
||||
return fmt.Errorf("save ChatGPT integration: %w", err)
|
||||
}
|
||||
if err := codexDesktop.Onboard(); err != nil {
|
||||
_ = config.SaveIntegration(codexDesktopIntegrationName, previous)
|
||||
return fmt.Errorf("save ChatGPT integration state: %w", err)
|
||||
}
|
||||
if err := codexDesktop.UseOllamaFromDesktop(primary, models, restartConfirmed); err != nil {
|
||||
_ = config.SaveIntegration(codexDesktopIntegrationName, previous)
|
||||
if errors.Is(err, errCodexDesktopRestartConfirmationRequired) {
|
||||
return err
|
||||
}
|
||||
if codexDesktop.OllamaConfigured() {
|
||||
if restoreErr := codexDesktop.RestoreFromDesktop(true); restoreErr != nil {
|
||||
return errors.Join(err, fmt.Errorf("restore ChatGPT after failed update: %w", restoreErr))
|
||||
}
|
||||
}
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func getCodexDesktopModelsSettings() (codexDesktopModelsSettings, error) {
|
||||
settings := codexDesktopModelsSettings{
|
||||
Supported: true,
|
||||
Installed: codexDesktop.Installed(),
|
||||
Connected: codexDesktop.OllamaConfigured(),
|
||||
Running: codexDesktop.Running(),
|
||||
Selected: []string{},
|
||||
Available: []string{},
|
||||
Models: []codexDesktopModelStatus{},
|
||||
MaxModels: codexDesktopMaxModels,
|
||||
}
|
||||
// Keep restart available when inventory cannot be loaded.
|
||||
settings.Selected = config.IntegrationModels(codexDesktopIntegrationName)
|
||||
settings.UsesDefaults = len(settings.Selected) == 0
|
||||
if len(settings.Selected) > codexDesktopMaxModels {
|
||||
settings.Selected = settings.Selected[:codexDesktopMaxModels]
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
inventory, err := loadCodexDesktopModelInventory(ctx)
|
||||
if err != nil {
|
||||
return settings, err
|
||||
}
|
||||
settings.Available = codexDesktopModelNames(inventory.Available)
|
||||
if len(settings.Selected) == 0 {
|
||||
settings.Selected = codexDesktopModelNames(codexDesktopDefaultModels(inventory))
|
||||
}
|
||||
settings.Models = codexDesktopModelStatuses(inventory, settings.Selected)
|
||||
return settings, nil
|
||||
}
|
||||
|
||||
func applyCodexDesktopModels(selected []string, restartConfirmed bool) error {
|
||||
codexDesktopMu.Lock()
|
||||
defer codexDesktopMu.Unlock()
|
||||
return applyCodexDesktopModelsLocked(selected, restartConfirmed, true)
|
||||
}
|
||||
|
||||
func resetCodexDesktopModels() error {
|
||||
codexDesktopMu.Lock()
|
||||
defer codexDesktopMu.Unlock()
|
||||
|
||||
// Reset preferences without enabling the integration or restarting ChatGPT.
|
||||
if len(config.IntegrationModels(codexDesktopIntegrationName)) == 0 && !codexDesktop.OllamaConfigured() {
|
||||
return nil
|
||||
}
|
||||
if err := config.SaveIntegration(codexDesktopIntegrationName, nil); err != nil {
|
||||
return fmt.Errorf("reset ChatGPT models: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func applyCodexDesktopModelsLocked(selected []string, restartConfirmed, openWhenStopped bool) error {
|
||||
previous := config.IntegrationModels(codexDesktopIntegrationName)
|
||||
savedSelection := append([]string(nil), selected...)
|
||||
wasConfigured := codexDesktop.OllamaConfigured()
|
||||
selectionUnchanged := slices.Equal(savedSelection, previous)
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
primary, models, err := codexDesktopLoadModels(ctx, selected)
|
||||
if err != nil {
|
||||
if openWhenStopped && wasConfigured && selectionUnchanged {
|
||||
return codexDesktop.RestartFromDesktop(restartConfirmed)
|
||||
}
|
||||
return err
|
||||
}
|
||||
if !wasConfigured && !openWhenStopped {
|
||||
if err := config.SaveIntegration(codexDesktopIntegrationName, savedSelection); err != nil {
|
||||
return fmt.Errorf("save ChatGPT models: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
running := codexDesktop.Running()
|
||||
if running && !restartConfirmed {
|
||||
return errCodexDesktopRestartConfirmationRequired
|
||||
}
|
||||
if err := config.SaveIntegration(codexDesktopIntegrationName, savedSelection); err != nil {
|
||||
return fmt.Errorf("save ChatGPT models: %w", err)
|
||||
}
|
||||
updateModels := codexDesktop.UseOllamaFromDesktop
|
||||
if !openWhenStopped {
|
||||
updateModels = codexDesktop.UpdateOllamaModelsFromDesktop
|
||||
}
|
||||
if err := updateModels(primary, models, restartConfirmed); err == nil {
|
||||
return nil
|
||||
} else if errors.Is(err, errCodexDesktopRestartConfirmationRequired) {
|
||||
_ = config.SaveIntegration(codexDesktopIntegrationName, previous)
|
||||
return err
|
||||
} else if !wasConfigured {
|
||||
if codexDesktop.OllamaConfigured() {
|
||||
if restoreErr := codexDesktop.RestoreFromDesktop(true); restoreErr != nil {
|
||||
_ = config.SaveIntegration(codexDesktopIntegrationName, previous)
|
||||
return errors.Join(err, fmt.Errorf("restore ChatGPT after failed update: %w", restoreErr))
|
||||
}
|
||||
}
|
||||
_ = config.SaveIntegration(codexDesktopIntegrationName, previous)
|
||||
return fmt.Errorf("start ChatGPT with selected Ollama models: %w", err)
|
||||
} else {
|
||||
applyErr := err
|
||||
_ = config.SaveIntegration(codexDesktopIntegrationName, previous)
|
||||
rollbackCtx, rollbackCancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer rollbackCancel()
|
||||
rollbackPrimary, rollbackModels, rollbackErr := codexDesktopLoadModels(rollbackCtx, previous)
|
||||
if rollbackErr == nil {
|
||||
rollbackErr = updateModels(rollbackPrimary, rollbackModels, true)
|
||||
}
|
||||
if rollbackErr != nil {
|
||||
// Restore the original profile if the previous selection is no longer usable.
|
||||
if restoreErr := codexDesktop.RestoreFromDesktop(true); restoreErr != nil {
|
||||
return errors.Join(
|
||||
fmt.Errorf("apply ChatGPT models: %v; restore previous Ollama profile: %w", applyErr, rollbackErr),
|
||||
fmt.Errorf("restore normal ChatGPT profile: %w", restoreErr),
|
||||
)
|
||||
}
|
||||
return fmt.Errorf("apply ChatGPT models: %v; restore previous Ollama profile: %v; restored the normal ChatGPT profile", applyErr, rollbackErr)
|
||||
}
|
||||
return fmt.Errorf("apply ChatGPT models: %w", applyErr)
|
||||
}
|
||||
}
|
||||
|
||||
func loadCodexDesktopModels(ctx context.Context, selected []string) (string, []launch.LaunchModel, error) {
|
||||
inventory, err := loadCodexDesktopModelInventory(ctx)
|
||||
if err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
if len(selected) == 0 {
|
||||
selected = codexDesktopModelNames(codexDesktopDefaultModels(inventory))
|
||||
}
|
||||
_, models, err := selectCodexDesktopModels(selected, inventory.Available)
|
||||
if err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
primary := codexDesktopPreferredPrimary(inventory.DefaultPrimary, models)
|
||||
return primary, hydrateCodexDesktopModelCapabilities(ctx, models), nil
|
||||
}
|
||||
|
||||
func loadCodexDesktopConnectionModels(ctx context.Context, selected []string) (string, []launch.LaunchModel, error) {
|
||||
inventory, err := loadCodexDesktopModelInventory(ctx)
|
||||
if err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
defaults := codexDesktopDefaultModels(inventory)
|
||||
if len(selected) == 0 {
|
||||
selected = codexDesktopModelNames(defaults)
|
||||
}
|
||||
_, models, err := reconcileCodexDesktopModels(selected, inventory.Available, defaults)
|
||||
if err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
primary := codexDesktopPreferredPrimary(inventory.DefaultPrimary, models)
|
||||
return primary, hydrateCodexDesktopModelCapabilities(ctx, models), nil
|
||||
}
|
||||
|
||||
// /api/show supplies capabilities and family metadata without replacing recommended thinking controls.
|
||||
func hydrateCodexDesktopModelCapabilities(ctx context.Context, models []launch.LaunchModel) []launch.LaunchModel {
|
||||
client, err := codexDesktopClientFactory()
|
||||
if err != nil {
|
||||
return models
|
||||
}
|
||||
|
||||
hydrated := append([]launch.LaunchModel(nil), models...)
|
||||
for i := range hydrated {
|
||||
response, err := client.Show(ctx, &api.ShowRequest{Model: hydrated[i].Name})
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
if len(response.Capabilities) > 0 {
|
||||
hydrated[i].Capabilities = append([]modelpkg.Capability(nil), response.Capabilities...)
|
||||
}
|
||||
if response.Details.Family != "" || len(response.Details.Families) > 0 {
|
||||
hydrated[i].Details = response.Details
|
||||
}
|
||||
}
|
||||
return hydrated
|
||||
}
|
||||
|
||||
func loadCodexDesktopAvailableModels(ctx context.Context) ([]launch.LaunchModel, error) {
|
||||
inventory, err := loadCodexDesktopModelInventory(ctx)
|
||||
return inventory.Available, err
|
||||
}
|
||||
|
||||
func loadCodexDesktopModelInventory(ctx context.Context) (codexDesktopModelInventory, error) {
|
||||
client, err := codexDesktopClientFactory()
|
||||
if err != nil {
|
||||
return codexDesktopModelInventory{}, err
|
||||
}
|
||||
|
||||
recommendations, recommendationsErr := codexDesktopRecommendations(ctx)
|
||||
if recommendationsErr != nil {
|
||||
slog.Debug("could not load ChatGPT model recommendations", "error", recommendationsErr)
|
||||
}
|
||||
var access proxy.ClaudeDesktopAccessState
|
||||
accessKnown := false
|
||||
var last codexDesktopModelInventory
|
||||
for attempt := range codexDesktopModelLoadAttempts {
|
||||
if !accessKnown {
|
||||
resolved, accessErr := codexDesktopAccessState(ctx)
|
||||
if accessErr == nil {
|
||||
access = resolved
|
||||
accessKnown = true
|
||||
} else {
|
||||
slog.Debug("could not determine ChatGPT model access", "error", accessErr)
|
||||
}
|
||||
}
|
||||
|
||||
var listed []api.ListModelResponse
|
||||
listKnown := false
|
||||
if response, listErr := client.List(ctx); listErr == nil {
|
||||
listed = response.Models
|
||||
listKnown = true
|
||||
}
|
||||
var accountCloud []string
|
||||
cloudKnown := false
|
||||
if names, cloudErr := codexDesktopCloudModels(ctx); cloudErr == nil {
|
||||
accountCloud = names
|
||||
cloudKnown = true
|
||||
}
|
||||
|
||||
last = buildCodexDesktopModelInventory(recommendations, listed, accountCloud, access, accessKnown, listKnown, cloudKnown)
|
||||
// Retry access lookup failures even when recommendations are available.
|
||||
if len(last.Available) > 0 && (accessKnown || attempt+1 == codexDesktopModelLoadAttempts) {
|
||||
return last, nil
|
||||
}
|
||||
if attempt+1 == codexDesktopModelLoadAttempts {
|
||||
break
|
||||
}
|
||||
timer := time.NewTimer(codexDesktopModelRetryWait)
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
timer.Stop()
|
||||
return codexDesktopModelInventory{}, ctx.Err()
|
||||
case <-timer.C:
|
||||
}
|
||||
}
|
||||
if len(last.Catalog) > 0 {
|
||||
return last, nil
|
||||
}
|
||||
return codexDesktopModelInventory{}, errors.New("no Ollama models are available for ChatGPT")
|
||||
}
|
||||
|
||||
func loadCodexDesktopRecommendations(ctx context.Context) ([]api.ModelRecommendation, error) {
|
||||
req, err := newSignedOllamaRequest(ctx, http.MethodGet, codexDesktopRecommendationsEndpoint())
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("prepare ChatGPT model recommendations request: %w", err)
|
||||
}
|
||||
resp, err := codexDesktopRecommendationsClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("fetch ChatGPT model recommendations: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
_, _ = io.Copy(io.Discard, io.LimitReader(resp.Body, codexDesktopRecommendationsMaxBody))
|
||||
return nil, fmt.Errorf("fetch ChatGPT model recommendations: status %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
var payload api.ModelRecommendationsResponse
|
||||
decoder := json.NewDecoder(io.LimitReader(resp.Body, codexDesktopRecommendationsMaxBody+1))
|
||||
if err := decoder.Decode(&payload); err != nil {
|
||||
return nil, fmt.Errorf("decode ChatGPT model recommendations: %w", err)
|
||||
}
|
||||
if len(payload.Recommendations) == 0 {
|
||||
return nil, errors.New("ChatGPT model recommendations are empty")
|
||||
}
|
||||
return payload.Recommendations, nil
|
||||
}
|
||||
|
||||
func loadCodexDesktopAccountCloudModels(ctx context.Context) ([]string, error) {
|
||||
models, err := currentClaudeDesktopCloudModels(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
names := make([]string, 0, len(models))
|
||||
for _, model := range models {
|
||||
name := strings.TrimSpace(model.OllamaModel)
|
||||
if name == "" {
|
||||
name = strings.TrimSpace(model.Name)
|
||||
}
|
||||
if name != "" {
|
||||
names = append(names, name)
|
||||
}
|
||||
}
|
||||
return names, nil
|
||||
}
|
||||
|
||||
func buildCodexDesktopModelInventory(
|
||||
recommendations []api.ModelRecommendation,
|
||||
listed []api.ListModelResponse,
|
||||
accountCloud []string,
|
||||
access proxy.ClaudeDesktopAccessState,
|
||||
accessKnown, localInventoryKnown, cloudInventoryKnown bool,
|
||||
) codexDesktopModelInventory {
|
||||
actual := codexDesktopAvailableModels(listed, accountCloud)
|
||||
actualByName := make(map[string]launch.LaunchModel, len(actual))
|
||||
for _, model := range actual {
|
||||
actualByName[codexDesktopModelKey(model.Name)] = model
|
||||
}
|
||||
|
||||
seen := make(map[string]bool, len(actual)+len(recommendations))
|
||||
recommended := make([]codexDesktopCatalogModel, 0, len(recommendations))
|
||||
for _, recommendation := range recommendations {
|
||||
route := codexDesktopRecommendationRoute(recommendation)
|
||||
key := codexDesktopModelKey(route)
|
||||
if key == "" || seen[key] {
|
||||
continue
|
||||
}
|
||||
seen[key] = true
|
||||
|
||||
model, present := actualByName[key]
|
||||
if !present {
|
||||
model = launch.LaunchModel{Name: route, Remote: codexDesktopCloudModel(route)}
|
||||
}
|
||||
if recommendation.ContextLength > 0 {
|
||||
model.ContextLength = recommendation.ContextLength
|
||||
}
|
||||
if recommendation.MaxOutputTokens > 0 {
|
||||
model.MaxOutputTokens = recommendation.MaxOutputTokens
|
||||
}
|
||||
if recommendation.Thinking != nil {
|
||||
model.Thinking = recommendation.Thinking.Clone()
|
||||
}
|
||||
|
||||
availability, reason := codexDesktopRecommendationAccess(
|
||||
model,
|
||||
present,
|
||||
strings.TrimSpace(recommendation.RequiredPlan),
|
||||
access,
|
||||
accessKnown,
|
||||
localInventoryKnown,
|
||||
)
|
||||
entry := codexDesktopCatalogModel{
|
||||
Model: model,
|
||||
DisplayName: strings.TrimSpace(recommendation.Model),
|
||||
Description: strings.TrimSpace(recommendation.Description),
|
||||
Recommended: true,
|
||||
Availability: availability,
|
||||
Reason: reason,
|
||||
RequiredPlan: strings.TrimSpace(recommendation.RequiredPlan),
|
||||
}
|
||||
recommended = append(recommended, entry)
|
||||
}
|
||||
|
||||
extras := make([]codexDesktopCatalogModel, 0, len(actual))
|
||||
for _, model := range actual {
|
||||
key := codexDesktopModelKey(model.Name)
|
||||
if key == "" || seen[key] {
|
||||
continue
|
||||
}
|
||||
seen[key] = true
|
||||
availability, reason := codexDesktopInventoryModelAccess(model, access, accessKnown, cloudInventoryKnown)
|
||||
extras = append(extras, codexDesktopCatalogModel{
|
||||
Model: model,
|
||||
DisplayName: model.Name,
|
||||
Availability: availability,
|
||||
Reason: reason,
|
||||
})
|
||||
}
|
||||
|
||||
// Sort recommendations only; preserve saved list order.
|
||||
slices.SortStableFunc(recommended, func(a, b codexDesktopCatalogModel) int {
|
||||
return codexDesktopRecommendationPriority(a.Model.Name) - codexDesktopRecommendationPriority(b.Model.Name)
|
||||
})
|
||||
catalog := make([]codexDesktopCatalogModel, 0, len(recommended)+len(extras))
|
||||
catalog = append(catalog, recommended...)
|
||||
catalog = append(catalog, extras...)
|
||||
available := make([]launch.LaunchModel, 0, len(catalog))
|
||||
for _, entry := range catalog {
|
||||
// Recommendations remain configurable regardless of current availability.
|
||||
if entry.Recommended || entry.Availability == proxy.ClaudeDesktopAvailabilityAvailable {
|
||||
available = append(available, entry.Model)
|
||||
}
|
||||
}
|
||||
defaults := codexDesktopRecommendationDefaults(catalog)
|
||||
|
||||
return codexDesktopModelInventory{
|
||||
Available: available,
|
||||
Catalog: catalog,
|
||||
Defaults: defaults,
|
||||
DefaultPrimary: codexDesktopDefaultPrimary(catalog, defaults, access, accessKnown),
|
||||
}
|
||||
}
|
||||
|
||||
func codexDesktopRecommendationPriority(name string) int {
|
||||
switch codexDesktopModelKey(name) {
|
||||
case "kimi-k3:cloud":
|
||||
return 0
|
||||
case "glm-5.3:cloud":
|
||||
return 1
|
||||
case "glm-5.3-flash:cloud":
|
||||
return 2
|
||||
case "deepseek-v4-flash:cloud":
|
||||
return 3
|
||||
case "gemma4:31b:cloud":
|
||||
return 4
|
||||
default:
|
||||
return 5
|
||||
}
|
||||
}
|
||||
|
||||
func codexDesktopRecommendationRoute(recommendation api.ModelRecommendation) string {
|
||||
name := strings.TrimSpace(recommendation.Model)
|
||||
if name != "" && recommendation.RequiredPlan != "" && !modelref.HasExplicitCloudSource(name) {
|
||||
name += ":cloud"
|
||||
}
|
||||
return name
|
||||
}
|
||||
|
||||
func codexDesktopRecommendationAccess(
|
||||
model launch.LaunchModel,
|
||||
present bool,
|
||||
requiredPlan string,
|
||||
access proxy.ClaudeDesktopAccessState,
|
||||
accessKnown, localInventoryKnown bool,
|
||||
) (proxy.ClaudeDesktopAvailability, proxy.ClaudeDesktopAccessReason) {
|
||||
if !model.Remote {
|
||||
if !localInventoryKnown {
|
||||
return proxy.ClaudeDesktopAvailabilityUnknown, proxy.ClaudeDesktopAccessVerificationUnavailable
|
||||
}
|
||||
if present {
|
||||
return proxy.ClaudeDesktopAvailabilityAvailable, ""
|
||||
}
|
||||
return proxy.ClaudeDesktopAvailabilityUnavailable, proxy.ClaudeDesktopAccessModelNotInstalled
|
||||
}
|
||||
if !accessKnown {
|
||||
return proxy.ClaudeDesktopAvailabilityUnknown, proxy.ClaudeDesktopAccessVerificationUnavailable
|
||||
}
|
||||
if access.Cloud == proxy.ClaudeDesktopCloudOff {
|
||||
return proxy.ClaudeDesktopAvailabilityUnavailable, proxy.ClaudeDesktopAccessCloudOff
|
||||
}
|
||||
if access.Cloud != proxy.ClaudeDesktopCloudOn || access.Account == proxy.ClaudeDesktopAccountUnknown {
|
||||
return proxy.ClaudeDesktopAvailabilityUnknown, proxy.ClaudeDesktopAccessVerificationUnavailable
|
||||
}
|
||||
if access.Account == proxy.ClaudeDesktopAccountSignedOut {
|
||||
return proxy.ClaudeDesktopAvailabilityUnavailable, proxy.ClaudeDesktopAccessSignInRequired
|
||||
}
|
||||
if !codexDesktopPlanSatisfies(access.Plan, requiredPlan) {
|
||||
return proxy.ClaudeDesktopAvailabilityUnavailable, proxy.ClaudeDesktopAccessUpgradeRequired
|
||||
}
|
||||
// Recommended cloud models need not appear in /api/tags.
|
||||
return proxy.ClaudeDesktopAvailabilityAvailable, ""
|
||||
}
|
||||
|
||||
func codexDesktopInventoryModelAccess(
|
||||
model launch.LaunchModel,
|
||||
access proxy.ClaudeDesktopAccessState,
|
||||
accessKnown, cloudInventoryKnown bool,
|
||||
) (proxy.ClaudeDesktopAvailability, proxy.ClaudeDesktopAccessReason) {
|
||||
if !model.Remote {
|
||||
return proxy.ClaudeDesktopAvailabilityAvailable, ""
|
||||
}
|
||||
if !accessKnown {
|
||||
return proxy.ClaudeDesktopAvailabilityUnknown, proxy.ClaudeDesktopAccessVerificationUnavailable
|
||||
}
|
||||
if access.Cloud == proxy.ClaudeDesktopCloudOff {
|
||||
return proxy.ClaudeDesktopAvailabilityUnavailable, proxy.ClaudeDesktopAccessCloudOff
|
||||
}
|
||||
if !cloudInventoryKnown || access.Cloud != proxy.ClaudeDesktopCloudOn || access.Account == proxy.ClaudeDesktopAccountUnknown {
|
||||
return proxy.ClaudeDesktopAvailabilityUnknown, proxy.ClaudeDesktopAccessVerificationUnavailable
|
||||
}
|
||||
if access.Account == proxy.ClaudeDesktopAccountSignedOut {
|
||||
return proxy.ClaudeDesktopAvailabilityUnavailable, proxy.ClaudeDesktopAccessSignInRequired
|
||||
}
|
||||
return proxy.ClaudeDesktopAvailabilityAvailable, ""
|
||||
}
|
||||
|
||||
func codexDesktopPlanSatisfies(plan, required string) bool {
|
||||
plan = strings.ToLower(strings.TrimSpace(plan))
|
||||
required = strings.ToLower(strings.TrimSpace(required))
|
||||
if required == "" || required == "free" {
|
||||
return true
|
||||
}
|
||||
return plan != "" && plan != "free"
|
||||
}
|
||||
|
||||
func codexDesktopRecommendationDefaults(catalog []codexDesktopCatalogModel) []launch.LaunchModel {
|
||||
defaults := make([]launch.LaunchModel, 0, codexDesktopMaxModels)
|
||||
for _, entry := range catalog {
|
||||
if !entry.Recommended {
|
||||
continue
|
||||
}
|
||||
defaults = append(defaults, entry.Model)
|
||||
if len(defaults) == codexDesktopMaxModels {
|
||||
return defaults
|
||||
}
|
||||
}
|
||||
if len(defaults) > 0 {
|
||||
return defaults
|
||||
}
|
||||
|
||||
for _, entry := range catalog {
|
||||
if entry.Availability != proxy.ClaudeDesktopAvailabilityAvailable {
|
||||
continue
|
||||
}
|
||||
defaults = append(defaults, entry.Model)
|
||||
if len(defaults) == codexDesktopMaxModels {
|
||||
break
|
||||
}
|
||||
}
|
||||
return defaults
|
||||
}
|
||||
|
||||
func codexDesktopDefaultPrimary(
|
||||
catalog []codexDesktopCatalogModel,
|
||||
defaults []launch.LaunchModel,
|
||||
access proxy.ClaudeDesktopAccessState,
|
||||
accessKnown bool,
|
||||
) string {
|
||||
if len(defaults) == 0 {
|
||||
return ""
|
||||
}
|
||||
|
||||
// Choose the starting model independently of picker order.
|
||||
if accessKnown && access.Account == proxy.ClaudeDesktopAccountSignedIn && codexDesktopPlanSatisfies(access.Plan, "pro") {
|
||||
return codexDesktopPreferredPrimary("glm-5.3-flash:cloud", defaults)
|
||||
}
|
||||
for _, entry := range catalog {
|
||||
if !entry.Recommended {
|
||||
continue
|
||||
}
|
||||
required := strings.ToLower(strings.TrimSpace(entry.RequiredPlan))
|
||||
if required == "" || required == "free" {
|
||||
return entry.Model.Name
|
||||
}
|
||||
}
|
||||
return defaults[0].Name
|
||||
}
|
||||
|
||||
func codexDesktopPreferredPrimary(preferred string, models []launch.LaunchModel) string {
|
||||
preferredKey := codexDesktopModelKey(preferred)
|
||||
for _, model := range models {
|
||||
if preferredKey != "" && codexDesktopModelKey(model.Name) == preferredKey {
|
||||
return model.Name
|
||||
}
|
||||
}
|
||||
if len(models) > 0 {
|
||||
return models[0].Name
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func codexDesktopModelStatuses(inventory codexDesktopModelInventory, selected []string) []codexDesktopModelStatus {
|
||||
selectedSet := make(map[string]bool, len(selected))
|
||||
for _, name := range selected {
|
||||
selectedSet[codexDesktopModelKey(name)] = true
|
||||
}
|
||||
statuses := make([]codexDesktopModelStatus, 0, len(inventory.Catalog)+len(selected))
|
||||
seen := make(map[string]bool, cap(statuses))
|
||||
for _, entry := range inventory.Catalog {
|
||||
key := codexDesktopModelKey(entry.Model.Name)
|
||||
seen[key] = true
|
||||
displayName := entry.DisplayName
|
||||
if displayName == "" {
|
||||
displayName = entry.Model.Name
|
||||
}
|
||||
statuses = append(statuses, codexDesktopModelStatus{
|
||||
Name: entry.Model.Name,
|
||||
DisplayName: displayName,
|
||||
Description: entry.Description,
|
||||
Recommended: entry.Recommended,
|
||||
Selected: selectedSet[key],
|
||||
Availability: string(entry.Availability),
|
||||
Reason: string(entry.Reason),
|
||||
RequiredPlan: entry.RequiredPlan,
|
||||
})
|
||||
}
|
||||
for _, name := range selected {
|
||||
key := codexDesktopModelKey(name)
|
||||
if key == "" || seen[key] {
|
||||
continue
|
||||
}
|
||||
seen[key] = true
|
||||
statuses = append(statuses, codexDesktopModelStatus{
|
||||
Name: name,
|
||||
DisplayName: name,
|
||||
Selected: true,
|
||||
Availability: string(proxy.ClaudeDesktopAvailabilityUnknown),
|
||||
Reason: string(proxy.ClaudeDesktopAccessVerificationUnavailable),
|
||||
})
|
||||
}
|
||||
return statuses
|
||||
}
|
||||
|
||||
func buildCodexDesktopModels(selected []string, listed []api.ListModelResponse, accountCloud []string) (string, []launch.LaunchModel, error) {
|
||||
available := codexDesktopAvailableModels(listed, accountCloud)
|
||||
return selectCodexDesktopModels(selected, available)
|
||||
}
|
||||
|
||||
func codexDesktopAvailableModels(listed []api.ListModelResponse, accountCloud []string) []launch.LaunchModel {
|
||||
installed := make(map[string]api.ListModelResponse, len(listed))
|
||||
for _, model := range listed {
|
||||
for _, name := range []string{model.Name, model.Model} {
|
||||
if key := codexDesktopModelKey(name); key != "" {
|
||||
installed[key] = model
|
||||
}
|
||||
}
|
||||
}
|
||||
accountCloudSet := make(map[string]bool, len(accountCloud))
|
||||
for _, name := range accountCloud {
|
||||
if key := codexDesktopModelKey(name); key != "" {
|
||||
accountCloudSet[key] = true
|
||||
}
|
||||
}
|
||||
models := make([]launch.LaunchModel, 0, len(listed)+len(accountCloud))
|
||||
seen := make(map[string]bool, cap(models))
|
||||
add := func(model launch.LaunchModel) {
|
||||
model.Name = strings.TrimSpace(model.Name)
|
||||
key := codexDesktopModelKey(model.Name)
|
||||
if key == "" || seen[key] {
|
||||
return
|
||||
}
|
||||
seen[key] = true
|
||||
models = append(models, model)
|
||||
}
|
||||
|
||||
for _, model := range listed {
|
||||
if codexDesktopListedModelIsCloud(model) && !accountCloudSet[codexDesktopModelKey(model.Name)] && !accountCloudSet[codexDesktopModelKey(model.Model)] {
|
||||
continue
|
||||
}
|
||||
add(codexDesktopLaunchModel(model))
|
||||
}
|
||||
for _, name := range accountCloud {
|
||||
key := codexDesktopModelKey(name)
|
||||
if listedModel, ok := installed[key]; ok {
|
||||
add(codexDesktopLaunchModel(listedModel))
|
||||
continue
|
||||
}
|
||||
add(launch.LaunchModel{Name: strings.TrimSpace(name), Remote: true})
|
||||
}
|
||||
|
||||
return models
|
||||
}
|
||||
|
||||
func codexDesktopDefaultModels(inventory codexDesktopModelInventory) []launch.LaunchModel {
|
||||
if inventory.Catalog != nil || inventory.Defaults != nil {
|
||||
return append([]launch.LaunchModel(nil), inventory.Defaults...)
|
||||
}
|
||||
return append([]launch.LaunchModel(nil), inventory.Available[:min(len(inventory.Available), codexDesktopMaxModels)]...)
|
||||
}
|
||||
|
||||
func codexDesktopListedModelIsCloud(model api.ListModelResponse) bool {
|
||||
return model.RemoteModel != "" || model.RemoteHost != "" ||
|
||||
codexDesktopCloudModel(model.Name) ||
|
||||
codexDesktopCloudModel(model.Model)
|
||||
}
|
||||
|
||||
func selectCodexDesktopModels(selected []string, available []launch.LaunchModel) (string, []launch.LaunchModel, error) {
|
||||
byName := make(map[string]launch.LaunchModel, len(available))
|
||||
for _, model := range available {
|
||||
byName[codexDesktopModelKey(model.Name)] = model
|
||||
}
|
||||
|
||||
if len(selected) > codexDesktopMaxModels {
|
||||
return "", nil, fmt.Errorf("choose up to %d models for ChatGPT", codexDesktopMaxModels)
|
||||
}
|
||||
resolved := make([]launch.LaunchModel, 0, codexDesktopMaxModels)
|
||||
seen := make(map[string]bool, codexDesktopMaxModels)
|
||||
for _, name := range selected {
|
||||
name = strings.TrimSpace(name)
|
||||
key := codexDesktopModelKey(name)
|
||||
if key == "" || seen[key] {
|
||||
continue
|
||||
}
|
||||
model, ok := byName[key]
|
||||
if !ok {
|
||||
return "", nil, fmt.Errorf("ChatGPT model %q is not available", name)
|
||||
}
|
||||
seen[key] = true
|
||||
resolved = append(resolved, model)
|
||||
}
|
||||
|
||||
if len(selected) == 0 {
|
||||
for _, model := range available {
|
||||
if len(resolved) == codexDesktopMaxModels {
|
||||
break
|
||||
}
|
||||
key := codexDesktopModelKey(model.Name)
|
||||
if key == "" || seen[key] {
|
||||
continue
|
||||
}
|
||||
seen[key] = true
|
||||
resolved = append(resolved, model)
|
||||
}
|
||||
}
|
||||
if len(resolved) == 0 {
|
||||
return "", nil, errors.New("choose at least one available Ollama model for ChatGPT")
|
||||
}
|
||||
return resolved[0].Name, resolved, nil
|
||||
}
|
||||
|
||||
// Reopening tolerates stale selections; explicit Settings changes use strict validation.
|
||||
func reconcileCodexDesktopModels(selected []string, available, defaults []launch.LaunchModel) (string, []launch.LaunchModel, error) {
|
||||
if len(selected) == 0 {
|
||||
if len(defaults) > 0 {
|
||||
return selectCodexDesktopModels(codexDesktopModelNames(defaults), available)
|
||||
}
|
||||
return selectCodexDesktopModels(nil, available)
|
||||
}
|
||||
|
||||
byName := make(map[string]launch.LaunchModel, len(available))
|
||||
for _, model := range available {
|
||||
byName[codexDesktopModelKey(model.Name)] = model
|
||||
}
|
||||
resolved := make([]launch.LaunchModel, 0, min(len(selected), codexDesktopMaxModels))
|
||||
seen := make(map[string]bool, codexDesktopMaxModels)
|
||||
for _, name := range selected {
|
||||
key := codexDesktopModelKey(name)
|
||||
model, ok := byName[key]
|
||||
if key == "" || !ok || seen[key] {
|
||||
continue
|
||||
}
|
||||
seen[key] = true
|
||||
resolved = append(resolved, model)
|
||||
if len(resolved) == codexDesktopMaxModels {
|
||||
break
|
||||
}
|
||||
}
|
||||
if len(resolved) == 0 {
|
||||
if len(defaults) > 0 {
|
||||
return selectCodexDesktopModels(codexDesktopModelNames(defaults), available)
|
||||
}
|
||||
return selectCodexDesktopModels(nil, available)
|
||||
}
|
||||
return resolved[0].Name, resolved, nil
|
||||
}
|
||||
|
||||
func codexDesktopModelNames(models []launch.LaunchModel) []string {
|
||||
names := make([]string, 0, len(models))
|
||||
for _, model := range models {
|
||||
if name := strings.TrimSpace(model.Name); name != "" {
|
||||
names = append(names, name)
|
||||
}
|
||||
}
|
||||
return names
|
||||
}
|
||||
|
||||
func codexDesktopLaunchModel(model api.ListModelResponse) launch.LaunchModel {
|
||||
name := strings.TrimSpace(model.Name)
|
||||
if name == "" {
|
||||
name = strings.TrimSpace(model.Model)
|
||||
}
|
||||
return launch.LaunchModel{
|
||||
Name: name,
|
||||
Remote: model.RemoteModel != "" || model.RemoteHost != "" || codexDesktopCloudModel(name),
|
||||
Capabilities: append([]modelpkg.Capability(nil), model.Capabilities...),
|
||||
ContextLength: model.Details.ContextLength,
|
||||
EmbeddingLength: model.Details.EmbeddingLength,
|
||||
Size: model.Size,
|
||||
Details: model.Details,
|
||||
}
|
||||
}
|
||||
|
||||
func codexDesktopModelKey(name string) string {
|
||||
name = strings.TrimSpace(name)
|
||||
parsed, err := modelref.ParseRef(name)
|
||||
if err != nil {
|
||||
return strings.TrimSuffix(name, ":latest")
|
||||
}
|
||||
base := strings.TrimSuffix(strings.TrimSpace(parsed.Base), ":latest")
|
||||
if parsed.Source == modelref.ModelSourceCloud {
|
||||
return base + ":cloud"
|
||||
}
|
||||
return base
|
||||
}
|
||||
|
||||
func codexDesktopCloudModel(name string) bool {
|
||||
name = strings.ToLower(strings.TrimSpace(name))
|
||||
return strings.HasSuffix(name, ":cloud") || strings.HasSuffix(name, "-cloud")
|
||||
}
|
||||
@@ -0,0 +1,212 @@
|
||||
//go:build darwin
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const (
|
||||
maxCodexDesktopDiskImageBytes = 2 << 30
|
||||
codexDesktopBundleID = "com.openai.codex"
|
||||
codexDesktopTeamID = "2DC432GLL2"
|
||||
)
|
||||
|
||||
var errCodexDesktopDestinationExists = errors.New("ChatGPT installation destination already exists")
|
||||
|
||||
func codexDesktopInstallDestinations() []string {
|
||||
destinations := []string{"/Applications/ChatGPT.app"}
|
||||
if home, err := os.UserHomeDir(); err == nil {
|
||||
destinations = append(destinations, filepath.Join(home, "Applications", "ChatGPT.app"))
|
||||
}
|
||||
return destinations
|
||||
}
|
||||
|
||||
func installCodexDesktopDiskImage(imagePath string, destinations []string, verify func(string) error) (installedPath string, err error) {
|
||||
if len(destinations) == 0 {
|
||||
return "", errors.New("ChatGPT installation destination is required")
|
||||
}
|
||||
if verify == nil {
|
||||
return "", errors.New("ChatGPT bundle verifier is required")
|
||||
}
|
||||
info, err := os.Stat(imagePath)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("stat ChatGPT disk image: %w", err)
|
||||
}
|
||||
if !info.Mode().IsRegular() {
|
||||
return "", errors.New("ChatGPT disk image is not a regular file")
|
||||
}
|
||||
if info.Size() > maxCodexDesktopDiskImageBytes {
|
||||
return "", fmt.Errorf("ChatGPT disk image exceeds %d bytes", maxCodexDesktopDiskImageBytes)
|
||||
}
|
||||
|
||||
workDir, err := os.MkdirTemp("", "ollama-chatgpt-install-")
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("create ChatGPT installation directory: %w", err)
|
||||
}
|
||||
defer os.RemoveAll(workDir)
|
||||
mountPath := filepath.Join(workDir, "volume")
|
||||
if err := os.Mkdir(mountPath, 0o700); err != nil {
|
||||
return "", fmt.Errorf("create ChatGPT mount point: %w", err)
|
||||
}
|
||||
|
||||
output, err := exec.Command(
|
||||
"/usr/bin/hdiutil",
|
||||
"attach",
|
||||
"-nobrowse",
|
||||
"-readonly",
|
||||
"-mountpoint",
|
||||
mountPath,
|
||||
imagePath,
|
||||
).CombinedOutput()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("mount ChatGPT disk image: %w: %s", err, strings.TrimSpace(string(output)))
|
||||
}
|
||||
defer func() {
|
||||
detachOutput, detachErr := exec.Command("/usr/bin/hdiutil", "detach", mountPath).CombinedOutput()
|
||||
if detachErr == nil {
|
||||
return
|
||||
}
|
||||
forceOutput, forceErr := exec.Command("/usr/bin/hdiutil", "detach", "-force", mountPath).CombinedOutput()
|
||||
if forceErr != nil && err == nil {
|
||||
err = fmt.Errorf(
|
||||
"unmount ChatGPT disk image: %v: %s; force detach: %v: %s",
|
||||
detachErr,
|
||||
strings.TrimSpace(string(detachOutput)),
|
||||
forceErr,
|
||||
strings.TrimSpace(string(forceOutput)),
|
||||
)
|
||||
}
|
||||
}()
|
||||
|
||||
bundlePath, err := codexDesktopBundleOnVolume(mountPath)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return installCodexDesktopBundle(bundlePath, destinations, verify)
|
||||
}
|
||||
|
||||
func codexDesktopBundleOnVolume(mountPath string) (string, error) {
|
||||
for _, name := range []string{"ChatGPT.app", "Codex.app"} {
|
||||
bundlePath := filepath.Join(mountPath, name)
|
||||
info, err := os.Lstat(bundlePath)
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
continue
|
||||
}
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("inspect ChatGPT bundle: %w", err)
|
||||
}
|
||||
if info.Mode()&os.ModeSymlink != 0 || !info.IsDir() {
|
||||
return "", fmt.Errorf("ChatGPT disk image contains an invalid %s", name)
|
||||
}
|
||||
return bundlePath, nil
|
||||
}
|
||||
return "", errors.New("ChatGPT disk image does not contain ChatGPT.app")
|
||||
}
|
||||
|
||||
func installCodexDesktopBundle(bundlePath string, destinations []string, verify func(string) error) (string, error) {
|
||||
if err := validateCodexDesktopBundle(bundlePath); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if err := verify(bundlePath); err != nil {
|
||||
return "", fmt.Errorf("verify ChatGPT signature: %w", err)
|
||||
}
|
||||
|
||||
var permissionErr error
|
||||
for _, destination := range destinations {
|
||||
if strings.TrimSpace(destination) == "" {
|
||||
continue
|
||||
}
|
||||
if _, err := os.Lstat(destination); err == nil {
|
||||
return "", fmt.Errorf("%w: %s", errCodexDesktopDestinationExists, destination)
|
||||
} else if !errors.Is(err, os.ErrNotExist) {
|
||||
return "", fmt.Errorf("check ChatGPT destination %s: %w", destination, err)
|
||||
}
|
||||
parent := filepath.Dir(destination)
|
||||
if err := os.MkdirAll(parent, 0o755); err != nil {
|
||||
if errors.Is(err, os.ErrPermission) {
|
||||
permissionErr = err
|
||||
continue
|
||||
}
|
||||
return "", fmt.Errorf("create ChatGPT destination: %w", err)
|
||||
}
|
||||
stageDir, err := os.MkdirTemp(parent, ".ollama-chatgpt-install-")
|
||||
if err != nil {
|
||||
if errors.Is(err, os.ErrPermission) {
|
||||
permissionErr = err
|
||||
continue
|
||||
}
|
||||
return "", fmt.Errorf("create staged ChatGPT destination: %w", err)
|
||||
}
|
||||
stagedBundle := filepath.Join(stageDir, "ChatGPT.app")
|
||||
copyOutput, copyErr := exec.Command("/usr/bin/ditto", bundlePath, stagedBundle).CombinedOutput()
|
||||
if copyErr == nil {
|
||||
copyErr = validateCodexDesktopBundle(stagedBundle)
|
||||
}
|
||||
if copyErr == nil {
|
||||
copyErr = verify(stagedBundle)
|
||||
}
|
||||
if copyErr == nil {
|
||||
copyErr = os.Rename(stagedBundle, destination)
|
||||
}
|
||||
removeErr := os.RemoveAll(stageDir)
|
||||
if copyErr != nil {
|
||||
if errors.Is(copyErr, os.ErrPermission) {
|
||||
permissionErr = copyErr
|
||||
continue
|
||||
}
|
||||
return "", fmt.Errorf("install ChatGPT in %s: %w: %s", parent, copyErr, strings.TrimSpace(string(copyOutput)))
|
||||
}
|
||||
if removeErr != nil {
|
||||
return "", fmt.Errorf("remove staged ChatGPT destination: %w", removeErr)
|
||||
}
|
||||
return destination, nil
|
||||
}
|
||||
if permissionErr != nil {
|
||||
return "", fmt.Errorf("install ChatGPT in Applications: %w", permissionErr)
|
||||
}
|
||||
return "", errors.New("ChatGPT installation destination is required")
|
||||
}
|
||||
|
||||
func validateCodexDesktopBundle(bundlePath string) error {
|
||||
info, err := os.Stat(bundlePath)
|
||||
if err != nil || !info.IsDir() {
|
||||
return errors.New("ChatGPT disk image does not contain a valid app bundle")
|
||||
}
|
||||
for _, executableName := range []string{"ChatGPT", "Codex"} {
|
||||
executable := filepath.Join(bundlePath, "Contents", "MacOS", executableName)
|
||||
info, err = os.Stat(executable)
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
continue
|
||||
}
|
||||
if err != nil {
|
||||
return fmt.Errorf("inspect ChatGPT executable: %w", err)
|
||||
}
|
||||
if info.Mode().IsRegular() && info.Mode()&0o111 != 0 {
|
||||
return nil
|
||||
}
|
||||
return errors.New("ChatGPT executable is not executable")
|
||||
}
|
||||
return errors.New("ChatGPT executable is missing")
|
||||
}
|
||||
|
||||
func verifyCodexDesktopBundle(bundlePath string) error {
|
||||
if output, err := exec.Command("/usr/bin/codesign", "--verify", "--deep", "--strict", bundlePath).CombinedOutput(); err != nil {
|
||||
return fmt.Errorf("codesign verification failed: %w: %s", err, strings.TrimSpace(string(output)))
|
||||
}
|
||||
output, err := exec.Command("/usr/bin/codesign", "-d", "--verbose=4", bundlePath).CombinedOutput()
|
||||
if err != nil {
|
||||
return fmt.Errorf("read code signature: %w: %s", err, strings.TrimSpace(string(output)))
|
||||
}
|
||||
details := string(output)
|
||||
if !strings.Contains(details, "Identifier="+codexDesktopBundleID) ||
|
||||
!strings.Contains(details, "TeamIdentifier="+codexDesktopTeamID) {
|
||||
return errors.New("unexpected ChatGPT signing identity")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
//go:build darwin
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestInstallCodexDesktopBundle(t *testing.T) {
|
||||
bundle := writeCodexDesktopTestBundle(t, "ChatGPT.app", "ChatGPT")
|
||||
destination := filepath.Join(t.TempDir(), "Applications", "ChatGPT.app")
|
||||
verified := 0
|
||||
installed, err := installCodexDesktopBundle(bundle, []string{destination}, func(string) error {
|
||||
verified++
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if installed != destination {
|
||||
t.Fatalf("installed = %q, want %q", installed, destination)
|
||||
}
|
||||
if verified != 2 {
|
||||
t.Fatalf("signature verification count = %d, want 2", verified)
|
||||
}
|
||||
info, err := os.Stat(filepath.Join(installed, "Contents", "MacOS", "ChatGPT"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if info.Mode()&0o111 == 0 {
|
||||
t.Fatal("installed ChatGPT executable is not executable")
|
||||
}
|
||||
}
|
||||
|
||||
func TestInstallCodexDesktopBundleAcceptsCodexNamedSource(t *testing.T) {
|
||||
bundle := writeCodexDesktopTestBundle(t, "Codex.app", "Codex")
|
||||
destination := filepath.Join(t.TempDir(), "Applications", "ChatGPT.app")
|
||||
if _, err := installCodexDesktopBundle(bundle, []string{destination}, func(string) error { return nil }); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(destination, "Contents", "MacOS", "Codex")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInstallCodexDesktopBundleVerifiesBeforeCopy(t *testing.T) {
|
||||
bundle := writeCodexDesktopTestBundle(t, "ChatGPT.app", "ChatGPT")
|
||||
destination := filepath.Join(t.TempDir(), "ChatGPT.app")
|
||||
wantErr := errors.New("invalid signature")
|
||||
if _, err := installCodexDesktopBundle(bundle, []string{destination}, func(string) error { return wantErr }); !errors.Is(err, wantErr) {
|
||||
t.Fatalf("error = %v, want %v", err, wantErr)
|
||||
}
|
||||
if _, err := os.Stat(destination); !errors.Is(err, os.ErrNotExist) {
|
||||
t.Fatalf("invalid bundle created destination: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInstallCodexDesktopBundleDoesNotOverwrite(t *testing.T) {
|
||||
bundle := writeCodexDesktopTestBundle(t, "ChatGPT.app", "ChatGPT")
|
||||
destination := filepath.Join(t.TempDir(), "ChatGPT.app")
|
||||
if err := os.MkdirAll(destination, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := installCodexDesktopBundle(bundle, []string{destination}, func(string) error { return nil }); !errors.Is(err, errCodexDesktopDestinationExists) {
|
||||
t.Fatalf("error = %v, want destination exists", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInstallCodexDesktopBundleDoesNotOverwriteBrokenSymlink(t *testing.T) {
|
||||
bundle := writeCodexDesktopTestBundle(t, "ChatGPT.app", "ChatGPT")
|
||||
destination := filepath.Join(t.TempDir(), "ChatGPT.app")
|
||||
if err := os.Symlink(filepath.Join(t.TempDir(), "missing"), destination); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := installCodexDesktopBundle(bundle, []string{destination}, func(string) error { return nil }); !errors.Is(err, errCodexDesktopDestinationExists) {
|
||||
t.Fatalf("error = %v, want destination exists", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCodexDesktopBundleOnVolumeRejectsSymlink(t *testing.T) {
|
||||
volume := t.TempDir()
|
||||
target := writeCodexDesktopTestBundle(t, "ChatGPT.app", "ChatGPT")
|
||||
if err := os.Symlink(target, filepath.Join(volume, "ChatGPT.app")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := codexDesktopBundleOnVolume(volume); err == nil {
|
||||
t.Fatal("codexDesktopBundleOnVolume accepted a symlink")
|
||||
}
|
||||
}
|
||||
|
||||
func TestInstallCodexDesktopDiskImageRealArchive(t *testing.T) {
|
||||
image := os.Getenv("OLLAMA_TEST_CODEX_DESKTOP_DMG")
|
||||
if image == "" {
|
||||
t.Skip("set OLLAMA_TEST_CODEX_DESKTOP_DMG to the official ChatGPT DMG")
|
||||
}
|
||||
destination := filepath.Join(t.TempDir(), "Applications", "ChatGPT.app")
|
||||
installed, err := installCodexDesktopDiskImage(image, []string{destination}, verifyCodexDesktopBundle)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if installed != destination {
|
||||
t.Fatalf("installed = %q, want %q", installed, destination)
|
||||
}
|
||||
}
|
||||
|
||||
func writeCodexDesktopTestBundle(t *testing.T, appName, executableName string) string {
|
||||
t.Helper()
|
||||
bundle := filepath.Join(t.TempDir(), appName)
|
||||
executable := filepath.Join(bundle, "Contents", "MacOS", executableName)
|
||||
if err := os.MkdirAll(filepath.Dir(executable), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(executable, []byte("binary"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return bundle
|
||||
}
|
||||
@@ -16,6 +16,7 @@ import (
|
||||
"runtime"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
"unsafe"
|
||||
|
||||
@@ -24,11 +25,21 @@ import (
|
||||
"github.com/ollama/ollama/app/webview"
|
||||
)
|
||||
|
||||
const (
|
||||
defaultWindowWidth = 1360
|
||||
defaultWindowHeight = 960
|
||||
onboardingWindowWidth = 900
|
||||
onboardingWindowHeight = 660
|
||||
minimumWindowWidth = onboardingWindowWidth
|
||||
minimumWindowHeight = onboardingWindowHeight
|
||||
)
|
||||
|
||||
type Webview struct {
|
||||
port int
|
||||
token string
|
||||
webview webview.WebView
|
||||
mutex sync.Mutex
|
||||
port int
|
||||
token string
|
||||
webview webview.WebView
|
||||
mutex sync.Mutex
|
||||
onboarding atomic.Bool
|
||||
|
||||
Store *store.Store
|
||||
}
|
||||
@@ -88,85 +99,38 @@ func (w *Webview) Run(path string) unsafe.Pointer {
|
||||
// Windows-specific scrollbar styling
|
||||
if runtime.GOOS == "windows" {
|
||||
init += `
|
||||
// Fix scrollbar styling for Edge WebView2 on Windows only
|
||||
// Keep Edge WebView2 scrollbars aligned with the system theme.
|
||||
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; }
|
||||
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; }
|
||||
@media (prefers-color-scheme: dark) {
|
||||
::-webkit-scrollbar-track { background: #1a1a1a !important; }
|
||||
::-webkit-scrollbar-thumb { background: #404040 !important; border-radius: 6px !important; }
|
||||
::-webkit-scrollbar-thumb { background: #404040 !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;
|
||||
}
|
||||
` + "`" + `;
|
||||
}
|
||||
}
|
||||
::-webkit-scrollbar-button {
|
||||
background: transparent !important;
|
||||
border: none !important;
|
||||
width: 0px !important;
|
||||
height: 0px !important;
|
||||
margin: 0 !important;
|
||||
padding: 0 !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
|
||||
@@ -187,15 +151,32 @@ func (w *Webview) Run(path string) unsafe.Pointer {
|
||||
`
|
||||
}
|
||||
|
||||
init += `
|
||||
init += fmt.Sprintf(`
|
||||
window.OLLAMA_PLATFORM = %q;
|
||||
window.OLLAMA_WEBSEARCH = true;
|
||||
`
|
||||
`, runtime.GOOS)
|
||||
|
||||
wv.Init(init)
|
||||
|
||||
// Add keyboard handler for zoom
|
||||
wv.Init(`
|
||||
window.addEventListener('keydown', function(e) {
|
||||
const isZoomShortcut = (e.metaKey || e.ctrlKey) && (
|
||||
e.key === '+' || e.key === '=' || e.key === '-' ||
|
||||
e.key === '_' || e.key === '0' ||
|
||||
e.code === 'NumpadAdd' || e.code === 'NumpadSubtract'
|
||||
);
|
||||
|
||||
// Keep fixed-scale onboarding and apps pages at their intended size.
|
||||
const isFixedScalePage =
|
||||
window.location.pathname === '/onboarding' ||
|
||||
window.location.pathname === '/connect';
|
||||
if (isFixedScalePage && isZoomShortcut) {
|
||||
e.preventDefault();
|
||||
e.stopImmediatePropagation();
|
||||
return false;
|
||||
}
|
||||
|
||||
// CMD/Ctrl + Plus/Equals (zoom in)
|
||||
if ((e.metaKey || e.ctrlKey) && (e.key === '+' || e.key === '=')) {
|
||||
e.preventDefault();
|
||||
@@ -237,10 +218,42 @@ func (w *Webview) Run(path string) unsafe.Pointer {
|
||||
showWindow(wv.Window())
|
||||
})
|
||||
|
||||
wv.Bind("activateOllama", func() {
|
||||
showWindow(wv.Window())
|
||||
})
|
||||
|
||||
bindClaudeDesktop(wv)
|
||||
bindCodexDesktop(wv)
|
||||
|
||||
wv.Bind("close", func() {
|
||||
hideWindow(wv.Window())
|
||||
})
|
||||
|
||||
wv.Bind("setOnboardingWindow", func(enabled bool) {
|
||||
w.onboarding.Store(enabled)
|
||||
wv.Dispatch(func() {
|
||||
if enabled {
|
||||
wv.SetSize(onboardingWindowWidth, onboardingWindowHeight, webview.HintFixed)
|
||||
setOnboardingWindowStyle(wv.Window(), true)
|
||||
return
|
||||
}
|
||||
|
||||
width, height := defaultWindowWidth, defaultWindowHeight
|
||||
if w.Store != nil {
|
||||
storedWidth, storedHeight, err := w.Store.WindowSize()
|
||||
if err != nil {
|
||||
slog.Error("failed to restore window size", "error", err)
|
||||
} else if storedWidth > 0 && storedHeight > 0 {
|
||||
width, height = storedWidth, storedHeight
|
||||
}
|
||||
}
|
||||
|
||||
wv.SetSize(width, height, webview.HintNone)
|
||||
wv.SetSize(minimumWindowWidth, minimumWindowHeight, webview.HintMin)
|
||||
setOnboardingWindowStyle(wv.Window(), false)
|
||||
})
|
||||
})
|
||||
|
||||
// Webviews do not allow access to the file system by default, so we need to
|
||||
// bind file system operations here
|
||||
wv.Bind("selectModelsDirectory", func() {
|
||||
@@ -450,18 +463,18 @@ func (w *Webview) Run(path string) unsafe.Pointer {
|
||||
}()
|
||||
}
|
||||
|
||||
width, height := defaultWindowWidth, defaultWindowHeight
|
||||
if w.Store != nil {
|
||||
width, height, err := w.Store.WindowSize()
|
||||
storedWidth, storedHeight, 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)
|
||||
if storedWidth > 0 && storedHeight > 0 {
|
||||
width, height = storedWidth, storedHeight
|
||||
}
|
||||
}
|
||||
wv.SetSize(800, 600, webview.HintMin)
|
||||
wv.SetSize(width, height, webview.HintNone)
|
||||
wv.SetSize(minimumWindowWidth, minimumWindowHeight, webview.HintMin)
|
||||
|
||||
w.webview = wv
|
||||
w.webview.Navigate(url)
|
||||
@@ -476,6 +489,7 @@ func (w *Webview) Run(path string) unsafe.Pointer {
|
||||
}
|
||||
|
||||
func (w *Webview) Terminate() {
|
||||
w.onboarding.Store(false)
|
||||
w.mutex.Lock()
|
||||
if w.webview == nil {
|
||||
w.mutex.Unlock()
|
||||
@@ -489,6 +503,10 @@ func (w *Webview) Terminate() {
|
||||
wv.Destroy()
|
||||
}
|
||||
|
||||
func (w *Webview) OnboardingActive() bool {
|
||||
return w.onboarding.Load()
|
||||
}
|
||||
|
||||
func (w *Webview) IsRunning() bool {
|
||||
w.mutex.Lock()
|
||||
defer w.mutex.Unlock()
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 320 320"><path d="m297.06 130.97c7.26-21.79 4.76-45.66-6.85-65.48-17.46-30.4-52.56-46.04-86.84-38.68-15.25-17.18-37.16-26.95-60.13-26.81-35.04-.08-66.13 22.48-76.91 55.82-22.51 4.61-41.94 18.7-53.31 38.67-17.59 30.32-13.58 68.54 9.92 94.54-7.26 21.79-4.76 45.66 6.85 65.48 17.46 30.4 52.56 46.04 86.84 38.68 15.24 17.18 37.16 26.95 60.13 26.8 35.06.09 66.16-22.49 76.94-55.86 22.51-4.61 41.94-18.7 53.31-38.67 17.57-30.32 13.55-68.51-9.94-94.51zm-120.28 168.11c-14.03.02-27.62-4.89-38.39-13.88.49-.26 1.34-.73 1.89-1.07l63.72-36.8c3.26-1.85 5.26-5.32 5.24-9.07v-89.83l26.93 15.55c.29.14.48.42.52.74v74.39c-.04 33.08-26.83 59.9-59.91 59.97zm-128.84-55.03c-7.03-12.14-9.56-26.37-7.15-40.18.47.28 1.3.79 1.89 1.13l63.72 36.8c3.23 1.89 7.23 1.89 10.47 0l77.79-44.92v31.1c.02.32-.13.63-.38.83l-64.41 37.19c-28.69 16.52-65.33 6.7-81.92-21.95zm-16.77-139.09c7-12.16 18.05-21.46 31.21-26.29 0 .55-.03 1.52-.03 2.2v73.61c-.02 3.74 1.98 7.21 5.23 9.06l77.79 44.91-26.93 15.55c-.27.18-.61.21-.91.08l-64.42-37.22c-28.63-16.58-38.45-53.21-21.95-81.89zm221.26 51.49-77.79-44.92 26.93-15.54c.27-.18.61-.21.91-.08l64.42 37.19c28.68 16.57 38.51 53.26 21.94 81.94-7.01 12.14-18.05 21.44-31.2 26.28v-75.81c.03-3.74-1.96-7.2-5.2-9.06zm26.8-40.34c-.47-.29-1.3-.79-1.89-1.13l-63.72-36.8c-3.23-1.89-7.23-1.89-10.47 0l-77.79 44.92v-31.1c-.02-.32.13-.63.38-.83l64.41-37.16c28.69-16.55 65.37-6.7 81.91 22 6.99 12.12 9.52 26.31 7.15 40.1zm-168.51 55.43-26.94-15.55c-.29-.14-.48-.42-.52-.74v-74.39c.02-33.12 26.89-59.96 60.01-59.94 14.01 0 27.57 4.92 38.34 13.88-.49.26-1.33.73-1.89 1.07l-63.72 36.8c-3.26 1.85-5.26 5.31-5.24 9.06l-.04 89.79zm14.63-31.54 34.65-20.01 34.65 20v40.01l-34.65 20-34.65-20z"/></svg>
|
||||
|
After Width: | Height: | Size: 1.7 KiB |
@@ -0,0 +1,7 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!-- Generated by Pixelmator Pro 3.6.17 -->
|
||||
<svg width="1200" height="1200" viewBox="0 0 1200 1200" xmlns="http://www.w3.org/2000/svg">
|
||||
<g id="g314">
|
||||
<path id="path147" fill="#d97757" stroke="none" d="M 233.959793 800.214905 L 468.644287 668.536987 L 472.590637 657.100647 L 468.644287 650.738403 L 457.208069 650.738403 L 417.986633 648.322144 L 283.892639 644.69812 L 167.597321 639.865845 L 54.926208 633.825623 L 26.577238 627.785339 L 3.3e-05 592.751709 L 2.73832 575.27533 L 26.577238 559.248352 L 60.724873 562.228149 L 136.187973 567.382629 L 249.422867 575.194763 L 331.570496 580.026978 L 453.261841 592.671082 L 472.590637 592.671082 L 475.328857 584.859009 L 468.724915 580.026978 L 463.570557 575.194763 L 346.389313 495.785217 L 219.543671 411.865906 L 153.100723 363.543762 L 117.181267 339.060425 L 99.060455 316.107361 L 91.248367 266.01355 L 123.865784 230.093994 L 167.677887 233.073853 L 178.872513 236.053772 L 223.248367 270.201477 L 318.040283 343.570496 L 441.825592 434.738342 L 459.946411 449.798706 L 467.194672 444.64447 L 468.080597 441.020203 L 459.946411 427.409485 L 392.617493 305.718323 L 320.778564 181.932983 L 288.80542 130.630859 L 280.348999 99.865845 C 277.369171 87.221436 275.194641 76.590698 275.194641 63.624268 L 312.322174 13.20813 L 332.8591 6.604126 L 382.389313 13.20813 L 403.248352 31.328979 L 434.013519 101.71814 L 483.865753 212.537048 L 561.181274 363.221497 L 583.812134 407.919434 L 595.892639 449.315491 L 600.40271 461.959839 L 608.214783 461.959839 L 608.214783 454.711609 L 614.577271 369.825623 L 626.335632 265.61084 L 637.771851 131.516846 L 641.718201 93.745117 L 660.402832 48.483276 L 697.530334 24.000122 L 726.52356 37.852417 L 750.362549 72 L 747.060486 94.067139 L 732.886047 186.201416 L 705.100708 330.52356 L 686.979919 427.167847 L 697.530334 427.167847 L 709.61084 415.087341 L 758.496704 350.174561 L 840.644348 247.490051 L 876.885925 206.738342 L 919.167847 161.71814 L 946.308838 140.29541 L 997.61084 140.29541 L 1035.38269 196.429626 L 1018.469849 254.416199 L 965.637634 321.422852 L 921.825562 378.201538 L 859.006714 462.765259 L 819.785278 530.41626 L 823.409424 535.812073 L 832.75177 534.92627 L 974.657776 504.724915 L 1051.328979 490.872559 L 1142.818848 475.167786 L 1184.214844 494.496582 L 1188.724854 514.147644 L 1172.456421 554.335693 L 1074.604126 578.496765 L 959.838989 601.449829 L 788.939636 641.879272 L 786.845764 643.409485 L 789.261841 646.389343 L 866.255127 653.637634 L 899.194702 655.409424 L 979.812134 655.409424 L 1129.932861 666.604187 L 1169.154419 692.537109 L 1192.671265 724.268677 L 1188.724854 748.429688 L 1128.322144 779.194641 L 1046.818848 759.865845 L 856.590759 714.604126 L 791.355774 698.335754 L 782.335693 698.335754 L 782.335693 703.731567 L 836.69812 756.885986 L 936.322205 846.845581 L 1061.073975 962.81897 L 1067.436279 991.490112 L 1051.409424 1014.120911 L 1034.496704 1011.704712 L 924.885986 929.234924 L 882.604126 892.107544 L 786.845764 811.48999 L 780.483276 811.48999 L 780.483276 819.946289 L 802.550415 852.241699 L 919.087341 1027.409424 L 925.127625 1081.127686 L 916.671204 1098.604126 L 886.469849 1109.154419 L 853.288696 1103.114136 L 785.073914 1007.355835 L 714.684631 899.516785 L 657.906067 802.872498 L 650.979858 806.81897 L 617.476624 1167.704834 L 601.771851 1186.147705 L 565.530212 1200 L 535.328857 1177.046997 L 519.302124 1139.919556 L 535.328857 1066.550537 L 554.657776 970.792053 L 570.362488 894.68457 L 584.536926 800.134277 L 592.993347 768.724976 L 592.429626 766.630859 L 585.503479 767.516968 L 514.22821 865.369263 L 405.825531 1011.865906 L 320.053711 1103.677979 L 299.516815 1111.812256 L 263.919525 1093.369263 L 267.221497 1060.429688 L 287.114136 1031.114136 L 405.825531 880.107361 L 477.422913 786.52356 L 523.651062 732.483276 L 523.328918 724.671265 L 520.590698 724.671265 L 205.288605 929.395935 L 149.154434 936.644409 L 124.993355 914.01355 L 127.973183 876.885986 L 139.409409 864.80542 L 234.201385 799.570435 L 233.879227 799.8927 Z"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 4.0 KiB |
@@ -143,13 +143,13 @@ func utf16ptr(utf16 []uint16) *uint16 {
|
||||
|
||||
func utf16slice(ptr *uint16) []uint16 { //nolint:unused
|
||||
hdr := reflect.SliceHeader{Data: uintptr(unsafe.Pointer(ptr)), Len: 1, Cap: 1}
|
||||
slice := *((*[]uint16)(unsafe.Pointer(&hdr))) //nolint:govet
|
||||
slice := *(*[]uint16)(unsafe.Pointer(&hdr)) //nolint:govet
|
||||
i := 0
|
||||
for slice[len(slice)-1] != 0 {
|
||||
i++
|
||||
}
|
||||
hdr.Len = i
|
||||
slice = *((*[]uint16)(unsafe.Pointer(&hdr))) //nolint:govet
|
||||
slice = *(*[]uint16)(unsafe.Pointer(&hdr)) //nolint:govet
|
||||
return slice
|
||||
}
|
||||
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
#define MyAppPublisher "Ollama"
|
||||
#define MyAppURL "https://ollama.com/"
|
||||
#define MyAppExeName "ollama app.exe"
|
||||
#define LlamaServerExeName "llama-server.exe"
|
||||
#define MyIcon ".\assets\app.ico"
|
||||
|
||||
[Setup]
|
||||
@@ -90,9 +91,8 @@ DialogFontSize=12
|
||||
[Files]
|
||||
#if FileExists("..\dist\windows-ollama-app-amd64.exe")
|
||||
Source: "..\dist\windows-ollama-app-amd64.exe"; DestDir: "{app}"; DestName: "{#MyAppExeName}" ;Check: not IsArm64(); Flags: ignoreversion 64bit; BeforeInstall: TaskKill('{#MyAppExeName}')
|
||||
Source: "..\dist\windows-amd64\vc_redist.x64.exe"; DestDir: "{tmp}"; Check: not IsArm64() and vc_redist_needed(); Flags: deleteafterinstall
|
||||
Source: "..\dist\windows-amd64\ollama.exe"; DestDir: "{app}"; Check: not IsArm64(); Flags: ignoreversion 64bit; BeforeInstall: TaskKill('ollama.exe')
|
||||
Source: "..\dist\windows-amd64\lib\ollama\*"; DestDir: "{app}\lib\ollama\"; Check: not IsArm64(); Flags: ignoreversion 64bit recursesubdirs
|
||||
Source: "..\dist\windows-amd64\lib\ollama\*"; Excludes: "\mlx_*\*"; DestDir: "{app}\lib\ollama\"; Check: not IsArm64(); Flags: ignoreversion 64bit recursesubdirs
|
||||
#endif
|
||||
|
||||
; For local development, rely on binary compatibility at runtime since we can't cross compile
|
||||
@@ -103,9 +103,11 @@ Source: "..\dist\windows-ollama-app-amd64.exe"; DestDir: "{app}"; DestName: "{#M
|
||||
#endif
|
||||
|
||||
#if FileExists("..\dist\windows-arm64\ollama.exe")
|
||||
Source: "..\dist\windows-arm64\vc_redist.arm64.exe"; DestDir: "{tmp}"; Check: IsArm64() and vc_redist_needed(); Flags: deleteafterinstall
|
||||
Source: "..\dist\windows-arm64\ollama.exe"; DestDir: "{app}"; Check: IsArm64(); Flags: ignoreversion 64bit; BeforeInstall: TaskKill('ollama.exe')
|
||||
#endif
|
||||
#if DirExists("..\dist\windows-arm64\lib\ollama")
|
||||
Source: "..\dist\windows-arm64\lib\ollama\*"; DestDir: "{app}\lib\ollama\"; Check: IsArm64(); Flags: ignoreversion 64bit recursesubdirs
|
||||
#endif
|
||||
|
||||
Source: ".\assets\app.ico"; DestDir: "{app}"; Flags: ignoreversion
|
||||
|
||||
@@ -118,12 +120,6 @@ Name: "{userprograms}\{#MyAppName}"; Filename: "{app}\{#MyAppExeName}"; IconFile
|
||||
Type: files; Name: "{%LOCALAPPDATA}\Ollama\updates"
|
||||
|
||||
[Run]
|
||||
#if DirExists("..\dist\windows-arm64")
|
||||
Filename: "{tmp}\vc_redist.arm64.exe"; Parameters: "/install /passive /norestart"; Check: IsArm64() and vc_redist_needed(); StatusMsg: "Installing VC++ Redistributables..."; Flags: waituntilterminated
|
||||
#endif
|
||||
#if DirExists("..\dist\windows-amd64")
|
||||
Filename: "{tmp}\vc_redist.x64.exe"; Parameters: "/install /passive /norestart"; Check: not IsArm64() and vc_redist_needed(); StatusMsg: "Installing VC++ Redistributables..."; Flags: waituntilterminated
|
||||
#endif
|
||||
Filename: "{cmd}"; Parameters: "/C set PATH={app};%PATH% & ""{app}\{#MyAppExeName}"""; Flags: postinstall nowait runhidden
|
||||
|
||||
[UninstallRun]
|
||||
@@ -131,6 +127,7 @@ Filename: "{cmd}"; Parameters: "/C set PATH={app};%PATH% & ""{app}\{#MyAppExeNam
|
||||
; Filename: "{cmd}"; Parameters: "/C ""taskkill /im ollama.exe /f /t"; Flags: runhidden
|
||||
Filename: "taskkill"; Parameters: "/im ""{#MyAppExeName}"" /f /t"; Flags: runhidden
|
||||
Filename: "taskkill"; Parameters: "/im ""ollama.exe"" /f /t"; Flags: runhidden
|
||||
Filename: "taskkill"; Parameters: "/im ""{#LlamaServerExeName}"" /f /t"; Flags: runhidden
|
||||
; HACK! need to give the server and app enough time to exit
|
||||
; TODO - convert this to a Pascal code script so it waits until they're no longer running, then completes
|
||||
Filename: "{cmd}"; Parameters: "/c timeout 5"; Flags: runhidden
|
||||
@@ -184,46 +181,6 @@ begin
|
||||
Result := Pos(';' + ExpandConstant(Param) + ';', ';' + OrigPath + ';') = 0;
|
||||
end;
|
||||
|
||||
{ --- VC Runtime libraries discovery code - Only install vc_redist if it isn't already installed ----- }
|
||||
const VCRTL_MIN_V1 = 14;
|
||||
const VCRTL_MIN_V2 = 40;
|
||||
const VCRTL_MIN_V3 = 33807;
|
||||
const VCRTL_MIN_V4 = 0;
|
||||
|
||||
// check if the minimum required vc redist is installed (by looking the registry)
|
||||
function vc_redist_needed (): Boolean;
|
||||
var
|
||||
sRegKey: string;
|
||||
v1: Cardinal;
|
||||
v2: Cardinal;
|
||||
v3: Cardinal;
|
||||
v4: Cardinal;
|
||||
begin
|
||||
if (IsArm64()) then begin
|
||||
sRegKey := 'SOFTWARE\WOW6432Node\Microsoft\VisualStudio\14.0\VC\Runtimes\arm64';
|
||||
end else begin
|
||||
sRegKey := 'SOFTWARE\Microsoft\VisualStudio\14.0\VC\Runtimes\x64';
|
||||
end;
|
||||
if (RegQueryDWordValue (HKEY_LOCAL_MACHINE, sRegKey, 'Major', v1) and
|
||||
RegQueryDWordValue (HKEY_LOCAL_MACHINE, sRegKey, 'Minor', v2) and
|
||||
RegQueryDWordValue (HKEY_LOCAL_MACHINE, sRegKey, 'Bld', v3) and
|
||||
RegQueryDWordValue (HKEY_LOCAL_MACHINE, sRegKey, 'RBld', v4)) then
|
||||
begin
|
||||
Log ('VC Redist version: ' + IntToStr (v1) +
|
||||
'.' + IntToStr (v2) + '.' + IntToStr (v3) +
|
||||
'.' + IntToStr (v4));
|
||||
{ Version info was found. Return true if later or equal to our
|
||||
minimal required version RTL_MIN_Vx }
|
||||
Result := not (
|
||||
(v1 > VCRTL_MIN_V1) or ((v1 = VCRTL_MIN_V1) and
|
||||
((v2 > VCRTL_MIN_V2) or ((v2 = VCRTL_MIN_V2) and
|
||||
((v3 > VCRTL_MIN_V3) or ((v3 = VCRTL_MIN_V3) and
|
||||
(v4 >= VCRTL_MIN_V4)))))));
|
||||
end
|
||||
else
|
||||
Result := TRUE;
|
||||
end;
|
||||
|
||||
function GetDirSize(Path: String): Int64;
|
||||
var
|
||||
FindRec: TFindRec;
|
||||
@@ -370,5 +327,8 @@ procedure TaskKill(FileName: String);
|
||||
var
|
||||
ResultCode: Integer;
|
||||
begin
|
||||
Exec('taskkill.exe', '/f /im ' + '"' + FileName + '"', '', SW_HIDE, ewWaitUntilTerminated, ResultCode);
|
||||
Exec('taskkill.exe', '/f /t /im ' + '"' + FileName + '"', '', SW_HIDE, ewWaitUntilTerminated, ResultCode);
|
||||
if FileName <> '{#LlamaServerExeName}' then begin
|
||||
Exec('taskkill.exe', '/f /t /im "{#LlamaServerExeName}"', '', SW_HIDE, ewWaitUntilTerminated, ResultCode);
|
||||
end;
|
||||
end;
|
||||
@@ -365,7 +365,7 @@ time=2025-06-30T09:25:56.197-07:00 level=DEBUG source=ggml.go:155 msg="key not f
|
||||
if err != nil {
|
||||
t.Fatalf("failed to write log file %s: %s", serverLogPath, err)
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(t.Context(), 10*time.Millisecond)
|
||||
ctx, cancel := context.WithTimeout(t.Context(), time.Second)
|
||||
defer cancel()
|
||||
info, err := GetInferenceInfo(ctx)
|
||||
if err != nil {
|
||||
|
||||
@@ -164,7 +164,7 @@ func reapServers() error {
|
||||
continue
|
||||
}
|
||||
|
||||
cmd := exec.Command("taskkill", "/F", "/PID", pidStr)
|
||||
cmd := exec.Command("taskkill", "/F", "/T", "/PID", pidStr)
|
||||
if err := cmd.Run(); err != nil {
|
||||
slog.Warn("failed to kill ollama process", "pid", pid, "err", err)
|
||||
}
|
||||
|
||||
@@ -14,7 +14,7 @@ import (
|
||||
|
||||
// currentSchemaVersion defines the current database schema version.
|
||||
// Increment this when making schema changes that require migrations.
|
||||
const currentSchemaVersion = 16
|
||||
const currentSchemaVersion = 18
|
||||
|
||||
// database wraps the SQLite connection.
|
||||
// SQLite handles its own locking for concurrent access:
|
||||
@@ -82,12 +82,14 @@ func (db *database) init() error {
|
||||
websearch_enabled BOOLEAN NOT NULL DEFAULT 0,
|
||||
selected_model TEXT NOT NULL DEFAULT '',
|
||||
sidebar_open BOOLEAN NOT NULL DEFAULT 0,
|
||||
last_home_view TEXT NOT NULL DEFAULT 'launch',
|
||||
last_home_view TEXT NOT NULL DEFAULT 'chat',
|
||||
onboarding_version INTEGER NOT NULL DEFAULT 0,
|
||||
think_enabled BOOLEAN NOT NULL DEFAULT 0,
|
||||
think_level TEXT NOT NULL DEFAULT '',
|
||||
cloud_setting_migrated BOOLEAN NOT NULL DEFAULT 0,
|
||||
remote TEXT NOT NULL DEFAULT '', -- deprecated
|
||||
auto_update_enabled BOOLEAN NOT NULL DEFAULT 1,
|
||||
claude_desktop_used BOOLEAN NOT NULL DEFAULT 0,
|
||||
schema_version INTEGER NOT NULL DEFAULT %d
|
||||
);
|
||||
|
||||
@@ -271,6 +273,18 @@ func (db *database) migrate() error {
|
||||
return fmt.Errorf("migrate v15 to v16: %w", err)
|
||||
}
|
||||
version = 16
|
||||
case 16:
|
||||
// Existing users should not be shown onboarding after an upgrade.
|
||||
if err := db.migrateV16ToV17(); err != nil {
|
||||
return fmt.Errorf("migrate v16 to v17: %w", err)
|
||||
}
|
||||
version = 17
|
||||
case 17:
|
||||
// Remember that Claude Desktop has been connected at least once.
|
||||
if err := db.migrateV17ToV18(); err != nil {
|
||||
return fmt.Errorf("migrate v17 to v18: %w", err)
|
||||
}
|
||||
version = 18
|
||||
default:
|
||||
// If we have a version we don't recognize, just set it to current
|
||||
// This might happen during development
|
||||
@@ -527,7 +541,7 @@ func (db *database) migrateV14ToV15() error {
|
||||
|
||||
// migrateV15ToV16 adds the last_home_view column to the settings table
|
||||
func (db *database) migrateV15ToV16() error {
|
||||
_, err := db.conn.Exec(`ALTER TABLE settings ADD COLUMN last_home_view TEXT NOT NULL DEFAULT 'launch'`)
|
||||
_, err := db.conn.Exec(`ALTER TABLE settings ADD COLUMN last_home_view TEXT NOT NULL DEFAULT 'chat'`)
|
||||
if err != nil && !duplicateColumnError(err) {
|
||||
return fmt.Errorf("add last_home_view column: %w", err)
|
||||
}
|
||||
@@ -540,6 +554,38 @@ func (db *database) migrateV15ToV16() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// migrateV16ToV17 adds versioned onboarding state. The schema default stays at
|
||||
// zero for genuinely new installs, while all existing rows are marked complete
|
||||
// and moved off the retired launch home view.
|
||||
func (db *database) migrateV16ToV17() error {
|
||||
_, err := db.conn.Exec(`ALTER TABLE settings ADD COLUMN onboarding_version INTEGER NOT NULL DEFAULT 0`)
|
||||
if err != nil && !duplicateColumnError(err) {
|
||||
return fmt.Errorf("add onboarding_version column: %w", err)
|
||||
}
|
||||
|
||||
_, err = db.conn.Exec(`UPDATE settings SET onboarding_version = 1, last_home_view = 'chat', schema_version = 17`)
|
||||
if err != nil {
|
||||
return fmt.Errorf("complete onboarding for existing users: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// migrateV17ToV18 adds durable Claude Desktop integration history.
|
||||
func (db *database) migrateV17ToV18() error {
|
||||
_, err := db.conn.Exec(`ALTER TABLE settings ADD COLUMN claude_desktop_used BOOLEAN NOT NULL DEFAULT 0`)
|
||||
if err != nil && !duplicateColumnError(err) {
|
||||
return fmt.Errorf("add claude_desktop_used column: %w", err)
|
||||
}
|
||||
|
||||
_, err = db.conn.Exec(`UPDATE settings SET schema_version = 18`)
|
||||
if err != nil {
|
||||
return fmt.Errorf("update schema version: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// cleanupOrphanedData removes orphaned records that may exist due to the foreign key bug
|
||||
func (db *database) cleanupOrphanedData() error {
|
||||
_, err := db.conn.Exec(`
|
||||
@@ -1188,9 +1234,9 @@ func (db *database) getSettings() (Settings, error) {
|
||||
var s Settings
|
||||
|
||||
err := db.conn.QueryRow(`
|
||||
SELECT expose, survey, browser, models, agent, tools, working_dir, context_length, turbo_enabled, websearch_enabled, selected_model, sidebar_open, last_home_view, think_enabled, think_level, auto_update_enabled
|
||||
SELECT expose, survey, browser, models, agent, tools, working_dir, context_length, turbo_enabled, websearch_enabled, selected_model, sidebar_open, last_home_view, onboarding_version, think_enabled, think_level, auto_update_enabled, claude_desktop_used
|
||||
FROM settings
|
||||
`).Scan(&s.Expose, &s.Survey, &s.Browser, &s.Models, &s.Agent, &s.Tools, &s.WorkingDir, &s.ContextLength, &s.TurboEnabled, &s.WebSearchEnabled, &s.SelectedModel, &s.SidebarOpen, &s.LastHomeView, &s.ThinkEnabled, &s.ThinkLevel, &s.AutoUpdateEnabled)
|
||||
`).Scan(&s.Expose, &s.Survey, &s.Browser, &s.Models, &s.Agent, &s.Tools, &s.WorkingDir, &s.ContextLength, &s.TurboEnabled, &s.WebSearchEnabled, &s.SelectedModel, &s.SidebarOpen, &s.LastHomeView, &s.OnboardingVersion, &s.ThinkEnabled, &s.ThinkLevel, &s.AutoUpdateEnabled, &s.ClaudeDesktopUsed)
|
||||
if err != nil {
|
||||
return Settings{}, fmt.Errorf("get settings: %w", err)
|
||||
}
|
||||
@@ -1200,28 +1246,14 @@ func (db *database) getSettings() (Settings, error) {
|
||||
|
||||
func (db *database) setSettings(s Settings) error {
|
||||
lastHomeView := strings.ToLower(strings.TrimSpace(s.LastHomeView))
|
||||
validLaunchView := map[string]struct{}{
|
||||
"launch": {},
|
||||
"openclaw": {},
|
||||
"claude": {},
|
||||
"hermes": {},
|
||||
"codex": {},
|
||||
"codex-app": {},
|
||||
"copilot": {},
|
||||
"opencode": {},
|
||||
"droid": {},
|
||||
"pi": {},
|
||||
}
|
||||
if lastHomeView != "chat" {
|
||||
if _, ok := validLaunchView[lastHomeView]; !ok {
|
||||
lastHomeView = "launch"
|
||||
}
|
||||
lastHomeView = "chat"
|
||||
}
|
||||
|
||||
_, err := db.conn.Exec(`
|
||||
UPDATE settings
|
||||
SET expose = ?, survey = ?, browser = ?, models = ?, agent = ?, tools = ?, working_dir = ?, context_length = ?, turbo_enabled = ?, websearch_enabled = ?, selected_model = ?, sidebar_open = ?, last_home_view = ?, think_enabled = ?, think_level = ?, auto_update_enabled = ?
|
||||
`, s.Expose, s.Survey, s.Browser, s.Models, s.Agent, s.Tools, s.WorkingDir, s.ContextLength, s.TurboEnabled, s.WebSearchEnabled, s.SelectedModel, s.SidebarOpen, lastHomeView, s.ThinkEnabled, s.ThinkLevel, s.AutoUpdateEnabled)
|
||||
SET expose = ?, survey = ?, browser = ?, models = ?, agent = ?, tools = ?, working_dir = ?, context_length = ?, turbo_enabled = ?, websearch_enabled = ?, selected_model = ?, sidebar_open = ?, last_home_view = ?, onboarding_version = ?, think_enabled = ?, think_level = ?, auto_update_enabled = ?, claude_desktop_used = ?
|
||||
`, s.Expose, s.Survey, s.Browser, s.Models, s.Agent, s.Tools, s.WorkingDir, s.ContextLength, s.TurboEnabled, s.WebSearchEnabled, s.SelectedModel, s.SidebarOpen, lastHomeView, s.OnboardingVersion, s.ThinkEnabled, s.ThinkLevel, s.AutoUpdateEnabled, s.ClaudeDesktopUsed)
|
||||
if err != nil {
|
||||
return fmt.Errorf("set settings: %w", err)
|
||||
}
|
||||
|
||||
@@ -135,7 +135,7 @@ func TestMigrationV13ToV14ContextLength(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestMigrationV15ToV16LastHomeViewDefaultsToLaunch(t *testing.T) {
|
||||
func TestMigrationV15ToV16LastHomeViewMigratesToChat(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
dbPath := filepath.Join(tmpDir, "test.db")
|
||||
|
||||
@@ -161,8 +161,8 @@ func TestMigrationV15ToV16LastHomeViewDefaultsToLaunch(t *testing.T) {
|
||||
t.Fatalf("failed to read last_home_view: %v", err)
|
||||
}
|
||||
|
||||
if lastHomeView != "launch" {
|
||||
t.Fatalf("expected last_home_view to default to launch after migration, got %q", lastHomeView)
|
||||
if lastHomeView != "chat" {
|
||||
t.Fatalf("expected last_home_view to migrate to chat, got %q", lastHomeView)
|
||||
}
|
||||
|
||||
version, err := db.getSchemaVersion()
|
||||
@@ -174,6 +174,88 @@ func TestMigrationV15ToV16LastHomeViewDefaultsToLaunch(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestOnboardingVersionDefaultsAndMigration(t *testing.T) {
|
||||
t.Run("fresh installs need onboarding", func(t *testing.T) {
|
||||
dbPath := filepath.Join(t.TempDir(), "fresh.db")
|
||||
db, err := newDatabase(dbPath)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create database: %v", err)
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
settings, err := db.getSettings()
|
||||
if err != nil {
|
||||
t.Fatalf("failed to read settings: %v", err)
|
||||
}
|
||||
if settings.OnboardingVersion != 0 {
|
||||
t.Fatalf("expected fresh install onboarding version 0, got %d", settings.OnboardingVersion)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("existing installs skip onboarding", func(t *testing.T) {
|
||||
dbPath := filepath.Join(t.TempDir(), "existing.db")
|
||||
db, err := newDatabase(dbPath)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create database: %v", err)
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
if _, err := db.conn.Exec(`
|
||||
ALTER TABLE settings DROP COLUMN onboarding_version;
|
||||
UPDATE settings SET schema_version = 16;
|
||||
`); err != nil {
|
||||
t.Fatalf("failed to seed v16 settings row: %v", err)
|
||||
}
|
||||
|
||||
if err := db.migrate(); err != nil {
|
||||
t.Fatalf("migration from v16 to v17 failed: %v", err)
|
||||
}
|
||||
|
||||
settings, err := db.getSettings()
|
||||
if err != nil {
|
||||
t.Fatalf("failed to read settings: %v", err)
|
||||
}
|
||||
if settings.OnboardingVersion != 1 {
|
||||
t.Fatalf("expected existing install onboarding version 1, got %d", settings.OnboardingVersion)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestClaudeDesktopUsedDefaultsAndMigration(t *testing.T) {
|
||||
dbPath := filepath.Join(t.TempDir(), "claude-history.db")
|
||||
db, err := newDatabase(dbPath)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create database: %v", err)
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
settings, err := db.getSettings()
|
||||
if err != nil {
|
||||
t.Fatalf("failed to read settings: %v", err)
|
||||
}
|
||||
if settings.ClaudeDesktopUsed {
|
||||
t.Fatal("expected fresh installs to have no Claude Desktop history")
|
||||
}
|
||||
|
||||
if _, err := db.conn.Exec(`
|
||||
ALTER TABLE settings DROP COLUMN claude_desktop_used;
|
||||
UPDATE settings SET schema_version = 17;
|
||||
`); err != nil {
|
||||
t.Fatalf("failed to seed v17 settings row: %v", err)
|
||||
}
|
||||
if err := db.migrate(); err != nil {
|
||||
t.Fatalf("migration from v17 to v18 failed: %v", err)
|
||||
}
|
||||
|
||||
settings, err = db.getSettings()
|
||||
if err != nil {
|
||||
t.Fatalf("failed to read migrated settings: %v", err)
|
||||
}
|
||||
if settings.ClaudeDesktopUsed {
|
||||
t.Fatal("expected existing installs to start with no inferred Claude Desktop history")
|
||||
}
|
||||
}
|
||||
|
||||
func TestChatDeletionWithCascade(t *testing.T) {
|
||||
t.Run("chat deletion cascades to related messages", func(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
|
||||
@@ -57,6 +57,14 @@ func TestConfigMigration(t *testing.T) {
|
||||
t.Error("expected has completed first run to be true after migration")
|
||||
}
|
||||
|
||||
settings, err := s.Settings()
|
||||
if err != nil {
|
||||
t.Fatalf("failed to get settings: %v", err)
|
||||
}
|
||||
if settings.OnboardingVersion != CurrentOnboardingVersion {
|
||||
t.Fatalf("expected migrated user to skip onboarding, got version %d", settings.OnboardingVersion)
|
||||
}
|
||||
|
||||
// Verify migration is marked as complete
|
||||
migrated, err := s.db.isConfigMigrated()
|
||||
if err != nil {
|
||||
|
||||
@@ -167,13 +167,22 @@ type Settings struct {
|
||||
// SidebarOpen indicates if the chat sidebar is open
|
||||
SidebarOpen bool
|
||||
|
||||
// LastHomeView stores the preferred home route target ("chat" or integration name)
|
||||
// LastHomeView is retained for settings compatibility and resolves to chat.
|
||||
LastHomeView string
|
||||
|
||||
// OnboardingVersion stores the latest onboarding flow the user has completed.
|
||||
OnboardingVersion int
|
||||
|
||||
// AutoUpdateEnabled indicates if automatic updates should be downloaded
|
||||
AutoUpdateEnabled bool
|
||||
|
||||
// ClaudeDesktopUsed records whether Claude Desktop has ever been connected through Ollama.
|
||||
ClaudeDesktopUsed bool
|
||||
}
|
||||
|
||||
// Keep in sync with CURRENT_ONBOARDING_VERSION in app/ui/app/src/lib/onboarding.ts.
|
||||
const CurrentOnboardingVersion = 1
|
||||
|
||||
type Store struct {
|
||||
// DBPath allows overriding the default database path (mainly for testing)
|
||||
DBPath string
|
||||
@@ -334,6 +343,16 @@ func (s *Store) migrateFromConfig(database *database) error {
|
||||
if err := database.setHasCompletedFirstRun(hasCompleted); err != nil {
|
||||
return fmt.Errorf("migrate first time run: %w", err)
|
||||
}
|
||||
if hasCompleted {
|
||||
settings, err := database.getSettings()
|
||||
if err != nil {
|
||||
return fmt.Errorf("read settings for onboarding migration: %w", err)
|
||||
}
|
||||
settings.OnboardingVersion = CurrentOnboardingVersion
|
||||
if err := database.setSettings(settings); err != nil {
|
||||
return fmt.Errorf("migrate onboarding completion: %w", err)
|
||||
}
|
||||
}
|
||||
slog.Info("migrated first run status from config.json", "hasCompleted", hasCompleted)
|
||||
|
||||
// Mark as migrated
|
||||
@@ -393,7 +412,7 @@ func (s *Store) Settings() (Settings, error) {
|
||||
}
|
||||
|
||||
if settings.LastHomeView == "" {
|
||||
settings.LastHomeView = "launch"
|
||||
settings.LastHomeView = "chat"
|
||||
}
|
||||
|
||||
return settings, nil
|
||||
|
||||
@@ -81,18 +81,18 @@ func TestStore(t *testing.T) {
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("settings default home view is launch", func(t *testing.T) {
|
||||
t.Run("settings default home view is chat", func(t *testing.T) {
|
||||
loaded, err := s.Settings()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if loaded.LastHomeView != "launch" {
|
||||
t.Fatalf("expected default LastHomeView to be launch, got %q", loaded.LastHomeView)
|
||||
if loaded.LastHomeView != "chat" {
|
||||
t.Fatalf("expected default LastHomeView to be chat, got %q", loaded.LastHomeView)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("settings empty home view falls back to launch", func(t *testing.T) {
|
||||
t.Run("settings empty home view falls back to chat", func(t *testing.T) {
|
||||
if err := s.SetSettings(Settings{LastHomeView: ""}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -102,12 +102,12 @@ func TestStore(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if loaded.LastHomeView != "launch" {
|
||||
t.Fatalf("expected empty LastHomeView to fall back to launch, got %q", loaded.LastHomeView)
|
||||
if loaded.LastHomeView != "chat" {
|
||||
t.Fatalf("expected empty LastHomeView to fall back to chat, got %q", loaded.LastHomeView)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("settings disabled home view falls back to launch", func(t *testing.T) {
|
||||
t.Run("settings retired home view falls back to chat", func(t *testing.T) {
|
||||
if err := s.SetSettings(Settings{LastHomeView: "claude-desktop"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -117,12 +117,12 @@ func TestStore(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if loaded.LastHomeView != "launch" {
|
||||
t.Fatalf("expected disabled LastHomeView to fall back to launch, got %q", loaded.LastHomeView)
|
||||
if loaded.LastHomeView != "chat" {
|
||||
t.Fatalf("expected retired LastHomeView to fall back to chat, got %q", loaded.LastHomeView)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("settings codex app home view is accepted", func(t *testing.T) {
|
||||
t.Run("settings integration home view falls back to chat", func(t *testing.T) {
|
||||
if err := s.SetSettings(Settings{LastHomeView: "codex-app"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -132,8 +132,8 @@ func TestStore(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if loaded.LastHomeView != "codex-app" {
|
||||
t.Fatalf("expected codex-app LastHomeView to be preserved, got %q", loaded.LastHomeView)
|
||||
if loaded.LastHomeView != "chat" {
|
||||
t.Fatalf("expected integration LastHomeView to fall back to chat, got %q", loaded.LastHomeView)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -227,6 +227,58 @@ func TestStore(t *testing.T) {
|
||||
})
|
||||
}
|
||||
|
||||
func TestOnboardingVersionRoundTrip(t *testing.T) {
|
||||
s, cleanup := setupTestStore(t)
|
||||
defer cleanup()
|
||||
|
||||
settings, err := s.Settings()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if settings.OnboardingVersion != 0 {
|
||||
t.Fatalf("expected onboarding version 0 by default, got %d", settings.OnboardingVersion)
|
||||
}
|
||||
|
||||
settings.OnboardingVersion = 1
|
||||
if err := s.SetSettings(settings); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
loaded, err := s.Settings()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if loaded.OnboardingVersion != 1 {
|
||||
t.Fatalf("expected onboarding version 1, got %d", loaded.OnboardingVersion)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClaudeDesktopUsedRoundTrip(t *testing.T) {
|
||||
s, cleanup := setupTestStore(t)
|
||||
defer cleanup()
|
||||
|
||||
settings, err := s.Settings()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if settings.ClaudeDesktopUsed {
|
||||
t.Fatal("expected Claude Desktop history to be false by default")
|
||||
}
|
||||
|
||||
settings.ClaudeDesktopUsed = true
|
||||
if err := s.SetSettings(settings); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
loaded, err := s.Settings()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !loaded.ClaudeDesktopUsed {
|
||||
t.Fatal("expected Claude Desktop history to persist")
|
||||
}
|
||||
}
|
||||
|
||||
// setupTestStore creates a temporary store for testing
|
||||
func setupTestStore(t *testing.T) (*Store, func()) {
|
||||
t.Helper()
|
||||
|
||||
@@ -563,6 +563,10 @@ func (b *BrowserOpen) Execute(ctx context.Context, args map[string]any) (any, st
|
||||
return b.state.Data, pageText, nil
|
||||
}
|
||||
|
||||
if !allowedDirectURL(ctx, url) {
|
||||
return nil, "", fmt.Errorf("direct URL open is only allowed for URLs provided by the user")
|
||||
}
|
||||
|
||||
// Page not in cache, need to crawl it
|
||||
if b.crawlPage == nil {
|
||||
b.crawlPage = &BrowserCrawler{}
|
||||
|
||||
@@ -65,6 +65,27 @@ func TestBrowserOpen_UseCacheByURL(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestBrowserOpen_RejectsUncachedDirectURL(t *testing.T) {
|
||||
b := NewBrowser(&responses.BrowserStateData{PageStack: []string{}, ViewTokens: 1024, URLToPage: map[string]*responses.Page{}})
|
||||
bo := NewBrowserOpen(b)
|
||||
|
||||
_, _, err := bo.Execute(t.Context(), map[string]any{"id": "https://attacker.example/?data=secret"})
|
||||
if err == nil || !strings.Contains(err.Error(), "only allowed for URLs provided by the user") {
|
||||
t.Fatalf("expected direct URL rejection, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDirectURLsFromText_AllowsExactUserURLsOnly(t *testing.T) {
|
||||
ctx := WithAllowedDirectURLs(t.Context(), "summarize https://example.com/article?q=1 please")
|
||||
|
||||
if !allowedDirectURL(ctx, "https://example.com/article?q=1") {
|
||||
t.Fatal("expected exact user-provided URL to be allowed")
|
||||
}
|
||||
if allowedDirectURL(ctx, "https://example.com/article?q=secret") {
|
||||
t.Fatal("did not expect modified URL to be allowed")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDisplayPage_InvalidLoc(t *testing.T) {
|
||||
b := NewBrowser(&responses.BrowserStateData{PageStack: []string{}, ViewTokens: 1024, URLToPage: map[string]*responses.Page{}})
|
||||
p := makeTestPage("https://example.com/x")
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
//go:build windows || darwin
|
||||
|
||||
package tools
|
||||
|
||||
import (
|
||||
"context"
|
||||
"regexp"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type directURLContextKey struct{}
|
||||
|
||||
var directURLPattern = regexp.MustCompile("https?://[^\\s<>\"'`]+")
|
||||
|
||||
func WithAllowedDirectURLs(ctx context.Context, text string) context.Context {
|
||||
allowed := make(map[string]struct{})
|
||||
for _, match := range directURLPattern.FindAllString(text, -1) {
|
||||
addAllowedDirectURLToMap(allowed, match)
|
||||
}
|
||||
return context.WithValue(ctx, directURLContextKey{}, allowed)
|
||||
}
|
||||
|
||||
func addAllowedDirectURL(ctx context.Context, raw string) {
|
||||
allowed, _ := ctx.Value(directURLContextKey{}).(map[string]struct{})
|
||||
addAllowedDirectURLToMap(allowed, raw)
|
||||
}
|
||||
|
||||
func addAllowedDirectURLToMap(allowed map[string]struct{}, raw string) {
|
||||
if allowed == nil {
|
||||
return
|
||||
}
|
||||
|
||||
raw = cleanDirectURL(raw)
|
||||
if raw == "" {
|
||||
return
|
||||
}
|
||||
|
||||
allowed[raw] = struct{}{}
|
||||
}
|
||||
|
||||
func allowedDirectURL(ctx context.Context, raw string) bool {
|
||||
allowed, _ := ctx.Value(directURLContextKey{}).(map[string]struct{})
|
||||
cleaned := cleanDirectURL(raw)
|
||||
if cleaned == "" || cleaned != raw {
|
||||
return false
|
||||
}
|
||||
|
||||
_, ok := allowed[cleaned]
|
||||
return ok
|
||||
}
|
||||
|
||||
func cleanDirectURL(raw string) string {
|
||||
raw = strings.TrimSpace(raw)
|
||||
raw = strings.TrimRight(raw, ".,;:!?)]}")
|
||||
|
||||
if !strings.HasPrefix(raw, "http://") && !strings.HasPrefix(raw, "https://") {
|
||||
return ""
|
||||
}
|
||||
|
||||
return raw
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
//go:build windows || darwin
|
||||
|
||||
package tools
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestDirectURLsFromText_RejectsChangedToolArgument(t *testing.T) {
|
||||
ctx := WithAllowedDirectURLs(t.Context(), "summarize https://attacker.example/x")
|
||||
|
||||
if allowedDirectURL(ctx, "https://attacker.example/x!!!!") {
|
||||
t.Fatal("expected changed tool argument to be rejected")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDirectURLsFromText_ExtractsMarkdownCodeSpanURL(t *testing.T) {
|
||||
ctx := WithAllowedDirectURLs(t.Context(), "summarize `https://example.com/privacy`")
|
||||
|
||||
if !allowedDirectURL(ctx, "https://example.com/privacy") {
|
||||
t.Fatal("expected URL wrapped in backticks to be allowed")
|
||||
}
|
||||
}
|
||||
@@ -67,11 +67,17 @@ func (w *WebFetch) Execute(ctx context.Context, args map[string]any) (any, strin
|
||||
if !ok || strings.TrimSpace(urlStr) == "" {
|
||||
return nil, "", fmt.Errorf("url must be a non-empty string")
|
||||
}
|
||||
if !allowedDirectURL(ctx, urlStr) {
|
||||
return nil, "", fmt.Errorf("web fetch is only allowed for URLs provided by the user")
|
||||
}
|
||||
|
||||
result, err := performWebFetch(ctx, urlStr)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
for _, link := range result.Links {
|
||||
addAllowedDirectURL(ctx, link)
|
||||
}
|
||||
|
||||
return result, "", nil
|
||||
}
|
||||
|
||||
@@ -88,6 +88,9 @@ func (w *WebSearch) Execute(ctx context.Context, args map[string]any) (any, stri
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
for _, result := range result.Results {
|
||||
addAllowedDirectURL(ctx, result.URL)
|
||||
}
|
||||
|
||||
return result, "", nil
|
||||
}
|
||||
|
||||
@@ -415,7 +415,9 @@ export class Settings {
|
||||
SelectedModel: string;
|
||||
SidebarOpen: boolean;
|
||||
LastHomeView: string;
|
||||
OnboardingVersion: number;
|
||||
AutoUpdateEnabled: boolean;
|
||||
ClaudeDesktopUsed: boolean;
|
||||
|
||||
constructor(source: any = {}) {
|
||||
if ('string' === typeof source) source = JSON.parse(source);
|
||||
@@ -434,7 +436,9 @@ export class Settings {
|
||||
this.SelectedModel = source["SelectedModel"];
|
||||
this.SidebarOpen = source["SidebarOpen"];
|
||||
this.LastHomeView = source["LastHomeView"];
|
||||
this.OnboardingVersion = source["OnboardingVersion"];
|
||||
this.AutoUpdateEnabled = source["AutoUpdateEnabled"];
|
||||
this.ClaudeDesktopUsed = source["ClaudeDesktopUsed"];
|
||||
}
|
||||
}
|
||||
export class SettingsResponse {
|
||||
|
||||
@@ -2,12 +2,13 @@
|
||||
<html lang="en" style="overflow: hidden">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="color-scheme" content="light dark" />
|
||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<link rel="stylesheet" href="/src/index.css" />
|
||||
<title>Ollama</title>
|
||||
</head>
|
||||
<body class="dark:bg-neutral-900 select-text">
|
||||
<body class="bg-white dark:bg-neutral-900 select-text">
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
<script>
|
||||
|
||||
@@ -43,6 +43,7 @@
|
||||
"@types/node": "^24.7.2",
|
||||
"@types/react": "^19.1.2",
|
||||
"@types/react-dom": "^19.1.2",
|
||||
"@types/react-test-renderer": "^19.1.0",
|
||||
"@vitejs/plugin-react": "^4.4.1",
|
||||
"@vitest/browser": "^3.2.4",
|
||||
"@vitest/coverage-v8": "^3.2.4",
|
||||
@@ -56,6 +57,7 @@
|
||||
"playwright": "^1.53.2",
|
||||
"postcss-preset-env": "^10.2.4",
|
||||
"react-markdown": "^10.1.0",
|
||||
"react-test-renderer": "19.1.0",
|
||||
"remark": "^15.0.1",
|
||||
"remark-gfm": "^4.0.1",
|
||||
"remark-stringify": "^11.0.0",
|
||||
@@ -4593,6 +4595,16 @@
|
||||
"@types/react": "^19.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/react-test-renderer": {
|
||||
"version": "19.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@types/react-test-renderer/-/react-test-renderer-19.1.0.tgz",
|
||||
"integrity": "sha512-XD0WZrHqjNrxA/MaR9O22w/RNidWR9YZmBdRGI7wcnWGrv/3dA8wKCJ8m63Sn+tLJhcjmuhOi629N66W6kgWzQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/react": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/resolve": {
|
||||
"version": "1.20.6",
|
||||
"resolved": "https://registry.npmjs.org/@types/resolve/-/resolve-1.20.6.tgz",
|
||||
@@ -11152,6 +11164,27 @@
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/react-test-renderer": {
|
||||
"version": "19.1.0",
|
||||
"resolved": "https://registry.npmjs.org/react-test-renderer/-/react-test-renderer-19.1.0.tgz",
|
||||
"integrity": "sha512-jXkSl3CpvPYEF+p/eGDLB4sPoDX8pKkYvRl9+rR8HxLY0X04vW7hCm1/0zHoUSjPZ3bDa+wXWNTDVIw/R8aDVw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"react-is": "^19.1.0",
|
||||
"scheduler": "^0.26.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^19.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/react-test-renderer/node_modules/react-is": {
|
||||
"version": "19.2.8",
|
||||
"resolved": "https://registry.npmjs.org/react-is/-/react-is-19.2.8.tgz",
|
||||
"integrity": "sha512-s5un28nYxKJw5gvUHyW5PCC28CvBqLu9r3cWgzHT4Vo/5fqqkFcdRYsGcKf50WMPpjjFZS5d76fn3YCo2njKwQ==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/readdirp": {
|
||||
"version": "3.6.0",
|
||||
"resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz",
|
||||
|
||||
@@ -52,6 +52,7 @@
|
||||
"@types/node": "^24.7.2",
|
||||
"@types/react": "^19.1.2",
|
||||
"@types/react-dom": "^19.1.2",
|
||||
"@types/react-test-renderer": "^19.1.0",
|
||||
"@vitejs/plugin-react": "^4.4.1",
|
||||
"@vitest/browser": "^3.2.4",
|
||||
"@vitest/coverage-v8": "^3.2.4",
|
||||
@@ -65,6 +66,7 @@
|
||||
"playwright": "^1.53.2",
|
||||
"postcss-preset-env": "^10.2.4",
|
||||
"react-markdown": "^10.1.0",
|
||||
"react-test-renderer": "19.1.0",
|
||||
"remark": "^15.0.1",
|
||||
"remark-gfm": "^4.0.1",
|
||||
"remark-stringify": "^11.0.0",
|
||||
|
||||
|
After Width: | Height: | Size: 245 KiB |
|
Before Width: | Height: | Size: 21 KiB After Width: | Height: | Size: 10 KiB |
@@ -0,0 +1,8 @@
|
||||
<svg width="92" height="96" viewBox="0 0 92 96" xmlns="http://www.w3.org/2000/svg">
|
||||
<g fill="#24292F">
|
||||
<path fill-rule="evenodd" d="M65.45 16.8c10.89 0 19.71 8.86 19.71 19.8v6.6l5.74 11.46a4 4 0 0 1-.01 3.6l-5.73 11.34v6.6c0 10.94-8.82 19.8-19.71 19.8H26.02C15.13 96 6.31 87.14 6.31 76.2v-6.6L.45 58.3a4 4 0 0 1-.01-3.67l5.87-11.43v-6.6c0-10.94 8.82-19.8 19.71-19.8h39.43Zm-2.52 5.7H29.19c-9.32 0-16.87 7.56-16.87 16.88V45L7.44 54.46a4 4 0 0 0 .01 3.68L12.32 67.5v5.63c0 9.32 7.55 16.87 16.87 16.87h33.74c9.32 0 16.87-7.55 16.87-16.87V67.5l4.77-9.39a4 4 0 0 0 .01-3.61L79.8 45v-5.62c0-9.32-7.55-16.88-16.87-16.88Z"/>
|
||||
<circle cx="45.73" cy="11.5" r="11"/>
|
||||
<rect x="27" y="41" width="13" height="30" rx="6.5"/>
|
||||
<rect x="51" y="41" width="13" height="30" rx="6.5"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 795 B |
|
Before Width: | Height: | Size: 41 KiB |
@@ -0,0 +1 @@
|
||||
<svg viewBox="0 0 512 512" xmlns="http://www.w3.org/2000/svg" fill-rule="evenodd" clip-rule="evenodd" stroke-linejoin="round" stroke-miterlimit="2"><g transform="scale(32)"><clipPath id="codex-color-clip"><path d="M0 0h16v16H0z"/></clipPath><g clip-path="url(#codex-color-clip)"><path d="M13.003 0H2.997A3.012 3.012 0 000 2.997v10.006A3.012 3.012 0 002.997 16h10.006A3.012 3.012 0 0016 13.003V2.997A3.012 3.012 0 0013.003 0z" fill="#fff" fill-rule="nonzero"/><path d="M9.064 3.344a4.578 4.578 0 012.285-.312c1 .115 1.891.54 2.673 1.275.01.01.024.017.037.021a.104.104 0 00.043 0 4.556 4.556 0 013.046.275l.047.022.116.057a4.585 4.585 0 012.188 2.399c.209.51.313 1.041.315 1.595.015.412-.03.824-.134 1.223a.124.124 0 00.03.115c.594.607.988 1.33 1.183 2.17.289 1.425-.007 2.71-.887 3.854l-.136.166a4.548 4.548 0 01-2.201 1.388.12.12 0 00-.081.076c-.191.551-.383 1.023-.74 1.494-.9 1.187-2.222 1.846-3.711 1.838-1.187-.006-2.239-.44-3.157-1.302a.109.109 0 00-.105-.024c-.388.125-.78.143-1.204.138a4.438 4.438 0 01-1.945-.466 4.553 4.553 0 01-1.61-1.335c-.152-.202-.303-.392-.414-.617a5.797 5.797 0 01-.37-.961 4.575 4.575 0 01-.014-2.298.133.133 0 00.006-.056.083.083 0 00-.027-.048 4.467 4.467 0 01-1.034-1.651 3.898 3.898 0 01-.251-1.192 5.193 5.193 0 01.141-1.6c.337-1.112.982-1.985 1.933-2.618.212-.141.413-.251.601-.33a6.29 6.29 0 01.646-.227.1.1 0 00.065-.066 4.512 4.512 0 01.829-1.615 4.54 4.54 0 011.837-1.388zm3.482 10.565a.64.64 0 00-.601.636.64.64 0 00.601.636h3.636l.036.001a.64.64 0 00.637-.637.64.64 0 00-.637-.637l-.036.001h-3.636zM8.462 9.23a.64.64 0 00-.543-.304.64.64 0 00-.563.935l1.272 2.224-1.266 2.136a.638.638 0 001.095.649l1.454-2.455a.637.637 0 00.005-.64L8.462 9.23z" fill="url(#codex-color-gradient)" fill-rule="nonzero" transform="scale(.66667)"/></g></g><defs><linearGradient id="codex-color-gradient" x1="0" y1="0" x2="1" y2="0" gradientUnits="userSpaceOnUse" gradientTransform="matrix(0 18 -18 0 12 3)"><stop offset="0" stop-color="#b1a7ff"/><stop offset=".5" stop-color="#7a9dff"/><stop offset="1" stop-color="#3941ff"/></linearGradient></defs></svg>
|
||||
|
After Width: | Height: | Size: 2.0 KiB |
@@ -0,0 +1,3 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="50" height="50" viewBox="0 0 50 50" fill="none">
|
||||
<path d="M48.8354 10.0479C48.3232 9.79199 48.1025 10.2798 47.8032 10.5278C47.7007 10.6079 47.6143 10.7119 47.5273 10.8076C46.7793 11.624 45.9048 12.1597 44.7622 12.0957C43.0923 12 41.666 12.5356 40.4058 13.8398C40.1377 12.2319 39.2476 11.272 37.8926 10.6558C37.1836 10.3359 36.4668 10.0156 35.9702 9.31982C35.6235 8.82373 35.5293 8.27197 35.356 7.72754C35.2456 7.3999 35.1353 7.06396 34.7651 7.00781C34.3633 6.94385 34.2056 7.2876 34.0479 7.57568C33.418 8.75195 33.1733 10.0479 33.1973 11.3599C33.2524 14.312 34.4736 16.6641 36.8999 18.3359C37.1758 18.5278 37.2466 18.7197 37.1597 19C36.9946 19.5757 36.7974 20.1357 36.624 20.7119C36.5137 21.0801 36.3486 21.1597 35.9624 21C34.6309 20.4321 33.481 19.5918 32.4644 18.5757C30.7393 16.8721 29.1792 14.9917 27.2334 13.52C26.7764 13.1758 26.3193 12.856 25.8467 12.5518C23.8618 10.584 26.1069 8.96777 26.627 8.77588C27.1704 8.57568 26.8159 7.8877 25.0591 7.896C23.3022 7.90381 21.6953 8.50391 19.647 9.30371C19.3477 9.42383 19.0322 9.51172 18.7095 9.58398C16.8501 9.22363 14.9199 9.14355 12.9033 9.37598C9.10596 9.80762 6.07275 11.6396 3.84326 14.7681C1.16455 18.5278 0.53418 22.7998 1.30664 27.2559C2.11768 31.9521 4.46582 35.8398 8.07373 38.8799C11.8159 42.0322 16.1255 43.5762 21.041 43.2803C24.0269 43.104 27.3516 42.6963 31.1016 39.4561C32.0469 39.936 33.0396 40.1279 34.686 40.272C35.9546 40.3921 37.1758 40.208 38.1211 40.0078C39.6021 39.688 39.4995 38.2881 38.9639 38.0322C34.623 35.9678 35.5762 36.8081 34.71 36.1279C36.9155 33.4639 40.2402 30.6958 41.54 21.728C41.6426 21.0161 41.5557 20.5679 41.54 19.9917C41.5322 19.6396 41.6108 19.5039 42.0049 19.4639C43.0923 19.3359 44.1479 19.0317 45.1167 18.4878C47.9292 16.9199 49.064 14.3438 49.3315 11.2559C49.3711 10.7837 49.3237 10.2959 48.8354 10.0479ZM24.3262 37.8398C20.1196 34.4639 18.0791 33.3521 17.2358 33.3999C16.4482 33.4482 16.5898 34.3682 16.7632 34.9678C16.9443 35.5601 17.1812 35.9683 17.5117 36.4878C17.7402 36.832 17.8979 37.3442 17.2832 37.728C15.9282 38.584 13.5728 37.4399 13.4624 37.3838C10.7207 35.7358 8.42822 33.5601 6.81348 30.584C5.25342 27.7197 4.34766 24.6479 4.19775 21.3677C4.1582 20.5757 4.38672 20.2959 5.15869 20.1519C6.17529 19.96 7.22314 19.9199 8.23926 20.0718C12.5327 20.7119 16.1885 22.6719 19.2529 25.7759C21.002 27.5439 22.3252 29.6558 23.6885 31.7202C25.1377 33.9121 26.6978 36 28.6831 37.7119C29.3843 38.312 29.9434 38.7681 30.479 39.104C28.8643 39.2881 26.1699 39.3281 24.3262 37.8398ZM26.3433 24.6001C26.3433 24.248 26.6191 23.9678 26.9658 23.9678C27.0444 23.9678 27.1152 23.9839 27.1782 24.0078C27.2651 24.04 27.3438 24.0879 27.4067 24.1602C27.5171 24.272 27.5801 24.4321 27.5801 24.6001C27.5801 24.9521 27.3042 25.2319 26.9575 25.2319C26.6108 25.2319 26.3433 24.9521 26.3433 24.6001ZM32.6064 27.8799C32.2046 28.0479 31.8027 28.1919 31.4165 28.208C30.8179 28.2397 30.1641 27.9922 29.8096 27.688C29.2583 27.2158 28.8643 26.9521 28.6987 26.1279C28.6279 25.7759 28.6675 25.2319 28.7305 24.9199C28.8721 24.248 28.7144 23.8159 28.2495 23.4238C27.8716 23.104 27.3911 23.0161 26.8633 23.0161C26.666 23.0161 26.4849 22.9277 26.3511 22.856C26.1304 22.7441 25.9492 22.4639 26.1226 22.1201C26.1777 22.0078 26.4458 21.7358 26.5088 21.688C27.2256 21.272 28.0527 21.4077 28.8169 21.7197C29.5259 22.0161 30.0615 22.5601 30.834 23.3281C31.6216 24.2559 31.7632 24.5117 32.2124 25.208C32.5669 25.752 32.8901 26.312 33.1104 26.9521C33.2446 27.3521 33.0713 27.6802 32.6064 27.8799Z" fill="#4D6BFE" fill-rule="nonzero"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 3.5 KiB |
@@ -0,0 +1,11 @@
|
||||
<svg viewBox="0 0 64 64" xmlns="http://www.w3.org/2000/svg">
|
||||
<defs>
|
||||
<linearGradient id="omp-gradient" x1="0" y1="0" x2="1" y2="1">
|
||||
<stop offset="0" stop-color="#ed4abf"/>
|
||||
<stop offset=".5" stop-color="#9b4dff"/>
|
||||
<stop offset="1" stop-color="#5ad8e6"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<rect width="64" height="64" rx="12" fill="#0f0a14"/>
|
||||
<path fill="url(#omp-gradient)" d="M14 16h36v8H40v32h-8V24h-6v22h-8V24h-4z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 451 B |
@@ -0,0 +1,11 @@
|
||||
<svg viewBox="0 0 64 64" xmlns="http://www.w3.org/2000/svg">
|
||||
<defs>
|
||||
<linearGradient id="poolside-gradient" x1="8" y1="5" x2="55" y2="59" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#6c5cff"/>
|
||||
<stop offset="1" stop-color="#3c2cff"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<rect width="64" height="64" rx="13" fill="url(#poolside-gradient)"/>
|
||||
<path d="M13 32c0-10.5 8.5-19 19-19 10.49 0 19 8.5 19 19s-8.51 19-19 19c-10.5 0-19-8.5-19-19Z" fill="none" stroke="#fff" stroke-width="4"/>
|
||||
<path d="M16 24c8-4.1 17.1-.9 22.6 7.1 4.3-1.2 8.6.5 11 4.1M23.5 47.5 38 17.5" fill="none" stroke="#fff" stroke-linecap="round" stroke-linejoin="round" stroke-width="4"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 682 B |
@@ -0,0 +1,3 @@
|
||||
<svg viewBox="0 0 141.38 140" xmlns="http://www.w3.org/2000/svg">
|
||||
<path fill="#6D44E8" d="m140.93 85-16.35-28.33-1.93-3.34 8.66-15a3.32 3.32 0 0 0 0-3.34l-9.62-16.67a3.34 3.34 0 0 0-2.89-1.67H82.23l-8.66-15A3.33 3.33 0 0 0 70.68-.02H51.43a3.33 3.33 0 0 0-2.88 1.67L32.19 29.98l-1.92 3.33H12.96a3.34 3.34 0 0 0-2.88 1.67L.45 51.66a3.32 3.32 0 0 0 0 3.34l18.28 31.67-8.66 15a3.32 3.32 0 0 0 0 3.34l9.62 16.67a3.34 3.34 0 0 0 2.89 1.67h36.56l8.66 15a3.35 3.35 0 0 0 2.89 1.67h19.25a3.34 3.34 0 0 0 2.89-1.67l18.28-31.67h17.32a3.34 3.34 0 0 0 2.89-1.67l9.62-16.67a3.32 3.32 0 0 0-.01-3.34ZM51.44 3.33 61.07 20l-9.63 16.66h76.98l-9.62 16.66H45.67l-11.54-20zM57.21 120H22.58l9.63-16.67h19.25l-38.5-66.67h19.25l9.62 16.67L68.78 100l-11.55 20Zm61.59-33.34-9.62-16.67-38.49 66.67-9.63-16.67 9.63-16.66 26.94-46.67h23.1l17.32 30z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 832 B |
@@ -0,0 +1,168 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
const { listModels } = vi.hoisted(() => ({ listModels: vi.fn() }));
|
||||
vi.mock("./lib/ollama-client", () => ({
|
||||
ollamaClient: { list: listModels },
|
||||
}));
|
||||
|
||||
import {
|
||||
fetchConnectUrl,
|
||||
getClaudeDesktopAvailableModels,
|
||||
getIntegrationStatuses,
|
||||
} from "./api";
|
||||
|
||||
describe("fetchConnectUrl", () => {
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it("requests a desktop handoff after account creation", async () => {
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn().mockResolvedValue(
|
||||
new Response(
|
||||
JSON.stringify({
|
||||
signin_url:
|
||||
"https://ollama.com/connect?name=MacBook&key=public-key",
|
||||
}),
|
||||
{ status: 401 },
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
await expect(fetchConnectUrl()).resolves.toBe(
|
||||
"https://ollama.com/connect?name=MacBook&key=public-key&launch=true",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getIntegrationStatuses", () => {
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it("returns desktop and launcher integration metadata", async () => {
|
||||
const fetch = vi.fn().mockResolvedValue(
|
||||
new Response(
|
||||
JSON.stringify([
|
||||
{
|
||||
id: "claude-desktop",
|
||||
name: "Claude",
|
||||
description: "Use Ollama models in Claude Desktop",
|
||||
installed: true,
|
||||
},
|
||||
{
|
||||
id: "opencode",
|
||||
name: "OpenCode",
|
||||
description: "Open-source coding agent",
|
||||
command: "ollama launch opencode",
|
||||
},
|
||||
]),
|
||||
{ status: 200 },
|
||||
),
|
||||
);
|
||||
vi.stubGlobal("fetch", fetch);
|
||||
|
||||
await expect(getIntegrationStatuses()).resolves.toEqual([
|
||||
{
|
||||
id: "claude-desktop",
|
||||
name: "Claude",
|
||||
description: "Use Ollama models in Claude Desktop",
|
||||
installed: true,
|
||||
},
|
||||
{
|
||||
id: "opencode",
|
||||
name: "OpenCode",
|
||||
description: "Open-source coding agent",
|
||||
command: "ollama launch opencode",
|
||||
},
|
||||
]);
|
||||
expect(fetch).toHaveBeenCalledWith(
|
||||
"http://127.0.0.1:3001/api/v1/integrations",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getClaudeDesktopAvailableModels", () => {
|
||||
afterEach(() => {
|
||||
listModels.mockReset();
|
||||
vi.unstubAllGlobals();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("returns installed local models while pruning remote entries", async () => {
|
||||
listModels.mockResolvedValue({
|
||||
models: [
|
||||
{ name: "llama3.2:latest", digest: "local" },
|
||||
{
|
||||
name: "remote-placeholder",
|
||||
digest: "remote",
|
||||
remote_host: "https://ollama.com",
|
||||
},
|
||||
],
|
||||
});
|
||||
const fetch = vi.fn();
|
||||
vi.stubGlobal("fetch", fetch);
|
||||
|
||||
const models = await getClaudeDesktopAvailableModels();
|
||||
|
||||
expect(models.map((model) => model.model)).toEqual(["llama3.2"]);
|
||||
expect(fetch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not request cloud models when they are unavailable to the user", async () => {
|
||||
listModels.mockResolvedValue({
|
||||
models: [
|
||||
{ name: "qwen3:8b", digest: "local" },
|
||||
{ name: "deepseek-v4-flash:cloud", digest: "cached-cloud" },
|
||||
{ name: "gemma4:31b-cloud", digest: "legacy-cached-cloud" },
|
||||
],
|
||||
});
|
||||
const fetch = vi.fn();
|
||||
vi.stubGlobal("fetch", fetch);
|
||||
|
||||
const models = await getClaudeDesktopAvailableModels();
|
||||
|
||||
expect(models.map((model) => model.model)).toEqual(["qwen3:8b"]);
|
||||
expect(fetch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("loads the account cloud list in parallel when Cloud is available", async () => {
|
||||
listModels.mockResolvedValue({
|
||||
models: [{ name: "qwen3:8b", digest: "local" }],
|
||||
});
|
||||
const fetch = vi.fn().mockResolvedValue(
|
||||
new Response(
|
||||
JSON.stringify({
|
||||
models: [
|
||||
{ name: "glm-5.2", digest: "cloud" },
|
||||
{ name: "gemma4:31b-cloud", digest: "legacy-cloud" },
|
||||
{ name: "qwen3:8b", digest: "cloud-duplicate" },
|
||||
],
|
||||
}),
|
||||
),
|
||||
);
|
||||
vi.stubGlobal("fetch", fetch);
|
||||
|
||||
const models = await getClaudeDesktopAvailableModels(true);
|
||||
|
||||
expect(models.map((model) => model.model)).toEqual([
|
||||
"qwen3:8b",
|
||||
"glm-5.2:cloud",
|
||||
"gemma4:31b-cloud",
|
||||
]);
|
||||
expect(fetch).toHaveBeenCalledWith(
|
||||
"http://127.0.0.1:3001/api/v1/models/cloud",
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps local models when the account cloud list fails", async () => {
|
||||
listModels.mockResolvedValue({
|
||||
models: [{ name: "qwen3:8b", digest: "local" }],
|
||||
});
|
||||
vi.stubGlobal("fetch", vi.fn().mockRejectedValue(new Error("offline")));
|
||||
|
||||
const models = await getClaudeDesktopAvailableModels(true);
|
||||
|
||||
expect(models.map((model) => model.model)).toEqual(["qwen3:8b"]);
|
||||
});
|
||||
});
|
||||
@@ -32,6 +32,24 @@ export interface CloudStatusResponse {
|
||||
disabled: boolean;
|
||||
source: CloudStatusSource;
|
||||
}
|
||||
|
||||
export interface IntegrationStatus {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
installed?: boolean;
|
||||
command?: string;
|
||||
}
|
||||
|
||||
export type IntegrationStatuses = IntegrationStatus[];
|
||||
|
||||
export async function getIntegrationStatuses(): Promise<IntegrationStatuses> {
|
||||
const response = await fetch(`${API_BASE}/api/v1/integrations`);
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to fetch integration statuses: ${response.status}`);
|
||||
}
|
||||
return response.json();
|
||||
}
|
||||
// Helper function to convert Uint8Array to base64
|
||||
function uint8ArrayToBase64(uint8Array: Uint8Array): string {
|
||||
const chunkSize = 0x8000; // 32KB chunks to avoid stack overflow
|
||||
@@ -81,7 +99,9 @@ export async function fetchConnectUrl(): Promise<string> {
|
||||
if (response.status === 401) {
|
||||
const data = await response.json();
|
||||
if (data.signin_url) {
|
||||
return data.signin_url;
|
||||
const connectUrl = new URL(data.signin_url);
|
||||
connectUrl.searchParams.set("launch", "true");
|
||||
return connectUrl.toString();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -176,6 +196,84 @@ export async function getModels(query?: string): Promise<Model[]> {
|
||||
}
|
||||
}
|
||||
|
||||
export async function getClaudeDesktopAvailableModels(
|
||||
includeCloudModels = false,
|
||||
): Promise<Model[]> {
|
||||
try {
|
||||
const [localResult, cloudResult] = await Promise.all([
|
||||
ollama.list(),
|
||||
includeCloudModels
|
||||
? fetch(`${API_BASE}/api/v1/models/cloud`)
|
||||
.then(async (response) => {
|
||||
if (!response.ok) {
|
||||
throw new Error(`cloud model list returned ${response.status}`);
|
||||
}
|
||||
return (await response.json()) as { models?: ModelResponse[] };
|
||||
})
|
||||
.catch((error) => {
|
||||
console.warn("Failed to fetch cloud models:", error);
|
||||
return { models: [] };
|
||||
})
|
||||
: Promise.resolve({ models: [] as ModelResponse[] }),
|
||||
]);
|
||||
|
||||
const localModels = localResult.models.filter((model: ModelResponse) => {
|
||||
const response = model as ModelResponse & {
|
||||
remote_model?: string;
|
||||
remote_host?: string;
|
||||
};
|
||||
const name = model.name.replace(/:latest$/, "");
|
||||
return (
|
||||
!response.remote_model &&
|
||||
!response.remote_host &&
|
||||
!name.endsWith("cloud")
|
||||
);
|
||||
});
|
||||
const cloudModels = (cloudResult.models ?? []).map((model) => {
|
||||
const name = model.name.replace(/:latest$/, "");
|
||||
const tag = name.slice(name.lastIndexOf(":") + 1).toLowerCase();
|
||||
const explicitCloud =
|
||||
name.endsWith(":cloud") ||
|
||||
(name.includes(":") && tag.endsWith("-cloud"));
|
||||
return {
|
||||
...model,
|
||||
name: explicitCloud ? name : `${name}:cloud`,
|
||||
};
|
||||
});
|
||||
|
||||
const seen = new Set<string>();
|
||||
return [...localModels, ...cloudModels]
|
||||
.filter((model: ModelResponse) => {
|
||||
const base = model.name
|
||||
.replace(/:latest$/, "")
|
||||
.replace(/:cloud$/, "");
|
||||
if (!base || seen.has(base)) return false;
|
||||
|
||||
const families = model.details?.families;
|
||||
const supported =
|
||||
!families ||
|
||||
families.length === 0 ||
|
||||
!families.every((family: string) =>
|
||||
family.toLowerCase().includes("bert"),
|
||||
);
|
||||
if (supported) seen.add(base);
|
||||
return supported;
|
||||
})
|
||||
.map(
|
||||
(model: ModelResponse) =>
|
||||
new Model({
|
||||
model: model.name.replace(/:latest$/, ""),
|
||||
digest: model.digest,
|
||||
modified_at: model.modified_at
|
||||
? new Date(model.modified_at)
|
||||
: undefined,
|
||||
}),
|
||||
);
|
||||
} catch (err) {
|
||||
throw new Error(`Failed to fetch Ollama models: ${err}`);
|
||||
}
|
||||
}
|
||||
|
||||
export async function getModelCapabilities(
|
||||
modelName: string,
|
||||
): Promise<ModelCapabilitiesResponse> {
|
||||
@@ -418,7 +516,9 @@ export interface ModelRecommendationsResponse {
|
||||
recommendations: ModelRecommendation[];
|
||||
}
|
||||
|
||||
export async function getModelRecommendations(): Promise<ModelRecommendation[]> {
|
||||
export async function getModelRecommendations(): Promise<
|
||||
ModelRecommendation[]
|
||||
> {
|
||||
const response = await fetch(
|
||||
`${API_BASE}/api/experimental/model-recommendations`,
|
||||
);
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
import { Link } from "@/components/ui/link";
|
||||
import { ChatIcon } from "@/components/ChatIcon";
|
||||
import { Cog6ToothIcon, RectangleGroupIcon } from "@heroicons/react/24/outline";
|
||||
|
||||
type AppSection = "apps" | "chat" | "settings";
|
||||
|
||||
export function AppNavigation({ current }: { current: AppSection }) {
|
||||
const itemClass = (section: AppSection) =>
|
||||
`flex w-full items-center gap-3 rounded-lg px-2 py-2 text-left text-sm text-neutral-700 hover:bg-neutral-100 dark:text-neutral-100 dark:hover:bg-neutral-800 ${
|
||||
current === section ? "bg-neutral-100 dark:bg-neutral-800" : ""
|
||||
}`;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<Link to="/connect" className={itemClass("apps")} draggable={false}>
|
||||
<RectangleGroupIcon className="h-5 w-5 stroke-current" />
|
||||
<span className="truncate">Apps</span>
|
||||
</Link>
|
||||
<Link
|
||||
to="/c/$chatId"
|
||||
params={{ chatId: "new" }}
|
||||
mask={{ to: "/" }}
|
||||
className={itemClass("chat")}
|
||||
draggable={false}
|
||||
>
|
||||
<ChatIcon />
|
||||
<span className="truncate">Chat</span>
|
||||
</Link>
|
||||
<Link to="/settings" className={itemClass("settings")} draggable={false}>
|
||||
<Cog6ToothIcon className="h-5 w-5 stroke-current" />
|
||||
<span className="truncate">Settings</span>
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function AppSidebar({ current }: { current: AppSection }) {
|
||||
return (
|
||||
<nav className="flex flex-1 flex-col px-4 pb-4 select-none">
|
||||
<AppNavigation current={current} />
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
export function ChatIcon({ className = "h-5 w-5" }: { className?: string }) {
|
||||
return (
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
className={`${className} fill-current`}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path d="M17.0859 3.39949L15.2135 5.27196H7.27028C5.78649 5.27196 4.94684 6.11336 4.94684 7.59716V16.664C4.94684 18.1558 5.78649 18.9892 7.27028 18.9892H16.3406C17.8324 18.9892 18.6623 18.1558 18.6623 16.664V8.79514L20.5428 6.9115C20.567 7.11532 20.5773 7.33066 20.5773 7.55419V16.7149C20.5773 19.4069 19.0818 20.9024 16.3898 20.9024H7.22107C4.53708 20.9024 3.03357 19.4069 3.03357 16.7149V7.55419C3.03357 4.8622 4.53708 3.35869 7.22107 3.35869H16.3898C16.6329 3.35869 16.8662 3.37094 17.0859 3.39949Z" />
|
||||
<path d="M9.92714 14.381L11.914 13.5403L20.8312 4.63114L19.3404 3.1581L10.433 12.0655L9.55234 13.9964C9.45664 14.2169 9.70293 14.4714 9.92714 14.381ZM21.5767 3.89364L22.2588 3.19384C22.6347 2.80184 22.6435 2.2663 22.2711 1.90536L22.0148 1.64287C21.6822 1.31377 21.1334 1.36513 20.7689 1.72158L20.0859 2.39833L21.5767 3.89364Z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||