mirror of
https://github.com/ollama/ollama.git
synced 2026-09-09 12:40:21 -04:00
Compare commits
42
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
86f7292934 | ||
|
|
cd1c5a145d | ||
|
|
9160b3c0b8 | ||
|
|
3e02feac8d | ||
|
|
1b45aa19da | ||
|
|
34afa1a0c8 | ||
|
|
b5d373f340 | ||
|
|
9ef6c19341 | ||
|
|
3b5ab1fcfc | ||
|
|
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 |
No files matched your search
Executable
+366
@@ -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
|
||||
@@ -77,6 +77,8 @@ jobs:
|
||||
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: |
|
||||
@@ -169,6 +171,11 @@ jobs:
|
||||
- '"cufft_dev"'
|
||||
- '"nvrtc"'
|
||||
- '"nvrtc_dev"'
|
||||
- '"cusolver"'
|
||||
- '"cusolver_dev"'
|
||||
- '"cusparse"'
|
||||
- '"cusparse_dev"'
|
||||
- '"nvjitlink"'
|
||||
- '"crt"'
|
||||
- '"nvvm"'
|
||||
- '"nvptxcompiler"'
|
||||
@@ -446,6 +453,16 @@ jobs:
|
||||
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
|
||||
@@ -666,6 +683,7 @@ jobs:
|
||||
lib/ollama/vulkan*) echo $COMPONENT >>ollama-${{ matrix.os }}-${{ matrix.arch }}.tar.in ;;
|
||||
lib/ollama/mlx*) echo $COMPONENT >>ollama-${{ matrix.os }}-${{ matrix.arch }}-mlx.tar.in ;;
|
||||
lib/ollama/include*) echo $COMPONENT >>ollama-${{ matrix.os }}-${{ matrix.arch }}-mlx.tar.in ;;
|
||||
lib/ollama/*_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_v*) echo $COMPONENT >>ollama-${{ matrix.os }}-${{ matrix.arch }}-rocm.tar.in ;;
|
||||
|
||||
@@ -57,7 +57,10 @@ jobs:
|
||||
MLX_VERSION
|
||||
MLX_C_VERSION
|
||||
- name: Build unsigned Darwin runtime
|
||||
run: ./scripts/build_darwin.sh build package
|
||||
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
|
||||
@@ -240,6 +243,7 @@ jobs:
|
||||
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 ;;
|
||||
@@ -577,6 +581,16 @@ jobs:
|
||||
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
|
||||
|
||||
@@ -23,6 +23,7 @@ jobs:
|
||||
outputs:
|
||||
changed: ${{ steps.changes.outputs.changed }}
|
||||
app_changed: ${{ steps.changes.outputs.app_changed }}
|
||||
go_mod_changed: ${{ steps.changes.outputs.go_mod_changed }}
|
||||
enginehash: ${{ steps.changes.outputs.enginehash }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
@@ -56,6 +57,7 @@ jobs:
|
||||
'x/mlxrunner/xgrammar/native/**/*' \
|
||||
'.github/**/*') | tee -a $GITHUB_OUTPUT
|
||||
echo app_changed=$(changed 'app/**' 'app/**/*') | 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:
|
||||
@@ -229,6 +231,11 @@ jobs:
|
||||
- '"cufft_dev"'
|
||||
- '"nvrtc"'
|
||||
- '"nvrtc_dev"'
|
||||
- '"cusolver"'
|
||||
- '"cusolver_dev"'
|
||||
- '"cusparse"'
|
||||
- '"cusparse_dev"'
|
||||
- '"nvjitlink"'
|
||||
- '"crt"'
|
||||
- '"nvvm"'
|
||||
- '"nvptxcompiler"'
|
||||
@@ -368,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:
|
||||
@@ -389,6 +414,16 @@ jobs:
|
||||
- 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
|
||||
@@ -413,12 +448,58 @@ jobs:
|
||||
- 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
|
||||
|
||||
race:
|
||||
strategy:
|
||||
matrix:
|
||||
os: [ubuntu-latest, macos-latest]
|
||||
runs-on: ${{ matrix.os }}
|
||||
env:
|
||||
CGO_ENABLED: '1'
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version-file: 'go.mod'
|
||||
cache-dependency-path: |
|
||||
go.sum
|
||||
LLAMA_CPP_VERSION
|
||||
MLX_VERSION
|
||||
MLX_C_VERSION
|
||||
- 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: |
|
||||
npm ci
|
||||
npm run build
|
||||
- name: go test -race
|
||||
run: go test -race -count=1 ./...
|
||||
+8
-1
@@ -220,7 +220,6 @@ ENV CGO_LDFLAGS="-L/usr/local/cuda-13/lib64 -L/usr/local/cuda-13/targets/x86_64-
|
||||
WORKDIR /go/src/github.com/ollama/ollama
|
||||
COPY CMakeLists.txt CMakePresets.json .
|
||||
COPY cmake cmake
|
||||
COPY mlx mlx
|
||||
COPY x/mlxrunner/mlx x/mlxrunner/mlx
|
||||
COPY x/mlxrunner/xgrammar/native x/mlxrunner/xgrammar/native
|
||||
COPY go.mod go.sum .
|
||||
@@ -262,9 +261,15 @@ 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
|
||||
@@ -297,9 +302,11 @@ 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
|
||||
|
||||
+1
-1
@@ -1 +1 @@
|
||||
b10630
|
||||
b10760
|
||||
+1
-1
@@ -1 +1 @@
|
||||
fba4470b89073180056c9ea46c443051375f7399
|
||||
c74db5307cc8ce122f48d97ef951b30578674e7f
|
||||
+1
-1
@@ -1 +1 @@
|
||||
c793734eb715dbcfdb1ced58e348ec53c2d7ed85
|
||||
37c26e5755da637255d57ea34b4879196a485301
|
||||
+44
-16
@@ -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,
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -950,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{
|
||||
@@ -962,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,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
@@ -16,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}}
|
||||
@@ -30,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",
|
||||
@@ -861,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,
|
||||
},
|
||||
}
|
||||
|
||||
@@ -886,9 +946,17 @@ 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) {
|
||||
@@ -1072,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)
|
||||
@@ -1100,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)
|
||||
@@ -1118,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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
+53
-14
@@ -555,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
|
||||
@@ -800,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].
|
||||
@@ -973,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 {
|
||||
|
||||
@@ -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"
|
||||
)
|
||||
@@ -213,6 +216,22 @@ func TestMainGPUParsingFromJSON(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
|
||||
+3
-5
@@ -30,7 +30,6 @@ import (
|
||||
"github.com/ollama/ollama/app/ui"
|
||||
"github.com/ollama/ollama/app/updater"
|
||||
"github.com/ollama/ollama/app/version"
|
||||
ollamaAuth "github.com/ollama/ollama/auth"
|
||||
)
|
||||
|
||||
var (
|
||||
@@ -176,14 +175,13 @@ 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
|
||||
installSymlink()
|
||||
if err := ollamaAuth.EnsureKeypair(io.Discard); err != nil {
|
||||
slog.Warn("failed to ensure signing identity", "error", err)
|
||||
}
|
||||
|
||||
var ln net.Listener
|
||||
if devMode {
|
||||
|
||||
+350
-116
@@ -26,6 +26,7 @@ import (
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"syscall"
|
||||
"time"
|
||||
"unsafe"
|
||||
@@ -39,6 +40,7 @@ import (
|
||||
"github.com/ollama/ollama/envconfig"
|
||||
"github.com/ollama/ollama/internal/modelref"
|
||||
"github.com/ollama/ollama/internal/proxy"
|
||||
"golang.org/x/sys/unix"
|
||||
)
|
||||
|
||||
var ollamaPath = func() string {
|
||||
@@ -213,9 +215,259 @@ func maybeMoveAndRestart() appMove {
|
||||
return status
|
||||
}
|
||||
|
||||
// handleExistingInstance handles existing instances on macOS
|
||||
func handleExistingInstance(_ bool) {
|
||||
C.killOtherInstances()
|
||||
type appProcessIdentity struct {
|
||||
pid int
|
||||
startedAt int64
|
||||
}
|
||||
|
||||
func (p appProcessIdentity) sameProcess(other appProcessIdentity) bool {
|
||||
return p.pid == other.pid && p.startedAt == other.startedAt
|
||||
}
|
||||
|
||||
func (p appProcessIdentity) startedAfter(other appProcessIdentity) bool {
|
||||
if p.startedAt != other.startedAt {
|
||||
return p.startedAt > other.startedAt
|
||||
}
|
||||
return p.pid > other.pid
|
||||
}
|
||||
|
||||
type appProcessStopMode uint8
|
||||
|
||||
const (
|
||||
appProcessStopForHandoff appProcessStopMode = iota
|
||||
appProcessStopGracefully
|
||||
appProcessStopForcefully
|
||||
)
|
||||
|
||||
type appProcessController struct {
|
||||
discover func() ([]appProcessIdentity, error)
|
||||
running func(appProcessIdentity) (bool, error)
|
||||
stop func(appProcessIdentity, appProcessStopMode) error
|
||||
}
|
||||
|
||||
type appSyncBarrierConfig struct {
|
||||
handoffTimeout time.Duration
|
||||
terminateTimeout time.Duration
|
||||
killTimeout time.Duration
|
||||
pollInterval time.Duration
|
||||
settlePeriod time.Duration
|
||||
}
|
||||
|
||||
// runAppSyncBarrier elects the newest launch, stops every older instance, and
|
||||
// returns only after no other instances remain.
|
||||
func runAppSyncBarrier(self appProcessIdentity, controller appProcessController, config appSyncBarrierConfig) error {
|
||||
started := time.Now()
|
||||
handoffDeadline := started.Add(config.handoffTimeout)
|
||||
terminateDeadline := handoffDeadline.Add(config.terminateTimeout)
|
||||
deadline := terminateDeadline.Add(config.killTimeout)
|
||||
sawEmpty := false
|
||||
|
||||
for {
|
||||
processes, err := controller.discover()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// Overlapping launches are ordered by process age. Only the newest
|
||||
// candidate may terminate existing instances.
|
||||
for _, process := range processes {
|
||||
if process.startedAfter(self) {
|
||||
return fmt.Errorf("%w: pid %d", errNewerAppInstance, process.pid)
|
||||
}
|
||||
}
|
||||
|
||||
// Require two consecutive empty snapshots so a process that is still
|
||||
// appearing in NSWorkspace cannot slip through the barrier.
|
||||
if len(processes) == 0 {
|
||||
if sawEmpty {
|
||||
return nil
|
||||
}
|
||||
sawEmpty = true
|
||||
if !time.Now().Before(deadline) {
|
||||
return fmt.Errorf("timed out waiting for app instances to exit")
|
||||
}
|
||||
time.Sleep(config.settlePeriod)
|
||||
continue
|
||||
}
|
||||
sawEmpty = false
|
||||
|
||||
for _, process := range processes {
|
||||
if err := controller.stop(process, appProcessStopForHandoff); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
exited, err := waitForAppProcesses(processes, controller, handoffDeadline, config.pollInterval)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !exited {
|
||||
for _, process := range processes {
|
||||
if err := controller.stop(process, appProcessStopGracefully); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
exited, err = waitForAppProcesses(processes, controller, terminateDeadline, config.pollInterval)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if !exited {
|
||||
// Graceful shutdown owns most of the deadline. Force only exact
|
||||
// surviving identities so one stuck instance cannot block update.
|
||||
for _, process := range processes {
|
||||
if err := controller.stop(process, appProcessStopForcefully); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
exited, err = waitForAppProcesses(processes, controller, deadline, config.pollInterval)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if !exited {
|
||||
return fmt.Errorf("timed out waiting for app instances to exit")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func waitForAppProcesses(processes []appProcessIdentity, controller appProcessController, deadline time.Time, pollInterval time.Duration) (bool, error) {
|
||||
for {
|
||||
running := false
|
||||
for _, process := range processes {
|
||||
alive, err := controller.running(process)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
running = running || alive
|
||||
}
|
||||
if !running {
|
||||
return true, nil
|
||||
}
|
||||
if !time.Now().Before(deadline) {
|
||||
return false, nil
|
||||
}
|
||||
time.Sleep(pollInterval)
|
||||
}
|
||||
}
|
||||
|
||||
const (
|
||||
appSyncBarrierHandoffTimeout = 5 * time.Second
|
||||
appSyncBarrierTerminateTimeout = 30 * time.Second
|
||||
appSyncBarrierKillTimeout = 5 * time.Second
|
||||
appSyncBarrierPollInterval = 50 * time.Millisecond
|
||||
appSyncBarrierSettlePeriod = 100 * time.Millisecond
|
||||
)
|
||||
|
||||
var errNewerAppInstance = errors.New("newer app instance owns the handoff")
|
||||
|
||||
var killOtherInstances = runDarwinAppSyncBarrier
|
||||
|
||||
// Once a replacement handoff starts, later shutdown signals must not restore
|
||||
// the Claude profile out from under the new app.
|
||||
var appHandoffInProgress atomic.Bool
|
||||
|
||||
func darwinProcessIdentityForPID(pid int) (appProcessIdentity, error) {
|
||||
process, err := unix.SysctlKinfoProc("kern.proc.pid", pid)
|
||||
if err != nil {
|
||||
if errors.Is(err, unix.EIO) && errors.Is(syscall.Kill(pid, 0), syscall.ESRCH) {
|
||||
return appProcessIdentity{}, syscall.ESRCH
|
||||
}
|
||||
return appProcessIdentity{}, err
|
||||
}
|
||||
if int(process.Proc.P_pid) != pid {
|
||||
return appProcessIdentity{}, syscall.ESRCH
|
||||
}
|
||||
return appProcessIdentity{
|
||||
pid: pid,
|
||||
startedAt: process.Proc.P_starttime.Sec*1_000_000 + int64(process.Proc.P_starttime.Usec),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func darwinOtherOllamaProcesses() ([]appProcessIdentity, error) {
|
||||
var discovered *C.AppProcessIdentity
|
||||
var count C.size_t
|
||||
if !C.otherOllamaProcesses(&discovered, &count) {
|
||||
return nil, errors.New("discover other Ollama app processes")
|
||||
}
|
||||
defer C.free(unsafe.Pointer(discovered))
|
||||
|
||||
identities := unsafe.Slice(discovered, int(count))
|
||||
processes := make([]appProcessIdentity, len(identities))
|
||||
for i, process := range identities {
|
||||
processes[i] = appProcessIdentity{pid: int(process.pid), startedAt: int64(process.started_at)}
|
||||
}
|
||||
return processes, nil
|
||||
}
|
||||
|
||||
func darwinAppProcessRunning(expected appProcessIdentity) (bool, error) {
|
||||
actual, err := darwinProcessIdentityForPID(expected.pid)
|
||||
if errors.Is(err, syscall.ESRCH) {
|
||||
return false, nil
|
||||
}
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("inspect Ollama app process %d: %w", expected.pid, err)
|
||||
}
|
||||
return actual.sameProcess(expected), nil
|
||||
}
|
||||
|
||||
func stopDarwinAppProcess(process appProcessIdentity, mode appProcessStopMode) error {
|
||||
running, err := darwinAppProcessRunning(process)
|
||||
if err != nil || !running {
|
||||
return err
|
||||
}
|
||||
processSignal := syscall.SIGUSR1
|
||||
switch mode {
|
||||
case appProcessStopGracefully:
|
||||
processSignal = syscall.SIGTERM
|
||||
case appProcessStopForcefully:
|
||||
processSignal = syscall.SIGKILL
|
||||
}
|
||||
slog.Info("signaling Ollama app process", "pid", process.pid, "signal", processSignal)
|
||||
if err := syscall.Kill(process.pid, processSignal); err != nil && !errors.Is(err, syscall.ESRCH) {
|
||||
return fmt.Errorf("signal Ollama app process %d: %w", process.pid, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func runDarwinAppSyncBarrier() bool {
|
||||
// NSWorkspace snapshots are not atomic, so two concurrent launches can both
|
||||
// pass if neither is visible yet. This edge case is intentionally unhandled.
|
||||
self, err := darwinProcessIdentityForPID(os.Getpid())
|
||||
if err == nil {
|
||||
err = runAppSyncBarrier(self, appProcessController{
|
||||
discover: darwinOtherOllamaProcesses,
|
||||
running: darwinAppProcessRunning,
|
||||
stop: stopDarwinAppProcess,
|
||||
}, appSyncBarrierConfig{
|
||||
handoffTimeout: appSyncBarrierHandoffTimeout,
|
||||
terminateTimeout: appSyncBarrierTerminateTimeout,
|
||||
killTimeout: appSyncBarrierKillTimeout,
|
||||
pollInterval: appSyncBarrierPollInterval,
|
||||
settlePeriod: appSyncBarrierSettlePeriod,
|
||||
})
|
||||
}
|
||||
switch {
|
||||
case errors.Is(err, errNewerAppInstance):
|
||||
slog.Info("newer Ollama app instance owns the handoff")
|
||||
case err != nil:
|
||||
slog.Warn("app instance sync barrier failed, continuing startup", "error", err)
|
||||
}
|
||||
return continueAfterBarrierError(err)
|
||||
}
|
||||
|
||||
// continueAfterBarrierError reports whether startup may proceed after the sync
|
||||
// barrier. Losing the election to a newer instance is the only reason to block
|
||||
// launch; any other failure leaves at most a stale instance running, so the
|
||||
// app warns and continues rather than refusing to start.
|
||||
func continueAfterBarrierError(err error) bool {
|
||||
return err == nil || !errors.Is(err, errNewerAppInstance)
|
||||
}
|
||||
|
||||
// handleExistingInstance handles existing instances on macOS.
|
||||
func handleExistingInstance(_ bool) bool {
|
||||
if !isApp {
|
||||
return true
|
||||
}
|
||||
return killOtherInstances()
|
||||
}
|
||||
|
||||
func installSymlink() {
|
||||
@@ -268,8 +520,7 @@ func osRun(_ func(), hasCompletedFirstRun, startHidden, showOnboarding bool, _ s
|
||||
select {
|
||||
case <-handoffSignal:
|
||||
slog.Info("received app handoff signal, shutting down")
|
||||
stopClaudeAppProxy()
|
||||
C.quit()
|
||||
quitForHandoff()
|
||||
case <-handoffDone:
|
||||
}
|
||||
}()
|
||||
@@ -411,12 +662,8 @@ func resolveClaudeDesktopStartupCatalog(ctx context.Context) (available, selecte
|
||||
if err != nil {
|
||||
slog.Debug("could not load account cloud models for Claude startup", "error", err)
|
||||
} else {
|
||||
available = mergeClaudeDesktopCloudInventory(available, cloudModels, false)
|
||||
if hasExplicitCloudClaudeDesktopModelName(selectedNames) {
|
||||
selectable = mergeClaudeDesktopCloudInventory(available, cloudModels, true)
|
||||
} else {
|
||||
selectable = available
|
||||
}
|
||||
available = mergeClaudeDesktopCloudInventory(available, cloudModels)
|
||||
selectable = available
|
||||
}
|
||||
}
|
||||
if len(savedMappings) > 0 {
|
||||
@@ -424,10 +671,7 @@ func resolveClaudeDesktopStartupCatalog(ctx context.Context) (available, selecte
|
||||
} else if len(selectedNames) == 0 {
|
||||
selected = proxy.MapClaudeDesktopModels(
|
||||
selectable,
|
||||
proxy.DefaultClaudeDesktopMappingsForModels(
|
||||
selectable,
|
||||
err == nil && claudeDesktopHasFullDefaultAccess(state),
|
||||
),
|
||||
proxy.DefaultClaudeDesktopMappingsForModels(selectable),
|
||||
)
|
||||
} else {
|
||||
selected = proxy.SelectClaudeDesktopModels(selectable, selectedNames)
|
||||
@@ -501,8 +745,12 @@ func refreshClaudeDesktopCatalog(ctx context.Context, current []proxy.ClaudeDesk
|
||||
cloudInventoryKnown := false
|
||||
if reloaded {
|
||||
available, source = claudeModelsLoader(ctx)
|
||||
if source == "fallback" && len(previous) > 0 {
|
||||
available = preserveClaudeDesktopEntitlements(available, previous)
|
||||
if source == "fallback" {
|
||||
if len(previous) > 0 {
|
||||
available = proxy.PreserveClaudeDesktopCloudEntitlements(available, previous)
|
||||
}
|
||||
available = proxy.WithoutClaudeDesktopRecommendationMappings(available)
|
||||
current = proxy.WithoutClaudeDesktopRecommendationMappings(current)
|
||||
}
|
||||
state, err := claudeAccessStateResolver(ctx)
|
||||
if err == nil && state.Cloud == proxy.ClaudeDesktopCloudOn {
|
||||
@@ -512,7 +760,7 @@ func refreshClaudeDesktopCatalog(ctx context.Context, current []proxy.ClaudeDesk
|
||||
} else {
|
||||
cloudInventory = cloudModels
|
||||
cloudInventoryKnown = true
|
||||
available = mergeClaudeDesktopCloudInventory(available, cloudInventory, false)
|
||||
available = mergeClaudeDesktopCloudInventory(available, cloudInventory)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -526,7 +774,7 @@ func refreshClaudeDesktopCatalog(ctx context.Context, current []proxy.ClaudeDesk
|
||||
if cloudInventoryKnown {
|
||||
for _, accountModel := range cloudInventory {
|
||||
if accountModel.Name == model.Name || accountModel.OllamaModel == model.OllamaModel {
|
||||
available = mergeClaudeDesktopCloudInventory(available, []proxy.ClaudeDesktopModel{accountModel}, true)
|
||||
available = mergeClaudeDesktopCloudInventory(available, []proxy.ClaudeDesktopModel{accountModel})
|
||||
break
|
||||
}
|
||||
}
|
||||
@@ -563,25 +811,6 @@ func configuredClaudeDesktopModels(available, current []proxy.ClaudeDesktopModel
|
||||
return proxy.SelectClaudeDesktopModels(available, launch.ClaudeDesktopModels())
|
||||
}
|
||||
|
||||
func preserveClaudeDesktopEntitlements(fallback, previous []proxy.ClaudeDesktopModel) []proxy.ClaudeDesktopModel {
|
||||
models := proxy.UnverifyClaudeDesktopCloudEntitlements(fallback)
|
||||
known := make(map[string]proxy.ClaudeDesktopModel, len(previous)*2)
|
||||
for _, model := range previous {
|
||||
known[model.Name] = model
|
||||
known[model.OllamaModel] = model
|
||||
}
|
||||
for i, model := range models {
|
||||
if prior, ok := known[model.Name]; ok {
|
||||
models[i] = prior
|
||||
continue
|
||||
}
|
||||
if prior, ok := known[model.OllamaModel]; ok {
|
||||
models[i] = prior
|
||||
}
|
||||
}
|
||||
return models
|
||||
}
|
||||
|
||||
func loadClaudeDesktopModels(ctx context.Context) ([]proxy.ClaudeDesktopModel, string) {
|
||||
req, err := newSignedOllamaRequest(ctx, http.MethodGet, claudeRecommendationsEndpoint())
|
||||
if err != nil {
|
||||
@@ -690,15 +919,12 @@ func includeSelectedClaudeDesktopModels(available, selected []proxy.ClaudeDeskto
|
||||
return models
|
||||
}
|
||||
|
||||
func mergeClaudeDesktopCloudInventory(available, cloudModels []proxy.ClaudeDesktopModel, appendMissing bool) []proxy.ClaudeDesktopModel {
|
||||
func mergeClaudeDesktopCloudInventory(available, cloudModels []proxy.ClaudeDesktopModel) []proxy.ClaudeDesktopModel {
|
||||
models := proxy.VerifyClaudeDesktopModelsWithCloudInventory(available, cloudModels)
|
||||
seen := make(map[string]struct{}, len(models))
|
||||
for _, model := range models {
|
||||
seen[model.OllamaModel] = struct{}{}
|
||||
}
|
||||
if !appendMissing {
|
||||
return models
|
||||
}
|
||||
for _, model := range cloudModels {
|
||||
if _, ok := seen[model.OllamaModel]; ok {
|
||||
continue
|
||||
@@ -838,25 +1064,6 @@ func ensureClaudeDesktopModelsAvailable(ctx context.Context, models []proxy.Clau
|
||||
}
|
||||
}
|
||||
|
||||
func resolveClaudeDesktopAccessStateWithRetry(ctx context.Context) (proxy.ClaudeDesktopAccessState, error) {
|
||||
deadline := time.Now().Add(claudeAccessRetryWait)
|
||||
for {
|
||||
state, err := claudeAccessStateResolver(ctx)
|
||||
if err == nil || time.Now().After(deadline) {
|
||||
return state, err
|
||||
}
|
||||
slog.Debug("could not resolve Claude model access while refreshing defaults", "error", err)
|
||||
|
||||
timer := time.NewTimer(claudeAccessRetryPoll)
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
timer.Stop()
|
||||
return state, ctx.Err()
|
||||
case <-timer.C:
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func hasCloudClaudeDesktopModel(models []proxy.ClaudeDesktopModel) bool {
|
||||
for _, model := range models {
|
||||
if model.Cloud {
|
||||
@@ -1030,6 +1237,37 @@ func IsClaudeDesktopRunning() C.bool {
|
||||
return C._Bool(launch.ClaudeDesktopRunning())
|
||||
}
|
||||
|
||||
//export IsCodexDesktopInstalled
|
||||
func IsCodexDesktopInstalled() C.bool {
|
||||
return C._Bool(codexDesktop.Installed())
|
||||
}
|
||||
|
||||
//export IsCodexDesktopConnected
|
||||
func IsCodexDesktopConnected() C.bool {
|
||||
return C._Bool(codexDesktop.OllamaConfigured())
|
||||
}
|
||||
|
||||
//export IsCodexDesktopRunning
|
||||
func IsCodexDesktopRunning() C.bool {
|
||||
return C._Bool(codexDesktop.Running())
|
||||
}
|
||||
|
||||
//export CodexDesktopRequestCount
|
||||
func CodexDesktopRequestCount() C.ulonglong {
|
||||
return C.ulonglong(codexDesktop.OllamaRequestCount())
|
||||
}
|
||||
|
||||
//export SetCodexDesktopConnected
|
||||
func SetCodexDesktopConnected(connected, restartConfirmed C.bool) C.bool {
|
||||
shouldConnect := connected != C._Bool(false)
|
||||
confirmed := restartConfirmed != C._Bool(false)
|
||||
if err := setCodexDesktopConnection(shouldConnect, confirmed); err != nil {
|
||||
slog.Warn("failed to change ChatGPT integration", "connected", shouldConnect, "error", err)
|
||||
return C._Bool(false)
|
||||
}
|
||||
return C._Bool(true)
|
||||
}
|
||||
|
||||
//export IsClaudeGatewayConfigured
|
||||
func IsClaudeGatewayConfigured() C.bool {
|
||||
return C._Bool(claudeDesktop.UsesOllamaGateway())
|
||||
@@ -1185,11 +1423,8 @@ func getClaudeDesktopConnectionStatus() claudeDesktopStatus {
|
||||
})
|
||||
}
|
||||
mappedModels := proxy.ClaudeDesktopMappings(selectedModels)
|
||||
if len(launch.ClaudeDesktopModels()) == 0 {
|
||||
mappedModels = proxy.DefaultClaudeDesktopMappingsForModels(
|
||||
availableModels,
|
||||
claudeDesktopHasFullDefaultAccess(accessState),
|
||||
)
|
||||
if len(mappedModels) == 0 && len(launch.ClaudeDesktopModels()) == 0 {
|
||||
mappedModels = proxy.DefaultClaudeDesktopMappingsForModels(availableModels)
|
||||
}
|
||||
mappingStatuses := make([]claudeDesktopMappingStatus, 0, proxy.MaxClaudeDesktopModels)
|
||||
for _, route := range proxy.ClaudeDesktopRoutes() {
|
||||
@@ -1212,51 +1447,11 @@ func getClaudeDesktopConnectionStatus() claudeDesktopStatus {
|
||||
return status
|
||||
}
|
||||
|
||||
func claudeDesktopHasFullDefaultAccess(state proxy.ClaudeDesktopAccessState) bool {
|
||||
fullAccess, known := claudeDesktopDefaultAccessTier(state)
|
||||
return known && fullAccess
|
||||
}
|
||||
|
||||
func claudeDesktopDefaultAccessTier(state proxy.ClaudeDesktopAccessState) (fullAccess, known bool) {
|
||||
if state.Account != proxy.ClaudeDesktopAccountSignedIn {
|
||||
return false, false
|
||||
}
|
||||
plan := strings.TrimSpace(state.Plan)
|
||||
if plan == "" {
|
||||
return false, false
|
||||
}
|
||||
return !strings.EqualFold(plan, "free"), true
|
||||
}
|
||||
|
||||
func claudeDesktopDefaultMappingsComplete(mappings map[string]string, fullAccess bool) bool {
|
||||
required := proxy.DefaultClaudeDesktopMappings(fullAccess)
|
||||
if len(mappings) != len(required) {
|
||||
return false
|
||||
}
|
||||
for route := range required {
|
||||
if strings.TrimSpace(mappings[route]) == "" {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func resolveClaudeDesktopDefaultMappings(ctx context.Context) (map[string]string, error) {
|
||||
state, err := resolveClaudeDesktopAccessStateWithRetry(ctx)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("resolve Claude model access: %w", err)
|
||||
}
|
||||
if state.Cloud != proxy.ClaudeDesktopCloudOn {
|
||||
return nil, errors.New("Claude model mapping defaults require Cloud to be enabled")
|
||||
}
|
||||
fullAccess, known := claudeDesktopDefaultAccessTier(state)
|
||||
if !known {
|
||||
return nil, errors.New("Claude model mapping defaults require a signed-in account")
|
||||
}
|
||||
available, _ := claudeModelsLoader(ctx)
|
||||
mappings := proxy.DefaultClaudeDesktopMappingsForModels(available, fullAccess)
|
||||
if !claudeDesktopDefaultMappingsComplete(mappings, fullAccess) {
|
||||
return nil, errors.New("could not resolve every Claude model mapping default")
|
||||
mappings := proxy.DefaultClaudeDesktopMappingsForModels(available)
|
||||
if len(mappings) == 0 {
|
||||
return nil, errors.New("no Claude model mapping defaults are available")
|
||||
}
|
||||
return mappings, nil
|
||||
}
|
||||
@@ -1399,14 +1594,14 @@ func applyClaudeDesktopMappingsWithOpen(mappings map[string]string, restartConfi
|
||||
if err != nil {
|
||||
slog.Debug("could not load account cloud models for Claude selection", "error", err)
|
||||
} else {
|
||||
selectable = mergeClaudeDesktopCloudInventory(available, cloudModels, true)
|
||||
selectable = mergeClaudeDesktopCloudInventory(available, cloudModels)
|
||||
}
|
||||
}
|
||||
selected, err := mapKnownClaudeDesktopModels(selectable, current, localNames, mappings)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
if err := validateClaudeDesktopModels(selected, accessState, localNames, localErr == nil); err != nil {
|
||||
if err := ensureClaudeDesktopModelsAvailable(context.Background(), selected); err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
@@ -1561,6 +1756,21 @@ func requestClaudeDesktopInstall() claudeDesktopInstallResult {
|
||||
return claudeDesktopInstallResultFromCode(int(C.installClaudeDesktop()))
|
||||
}
|
||||
|
||||
func requestCodexDesktopInstall() codexDesktopInstallResult {
|
||||
return codexDesktopInstallResultFromCode(int(C.installCodexDesktop()))
|
||||
}
|
||||
|
||||
func codexDesktopInstallResultFromCode(code int) codexDesktopInstallResult {
|
||||
switch code {
|
||||
case int(C.ClaudeInstallerOpened):
|
||||
return codexDesktopInstallerOpened
|
||||
case int(C.ClaudeInstallCancelled):
|
||||
return codexDesktopInstallCancelled
|
||||
default:
|
||||
return codexDesktopInstallFailed
|
||||
}
|
||||
}
|
||||
|
||||
func claudeDesktopDownloadEndpoint(baseURL string) string {
|
||||
return strings.TrimRight(baseURL, "/") + "/download-app?app=claude-desktop&type=mac-zip"
|
||||
}
|
||||
@@ -1613,6 +1823,26 @@ func InstallClaudeDesktopArchive(path *C.cchar_t) C.bool {
|
||||
return C._Bool(true)
|
||||
}
|
||||
|
||||
//export InstallCodexDesktopDiskImage
|
||||
func InstallCodexDesktopDiskImage(path *C.cchar_t) C.bool {
|
||||
imagePath := C.GoString((*C.char)(unsafe.Pointer(path)))
|
||||
installedPath, err := installCodexDesktopDiskImage(imagePath, codexDesktopInstallDestinations(), verifyCodexDesktopBundle)
|
||||
if err != nil && installedPath != "" {
|
||||
slog.Warn("installed ChatGPT but could not clean up its disk image", "path", installedPath, "error", err)
|
||||
return C._Bool(true)
|
||||
}
|
||||
if errors.Is(err, errCodexDesktopDestinationExists) && codexDesktop.Installed() {
|
||||
slog.Info("ChatGPT was installed while its download was in progress")
|
||||
return C._Bool(true)
|
||||
}
|
||||
if err != nil {
|
||||
slog.Warn("failed to install ChatGPT disk image", "error", err)
|
||||
return C._Bool(false)
|
||||
}
|
||||
slog.Info("installed ChatGPT", "path", installedPath)
|
||||
return C._Bool(true)
|
||||
}
|
||||
|
||||
func getShowAppsInMenu() bool {
|
||||
return bool(C.ShowAppsInMenu())
|
||||
}
|
||||
@@ -1665,18 +1895,22 @@ func stopClaudeAppProxy() {
|
||||
}
|
||||
}
|
||||
|
||||
func quitForHandoff() {
|
||||
appHandoffInProgress.Store(true)
|
||||
quit()
|
||||
}
|
||||
|
||||
func quit() {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), claudeShutdownTimeout)
|
||||
defer cancel()
|
||||
handoff := bool(C.otherOllamaInstanceRunning())
|
||||
if err := restoreClaudeAppForTermination(ctx, handoff); err != nil {
|
||||
if err := restoreClaudeAppForTermination(ctx, appHandoffInProgress.Load()); err != nil {
|
||||
slog.Warn("failed to restore Claude before quitting", "error", err)
|
||||
}
|
||||
C.quit()
|
||||
}
|
||||
|
||||
func restoreClaudeBeforeQuit(ctx context.Context, handoff, configured bool, restore func(context.Context) error) error {
|
||||
if handoff || !configured {
|
||||
func restoreClaudeBeforeQuit(ctx context.Context, configured bool, restore func(context.Context) error) error {
|
||||
if !configured {
|
||||
return nil
|
||||
}
|
||||
return restore(ctx)
|
||||
@@ -1691,7 +1925,7 @@ func restoreClaudeAppForTermination(ctx context.Context, handoff bool) error {
|
||||
return nil
|
||||
}
|
||||
configured := claudeDesktop.UsesOllamaGateway()
|
||||
err := restoreClaudeBeforeQuit(ctx, handoff, configured, claudeDesktop.RestoreForShutdown)
|
||||
err := restoreClaudeBeforeQuit(ctx, configured, claudeDesktop.RestoreForShutdown)
|
||||
if !claudeDesktop.UsesOllamaGateway() {
|
||||
stopClaudeAppProxy()
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
@@ -17,8 +19,11 @@ enum AppMove
|
||||
};
|
||||
|
||||
void run(bool showOnboarding, bool startHidden);
|
||||
void killOtherInstances();
|
||||
bool otherOllamaInstanceRunning(void);
|
||||
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);
|
||||
@@ -50,6 +55,11 @@ 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);
|
||||
@@ -65,5 +75,7 @@ enum ClaudeInstallResult
|
||||
ClaudeInstallFailed,
|
||||
};
|
||||
enum ClaudeInstallResult installClaudeDesktop(void);
|
||||
enum ClaudeInstallResult installCodexDesktop(void);
|
||||
char *ClaudeDesktopDownloadRequest(char **authorization);
|
||||
bool InstallClaudeDesktopArchive(const char *archivePath);
|
||||
bool InstallCodexDesktopDiskImage(const char *imagePath);
|
||||
+452
-78
@@ -8,14 +8,54 @@
|
||||
#import <ServiceManagement/ServiceManagement.h>
|
||||
#import <WebKit/WebKit.h>
|
||||
#import <objc/runtime.h>
|
||||
#include <errno.h>
|
||||
#include <libproc.h>
|
||||
#include <stdlib.h>
|
||||
#include <unistd.h>
|
||||
|
||||
extern NSString *SystemWidePath;
|
||||
|
||||
static NSString *const ClaudeDownloadPageURL = @"https://claude.com/download";
|
||||
static NSString *const ChatGPTDownloadPageURL = @"https://chatgpt.com/download";
|
||||
static NSString *const ChatGPTDiskImageURL =
|
||||
@"https://persistent.oaistatic.com/codex-app-prod/Codex.dmg";
|
||||
static NSString *const ShowAppsInMenuDefaultsKey = @"ShowAppsInMenu";
|
||||
static NSBundle *OllamaResourceBundle(void);
|
||||
|
||||
typedef NS_ENUM(NSInteger, DesktopDownloadKind) {
|
||||
DesktopDownloadNone,
|
||||
DesktopDownloadClaude,
|
||||
DesktopDownloadChatGPT,
|
||||
};
|
||||
|
||||
typedef NS_ENUM(NSInteger, ChatGPTRestartAction) {
|
||||
ChatGPTRestartActionAddModels,
|
||||
ChatGPTRestartActionUpdateModels,
|
||||
ChatGPTRestartActionRemoveModels,
|
||||
};
|
||||
|
||||
static void configureChatGPTRestartAlert(NSAlert *alert,
|
||||
ChatGPTRestartAction action) {
|
||||
switch (action) {
|
||||
case ChatGPTRestartActionAddModels:
|
||||
[alert setMessageText:@"Restart ChatGPT to add Ollama models?"];
|
||||
[alert setInformativeText:
|
||||
@"ChatGPT must restart to add Ollama models. Any running task will stop."];
|
||||
break;
|
||||
case ChatGPTRestartActionUpdateModels:
|
||||
[alert setMessageText:@"Restart ChatGPT to update Ollama models?"];
|
||||
[alert setInformativeText:
|
||||
@"ChatGPT must restart to update Ollama models. Any running task will stop."];
|
||||
break;
|
||||
case ChatGPTRestartActionRemoveModels:
|
||||
[alert setMessageText:@"Restart ChatGPT to remove Ollama models?"];
|
||||
[alert setInformativeText:
|
||||
@"ChatGPT must restart to remove Ollama models. Any running task will stop."];
|
||||
break;
|
||||
}
|
||||
[alert addButtonWithTitle:@"Restart ChatGPT"];
|
||||
}
|
||||
|
||||
static BOOL shouldShowAppsInMenu(void) {
|
||||
NSUserDefaults *defaults = [NSUserDefaults standardUserDefaults];
|
||||
if ([defaults objectForKey:ShowAppsInMenuDefaultsKey] == nil) {
|
||||
@@ -44,7 +84,7 @@ static NSImage *integrationAppIcon(NSString *appName,
|
||||
NSBundle *bundle = OllamaResourceBundle();
|
||||
NSImage *bundledIcon = [bundle imageForResource:appName.lowercaseString];
|
||||
if (bundledIcon != nil) {
|
||||
[bundledIcon setTemplate:NO];
|
||||
[bundledIcon setTemplate:[appName isEqualToString:@"ChatGPT"]];
|
||||
return bundledIcon;
|
||||
}
|
||||
return [NSImage imageWithSystemSymbolName:fallbackSymbolName
|
||||
@@ -325,6 +365,8 @@ static NSImage *integrationAppIcon(NSString *appName,
|
||||
@property(assign, nonatomic) BOOL claudeAppReady;
|
||||
@property(strong, nonatomic) IntegrationMenuRow *claudeAppRow;
|
||||
@property(strong, nonatomic) NSMenuItem *claudeMenuItem;
|
||||
@property(strong, nonatomic) IntegrationMenuRow *codexAppRow;
|
||||
@property(strong, nonatomic) NSMenuItem *codexMenuItem;
|
||||
@property(strong, nonatomic) NSMenuItem *claudeMenuSeparatorItem;
|
||||
@property(strong, nonatomic) NSURLSession *claudeDownloadSession;
|
||||
@property(strong, nonatomic) NSURLSessionDownloadTask *claudeDownloadTask;
|
||||
@@ -335,16 +377,24 @@ static NSImage *integrationAppIcon(NSString *appName,
|
||||
@property(assign, nonatomic) BOOL claudeDownloadCancelled;
|
||||
@property(assign, nonatomic) BOOL claudeDownloadCompleted;
|
||||
@property(assign, nonatomic) BOOL claudeDownloadModalRunning;
|
||||
@property(assign, nonatomic) DesktopDownloadKind desktopDownloadKind;
|
||||
@property(assign, nonatomic) BOOL quitInProgress;
|
||||
@property(assign, nonatomic) BOOL systemTerminationReplyPending;
|
||||
@property(strong, nonatomic) NSApplication *systemTerminationApplication;
|
||||
- (void)openClaudeApp:(id)sender;
|
||||
- (void)openChatGPTApp:(id)sender;
|
||||
- (enum ClaudeInstallResult)downloadClaude;
|
||||
- (enum ClaudeInstallResult)finishClaudeDownload;
|
||||
- (enum ClaudeInstallResult)downloadChatGPT;
|
||||
- (enum ClaudeInstallResult)downloadDesktopApp:(DesktopDownloadKind)kind;
|
||||
- (enum ClaudeInstallResult)finishDesktopDownload;
|
||||
- (void)showClaudeDownloadFailure:(NSError *)error;
|
||||
- (void)showClaudeInstallFailure:(NSError *)error;
|
||||
- (void)showChatGPTDownloadFailure:(NSError *)error;
|
||||
- (void)showChatGPTInstallFailure:(NSError *)error;
|
||||
- (void)toggleClaudeAppProxy:(NSButton *)sender;
|
||||
- (void)refreshClaudeAppState;
|
||||
- (void)toggleCodexApp:(NSButton *)sender;
|
||||
- (void)refreshCodexAppState;
|
||||
- (void)applyShowAppsInMenu:(BOOL)visible;
|
||||
- (void)requestQuit;
|
||||
- (void)completeSystemTermination;
|
||||
@@ -435,6 +485,21 @@ static NSImage *ollamaApplicationIcon(void) {
|
||||
[self.claudeMenuItem setView:self.claudeAppRow];
|
||||
[menu addItem:self.claudeMenuItem];
|
||||
[self refreshClaudeAppState];
|
||||
|
||||
self.codexMenuItem = [[NSMenuItem alloc] initWithTitle:@"ChatGPT"
|
||||
action:nil
|
||||
keyEquivalent:@""];
|
||||
[self.codexMenuItem setEnabled:YES];
|
||||
self.codexAppRow = [[IntegrationMenuRow alloc]
|
||||
initWithTitle:@"ChatGPT"
|
||||
symbolName:@"bubble.left.and.bubble.right"
|
||||
target:self
|
||||
openAction:@selector(openChatGPTApp:)
|
||||
toggleAction:@selector(toggleCodexApp:)];
|
||||
[self.codexMenuItem setView:self.codexAppRow];
|
||||
[menu addItem:self.codexMenuItem];
|
||||
[self refreshCodexAppState];
|
||||
|
||||
self.claudeMenuSeparatorItem = [NSMenuItem separatorItem];
|
||||
[menu addItem:self.claudeMenuSeparatorItem];
|
||||
[self applyShowAppsInMenu:shouldShowAppsInMenu()];
|
||||
@@ -622,13 +687,10 @@ static NSImage *ollamaApplicationIcon(void) {
|
||||
return;
|
||||
}
|
||||
[self refreshClaudeAppState];
|
||||
[self refreshCodexAppState];
|
||||
}
|
||||
|
||||
- (void)refreshClaudeAppState {
|
||||
BOOL hasUsed = HasUsedClaudeDesktopIntegration();
|
||||
BOOL visible = shouldShowAppsInMenu() && hasUsed;
|
||||
[self.claudeMenuItem setHidden:!visible];
|
||||
[self.claudeMenuSeparatorItem setHidden:!visible];
|
||||
BOOL installed = IsClaudeDesktopInstalled();
|
||||
BOOL startFailed = ClaudeGatewayStartFailed();
|
||||
BOOL portConflict = startFailed && ClaudeGatewayPortConflict();
|
||||
@@ -645,17 +707,40 @@ static NSImage *ollamaApplicationIcon(void) {
|
||||
[self.claudeAppRow setIntegrationActive:self.claudeAppEnabled];
|
||||
[self.claudeAppRow setIntegrationReady:self.claudeAppReady];
|
||||
RefreshClaudeProxyMenu();
|
||||
[self applyShowAppsInMenu:shouldShowAppsInMenu()];
|
||||
}
|
||||
|
||||
- (void)refreshCodexAppState {
|
||||
BOOL installed = IsCodexDesktopInstalled();
|
||||
BOOL connected = IsCodexDesktopConnected();
|
||||
unsigned long long requests = connected ? CodexDesktopRequestCount() : 0;
|
||||
NSString *activeStatus = requests == 1
|
||||
? @"1 request this session"
|
||||
: [NSString stringWithFormat:@"%llu requests this session", requests];
|
||||
[self.codexAppRow setActiveStatusText:connected
|
||||
? activeStatus
|
||||
: nil];
|
||||
[self.codexAppRow setInactiveStatusText:installed
|
||||
? @"Use Ollama models in ChatGPT"
|
||||
: @"Not installed"];
|
||||
[self.codexAppRow setIntegrationActive:connected];
|
||||
[self.codexAppRow setIntegrationReady:installed && connected];
|
||||
[self applyShowAppsInMenu:shouldShowAppsInMenu()];
|
||||
}
|
||||
|
||||
- (void)applyShowAppsInMenu:(BOOL)visible {
|
||||
visible = visible && HasUsedClaudeDesktopIntegration();
|
||||
[self.claudeMenuItem setHidden:!visible];
|
||||
[self.claudeMenuSeparatorItem setHidden:!visible];
|
||||
BOOL claudeVisible = visible && HasUsedClaudeDesktopIntegration();
|
||||
// Keep the installation flow accessible from the menu.
|
||||
BOOL codexVisible = visible;
|
||||
[self.claudeMenuItem setHidden:!claudeVisible];
|
||||
[self.codexMenuItem setHidden:!codexVisible];
|
||||
[self.claudeMenuSeparatorItem setHidden:!(claudeVisible || codexVisible)];
|
||||
}
|
||||
|
||||
- (void)menuDidClose:(NSMenu *)menu {
|
||||
if (menu == self.statusItem.menu) {
|
||||
[self.claudeAppRow resetHover];
|
||||
[self.codexAppRow resetHover];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -724,6 +809,49 @@ static NSImage *ollamaApplicationIcon(void) {
|
||||
[alert runModal];
|
||||
}
|
||||
|
||||
- (void)openChatGPTApp:(id)sender {
|
||||
(void)sender;
|
||||
if (!IsCodexDesktopConnected()) {
|
||||
return;
|
||||
}
|
||||
[self.statusItem.menu cancelTracking];
|
||||
|
||||
NSArray<NSString *> *candidates = @[
|
||||
@"/Applications/ChatGPT.app",
|
||||
[NSHomeDirectory() stringByAppendingPathComponent:@"Applications/ChatGPT.app"],
|
||||
@"/Applications/Codex.app",
|
||||
[NSHomeDirectory() stringByAppendingPathComponent:@"Applications/Codex.app"],
|
||||
];
|
||||
for (NSString *path in candidates) {
|
||||
if (![[NSFileManager defaultManager] fileExistsAtPath:path]) {
|
||||
continue;
|
||||
}
|
||||
NSWorkspaceOpenConfiguration *configuration =
|
||||
[NSWorkspaceOpenConfiguration configuration];
|
||||
configuration.activates = YES;
|
||||
configuration.createsNewApplicationInstance = NO;
|
||||
[[NSWorkspace sharedWorkspace]
|
||||
openApplicationAtURL:[NSURL fileURLWithPath:path]
|
||||
configuration:configuration
|
||||
completionHandler:^(NSRunningApplication *application,
|
||||
NSError *error) {
|
||||
(void)application;
|
||||
if (error != nil) {
|
||||
appLogInfo([NSString stringWithFormat:
|
||||
@"Unable to open ChatGPT: %@", error]);
|
||||
}
|
||||
}];
|
||||
return;
|
||||
}
|
||||
|
||||
NSAlert *alert = [[NSAlert alloc] init];
|
||||
[alert setAlertStyle:NSAlertStyleWarning];
|
||||
[alert setMessageText:@"Unable to open ChatGPT"];
|
||||
[alert setInformativeText:
|
||||
@"Install ChatGPT in Applications, then try again."];
|
||||
[alert runModal];
|
||||
}
|
||||
|
||||
- (void)showClaudeDownloadFailure:(NSError *)error {
|
||||
appLogInfo([NSString stringWithFormat:@"Unable to download Claude: %@",
|
||||
error]);
|
||||
@@ -758,43 +886,102 @@ static NSImage *ollamaApplicationIcon(void) {
|
||||
}
|
||||
}
|
||||
|
||||
- (void)showChatGPTDownloadFailure:(NSError *)error {
|
||||
appLogInfo([NSString stringWithFormat:@"Unable to download ChatGPT: %@",
|
||||
error]);
|
||||
NSAlert *alert = [[NSAlert alloc] init];
|
||||
[alert setAlertStyle:NSAlertStyleWarning];
|
||||
[alert setIcon:ollamaApplicationIcon()];
|
||||
[alert setMessageText:@"ChatGPT couldn’t be downloaded"];
|
||||
[alert setInformativeText:
|
||||
@"Try again or download ChatGPT from its website."];
|
||||
[alert addButtonWithTitle:@"Open download page"];
|
||||
[alert addButtonWithTitle:@"Cancel"];
|
||||
if ([alert runModal] == NSAlertFirstButtonReturn) {
|
||||
[[NSWorkspace sharedWorkspace]
|
||||
openURL:[NSURL URLWithString:ChatGPTDownloadPageURL]];
|
||||
}
|
||||
}
|
||||
|
||||
- (void)showChatGPTInstallFailure:(NSError *)error {
|
||||
appLogInfo([NSString stringWithFormat:@"Unable to install ChatGPT: %@",
|
||||
error]);
|
||||
NSAlert *alert = [[NSAlert alloc] init];
|
||||
[alert setAlertStyle:NSAlertStyleWarning];
|
||||
[alert setIcon:ollamaApplicationIcon()];
|
||||
[alert setMessageText:@"ChatGPT couldn’t be installed"];
|
||||
[alert setInformativeText:
|
||||
@"Try again or install ChatGPT from its website."];
|
||||
[alert addButtonWithTitle:@"Open download page"];
|
||||
[alert addButtonWithTitle:@"Cancel"];
|
||||
if ([alert runModal] == NSAlertFirstButtonReturn) {
|
||||
[[NSWorkspace sharedWorkspace]
|
||||
openURL:[NSURL URLWithString:ChatGPTDownloadPageURL]];
|
||||
}
|
||||
}
|
||||
|
||||
- (enum ClaudeInstallResult)downloadClaude {
|
||||
return [self downloadDesktopApp:DesktopDownloadClaude];
|
||||
}
|
||||
|
||||
- (enum ClaudeInstallResult)downloadChatGPT {
|
||||
return [self downloadDesktopApp:DesktopDownloadChatGPT];
|
||||
}
|
||||
|
||||
- (enum ClaudeInstallResult)downloadDesktopApp:(DesktopDownloadKind)kind {
|
||||
if (self.claudeDownloadTask != nil) {
|
||||
return ClaudeInstallCancelled;
|
||||
}
|
||||
|
||||
BOOL chatGPT = kind == DesktopDownloadChatGPT;
|
||||
char *downloadAuthorization = NULL;
|
||||
char *downloadURL = ClaudeDesktopDownloadRequest(&downloadAuthorization);
|
||||
NSString *downloadURLString = downloadURL == NULL
|
||||
? nil
|
||||
: [NSString stringWithUTF8String:downloadURL];
|
||||
NSString *authorization = downloadAuthorization == NULL
|
||||
? nil
|
||||
: [NSString stringWithUTF8String:downloadAuthorization];
|
||||
free(downloadURL);
|
||||
free(downloadAuthorization);
|
||||
char *downloadURL = NULL;
|
||||
NSString *downloadURLString = ChatGPTDiskImageURL;
|
||||
NSString *authorization = nil;
|
||||
if (!chatGPT) {
|
||||
downloadURL = ClaudeDesktopDownloadRequest(&downloadAuthorization);
|
||||
downloadURLString = downloadURL == NULL
|
||||
? nil
|
||||
: [NSString stringWithUTF8String:downloadURL];
|
||||
authorization = downloadAuthorization == NULL
|
||||
? nil
|
||||
: [NSString stringWithUTF8String:downloadAuthorization];
|
||||
free(downloadURL);
|
||||
free(downloadAuthorization);
|
||||
}
|
||||
NSURL *url = downloadURLString == nil
|
||||
? nil
|
||||
: [NSURL URLWithString:downloadURLString];
|
||||
if (url == nil || authorization.length == 0) {
|
||||
if (url == nil || (!chatGPT && authorization.length == 0)) {
|
||||
NSError *error = [NSError
|
||||
errorWithDomain:@"com.ollama.app"
|
||||
code:3
|
||||
userInfo:@{NSLocalizedDescriptionKey:
|
||||
@"Ollama could not authenticate the download request."}];
|
||||
[self showClaudeDownloadFailure:error];
|
||||
chatGPT
|
||||
? @"Ollama could not prepare the ChatGPT download."
|
||||
: @"Ollama could not authenticate the download request."}];
|
||||
if (chatGPT) {
|
||||
[self showChatGPTDownloadFailure:error];
|
||||
} else {
|
||||
[self showClaudeDownloadFailure:error];
|
||||
}
|
||||
return ClaudeInstallFailed;
|
||||
}
|
||||
|
||||
[self.claudeAppRow setInactiveStatusText:@"Downloading Claude…"];
|
||||
[self.claudeAppRow.integrationSwitch setEnabled:NO];
|
||||
IntegrationMenuRow *row = chatGPT ? self.codexAppRow : self.claudeAppRow;
|
||||
NSString *appName = chatGPT ? @"ChatGPT" : @"Claude";
|
||||
[row setInactiveStatusText:
|
||||
[NSString stringWithFormat:@"Downloading %@…", appName]];
|
||||
[row.integrationSwitch setEnabled:NO];
|
||||
|
||||
self.claudeDownloadAlert = [[NSAlert alloc] init];
|
||||
[self.claudeDownloadAlert setAlertStyle:NSAlertStyleInformational];
|
||||
[self.claudeDownloadAlert setIcon:ollamaApplicationIcon()];
|
||||
[self.claudeDownloadAlert setMessageText:@"Downloading Claude"];
|
||||
[self.claudeDownloadAlert setMessageText:
|
||||
[NSString stringWithFormat:@"Downloading %@", appName]];
|
||||
[self.claudeDownloadAlert setInformativeText:
|
||||
@"Claude will be installed when the download finishes."];
|
||||
[NSString stringWithFormat:
|
||||
@"%@ will be installed when the download finishes.", appName]];
|
||||
[self.claudeDownloadAlert addButtonWithTitle:@"Cancel"];
|
||||
self.claudeDownloadProgress = [[NSProgressIndicator alloc]
|
||||
initWithFrame:NSMakeRect(0, 0, 260, 12)];
|
||||
@@ -809,6 +996,7 @@ static NSImage *ollamaApplicationIcon(void) {
|
||||
self.claudeDownloadCompleted = NO;
|
||||
self.claudeDownloadedInstallerURL = nil;
|
||||
self.claudeDownloadError = nil;
|
||||
self.desktopDownloadKind = kind;
|
||||
NSURLSessionConfiguration *configuration =
|
||||
[NSURLSessionConfiguration ephemeralSessionConfiguration];
|
||||
self.claudeDownloadSession = [NSURLSession
|
||||
@@ -816,7 +1004,9 @@ static NSImage *ollamaApplicationIcon(void) {
|
||||
delegate:self
|
||||
delegateQueue:[NSOperationQueue mainQueue]];
|
||||
NSMutableURLRequest *request = [NSMutableURLRequest requestWithURL:url];
|
||||
[request setValue:authorization forHTTPHeaderField:@"Authorization"];
|
||||
if (authorization.length > 0) {
|
||||
[request setValue:authorization forHTTPHeaderField:@"Authorization"];
|
||||
}
|
||||
self.claudeDownloadTask = [self.claudeDownloadSession
|
||||
downloadTaskWithRequest:request];
|
||||
|
||||
@@ -832,7 +1022,7 @@ static NSImage *ollamaApplicationIcon(void) {
|
||||
return ClaudeInstallCancelled;
|
||||
}
|
||||
if (self.claudeDownloadCompleted) {
|
||||
return [self finishClaudeDownload];
|
||||
return [self finishDesktopDownload];
|
||||
}
|
||||
return ClaudeInstallCancelled;
|
||||
}
|
||||
@@ -888,15 +1078,20 @@ didFinishDownloadingToURL:(NSURL *)location {
|
||||
? (NSHTTPURLResponse *)downloadTask.response
|
||||
: nil;
|
||||
NSString *host = response.URL.host.lowercaseString;
|
||||
BOOL trustedHost = [host isEqualToString:@"claude.ai"] ||
|
||||
[host hasSuffix:@".claude.ai"];
|
||||
BOOL chatGPT = self.desktopDownloadKind == DesktopDownloadChatGPT;
|
||||
BOOL trustedHost = chatGPT
|
||||
? [host isEqualToString:@"persistent.oaistatic.com"]
|
||||
: ([host isEqualToString:@"claude.ai"] ||
|
||||
[host hasSuffix:@".claude.ai"]);
|
||||
if (response.statusCode != 200 || !trustedHost ||
|
||||
![response.URL.scheme isEqualToString:@"https"]) {
|
||||
error = [NSError
|
||||
errorWithDomain:@"com.ollama.app"
|
||||
code:1
|
||||
userInfo:@{NSLocalizedDescriptionKey:
|
||||
@"Claude returned an invalid download response."}];
|
||||
chatGPT
|
||||
? @"ChatGPT returned an invalid download response."
|
||||
: @"Claude returned an invalid download response."}];
|
||||
}
|
||||
|
||||
if (error == nil) {
|
||||
@@ -906,15 +1101,18 @@ didFinishDownloadingToURL:(NSURL *)location {
|
||||
if (error == nil && [attributes fileSize] < 1024 * 1024) {
|
||||
error = [NSError
|
||||
errorWithDomain:@"com.ollama.app"
|
||||
code:2
|
||||
userInfo:@{NSLocalizedDescriptionKey:
|
||||
@"Claude returned an incomplete download."}];
|
||||
code:2
|
||||
userInfo:@{NSLocalizedDescriptionKey:
|
||||
chatGPT
|
||||
? @"ChatGPT returned an incomplete download."
|
||||
: @"Claude returned an incomplete download."}];
|
||||
}
|
||||
}
|
||||
|
||||
if (error == nil) {
|
||||
NSString *fileName = [NSString
|
||||
stringWithFormat:@"Claude-%@.zip", [NSUUID UUID].UUIDString];
|
||||
stringWithFormat:chatGPT ? @"ChatGPT-%@.dmg" : @"Claude-%@.zip",
|
||||
[NSUUID UUID].UUIDString];
|
||||
NSURL *installerURL = [NSURL fileURLWithPath:
|
||||
[NSTemporaryDirectory() stringByAppendingPathComponent:fileName]];
|
||||
if ([[NSFileManager defaultManager] moveItemAtURL:location
|
||||
@@ -941,16 +1139,19 @@ didCompleteWithError:(NSError *)error {
|
||||
[NSApp abortModal];
|
||||
return;
|
||||
}
|
||||
[self finishClaudeDownload];
|
||||
[self finishDesktopDownload];
|
||||
}
|
||||
|
||||
- (enum ClaudeInstallResult)finishClaudeDownload {
|
||||
- (enum ClaudeInstallResult)finishDesktopDownload {
|
||||
BOOL cancelled = self.claudeDownloadCancelled;
|
||||
BOOL chatGPT = self.desktopDownloadKind == DesktopDownloadChatGPT;
|
||||
IntegrationMenuRow *row = chatGPT ? self.codexAppRow : self.claudeAppRow;
|
||||
NSString *appName = chatGPT ? @"ChatGPT" : @"Claude";
|
||||
|
||||
[self.claudeDownloadProgress stopAnimation:nil];
|
||||
[self.claudeDownloadAlert.window orderOut:nil];
|
||||
[self.claudeAppRow.integrationSwitch setEnabled:YES];
|
||||
[self.claudeAppRow setInactiveStatusText:@"Not installed"];
|
||||
[row.integrationSwitch setEnabled:YES];
|
||||
[row setInactiveStatusText:@"Not installed"];
|
||||
[self.claudeDownloadSession finishTasksAndInvalidate];
|
||||
self.claudeDownloadTask = nil;
|
||||
self.claudeDownloadSession = nil;
|
||||
@@ -962,6 +1163,7 @@ didCompleteWithError:(NSError *)error {
|
||||
self.claudeDownloadedInstallerURL = nil;
|
||||
self.claudeDownloadCancelled = NO;
|
||||
self.claudeDownloadCompleted = NO;
|
||||
self.desktopDownloadKind = DesktopDownloadNone;
|
||||
return ClaudeInstallCancelled;
|
||||
}
|
||||
if (self.claudeDownloadError != nil ||
|
||||
@@ -971,39 +1173,97 @@ didCompleteWithError:(NSError *)error {
|
||||
errorWithDomain:@"com.ollama.app"
|
||||
code:3
|
||||
userInfo:@{NSLocalizedDescriptionKey:
|
||||
@"The Claude installer was not downloaded."}];
|
||||
chatGPT
|
||||
? @"The ChatGPT installer was not downloaded."
|
||||
: @"The Claude installer was not downloaded."}];
|
||||
}
|
||||
if (chatGPT) {
|
||||
[self showChatGPTDownloadFailure:self.claudeDownloadError];
|
||||
} else {
|
||||
[self showClaudeDownloadFailure:self.claudeDownloadError];
|
||||
}
|
||||
[self showClaudeDownloadFailure:self.claudeDownloadError];
|
||||
self.claudeDownloadError = nil;
|
||||
self.claudeDownloadedInstallerURL = nil;
|
||||
self.claudeDownloadCancelled = NO;
|
||||
self.claudeDownloadCompleted = NO;
|
||||
self.desktopDownloadKind = DesktopDownloadNone;
|
||||
return ClaudeInstallFailed;
|
||||
}
|
||||
[self.claudeAppRow setInactiveStatusText:@"Installing Claude…"];
|
||||
BOOL installed = InstallClaudeDesktopArchive(
|
||||
self.claudeDownloadedInstallerURL.fileSystemRepresentation);
|
||||
[row setInactiveStatusText:
|
||||
[NSString stringWithFormat:@"Installing %@…", appName]];
|
||||
BOOL installed = NO;
|
||||
if (chatGPT) {
|
||||
[row.integrationSwitch setEnabled:NO];
|
||||
NSAlert *installAlert = [[NSAlert alloc] init];
|
||||
[installAlert setAlertStyle:NSAlertStyleInformational];
|
||||
[installAlert setIcon:ollamaApplicationIcon()];
|
||||
[installAlert setMessageText:@"Installing ChatGPT"];
|
||||
[installAlert setInformativeText:
|
||||
@"Ollama is verifying and copying the ChatGPT app."];
|
||||
NSButton *installingButton =
|
||||
[installAlert addButtonWithTitle:@"Installing…"];
|
||||
[installingButton setEnabled:NO];
|
||||
NSProgressIndicator *installProgress = [[NSProgressIndicator alloc]
|
||||
initWithFrame:NSMakeRect(0, 0, 260, 12)];
|
||||
[installProgress setStyle:NSProgressIndicatorStyleBar];
|
||||
[installProgress setIndeterminate:YES];
|
||||
[installProgress startAnimation:nil];
|
||||
[installAlert setAccessoryView:installProgress];
|
||||
|
||||
NSString *installerPath =
|
||||
[self.claudeDownloadedInstallerURL.path copy];
|
||||
__block BOOL backgroundInstalled = NO;
|
||||
dispatch_async(
|
||||
dispatch_get_global_queue(QOS_CLASS_USER_INITIATED, 0), ^{
|
||||
backgroundInstalled = InstallCodexDesktopDiskImage(
|
||||
installerPath.fileSystemRepresentation);
|
||||
dispatch_async(dispatch_get_main_queue(), ^{
|
||||
[NSApp abortModal];
|
||||
});
|
||||
});
|
||||
[NSApp activateIgnoringOtherApps:YES];
|
||||
[installAlert runModal];
|
||||
installed = backgroundInstalled;
|
||||
[installProgress stopAnimation:nil];
|
||||
[installAlert.window orderOut:nil];
|
||||
[row.integrationSwitch setEnabled:YES];
|
||||
} else {
|
||||
installed = InstallClaudeDesktopArchive(
|
||||
self.claudeDownloadedInstallerURL.fileSystemRepresentation);
|
||||
}
|
||||
NSError *removeError = nil;
|
||||
[[NSFileManager defaultManager]
|
||||
removeItemAtURL:self.claudeDownloadedInstallerURL
|
||||
error:&removeError];
|
||||
if (removeError != nil) {
|
||||
appLogInfo([NSString stringWithFormat:
|
||||
@"Unable to remove downloaded Claude archive: %@", removeError]);
|
||||
appLogInfo([NSString stringWithFormat:chatGPT
|
||||
? @"Unable to remove downloaded ChatGPT installer: %@"
|
||||
: @"Unable to remove downloaded Claude archive: %@",
|
||||
removeError]);
|
||||
}
|
||||
if (!installed) {
|
||||
NSError *installError = [NSError
|
||||
errorWithDomain:@"com.ollama.app"
|
||||
code:4
|
||||
userInfo:@{NSLocalizedDescriptionKey:
|
||||
@"Claude could not be installed."}];
|
||||
[self showClaudeInstallFailure:installError];
|
||||
[NSString stringWithFormat:
|
||||
@"%@ could not be installed.", appName]}];
|
||||
if (chatGPT) {
|
||||
[self showChatGPTInstallFailure:installError];
|
||||
} else {
|
||||
[self showClaudeInstallFailure:installError];
|
||||
}
|
||||
}
|
||||
self.claudeDownloadError = nil;
|
||||
self.claudeDownloadedInstallerURL = nil;
|
||||
self.claudeDownloadCancelled = NO;
|
||||
self.claudeDownloadCompleted = NO;
|
||||
[self refreshClaudeAppState];
|
||||
self.desktopDownloadKind = DesktopDownloadNone;
|
||||
if (chatGPT) {
|
||||
[self refreshCodexAppState];
|
||||
} else {
|
||||
[self refreshClaudeAppState];
|
||||
}
|
||||
return installed ? ClaudeInstallerOpened : ClaudeInstallFailed;
|
||||
}
|
||||
|
||||
@@ -1092,6 +1352,64 @@ didCompleteWithError:(NSError *)error {
|
||||
});
|
||||
}
|
||||
|
||||
- (void)toggleCodexApp:(NSButton *)sender {
|
||||
BOOL enabled = sender.state == NSControlStateValueOn;
|
||||
if (enabled && !IsCodexDesktopInstalled()) {
|
||||
[self refreshCodexAppState];
|
||||
NSAlert *installAlert = [[NSAlert alloc] init];
|
||||
[installAlert setAlertStyle:NSAlertStyleInformational];
|
||||
[installAlert setIcon:ollamaApplicationIcon()];
|
||||
[installAlert setMessageText:@"ChatGPT is not installed"];
|
||||
[installAlert setInformativeText:
|
||||
@"Download ChatGPT to add Ollama models to the ChatGPT app."];
|
||||
[installAlert addButtonWithTitle:@"Download ChatGPT"];
|
||||
[installAlert addButtonWithTitle:@"Cancel"];
|
||||
if ([installAlert runModal] == NSAlertFirstButtonReturn) {
|
||||
if ([self downloadChatGPT] != ClaudeInstallerOpened) {
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
BOOL restartChatGPT = IsCodexDesktopRunning();
|
||||
if (restartChatGPT) {
|
||||
NSAlert *restartAlert = [[NSAlert alloc] init];
|
||||
[restartAlert setAlertStyle:NSAlertStyleWarning];
|
||||
[restartAlert setIcon:ollamaApplicationIcon()];
|
||||
configureChatGPTRestartAlert(restartAlert, enabled
|
||||
? ChatGPTRestartActionAddModels
|
||||
: ChatGPTRestartActionRemoveModels);
|
||||
[restartAlert addButtonWithTitle:@"Cancel"];
|
||||
if ([restartAlert runModal] != NSAlertFirstButtonReturn) {
|
||||
[self refreshCodexAppState];
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
[sender setEnabled:NO];
|
||||
dispatch_async(dispatch_get_global_queue(QOS_CLASS_USER_INITIATED, 0), ^{
|
||||
BOOL succeeded = SetCodexDesktopConnected(enabled, restartChatGPT);
|
||||
dispatch_async(dispatch_get_main_queue(), ^{
|
||||
[sender setEnabled:YES];
|
||||
[self refreshCodexAppState];
|
||||
if (!succeeded) {
|
||||
NSAlert *alert = [[NSAlert alloc] init];
|
||||
[alert setAlertStyle:NSAlertStyleWarning];
|
||||
[alert setIcon:ollamaApplicationIcon()];
|
||||
[alert setMessageText:enabled
|
||||
? @"Unable to add Ollama models to ChatGPT"
|
||||
: @"Unable to remove Ollama models from ChatGPT"];
|
||||
[alert setInformativeText:
|
||||
@"ChatGPT could not complete the model update. Check the Ollama log for details, then try again."];
|
||||
[alert runModal];
|
||||
return;
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
- (void)appsUI {
|
||||
[self uiRequest:@"/connect"];
|
||||
}
|
||||
@@ -1434,6 +1752,12 @@ decidePolicyForNavigationAction:(WKNavigationAction *)action
|
||||
[alert setInformativeText:
|
||||
@"Claude Desktop must restart to remove Ollama. Any running task will stop."];
|
||||
[alert addButtonWithTitle:@"Restart Claude Desktop"];
|
||||
} else if ([message hasPrefix:@"Restart ChatGPT to add Ollama models?"]) {
|
||||
configureChatGPTRestartAlert(alert, ChatGPTRestartActionAddModels);
|
||||
} else if ([message hasPrefix:@"Restart ChatGPT to update Ollama models?"]) {
|
||||
configureChatGPTRestartAlert(alert, ChatGPTRestartActionUpdateModels);
|
||||
} else if ([message hasPrefix:@"Restart ChatGPT to remove Ollama models?"]) {
|
||||
configureChatGPTRestartAlert(alert, ChatGPTRestartActionRemoveModels);
|
||||
} else {
|
||||
[alert setMessageText:message];
|
||||
[alert addButtonWithTitle:@"Confirm"];
|
||||
@@ -1542,7 +1866,6 @@ decidePolicyForNavigationAction:(WKNavigationAction *)action
|
||||
AppDelegate *appDelegate;
|
||||
void run(bool so, bool sh) {
|
||||
[NSApplication sharedApplication];
|
||||
[NSApp setAppearance:[NSAppearance appearanceNamed:NSAppearanceNameAqua]];
|
||||
[NSApp setActivationPolicy:NSApplicationActivationPolicyAccessory];
|
||||
appDelegate = [[AppDelegate alloc] init];
|
||||
[NSApp setDelegate:appDelegate];
|
||||
@@ -1562,38 +1885,74 @@ static BOOL isOllamaApplication(NSRunningApplication *app) {
|
||||
[bundleId isEqualToString:@"com.electron.ollama"];
|
||||
}
|
||||
|
||||
bool otherOllamaInstanceRunning(void) {
|
||||
pid_t myPid = getpid();
|
||||
for (NSRunningApplication *app in
|
||||
[[NSWorkspace sharedWorkspace] runningApplications]) {
|
||||
if (isOllamaApplication(app) && app.processIdentifier > 0 &&
|
||||
app.processIdentifier != myPid) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// killOtherInstances kills all other instances of the app currently
|
||||
// running. This way we can ensure that only the most recently started
|
||||
// instance of Ollama is running
|
||||
void killOtherInstances() {
|
||||
bool otherOllamaProcesses(AppProcessIdentity **processes, size_t *count) {
|
||||
pid_t myPid = getpid();
|
||||
NSArray *apps = [[NSWorkspace sharedWorkspace] runningApplications];
|
||||
|
||||
for (NSRunningApplication *app in apps) {
|
||||
if (isOllamaApplication(app)) {
|
||||
pid_t pid = app.processIdentifier;
|
||||
if (pid != myPid && pid > 0) {
|
||||
appLogInfo([NSString stringWithFormat:@"terminating other ollama instance %d", pid]);
|
||||
// Preserve the Claude profile while the replacement instance
|
||||
// takes ownership of the local gateway.
|
||||
kill(pid, SIGUSR1);
|
||||
} else if (pid == -1) {
|
||||
appLogInfo([NSString stringWithFormat:@"skipping app with invalid pid: %@", app.bundleIdentifier]);
|
||||
}
|
||||
}
|
||||
AppProcessIdentity *result = calloc(apps.count, sizeof(*result));
|
||||
if (result == NULL && apps.count > 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
size_t resultCount = 0;
|
||||
for (NSRunningApplication *app in apps) {
|
||||
pid_t pid = app.processIdentifier;
|
||||
if (!isOllamaApplication(app) || pid == myPid) {
|
||||
continue;
|
||||
}
|
||||
if (pid <= 0) {
|
||||
appLogInfo([NSString stringWithFormat:
|
||||
@"skipping app with invalid pid: %@", app.bundleIdentifier]);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Tie the NSWorkspace match to the kernel process. Re-read the start
|
||||
// time after confirming the current app so PID reuse is rejected.
|
||||
struct proc_bsdinfo before = {0};
|
||||
int size = proc_pidinfo(pid, PROC_PIDTBSDINFO, 0, &before,
|
||||
sizeof(before));
|
||||
if (size != sizeof(before)) {
|
||||
if (kill(pid, 0) != 0 && errno == ESRCH) {
|
||||
continue;
|
||||
}
|
||||
appLogInfo([NSString stringWithFormat:
|
||||
@"unable to inspect ollama instance %d", pid]);
|
||||
free(result);
|
||||
return false;
|
||||
}
|
||||
|
||||
NSRunningApplication *current =
|
||||
[NSRunningApplication runningApplicationWithProcessIdentifier:pid];
|
||||
if (current == nil || current.isTerminated ||
|
||||
!isOllamaApplication(current)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
struct proc_bsdinfo after = {0};
|
||||
size = proc_pidinfo(pid, PROC_PIDTBSDINFO, 0, &after, sizeof(after));
|
||||
if (size != sizeof(after)) {
|
||||
if (kill(pid, 0) != 0 && errno == ESRCH) {
|
||||
continue;
|
||||
}
|
||||
appLogInfo([NSString stringWithFormat:
|
||||
@"unable to confirm ollama instance %d", pid]);
|
||||
free(result);
|
||||
return false;
|
||||
}
|
||||
if (before.pbi_start_tvsec != after.pbi_start_tvsec ||
|
||||
before.pbi_start_tvusec != after.pbi_start_tvusec) {
|
||||
continue;
|
||||
}
|
||||
|
||||
result[resultCount++] = (AppProcessIdentity){
|
||||
.pid = pid,
|
||||
.started_at = (int64_t)after.pbi_start_tvsec * 1000000 +
|
||||
after.pbi_start_tvusec,
|
||||
};
|
||||
}
|
||||
|
||||
*processes = result;
|
||||
*count = resultCount;
|
||||
return true;
|
||||
}
|
||||
|
||||
// Move the source bundle to the system-wide applications location
|
||||
@@ -1968,6 +2327,21 @@ enum ClaudeInstallResult installClaudeDesktop(void) {
|
||||
return result;
|
||||
}
|
||||
|
||||
enum ClaudeInstallResult installCodexDesktop(void) {
|
||||
__block enum ClaudeInstallResult result = ClaudeInstallFailed;
|
||||
void (^install)(void) = ^{
|
||||
if (appDelegate != nil) {
|
||||
result = [appDelegate downloadChatGPT];
|
||||
}
|
||||
};
|
||||
if ([NSThread isMainThread]) {
|
||||
install();
|
||||
} else {
|
||||
dispatch_sync(dispatch_get_main_queue(), install);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
void quit() {
|
||||
dispatch_async(dispatch_get_main_queue(), ^{
|
||||
[appDelegate quit];
|
||||
|
||||
+685
-109
@@ -4,7 +4,9 @@ package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"maps"
|
||||
"net"
|
||||
"net/http"
|
||||
@@ -333,7 +335,10 @@ func TestResolveClaudeDesktopStartupCatalogMarksDefaultAccountModelsAutoEligible
|
||||
previousLoader := claudeModelsLoader
|
||||
previousResolver := claudeCloudModelsResolver
|
||||
claudeModelsLoader = func(context.Context) ([]proxy.ClaudeDesktopModel, string) {
|
||||
return proxy.ClaudeDesktopModelsFromRecommendations([]api.ModelRecommendation{{Model: "glm-5.2:cloud"}}), "endpoint"
|
||||
mappings := api.ModelRecommendationMappings{
|
||||
"claude-opus-5": {Model: "glm-5.2:cloud", RequiredPlan: "pro"},
|
||||
}
|
||||
return claudeDesktopRecommendationModelsForTest(t, []api.ModelRecommendation{{Model: "glm-5.2:cloud"}}, &mappings), "endpoint"
|
||||
}
|
||||
claudeCloudModelsResolver = func(context.Context) ([]proxy.ClaudeDesktopModel, error) {
|
||||
return proxy.ClaudeDesktopModelsFromCloudInventory([]string{"glm-5.2"}), nil
|
||||
@@ -352,7 +357,86 @@ func TestResolveClaudeDesktopStartupCatalogMarksDefaultAccountModelsAutoEligible
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveClaudeDesktopStartupCatalogUsesAccountDefaults(t *testing.T) {
|
||||
func TestClaudeDesktopCatalogListsAccountModelsWithoutChangingDefaults(t *testing.T) {
|
||||
t.Setenv("HOME", t.TempDir())
|
||||
mappings := api.ModelRecommendationMappings{
|
||||
"claude-sonnet-5": {Model: "glm-5.3-flash:cloud", RequiredPlan: "pro"},
|
||||
}
|
||||
recommendations := claudeDesktopRecommendationModelsForTest(t, []api.ModelRecommendation{
|
||||
{Model: "glm-5.3-flash:cloud", RequiredPlan: "pro"},
|
||||
}, &mappings)
|
||||
|
||||
previousLoader := claudeModelsLoader
|
||||
previousAccess := claudeAccessStateResolver
|
||||
previousCloud := claudeCloudModelsResolver
|
||||
claudeProxyMu.Lock()
|
||||
previousAvailable := claudeAvailableModels
|
||||
previousSource := claudeModelSource
|
||||
previousUpdated := claudeCatalogUpdated
|
||||
claudeProxyMu.Unlock()
|
||||
claudeModelsLoader = func(context.Context) ([]proxy.ClaudeDesktopModel, string) {
|
||||
return recommendations, "endpoint"
|
||||
}
|
||||
claudeAccessStateResolver = func(context.Context) (proxy.ClaudeDesktopAccessState, error) {
|
||||
return proxy.ClaudeDesktopAccessState{
|
||||
Cloud: proxy.ClaudeDesktopCloudOn,
|
||||
Account: proxy.ClaudeDesktopAccountSignedIn,
|
||||
Plan: "pro",
|
||||
}, nil
|
||||
}
|
||||
claudeCloudModelsResolver = func(context.Context) ([]proxy.ClaudeDesktopModel, error) {
|
||||
return proxy.ClaudeDesktopModelsFromCloudInventory([]string{
|
||||
"glm-5.3-flash:cloud",
|
||||
"deepseek-v4-flash:cloud",
|
||||
}), nil
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
claudeModelsLoader = previousLoader
|
||||
claudeAccessStateResolver = previousAccess
|
||||
claudeCloudModelsResolver = previousCloud
|
||||
claudeProxyMu.Lock()
|
||||
claudeAvailableModels = previousAvailable
|
||||
claudeModelSource = previousSource
|
||||
claudeCatalogUpdated = previousUpdated
|
||||
claudeProxyMu.Unlock()
|
||||
})
|
||||
|
||||
assertCatalog := func(t *testing.T, available, selected []proxy.ClaudeDesktopModel) {
|
||||
t.Helper()
|
||||
if got := proxy.ClaudeDesktopMappings(selected); !maps.Equal(got, map[string]string{
|
||||
"claude-sonnet-5": "glm-5.3-flash:cloud",
|
||||
}) {
|
||||
t.Fatalf("default mappings = %v, want GLM Flash for Sonnet 5", got)
|
||||
}
|
||||
for _, model := range available {
|
||||
if model.OllamaModel != "deepseek-v4-flash:cloud" {
|
||||
continue
|
||||
}
|
||||
if model.Recommended {
|
||||
t.Fatal("account DeepSeek model was marked as recommended")
|
||||
}
|
||||
if !model.AccountCloud {
|
||||
t.Fatal("account DeepSeek model is missing cloud inventory membership")
|
||||
}
|
||||
return
|
||||
}
|
||||
t.Fatal("account DeepSeek model is missing from the selectable catalog")
|
||||
}
|
||||
|
||||
available, selected, source := resolveClaudeDesktopStartupCatalog(context.Background())
|
||||
if source != "endpoint" {
|
||||
t.Fatalf("source = %q, want endpoint", source)
|
||||
}
|
||||
assertCatalog(t, available, selected)
|
||||
|
||||
available, selected, source = refreshClaudeDesktopCatalog(context.Background(), selected, true)
|
||||
if source != "endpoint" {
|
||||
t.Fatalf("refreshed source = %q, want endpoint", source)
|
||||
}
|
||||
assertCatalog(t, available, selected)
|
||||
}
|
||||
|
||||
func TestResolveClaudeDesktopStartupCatalogUsesSafeFallback(t *testing.T) {
|
||||
states := []struct {
|
||||
name string
|
||||
state proxy.ClaudeDesktopAccessState
|
||||
@@ -372,9 +456,7 @@ func TestResolveClaudeDesktopStartupCatalogUsesAccountDefaults(t *testing.T) {
|
||||
t.Cleanup(func() { claudeAccessStateResolver = previousAccess })
|
||||
|
||||
_, selected, source := resolveClaudeDesktopStartupCatalog(context.Background())
|
||||
want := proxy.DefaultClaudeDesktopMappings(
|
||||
claudeDesktopHasFullDefaultAccess(tt.state),
|
||||
)
|
||||
want := proxy.DefaultClaudeDesktopMappings()
|
||||
if got := proxy.ClaudeDesktopMappings(selected); !maps.Equal(got, want) {
|
||||
t.Fatalf("startup mappings = %v, want %v (source %q)", got, want, source)
|
||||
}
|
||||
@@ -382,6 +464,135 @@ func TestResolveClaudeDesktopStartupCatalogUsesAccountDefaults(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveClaudeDesktopStartupCatalogUsesEffectiveEndpointMappings(t *testing.T) {
|
||||
recommendations := []api.ModelRecommendation{
|
||||
{Model: "glm-5.3-flash:cloud", RequiredPlan: "pro"},
|
||||
{Model: "gemma4:31b-cloud", RequiredPlan: "free"},
|
||||
}
|
||||
tests := []struct {
|
||||
name string
|
||||
state proxy.ClaudeDesktopAccessState
|
||||
mappings api.ModelRecommendationMappings
|
||||
saved map[string]string
|
||||
want map[string]string
|
||||
}{
|
||||
{
|
||||
name: "mapping with free plan metadata",
|
||||
state: proxy.ClaudeDesktopAccessState{Cloud: proxy.ClaudeDesktopCloudOn, Account: proxy.ClaudeDesktopAccountSignedIn, Plan: "free"},
|
||||
mappings: api.ModelRecommendationMappings{"claude-sonnet-5": {Model: "gemma4:31b-cloud", RequiredPlan: "free"}},
|
||||
want: map[string]string{"claude-sonnet-5": "gemma4:31b-cloud"},
|
||||
},
|
||||
{
|
||||
name: "mapping plan metadata is informational",
|
||||
state: proxy.ClaudeDesktopAccessState{Cloud: proxy.ClaudeDesktopCloudOn, Account: proxy.ClaudeDesktopAccountSignedIn, Plan: "free"},
|
||||
mappings: api.ModelRecommendationMappings{"claude-sonnet-5": {Model: "glm-5.3-flash:cloud", RequiredPlan: "pro"}},
|
||||
want: map[string]string{"claude-sonnet-5": "glm-5.3-flash:cloud"},
|
||||
},
|
||||
{
|
||||
name: "persisted user mapping wins",
|
||||
state: proxy.ClaudeDesktopAccessState{Cloud: proxy.ClaudeDesktopCloudOn, Account: proxy.ClaudeDesktopAccountSignedIn, Plan: "pro"},
|
||||
mappings: api.ModelRecommendationMappings{"claude-sonnet-5": {Model: "glm-5.3-flash:cloud"}},
|
||||
saved: map[string]string{"claude-opus-5": "gemma4:31b-cloud"},
|
||||
want: map[string]string{"claude-opus-5": "gemma4:31b-cloud"},
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Setenv("HOME", t.TempDir())
|
||||
if tt.saved != nil {
|
||||
if err := launch.SaveClaudeDesktopModelMappings(tt.saved); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
models := claudeDesktopRecommendationModelsForTest(t, recommendations, &tt.mappings)
|
||||
|
||||
previousLoader := claudeModelsLoader
|
||||
previousAccess := claudeAccessStateResolver
|
||||
previousCloud := claudeCloudModelsResolver
|
||||
claudeModelsLoader = func(context.Context) ([]proxy.ClaudeDesktopModel, string) {
|
||||
return models, "endpoint"
|
||||
}
|
||||
claudeAccessStateResolver = func(context.Context) (proxy.ClaudeDesktopAccessState, error) {
|
||||
return tt.state, nil
|
||||
}
|
||||
claudeCloudModelsResolver = func(context.Context) ([]proxy.ClaudeDesktopModel, error) {
|
||||
return proxy.ClaudeDesktopModelsFromCloudInventory([]string{"glm-5.3-flash:cloud", "gemma4:31b-cloud"}), nil
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
claudeModelsLoader = previousLoader
|
||||
claudeAccessStateResolver = previousAccess
|
||||
claudeCloudModelsResolver = previousCloud
|
||||
})
|
||||
|
||||
_, selected, source := resolveClaudeDesktopStartupCatalog(context.Background())
|
||||
if got := proxy.ClaudeDesktopMappings(selected); !maps.Equal(got, tt.want) {
|
||||
t.Fatalf("startup mappings = %v, want %v (source %q)", got, tt.want, source)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveClaudeDesktopStartupCatalogUpdatesDefaultsAfterReconnect(t *testing.T) {
|
||||
t.Setenv("HOME", t.TempDir())
|
||||
endpointModels := claudeDesktopRecommendationModelsForTest(t, []api.ModelRecommendation{
|
||||
{Model: "glm-5.3-flash:cloud", RequiredPlan: "pro"},
|
||||
}, &api.ModelRecommendationMappings{"claude-sonnet-5": {Model: "glm-5.3-flash:cloud"}})
|
||||
|
||||
previousLoader := claudeModelsLoader
|
||||
previousAccess := claudeAccessStateResolver
|
||||
previousCloud := claudeCloudModelsResolver
|
||||
load := 0
|
||||
claudeModelsLoader = func(context.Context) ([]proxy.ClaudeDesktopModel, string) {
|
||||
load++
|
||||
if load == 1 {
|
||||
return fallbackClaudeDesktopModels(), "fallback"
|
||||
}
|
||||
return endpointModels, "endpoint"
|
||||
}
|
||||
claudeAccessStateResolver = func(context.Context) (proxy.ClaudeDesktopAccessState, error) {
|
||||
return proxy.ClaudeDesktopAccessState{Cloud: proxy.ClaudeDesktopCloudOn, Account: proxy.ClaudeDesktopAccountSignedIn, Plan: "pro"}, nil
|
||||
}
|
||||
claudeCloudModelsResolver = func(context.Context) ([]proxy.ClaudeDesktopModel, error) {
|
||||
return proxy.ClaudeDesktopModelsFromCloudInventory([]string{
|
||||
"glm-5.3-flash:cloud", "glm-5.2:cloud", "kimi-k3:cloud", "deepseek-v4-pro:cloud", "deepseek-v4-flash:0731:cloud", "gemma4:31b-cloud",
|
||||
}), nil
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
claudeModelsLoader = previousLoader
|
||||
claudeAccessStateResolver = previousAccess
|
||||
claudeCloudModelsResolver = previousCloud
|
||||
})
|
||||
|
||||
_, offline, source := resolveClaudeDesktopStartupCatalog(context.Background())
|
||||
if got := proxy.ClaudeDesktopMappings(offline)["claude-sonnet-5"]; source != "fallback" || got != "gemma4:31b-cloud" {
|
||||
t.Fatalf("offline Sonnet/source = %q/%q", got, source)
|
||||
}
|
||||
_, reconnected, source := resolveClaudeDesktopStartupCatalog(context.Background())
|
||||
if got := proxy.ClaudeDesktopMappings(reconnected); source != "endpoint" || !maps.Equal(got, map[string]string{"claude-sonnet-5": "glm-5.3-flash:cloud"}) {
|
||||
t.Fatalf("reconnected mappings/source = %v/%q", got, source)
|
||||
}
|
||||
}
|
||||
|
||||
func claudeDesktopRecommendationModelsForTest(t *testing.T, recommendations []api.ModelRecommendation, mappings *api.ModelRecommendationMappings) []proxy.ClaudeDesktopModel {
|
||||
t.Helper()
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
_ = json.NewEncoder(w).Encode(api.ModelRecommendationsResponse{
|
||||
Recommendations: recommendations,
|
||||
Mappings: mappings,
|
||||
})
|
||||
}))
|
||||
defer server.Close()
|
||||
req, err := http.NewRequest(http.MethodGet, server.URL, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
models, err := proxy.FetchClaudeDesktopModels(server.Client(), req)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return models
|
||||
}
|
||||
|
||||
func TestResolveClaudeDesktopStartupCatalogVerifiesFallbackFromAccountInventory(t *testing.T) {
|
||||
t.Setenv("HOME", t.TempDir())
|
||||
if err := launch.SaveClaudeDesktopModels([]string{"glm-5.2:cloud"}); err != nil {
|
||||
@@ -511,6 +722,51 @@ func TestRefreshClaudeDesktopCatalogUpdatesPolicyAndPreservesSlots(t *testing.T)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRefreshClaudeDesktopCatalogDropsStaleDefaultsOnFallback(t *testing.T) {
|
||||
t.Setenv("HOME", t.TempDir())
|
||||
endpointModels := claudeDesktopRecommendationModelsForTest(t, []api.ModelRecommendation{
|
||||
{Model: "glm-5.3-flash:cloud", RequiredPlan: "pro"},
|
||||
{Model: "glm-5.2:cloud", RequiredPlan: "pro"},
|
||||
{Model: "kimi-k3:cloud", RequiredPlan: "pro"},
|
||||
{Model: "deepseek-v4-pro", RequiredPlan: "pro"},
|
||||
{Model: "deepseek-v4-flash", RequiredPlan: "pro"},
|
||||
{Model: "gemma4:31b-cloud", RequiredPlan: "free"},
|
||||
}, &api.ModelRecommendationMappings{"claude-sonnet-5": {Model: "glm-5.3-flash:cloud"}})
|
||||
current := proxy.MapClaudeDesktopModels(endpointModels, map[string]string{"claude-sonnet-5": "glm-5.3-flash:cloud"})
|
||||
|
||||
previousLoader := claudeModelsLoader
|
||||
claudeProxyMu.Lock()
|
||||
previousAvailable := claudeAvailableModels
|
||||
previousSource := claudeModelSource
|
||||
previousUpdated := claudeCatalogUpdated
|
||||
claudeAvailableModels = endpointModels
|
||||
claudeModelSource = "endpoint"
|
||||
claudeCatalogUpdated = time.Now()
|
||||
claudeProxyMu.Unlock()
|
||||
claudeModelsLoader = func(context.Context) ([]proxy.ClaudeDesktopModel, string) {
|
||||
return fallbackClaudeDesktopModels(), "fallback"
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
claudeModelsLoader = previousLoader
|
||||
claudeProxyMu.Lock()
|
||||
claudeAvailableModels = previousAvailable
|
||||
claudeModelSource = previousSource
|
||||
claudeCatalogUpdated = previousUpdated
|
||||
claudeProxyMu.Unlock()
|
||||
})
|
||||
|
||||
available, selected, source := refreshClaudeDesktopCatalog(context.Background(), current, true)
|
||||
if source != "fallback" {
|
||||
t.Fatalf("source = %q, want fallback", source)
|
||||
}
|
||||
if got := proxy.ClaudeDesktopMappings(selected); !maps.Equal(got, map[string]string{"claude-sonnet-5": "glm-5.3-flash:cloud"}) {
|
||||
t.Fatalf("current mappings = %v, want preserved explicit mapping", got)
|
||||
}
|
||||
if got := proxy.DefaultClaudeDesktopMappingsForModels(available)["claude-sonnet-5"]; got != "gemma4:31b-cloud" {
|
||||
t.Fatalf("offline default Sonnet = %q, want compatibility fallback", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRefreshClaudeDesktopCatalogFailsClosedForRemovedSelection(t *testing.T) {
|
||||
t.Setenv("HOME", t.TempDir())
|
||||
previousLoader := claudeModelsLoader
|
||||
@@ -697,28 +953,23 @@ func TestMapKnownClaudeDesktopModelsAllowsSharedModels(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestClaudeDesktopDefaultsFollowAccountPlan(t *testing.T) {
|
||||
paidDefaults := proxy.DefaultClaudeDesktopMappings(true)
|
||||
restrictedDefaults := proxy.DefaultClaudeDesktopMappings(false)
|
||||
func TestClaudeDesktopDefaultsDoNotDependOnAccountPlan(t *testing.T) {
|
||||
want := proxy.DefaultClaudeDesktopMappings()
|
||||
tests := []struct {
|
||||
name string
|
||||
state proxy.ClaudeDesktopAccessState
|
||||
wantMappings map[string]string
|
||||
name string
|
||||
state proxy.ClaudeDesktopAccessState
|
||||
}{
|
||||
{name: "signed out", state: proxy.ClaudeDesktopAccessState{Account: proxy.ClaudeDesktopAccountSignedOut}, wantMappings: restrictedDefaults},
|
||||
{name: "free", state: proxy.ClaudeDesktopAccessState{Account: proxy.ClaudeDesktopAccountSignedIn, Plan: "free"}, wantMappings: restrictedDefaults},
|
||||
{name: "Pro", state: proxy.ClaudeDesktopAccessState{Account: proxy.ClaudeDesktopAccountSignedIn, Plan: "pro"}, wantMappings: paidDefaults},
|
||||
{name: "Team", state: proxy.ClaudeDesktopAccessState{Account: proxy.ClaudeDesktopAccountSignedIn, Plan: "team"}, wantMappings: paidDefaults},
|
||||
{name: "future paid plan", state: proxy.ClaudeDesktopAccessState{Account: proxy.ClaudeDesktopAccountSignedIn, Plan: "enterprise"}, wantMappings: paidDefaults},
|
||||
{name: "signed out", state: proxy.ClaudeDesktopAccessState{Account: proxy.ClaudeDesktopAccountSignedOut}},
|
||||
{name: "free", state: proxy.ClaudeDesktopAccessState{Account: proxy.ClaudeDesktopAccountSignedIn, Plan: "free"}},
|
||||
{name: "Pro", state: proxy.ClaudeDesktopAccessState{Account: proxy.ClaudeDesktopAccountSignedIn, Plan: "pro"}},
|
||||
{name: "Team", state: proxy.ClaudeDesktopAccessState{Account: proxy.ClaudeDesktopAccountSignedIn, Plan: "team"}},
|
||||
{name: "future paid plan", state: proxy.ClaudeDesktopAccessState{Account: proxy.ClaudeDesktopAccountSignedIn, Plan: "enterprise"}},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := proxy.DefaultClaudeDesktopMappingsForModels(
|
||||
proxy.DefaultClaudeDesktopModels(),
|
||||
claudeDesktopHasFullDefaultAccess(tt.state),
|
||||
)
|
||||
if !maps.Equal(got, tt.wantMappings) {
|
||||
t.Fatalf("default mappings = %v, want %v", got, tt.wantMappings)
|
||||
got := proxy.DefaultClaudeDesktopMappingsForModels(proxy.DefaultClaudeDesktopModels())
|
||||
if !maps.Equal(got, want) {
|
||||
t.Fatalf("default mappings for state %+v = %v, want %v", tt.state, got, want)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -907,7 +1158,6 @@ func TestSetClaudeDesktopAutoModeAvoidsUnnecessaryRestart(t *testing.T) {
|
||||
claudeAvailableModels = mergeClaudeDesktopCloudInventory(
|
||||
proxy.ClaudeDesktopModelsFromRecommendations([]api.ModelRecommendation{{Model: "glm-5.2:cloud"}}),
|
||||
proxy.ClaudeDesktopModelsFromCloudInventory([]string{"glm-5.2:cloud"}),
|
||||
false,
|
||||
)
|
||||
previousDesktop := claudeDesktop
|
||||
previousRunning := claudeDesktopRunning
|
||||
@@ -1014,7 +1264,7 @@ func TestClaudeDesktopAutoModeModelEligibility(t *testing.T) {
|
||||
"glm-5.2:cloud",
|
||||
"gemma4:31b-cloud",
|
||||
})
|
||||
recommended = mergeClaudeDesktopCloudInventory(recommended, accountCloud, false)
|
||||
recommended = mergeClaudeDesktopCloudInventory(recommended, accountCloud)
|
||||
custom := proxy.SelectClaudeDesktopModels(nil, []string{"qwen3:8b"})
|
||||
tagOnly := proxy.SelectClaudeDesktopModels(nil, []string{"made-up:cloud"})
|
||||
|
||||
@@ -1340,7 +1590,7 @@ func TestResetClaudeDesktopMappingsDoesNotOpenStoppedClaude(t *testing.T) {
|
||||
claudeProxyMu.Unlock()
|
||||
})
|
||||
|
||||
paidMappings := proxy.DefaultClaudeDesktopMappings(true)
|
||||
defaultMappings := proxy.DefaultClaudeDesktopMappings()
|
||||
applied, err := resetClaudeDesktopMappings(false)
|
||||
if err != nil || !applied {
|
||||
t.Fatalf("reset mappings = %v/%v, want persisted change", applied, err)
|
||||
@@ -1348,7 +1598,7 @@ func TestResetClaudeDesktopMappingsDoesNotOpenStoppedClaude(t *testing.T) {
|
||||
if !fake.configured || !fake.installed || fake.opened || fake.restart {
|
||||
t.Fatalf("stopped Claude reset = %+v, want configured without open or restart", fake)
|
||||
}
|
||||
if got := launch.ClaudeDesktopModelMappings(); !maps.Equal(got, paidMappings) {
|
||||
if got := launch.ClaudeDesktopModelMappings(); !maps.Equal(got, defaultMappings) {
|
||||
t.Fatalf("persisted reset mappings = %v", got)
|
||||
}
|
||||
|
||||
@@ -1357,10 +1607,10 @@ func TestResetClaudeDesktopMappingsDoesNotOpenStoppedClaude(t *testing.T) {
|
||||
fake.installed = false
|
||||
fake.configureCalls = 0
|
||||
plan = "free"
|
||||
disconnectedMappings := proxy.DefaultClaudeDesktopMappings(false)
|
||||
disconnectedMappings := proxy.DefaultClaudeDesktopMappings()
|
||||
applied, err = resetClaudeDesktopMappings(false)
|
||||
if err != nil || !applied {
|
||||
t.Fatalf("disconnected reset mappings = %v/%v, want persisted change", applied, err)
|
||||
if err != nil || applied {
|
||||
t.Fatalf("disconnected reset mappings = %v/%v, want unchanged defaults", applied, err)
|
||||
}
|
||||
if fake.configured || fake.installed || fake.opened || fake.restart || fake.configureCalls != 0 {
|
||||
t.Fatalf("disconnected Claude reset = %+v, want no connection side effects", fake)
|
||||
@@ -1376,6 +1626,41 @@ func TestResetClaudeDesktopMappingsDoesNotOpenStoppedClaude(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestResetClaudeDesktopMappingsPreservesSavedMappingsForEmptyContract(t *testing.T) {
|
||||
t.Setenv("HOME", t.TempDir())
|
||||
saved := map[string]string{"claude-opus-5": "glm-5.2:cloud"}
|
||||
if err := launch.SaveClaudeDesktopModelMappings(saved); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
previousStore := appStore
|
||||
appStore = &store.Store{DBPath: filepath.Join(t.TempDir(), "db.sqlite")}
|
||||
if err := markClaudeDesktopIntegrationUsed(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
empty := api.ModelRecommendationMappings{}
|
||||
models := claudeDesktopRecommendationModelsForTest(t, []api.ModelRecommendation{
|
||||
{Model: "gemma4:31b-cloud", RequiredPlan: "free"},
|
||||
}, &empty)
|
||||
previousLoader := claudeModelsLoader
|
||||
claudeModelsLoader = func(context.Context) ([]proxy.ClaudeDesktopModel, string) {
|
||||
return models, "endpoint"
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
_ = appStore.Close()
|
||||
appStore = previousStore
|
||||
claudeModelsLoader = previousLoader
|
||||
})
|
||||
|
||||
applied, err := resetClaudeDesktopMappings(false)
|
||||
if err == nil || applied {
|
||||
t.Fatalf("reset mappings = %v/%v, want authoritative empty-contract error", applied, err)
|
||||
}
|
||||
if got := launch.ClaudeDesktopModelMappings(); !maps.Equal(got, saved) {
|
||||
t.Fatalf("persisted mappings = %v, want preserved %v", got, saved)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResetClaudeDesktopMappingsSerializesDisconnectDuringCatalogRefresh(t *testing.T) {
|
||||
testResetClaudeDesktopMappingsSerializesLifecycleChange(t, "disconnect", func() error {
|
||||
return setClaudeDesktopConnection(false, false)
|
||||
@@ -1941,79 +2226,64 @@ func TestEnsureClaudeDesktopModelsAvailableRetriesStartupRace(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveClaudeDesktopDefaultMappingsHandlesAccountVerificationRestartRace(t *testing.T) {
|
||||
func TestResolveClaudeDesktopDefaultMappingsUsesContractWithoutAccountLookup(t *testing.T) {
|
||||
previousLoader := claudeModelsLoader
|
||||
previousAccess := claudeAccessStateResolver
|
||||
previousRetryWait := claudeAccessRetryWait
|
||||
previousRetryPoll := claudeAccessRetryPoll
|
||||
claudeAccessRetryWait = 10 * time.Millisecond
|
||||
claudeAccessRetryPoll = time.Millisecond
|
||||
t.Cleanup(func() {
|
||||
claudeModelsLoader = previousLoader
|
||||
claudeAccessStateResolver = previousAccess
|
||||
claudeAccessRetryWait = previousRetryWait
|
||||
claudeAccessRetryPoll = previousRetryPoll
|
||||
})
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
accessStateAfter int
|
||||
plan string
|
||||
catalog func() []proxy.ClaudeDesktopModel
|
||||
wantDefaults map[string]string
|
||||
name string
|
||||
catalog func(*testing.T) []proxy.ClaudeDesktopModel
|
||||
wantDefaults map[string]string
|
||||
}{
|
||||
{
|
||||
name: "retry restores paid defaults",
|
||||
accessStateAfter: 2,
|
||||
plan: "team",
|
||||
wantDefaults: proxy.DefaultClaudeDesktopMappings(true),
|
||||
name: "omitted contract uses safe fallback",
|
||||
catalog: func(*testing.T) []proxy.ClaudeDesktopModel { return proxy.DefaultClaudeDesktopModels() },
|
||||
wantDefaults: proxy.DefaultClaudeDesktopMappings(),
|
||||
},
|
||||
{
|
||||
name: "free account restores only the free default",
|
||||
accessStateAfter: 1,
|
||||
plan: "free",
|
||||
wantDefaults: proxy.DefaultClaudeDesktopMappings(false),
|
||||
name: "present contract supplies defaults",
|
||||
catalog: func(t *testing.T) []proxy.ClaudeDesktopModel {
|
||||
mappings := api.ModelRecommendationMappings{
|
||||
"claude-sonnet-5": {Model: "glm-5.3-flash:cloud", RequiredPlan: "pro"},
|
||||
}
|
||||
return claudeDesktopRecommendationModelsForTest(t, []api.ModelRecommendation{
|
||||
{Model: "glm-5.3-flash:cloud", RequiredPlan: "pro"},
|
||||
}, &mappings)
|
||||
},
|
||||
wantDefaults: map[string]string{"claude-sonnet-5": "glm-5.3-flash:cloud"},
|
||||
},
|
||||
{
|
||||
name: "incomplete paid catalog does not clear routes",
|
||||
accessStateAfter: 1,
|
||||
plan: "team",
|
||||
catalog: func() []proxy.ClaudeDesktopModel {
|
||||
return proxy.DefaultClaudeDesktopModels()[:4]
|
||||
name: "present empty contract has no defaults",
|
||||
catalog: func(t *testing.T) []proxy.ClaudeDesktopModel {
|
||||
mappings := api.ModelRecommendationMappings{}
|
||||
return claudeDesktopRecommendationModelsForTest(t, []api.ModelRecommendation{
|
||||
{Model: "gemma4:31b-cloud", RequiredPlan: "free"},
|
||||
}, &mappings)
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "missing free default does not clear routes",
|
||||
accessStateAfter: 1,
|
||||
plan: "free",
|
||||
catalog: func() []proxy.ClaudeDesktopModel {
|
||||
return proxy.DefaultClaudeDesktopModels()[:4]
|
||||
name: "missing fallback target has no defaults",
|
||||
catalog: func(t *testing.T) []proxy.ClaudeDesktopModel {
|
||||
return claudeDesktopRecommendationModelsForTest(t, []api.ModelRecommendation{
|
||||
{Model: "glm-5.3-flash:cloud", RequiredPlan: "pro"},
|
||||
}, nil)
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "persistent failure does not synthesize free defaults",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
claudeModelsLoader = func(context.Context) ([]proxy.ClaudeDesktopModel, string) {
|
||||
if tt.catalog != nil {
|
||||
return tt.catalog(), "endpoint"
|
||||
}
|
||||
return proxy.DefaultClaudeDesktopModels(), "endpoint"
|
||||
return tt.catalog(t), "endpoint"
|
||||
}
|
||||
accessCalls := 0
|
||||
claudeAccessStateResolver = func(context.Context) (proxy.ClaudeDesktopAccessState, error) {
|
||||
accessCalls++
|
||||
if tt.accessStateAfter == 0 || accessCalls < tt.accessStateAfter {
|
||||
return proxy.ClaudeDesktopAccessState{}, errors.New("server restarting")
|
||||
}
|
||||
return proxy.ClaudeDesktopAccessState{
|
||||
Cloud: proxy.ClaudeDesktopCloudOn,
|
||||
Account: proxy.ClaudeDesktopAccountSignedIn,
|
||||
Plan: tt.plan,
|
||||
}, nil
|
||||
return proxy.ClaudeDesktopAccessState{}, errors.New("account lookup must not run")
|
||||
}
|
||||
|
||||
gotDefaults, err := resolveClaudeDesktopDefaultMappings(context.Background())
|
||||
@@ -2021,39 +2291,19 @@ func TestResolveClaudeDesktopDefaultMappingsHandlesAccountVerificationRestartRac
|
||||
if err == nil {
|
||||
t.Fatalf("reset defaults = %v, want an error", gotDefaults)
|
||||
}
|
||||
if accessCalls != 0 {
|
||||
t.Fatalf("account lookups = %d, want 0", accessCalls)
|
||||
}
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !maps.Equal(gotDefaults, tt.wantDefaults) {
|
||||
t.Fatalf("reset defaults = %v, want %v after %d access checks", gotDefaults, tt.wantDefaults, accessCalls)
|
||||
t.Fatalf("reset defaults = %v, want %v", gotDefaults, tt.wantDefaults)
|
||||
}
|
||||
if tt.accessStateAfter > 0 && accessCalls < tt.accessStateAfter {
|
||||
t.Fatalf("access checks = %d, want at least %d", accessCalls, tt.accessStateAfter)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestClaudeDesktopDefaultAccessTierRequiresVerifiedAccount(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
state proxy.ClaudeDesktopAccessState
|
||||
wantFull bool
|
||||
wantKnown bool
|
||||
}{
|
||||
{name: "cloud off", state: proxy.ClaudeDesktopAccessState{Cloud: proxy.ClaudeDesktopCloudOff}},
|
||||
{name: "signed out", state: proxy.ClaudeDesktopAccessState{Cloud: proxy.ClaudeDesktopCloudOn, Account: proxy.ClaudeDesktopAccountSignedOut}},
|
||||
{name: "missing plan", state: proxy.ClaudeDesktopAccessState{Cloud: proxy.ClaudeDesktopCloudOn, Account: proxy.ClaudeDesktopAccountSignedIn}},
|
||||
{name: "free", state: proxy.ClaudeDesktopAccessState{Cloud: proxy.ClaudeDesktopCloudOn, Account: proxy.ClaudeDesktopAccountSignedIn, Plan: "free"}, wantKnown: true},
|
||||
{name: "team", state: proxy.ClaudeDesktopAccessState{Cloud: proxy.ClaudeDesktopCloudOn, Account: proxy.ClaudeDesktopAccountSignedIn, Plan: "team"}, wantFull: true, wantKnown: true},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
full, known := claudeDesktopDefaultAccessTier(tt.state)
|
||||
if full != tt.wantFull || known != tt.wantKnown {
|
||||
t.Fatalf("default access tier = %v/%v, want %v/%v", full, known, tt.wantFull, tt.wantKnown)
|
||||
if accessCalls != 0 {
|
||||
t.Fatalf("account lookups = %d, want 0", accessCalls)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -2280,6 +2530,90 @@ func TestClaudeGatewayStartupWithLocalSelectionSkipsCloudLookupsButSettingsLoads
|
||||
}
|
||||
}
|
||||
|
||||
func TestClaudeDesktopConnectionStatusPrefersActiveGatewayMappings(t *testing.T) {
|
||||
t.Setenv("HOME", t.TempDir())
|
||||
previousStore := appStore
|
||||
appStore = &store.Store{DBPath: filepath.Join(t.TempDir(), "db.sqlite")}
|
||||
if err := markClaudeDesktopIntegrationUsed(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
activeCatalog := proxy.ClaudeDesktopModelsFromRecommendations([]api.ModelRecommendation{
|
||||
{Model: "glm-5.3-flash:cloud", RequiredPlan: "pro"},
|
||||
{Model: "gemma4:31b-cloud", RequiredPlan: "free"},
|
||||
})
|
||||
activeModels := proxy.MapClaudeDesktopModels(activeCatalog, map[string]string{
|
||||
"claude-sonnet-5": "glm-5.3-flash:cloud",
|
||||
})
|
||||
gateway, err := proxy.NewClaudeDesktop(proxy.ClaudeDesktopConfig{
|
||||
ListenAddr: "127.0.0.1:0",
|
||||
OllamaURL: "http://127.0.0.1:11434",
|
||||
Model: activeModels[0].OllamaModel,
|
||||
Models: activeModels,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
previousLoader := claudeModelsLoader
|
||||
previousAccess := claudeAccessStateResolver
|
||||
previousLocal := claudeLocalModelsResolver
|
||||
previousCloud := claudeCloudModelsResolver
|
||||
claudeProxyMu.Lock()
|
||||
previousGateway := claudeAppProxy
|
||||
previousAvailable := claudeAvailableModels
|
||||
previousSource := claudeModelSource
|
||||
previousUpdated := claudeCatalogUpdated
|
||||
claudeAppProxy = gateway
|
||||
claudeAvailableModels = activeCatalog
|
||||
claudeModelSource = "endpoint"
|
||||
claudeCatalogUpdated = time.Time{}
|
||||
claudeProxyMu.Unlock()
|
||||
claudeModelsLoader = func(context.Context) ([]proxy.ClaudeDesktopModel, string) {
|
||||
return fallbackClaudeDesktopModels(), "fallback"
|
||||
}
|
||||
claudeAccessStateResolver = func(context.Context) (proxy.ClaudeDesktopAccessState, error) {
|
||||
return proxy.ClaudeDesktopAccessState{
|
||||
Cloud: proxy.ClaudeDesktopCloudOn,
|
||||
Account: proxy.ClaudeDesktopAccountSignedIn,
|
||||
Plan: "pro",
|
||||
}, nil
|
||||
}
|
||||
claudeLocalModelsResolver = func(context.Context) ([]string, error) { return nil, nil }
|
||||
claudeCloudModelsResolver = func(context.Context) ([]proxy.ClaudeDesktopModel, error) {
|
||||
return proxy.ClaudeDesktopModelsFromCloudInventory([]string{"glm-5.3-flash:cloud", "gemma4:31b-cloud"}), nil
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
_ = gateway.Close(context.Background())
|
||||
_ = appStore.Close()
|
||||
appStore = previousStore
|
||||
claudeModelsLoader = previousLoader
|
||||
claudeAccessStateResolver = previousAccess
|
||||
claudeLocalModelsResolver = previousLocal
|
||||
claudeCloudModelsResolver = previousCloud
|
||||
claudeProxyMu.Lock()
|
||||
claudeAppProxy = previousGateway
|
||||
claudeAvailableModels = previousAvailable
|
||||
claudeModelSource = previousSource
|
||||
claudeCatalogUpdated = previousUpdated
|
||||
claudeProxyMu.Unlock()
|
||||
})
|
||||
|
||||
status := getClaudeDesktopConnectionStatus()
|
||||
if got := proxy.ClaudeDesktopMappings(gateway.Models())["claude-sonnet-5"]; got != "glm-5.3-flash:cloud" {
|
||||
t.Fatalf("active gateway Sonnet mapping = %q", got)
|
||||
}
|
||||
for _, mapping := range status.Mappings {
|
||||
if mapping.RouteID == "claude-sonnet-5" {
|
||||
if mapping.Model != "glm-5.3-flash:cloud" {
|
||||
t.Fatalf("status Sonnet mapping = %q, want active gateway mapping", mapping.Model)
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
t.Fatal("status omitted Sonnet mapping")
|
||||
}
|
||||
|
||||
func TestClaudeGatewayLocalSelectionCatalogPolicy(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
@@ -2407,9 +2741,182 @@ func TestClaudeGatewayLocalSelectionCatalogPolicy(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunAppSyncBarrierStopsOlderInstances(t *testing.T) {
|
||||
for _, test := range []struct {
|
||||
name string
|
||||
exitOn appProcessStopMode
|
||||
wantStops []appProcessStopMode
|
||||
}{
|
||||
{name: "handoff", exitOn: appProcessStopForHandoff, wantStops: []appProcessStopMode{appProcessStopForHandoff}},
|
||||
{name: "graceful", exitOn: appProcessStopGracefully, wantStops: []appProcessStopMode{appProcessStopForHandoff, appProcessStopGracefully}},
|
||||
{name: "forced", exitOn: appProcessStopForcefully, wantStops: []appProcessStopMode{appProcessStopForHandoff, appProcessStopGracefully, appProcessStopForcefully}},
|
||||
} {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
self := appProcessIdentity{pid: 20, startedAt: 20}
|
||||
older := appProcessIdentity{pid: 10, startedAt: 10}
|
||||
alive := true
|
||||
var stops []appProcessStopMode
|
||||
controller := appProcessController{
|
||||
discover: func() ([]appProcessIdentity, error) {
|
||||
if alive {
|
||||
return []appProcessIdentity{older}, nil
|
||||
}
|
||||
return nil, nil
|
||||
},
|
||||
running: func(process appProcessIdentity) (bool, error) {
|
||||
return alive && process.sameProcess(older), nil
|
||||
},
|
||||
stop: func(process appProcessIdentity, mode appProcessStopMode) error {
|
||||
if !process.sameProcess(older) {
|
||||
t.Fatalf("stopped process %+v, want %+v", process, older)
|
||||
}
|
||||
stops = append(stops, mode)
|
||||
if mode == test.exitOn {
|
||||
alive = false
|
||||
}
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
err := runAppSyncBarrier(self, controller, appSyncBarrierConfig{
|
||||
killTimeout: time.Second,
|
||||
pollInterval: time.Millisecond,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !slices.Equal(stops, test.wantStops) {
|
||||
t.Fatalf("stop modes = %v, want %v", stops, test.wantStops)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunAppSyncBarrierStopsEveryOlderInstance(t *testing.T) {
|
||||
self := appProcessIdentity{pid: 30, startedAt: 30}
|
||||
graceful := appProcessIdentity{pid: 10, startedAt: 10}
|
||||
stubborn := appProcessIdentity{pid: 20, startedAt: 20}
|
||||
late := appProcessIdentity{pid: 25, startedAt: 25}
|
||||
alive := map[appProcessIdentity]bool{
|
||||
graceful: true,
|
||||
stubborn: true,
|
||||
}
|
||||
type stoppedProcess struct {
|
||||
process appProcessIdentity
|
||||
mode appProcessStopMode
|
||||
}
|
||||
var stops []stoppedProcess
|
||||
lateDiscovered := false
|
||||
controller := appProcessController{
|
||||
discover: func() ([]appProcessIdentity, error) {
|
||||
if !alive[graceful] && !alive[stubborn] && !lateDiscovered {
|
||||
alive[late] = true
|
||||
lateDiscovered = true
|
||||
}
|
||||
var processes []appProcessIdentity
|
||||
for _, process := range []appProcessIdentity{graceful, stubborn, late} {
|
||||
if alive[process] {
|
||||
processes = append(processes, process)
|
||||
}
|
||||
}
|
||||
return processes, nil
|
||||
},
|
||||
running: func(process appProcessIdentity) (bool, error) {
|
||||
return alive[process], nil
|
||||
},
|
||||
stop: func(process appProcessIdentity, mode appProcessStopMode) error {
|
||||
if !alive[process] {
|
||||
return nil
|
||||
}
|
||||
stops = append(stops, stoppedProcess{process: process, mode: mode})
|
||||
if !process.sameProcess(stubborn) || mode == appProcessStopForcefully {
|
||||
alive[process] = false
|
||||
}
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
err := runAppSyncBarrier(self, controller, appSyncBarrierConfig{
|
||||
killTimeout: time.Second,
|
||||
pollInterval: time.Millisecond,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
want := []stoppedProcess{
|
||||
{process: graceful, mode: appProcessStopForHandoff},
|
||||
{process: stubborn, mode: appProcessStopForHandoff},
|
||||
{process: stubborn, mode: appProcessStopGracefully},
|
||||
{process: stubborn, mode: appProcessStopForcefully},
|
||||
{process: late, mode: appProcessStopForHandoff},
|
||||
}
|
||||
if !slices.Equal(stops, want) {
|
||||
t.Fatalf("stops = %+v, want %+v", stops, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunAppSyncBarrierDefersToNewerInstance(t *testing.T) {
|
||||
self := appProcessIdentity{pid: 10, startedAt: 10}
|
||||
newer := appProcessIdentity{pid: 20, startedAt: 20}
|
||||
stopped := false
|
||||
err := runAppSyncBarrier(self, appProcessController{
|
||||
discover: func() ([]appProcessIdentity, error) {
|
||||
return []appProcessIdentity{newer}, nil
|
||||
},
|
||||
running: func(appProcessIdentity) (bool, error) { return true, nil },
|
||||
stop: func(appProcessIdentity, appProcessStopMode) error {
|
||||
stopped = true
|
||||
return nil
|
||||
},
|
||||
}, appSyncBarrierConfig{killTimeout: time.Second})
|
||||
if !errors.Is(err, errNewerAppInstance) {
|
||||
t.Fatalf("barrier error = %v, want newer-instance error", err)
|
||||
}
|
||||
if stopped {
|
||||
t.Fatal("older launch stopped the newer instance")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunAppSyncBarrierRequiresSettledEmptyList(t *testing.T) {
|
||||
queries := 0
|
||||
err := runAppSyncBarrier(appProcessIdentity{pid: 1, startedAt: 1}, appProcessController{
|
||||
discover: func() ([]appProcessIdentity, error) {
|
||||
queries++
|
||||
return nil, nil
|
||||
},
|
||||
}, appSyncBarrierConfig{killTimeout: time.Second})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if queries != 2 {
|
||||
t.Fatalf("discovery queries = %d, want 2", queries)
|
||||
}
|
||||
}
|
||||
|
||||
func TestContinueAfterBarrierErrorOnlyBlocksNewerInstance(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
err error
|
||||
want bool
|
||||
}{
|
||||
{name: "success", err: nil, want: true},
|
||||
{name: "discovery failure", err: errors.New("discover other Ollama app processes"), want: true},
|
||||
{name: "handoff timeout", err: errors.New("timed out waiting for app instances to exit"), want: true},
|
||||
{name: "newer instance", err: fmt.Errorf("%w: pid 2", errNewerAppInstance), want: false},
|
||||
{name: "wrapped newer instance", err: fmt.Errorf("barrier: %w", fmt.Errorf("%w: pid 2", errNewerAppInstance)), want: false},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if got := continueAfterBarrierError(tt.err); got != tt.want {
|
||||
t.Fatalf("continueAfterBarrierError(%v) = %v, want %v", tt.err, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestRestoreClaudeBeforeQuit(t *testing.T) {
|
||||
called := false
|
||||
if err := restoreClaudeBeforeQuit(context.Background(), false, false, func(context.Context) error {
|
||||
if err := restoreClaudeBeforeQuit(context.Background(), false, func(context.Context) error {
|
||||
called = true
|
||||
return nil
|
||||
}); err != nil {
|
||||
@@ -2419,7 +2926,7 @@ func TestRestoreClaudeBeforeQuit(t *testing.T) {
|
||||
t.Fatal("restore called while Claude was not configured")
|
||||
}
|
||||
|
||||
if err := restoreClaudeBeforeQuit(context.Background(), false, true, func(context.Context) error {
|
||||
if err := restoreClaudeBeforeQuit(context.Background(), true, func(context.Context) error {
|
||||
called = true
|
||||
return nil
|
||||
}); err != nil {
|
||||
@@ -2430,21 +2937,74 @@ func TestRestoreClaudeBeforeQuit(t *testing.T) {
|
||||
}
|
||||
|
||||
wantErr := errors.New("restore failed")
|
||||
if err := restoreClaudeBeforeQuit(context.Background(), false, true, func(context.Context) error {
|
||||
if err := restoreClaudeBeforeQuit(context.Background(), true, func(context.Context) error {
|
||||
return wantErr
|
||||
}); !errors.Is(err, wantErr) {
|
||||
t.Fatalf("restore error = %v, want %v", err, wantErr)
|
||||
}
|
||||
}
|
||||
|
||||
called = false
|
||||
if err := restoreClaudeBeforeQuit(context.Background(), true, true, func(context.Context) error {
|
||||
called = true
|
||||
return nil
|
||||
}); err != nil {
|
||||
func TestRestoreClaudeAppForTerminationPreservesHandoffProfile(t *testing.T) {
|
||||
previousDesktop := claudeDesktop
|
||||
fake := &fakeClaudeDesktopController{configured: true}
|
||||
claudeDesktop = fake
|
||||
t.Cleanup(func() { claudeDesktop = previousDesktop })
|
||||
|
||||
if err := restoreClaudeAppForTermination(context.Background(), true); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if fake.restoreCalls != 0 || !fake.configured {
|
||||
t.Fatalf("handoff restore calls/configured = %d/%v, want 0/true", fake.restoreCalls, fake.configured)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleExistingInstanceSkipsDevelopmentBuild(t *testing.T) {
|
||||
oldIsApp := isApp
|
||||
oldKillOtherInstances := killOtherInstances
|
||||
t.Cleanup(func() {
|
||||
isApp = oldIsApp
|
||||
killOtherInstances = oldKillOtherInstances
|
||||
})
|
||||
|
||||
isApp = false
|
||||
called := false
|
||||
killOtherInstances = func() bool {
|
||||
called = true
|
||||
return false
|
||||
}
|
||||
|
||||
if !handleExistingInstance(false) {
|
||||
t.Fatal("development instance did not continue startup")
|
||||
}
|
||||
if called {
|
||||
t.Fatal("restore called during an app replacement handoff")
|
||||
t.Fatal("development instance entered the packaged app handoff barrier")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleExistingInstanceReturnsBarrierResult(t *testing.T) {
|
||||
oldIsApp := isApp
|
||||
oldKillOtherInstances := killOtherInstances
|
||||
t.Cleanup(func() {
|
||||
isApp = oldIsApp
|
||||
killOtherInstances = oldKillOtherInstances
|
||||
})
|
||||
|
||||
isApp = true
|
||||
for _, want := range []bool{true, false} {
|
||||
killOtherInstances = func() bool { return want }
|
||||
if got := handleExistingInstance(false); got != want {
|
||||
t.Fatalf("handleExistingInstance() = %v, want %v", got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDarwinAppProcessRunningReportsExitedProcess(t *testing.T) {
|
||||
running, err := darwinAppProcessRunning(appProcessIdentity{pid: 1 << 30, startedAt: 1})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if running {
|
||||
t.Fatal("nonexistent process reported as running")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2525,6 +3085,22 @@ func TestClaudeDesktopInstallResultFromCode(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestCodexDesktopInstallResultFromCode(t *testing.T) {
|
||||
for _, tt := range []struct {
|
||||
code int
|
||||
want codexDesktopInstallResult
|
||||
}{
|
||||
{code: 0, want: codexDesktopInstallCancelled},
|
||||
{code: 1, want: codexDesktopInstallerOpened},
|
||||
{code: 2, want: codexDesktopInstallFailed},
|
||||
{code: 99, want: codexDesktopInstallFailed},
|
||||
} {
|
||||
if got := codexDesktopInstallResultFromCode(tt.code); got != tt.want {
|
||||
t.Errorf("codexDesktopInstallResultFromCode(%d) = %q, want %q", tt.code, got, tt.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestClaudeGatewayRejectsOllamaHostPortConflict(t *testing.T) {
|
||||
t.Setenv("OLLAMA_HOST", "0.0.0.0:11435")
|
||||
|
||||
|
||||
@@ -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() {}
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
//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("markCodexDesktopIntegrationUsed", func() string {
|
||||
if err := markCodexDesktopIntegrationUsed(); err != nil {
|
||||
return err.Error()
|
||||
}
|
||||
return ""
|
||||
})
|
||||
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) {}
|
||||
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
@@ -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
|
||||
}
|
||||
@@ -99,7 +99,7 @@ func (w *Webview) Run(path string) unsafe.Pointer {
|
||||
// Windows-specific scrollbar styling
|
||||
if runtime.GOOS == "windows" {
|
||||
init += `
|
||||
// Keep Edge WebView2 scrollbars aligned with the light-only app theme.
|
||||
// Keep Edge WebView2 scrollbars aligned with the system theme.
|
||||
function updateScrollbarStyles() {
|
||||
const existingStyle = document.getElementById('scrollbar-style');
|
||||
if (existingStyle) existingStyle.remove();
|
||||
@@ -112,6 +112,12 @@ func (w *Webview) Run(path string) unsafe.Pointer {
|
||||
::-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; }
|
||||
::-webkit-scrollbar-thumb:hover { background: #505050 !important; }
|
||||
::-webkit-scrollbar-corner { background: #1a1a1a !important; }
|
||||
}
|
||||
::-webkit-scrollbar-button {
|
||||
background: transparent !important;
|
||||
border: none !important;
|
||||
@@ -217,6 +223,7 @@ func (w *Webview) Run(path string) unsafe.Pointer {
|
||||
})
|
||||
|
||||
bindClaudeDesktop(wv)
|
||||
bindCodexDesktop(wv)
|
||||
|
||||
wv.Bind("close", func() {
|
||||
hideWindow(wv.Window())
|
||||
|
||||
@@ -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 |
@@ -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 {
|
||||
|
||||
+24
-3
@@ -14,7 +14,7 @@ import (
|
||||
|
||||
// currentSchemaVersion defines the current database schema version.
|
||||
// Increment this when making schema changes that require migrations.
|
||||
const currentSchemaVersion = 18
|
||||
const currentSchemaVersion = 19
|
||||
|
||||
// database wraps the SQLite connection.
|
||||
// SQLite handles its own locking for concurrent access:
|
||||
@@ -90,6 +90,7 @@ func (db *database) init() error {
|
||||
remote TEXT NOT NULL DEFAULT '', -- deprecated
|
||||
auto_update_enabled BOOLEAN NOT NULL DEFAULT 1,
|
||||
claude_desktop_used BOOLEAN NOT NULL DEFAULT 0,
|
||||
codex_desktop_used BOOLEAN NOT NULL DEFAULT 0,
|
||||
schema_version INTEGER NOT NULL DEFAULT %d
|
||||
);
|
||||
|
||||
@@ -285,6 +286,11 @@ func (db *database) migrate() error {
|
||||
return fmt.Errorf("migrate v17 to v18: %w", err)
|
||||
}
|
||||
version = 18
|
||||
case 18:
|
||||
if err := db.migrateV18ToV19(); err != nil {
|
||||
return fmt.Errorf("migrate v18 to v19: %w", err)
|
||||
}
|
||||
version = 19
|
||||
default:
|
||||
// If we have a version we don't recognize, just set it to current
|
||||
// This might happen during development
|
||||
@@ -586,6 +592,16 @@ func (db *database) migrateV17ToV18() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// migrateV18ToV19 records successful ChatGPT integration use.
|
||||
func (db *database) migrateV18ToV19() error {
|
||||
_, err := db.conn.Exec(`ALTER TABLE settings ADD COLUMN codex_desktop_used BOOLEAN NOT NULL DEFAULT 0`)
|
||||
if err != nil && !duplicateColumnError(err) {
|
||||
return fmt.Errorf("add codex_desktop_used column: %w", err)
|
||||
}
|
||||
_, err = db.conn.Exec(`UPDATE settings SET schema_version = 19`)
|
||||
return err
|
||||
}
|
||||
|
||||
// cleanupOrphanedData removes orphaned records that may exist due to the foreign key bug
|
||||
func (db *database) cleanupOrphanedData() error {
|
||||
_, err := db.conn.Exec(`
|
||||
@@ -1234,9 +1250,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, onboarding_version, think_enabled, think_level, auto_update_enabled, claude_desktop_used
|
||||
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, codex_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.OnboardingVersion, &s.ThinkEnabled, &s.ThinkLevel, &s.AutoUpdateEnabled, &s.ClaudeDesktopUsed)
|
||||
`).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, &s.CodexDesktopUsed)
|
||||
if err != nil {
|
||||
return Settings{}, fmt.Errorf("get settings: %w", err)
|
||||
}
|
||||
@@ -1260,6 +1276,11 @@ func (db *database) setSettings(s Settings) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (db *database) markCodexDesktopUsed() error {
|
||||
_, err := db.conn.Exec(`UPDATE settings SET codex_desktop_used = 1`)
|
||||
return err
|
||||
}
|
||||
|
||||
func (db *database) isCloudSettingMigrated() (bool, error) {
|
||||
var migrated bool
|
||||
err := db.conn.QueryRow("SELECT cloud_setting_migrated FROM settings").Scan(&migrated)
|
||||
|
||||
@@ -563,3 +563,38 @@ func loadV2Schema(t *testing.T, dbPath string) *database {
|
||||
|
||||
return &database{conn: conn}
|
||||
}
|
||||
|
||||
func TestCodexDesktopUsedMigration(t *testing.T) {
|
||||
dbPath := filepath.Join(t.TempDir(), "codex-intro.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.CodexDesktopUsed {
|
||||
t.Fatal("expected fresh installs to have no ChatGPT intro acknowledgment")
|
||||
}
|
||||
|
||||
if _, err := db.conn.Exec(`
|
||||
ALTER TABLE settings DROP COLUMN codex_desktop_used;
|
||||
UPDATE settings SET schema_version = 18;
|
||||
`); err != nil {
|
||||
t.Fatalf("failed to seed v18 settings row: %v", err)
|
||||
}
|
||||
if err := db.migrate(); err != nil {
|
||||
t.Fatalf("migration from v18 to v19 failed: %v", err)
|
||||
}
|
||||
|
||||
settings, err = db.getSettings()
|
||||
if err != nil {
|
||||
t.Fatalf("failed to read migrated settings: %v", err)
|
||||
}
|
||||
if settings.CodexDesktopUsed {
|
||||
t.Fatal("expected existing installs to start with no inferred ChatGPT intro acknowledgment")
|
||||
}
|
||||
}
|
||||
@@ -178,6 +178,10 @@ type Settings struct {
|
||||
|
||||
// ClaudeDesktopUsed records whether Claude Desktop has ever been connected through Ollama.
|
||||
ClaudeDesktopUsed bool
|
||||
|
||||
// CodexDesktopUsed records whether ChatGPT has successfully connected through Ollama.
|
||||
// Only MarkCodexDesktopUsed updates it; SetSettings preserves the stored value.
|
||||
CodexDesktopUsed bool
|
||||
}
|
||||
|
||||
// Keep in sync with CURRENT_ONBOARDING_VERSION in app/ui/app/src/lib/onboarding.ts.
|
||||
@@ -426,6 +430,13 @@ func (s *Store) SetSettings(settings Settings) error {
|
||||
return s.db.setSettings(settings)
|
||||
}
|
||||
|
||||
func (s *Store) MarkCodexDesktopUsed() error {
|
||||
if err := s.ensureDB(); err != nil {
|
||||
return err
|
||||
}
|
||||
return s.db.markCodexDesktopUsed()
|
||||
}
|
||||
|
||||
func (s *Store) Chats() ([]Chat, error) {
|
||||
if err := s.ensureDB(); err != nil {
|
||||
return nil, err
|
||||
|
||||
@@ -279,6 +279,61 @@ func TestClaudeDesktopUsedRoundTrip(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestCodexDesktopUsedPreservedBySettings(t *testing.T) {
|
||||
s, cleanup := setupTestStore(t)
|
||||
defer cleanup()
|
||||
|
||||
settings, err := s.Settings()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
settings.Browser = true
|
||||
settings.ClaudeDesktopUsed = true
|
||||
settings.CodexDesktopUsed = true
|
||||
if err := s.SetSettings(settings); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
saved, err := s.Settings()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if saved.CodexDesktopUsed {
|
||||
t.Fatal("ordinary settings save acknowledged the intro")
|
||||
}
|
||||
settings.CodexDesktopUsed = false
|
||||
if saved != settings {
|
||||
t.Fatal("ordinary settings save lost unrelated settings")
|
||||
}
|
||||
|
||||
for range 2 {
|
||||
if err := s.MarkCodexDesktopUsed(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
saved, err = s.Settings()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
want := settings
|
||||
want.CodexDesktopUsed = true
|
||||
if saved != want {
|
||||
t.Fatal("acknowledgment did not preserve unrelated settings")
|
||||
}
|
||||
|
||||
settings.Browser = false
|
||||
if err := s.SetSettings(settings); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
saved, err = s.Settings()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
want.Browser = false
|
||||
if saved != want {
|
||||
t.Fatal("stale settings save lost acknowledgment or the requested setting")
|
||||
}
|
||||
}
|
||||
|
||||
// setupTestStore creates a temporary store for testing
|
||||
func setupTestStore(t *testing.T) (*Store, func()) {
|
||||
t.Helper()
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
<!doctype html>
|
||||
<html lang="en" style="overflow: hidden; color-scheme: light">
|
||||
<html lang="en" style="overflow: hidden">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="color-scheme" content="light" />
|
||||
<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="bg-white 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>
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 520 KiB |
Binary file not shown.
|
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 |
@@ -1,4 +1,3 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="50" height="50" viewBox="0 0 50 50" fill="none">
|
||||
<style>@media (prefers-color-scheme: dark) { path { fill: #fff; } }</style>
|
||||
<path d="M48.8354 10.0479C48.3232 9.79199 48.1025 10.2798 47.8032 10.5278C46.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.6558C36.4668 10.0156 35.9702 9.31982 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 21C32.4092 19.4878 30.0381 16.2319 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.896C22.8691 7.90381 20.4507 9.06396 18.7095 9.58398C16.8501 9.22363 14.9199 9.14355 12.9033 9.37598C5.30859 10.2397 1.15674 16.4717 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.4561C33.0396 40.1279 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.4639C46.6924 18.9116 49.064 15.9038 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.3838C7.97949 34.0879 4.48926 28.9282 4.19775 21.3677C4.1582 20.5757 4.38672 20.2959 5.15869 20.1519C11.8945 18.8799 17.165 22.0879 19.2529 25.7759C23.5381 30.104 25.335 35.1523 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.3042 23.9678 27.5801 24.248 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.8799C31.6372 28.2881 30.6289 28.3042 29.8096 27.688C28.6987 26.8555 28.6279 25.7759 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.856C25.8467 22.5762 25.9805 22.1758 26.5088 21.688C28.0996 20.7598 29.6362 21.9917 30.834 23.3281C31.6216 24.2559 32.8901 26.312 33.1104 26.9521C33.2446 27.3521 33.0713 27.6802 32.6064 27.8799Z" fill="#000"/>
|
||||
<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>
|
||||
|
Before Width: | Height: | Size: 2.7 KiB After Width: | Height: | Size: 3.5 KiB |
@@ -6,51 +6,10 @@ vi.mock("./lib/ollama-client", () => ({
|
||||
|
||||
import {
|
||||
fetchConnectUrl,
|
||||
getFeatureFlag,
|
||||
getClaudeDesktopAvailableModels,
|
||||
getIntegrationStatuses,
|
||||
} from "./api";
|
||||
|
||||
describe("getFeatureFlag", () => {
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it("returns boolean and string values from the local app service", async () => {
|
||||
const fetch = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce(new Response(JSON.stringify({ value: true })))
|
||||
.mockResolvedValueOnce(
|
||||
new Response(JSON.stringify({ value: "compact" })),
|
||||
);
|
||||
vi.stubGlobal("fetch", fetch);
|
||||
|
||||
await expect(getFeatureFlag("new-chat", false)).resolves.toBe(true);
|
||||
await expect(getFeatureFlag("chat-layout", "standard")).resolves.toBe(
|
||||
"compact",
|
||||
);
|
||||
expect(fetch).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
"http://127.0.0.1:3001/api/v1/feature-flags/new-chat?type=boolean&default=false",
|
||||
);
|
||||
expect(fetch).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
"http://127.0.0.1:3001/api/v1/feature-flags/chat-layout?type=string&default=standard",
|
||||
);
|
||||
});
|
||||
|
||||
it("returns the compiled fallback for unavailable or invalid responses", async () => {
|
||||
const fetch = vi
|
||||
.fn()
|
||||
.mockRejectedValueOnce(new Error("offline"))
|
||||
.mockResolvedValueOnce(new Response(JSON.stringify({ value: "wrong" })));
|
||||
vi.stubGlobal("fetch", fetch);
|
||||
|
||||
await expect(getFeatureFlag("new-chat", false)).resolves.toBe(false);
|
||||
await expect(getFeatureFlag("new-chat", true)).resolves.toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("fetchConnectUrl", () => {
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
|
||||
@@ -33,38 +33,6 @@ export interface CloudStatusResponse {
|
||||
source: CloudStatusSource;
|
||||
}
|
||||
|
||||
export async function getFeatureFlag(
|
||||
key: string,
|
||||
defaultValue: boolean,
|
||||
): Promise<boolean>;
|
||||
export async function getFeatureFlag(
|
||||
key: string,
|
||||
defaultValue: string,
|
||||
): Promise<string>;
|
||||
export async function getFeatureFlag(
|
||||
key: string,
|
||||
defaultValue: boolean | string,
|
||||
): Promise<boolean | string> {
|
||||
const type = typeof defaultValue === "boolean" ? "boolean" : "string";
|
||||
const query = new URLSearchParams({
|
||||
type,
|
||||
default: String(defaultValue),
|
||||
});
|
||||
|
||||
try {
|
||||
const response = await fetch(
|
||||
`${API_BASE}/api/v1/feature-flags/${encodeURIComponent(key)}?${query}`,
|
||||
);
|
||||
if (!response.ok) return defaultValue;
|
||||
const data = await response.json();
|
||||
return typeof data.value === typeof defaultValue
|
||||
? data.value
|
||||
: defaultValue;
|
||||
} catch {
|
||||
return defaultValue;
|
||||
}
|
||||
}
|
||||
|
||||
export interface IntegrationStatus {
|
||||
id: string;
|
||||
name: string;
|
||||
|
||||
@@ -38,6 +38,7 @@ interface ClaudeDesktopModelsSettingsProps {
|
||||
initialCloudModels?: string[];
|
||||
includeCloudModels?: boolean;
|
||||
onDraftChange?: (hasChanges: boolean) => void;
|
||||
showSectionHeading?: boolean;
|
||||
}
|
||||
|
||||
const fallbackRoutes: ClaudeDesktopMappingStatus[] = [
|
||||
@@ -266,6 +267,7 @@ export const ClaudeDesktopModelsSettings = forwardRef<
|
||||
initialCloudModels,
|
||||
includeCloudModels = false,
|
||||
onDraftChange,
|
||||
showSectionHeading = true,
|
||||
},
|
||||
ref,
|
||||
) {
|
||||
@@ -575,13 +577,18 @@ export const ClaudeDesktopModelsSettings = forwardRef<
|
||||
: null);
|
||||
|
||||
return (
|
||||
<section aria-labelledby="apps-settings-heading" className="space-y-2">
|
||||
<h2
|
||||
id="apps-settings-heading"
|
||||
className="px-1 text-xs font-medium uppercase tracking-wider text-neutral-400 dark:text-neutral-500"
|
||||
>
|
||||
Apps
|
||||
</h2>
|
||||
<div
|
||||
aria-label={showSectionHeading ? undefined : "Claude settings"}
|
||||
className="space-y-2"
|
||||
>
|
||||
{showSectionHeading && (
|
||||
<h2
|
||||
id="apps-settings-heading"
|
||||
className="px-1 text-xs font-medium uppercase tracking-wider text-neutral-400 dark:text-neutral-500"
|
||||
>
|
||||
Apps
|
||||
</h2>
|
||||
)}
|
||||
<div
|
||||
aria-labelledby="claude-settings-heading"
|
||||
className="overflow-visible rounded-xl bg-white p-4 dark:bg-neutral-800"
|
||||
@@ -689,6 +696,6 @@ export const ClaudeDesktopModelsSettings = forwardRef<
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,35 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { act, create } from "react-test-renderer";
|
||||
import { afterEach, expect, it, vi } from "vitest";
|
||||
import { CodexConnectedIntro } from "./CodexConnectedIntro";
|
||||
|
||||
vi.mock("@headlessui/react", () => {
|
||||
const Container = ({ children }: { children: ReactNode }) => (
|
||||
<div>{children}</div>
|
||||
);
|
||||
return {
|
||||
Dialog: Container,
|
||||
DialogPanel: Container,
|
||||
DialogTitle: Container,
|
||||
Description: Container,
|
||||
};
|
||||
});
|
||||
afterEach(() => vi.unstubAllGlobals());
|
||||
|
||||
it("hands Continue to the connection flow, like Claude's intro", async () => {
|
||||
const done = vi.fn();
|
||||
vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
|
||||
let renderer;
|
||||
try {
|
||||
await act(async () => {
|
||||
renderer = create(<CodexConnectedIntro onDone={done} />);
|
||||
});
|
||||
expect(done).not.toHaveBeenCalled();
|
||||
const button = renderer!.root.findByType("button");
|
||||
expect(button.children).toEqual(["Continue"]);
|
||||
await act(async () => button.props.onClick());
|
||||
expect(done).toHaveBeenCalledOnce();
|
||||
} finally {
|
||||
await act(async () => renderer?.unmount());
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,48 @@
|
||||
import {
|
||||
Dialog,
|
||||
DialogPanel,
|
||||
DialogTitle,
|
||||
Description,
|
||||
} from "@headlessui/react";
|
||||
|
||||
export function CodexConnectedIntro({ onDone }: { onDone: () => void }) {
|
||||
return (
|
||||
<Dialog open onClose={() => {}} className="relative z-50">
|
||||
<div
|
||||
className="claude-connected-backdrop fixed inset-0 bg-black/20 dark:bg-black/50"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<div className="fixed inset-0 flex items-center justify-center overflow-y-auto p-6">
|
||||
<DialogPanel className="claude-connected-dialog relative max-h-full w-full max-w-md overflow-y-auto rounded-2xl bg-white font-sans shadow-2xl ring-1 ring-black/10 dark:bg-neutral-800 dark:ring-white/10">
|
||||
<img
|
||||
src="/chatgpt-connected.png"
|
||||
alt="Ollama models alongside OpenAI models in the ChatGPT Codex model picker"
|
||||
width={1172}
|
||||
height={1084}
|
||||
className="h-auto w-full object-contain"
|
||||
draggable={false}
|
||||
/>
|
||||
<div className="p-6">
|
||||
<DialogTitle className="font-rounded text-lg font-medium leading-6 text-neutral-950 dark:text-neutral-100">
|
||||
Use Ollama models in ChatGPT
|
||||
</DialogTitle>
|
||||
<Description className="mt-2 text-[13px] leading-5 text-neutral-500 dark:text-neutral-400">
|
||||
Click Continue to open ChatGPT. In Codex mode, choose an Ollama
|
||||
model from the model picker for your task.
|
||||
</Description>
|
||||
<div className="mt-5 flex justify-end">
|
||||
<button
|
||||
type="button"
|
||||
data-autofocus
|
||||
onClick={onDone}
|
||||
className="rounded-full bg-neutral-100 px-6 py-2 text-sm font-normal text-neutral-950 transition-colors hover:bg-neutral-200 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-neutral-500 dark:bg-white dark:hover:bg-neutral-100"
|
||||
>
|
||||
Continue
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</DialogPanel>
|
||||
</div>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,645 @@
|
||||
import { Button } from "@/components/ui/button";
|
||||
import type {
|
||||
CodexDesktopModelStatus,
|
||||
CodexDesktopModelsSettings as ModelsSettings,
|
||||
CodexDesktopModelsSettingsResult,
|
||||
CodexDesktopStatus,
|
||||
} from "@/types/webview";
|
||||
import {
|
||||
ArrowPathIcon,
|
||||
CheckIcon,
|
||||
MagnifyingGlassIcon,
|
||||
XMarkIcon,
|
||||
} from "@heroicons/react/20/solid";
|
||||
import { Popover, PopoverButton, PopoverPanel } from "@headlessui/react";
|
||||
import {
|
||||
forwardRef,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useImperativeHandle,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
|
||||
export interface CodexDesktopModelsSettingsHandle {
|
||||
resetToDefaults: () => Promise<boolean>;
|
||||
}
|
||||
|
||||
interface CodexDesktopModelsSettingsProps {
|
||||
initialSettings?: ModelsSettings;
|
||||
accountKey?: string;
|
||||
onDraftChange?: (hasChanges: boolean) => void;
|
||||
}
|
||||
|
||||
const CHATGPT_OPEN_POLL_INTERVAL_MS = 250;
|
||||
const CHATGPT_OPEN_TIMEOUT_MS = 30_000;
|
||||
|
||||
function wait(milliseconds: number): Promise<void> {
|
||||
return new Promise((resolve) => globalThis.setTimeout(resolve, milliseconds));
|
||||
}
|
||||
|
||||
async function waitForChatGPTToOpen(): Promise<
|
||||
CodexDesktopStatus | null | undefined
|
||||
> {
|
||||
const getStatus = window.getCodexDesktopStatus;
|
||||
if (!getStatus) return undefined;
|
||||
|
||||
const deadline = Date.now() + CHATGPT_OPEN_TIMEOUT_MS;
|
||||
while (Date.now() < deadline) {
|
||||
try {
|
||||
const status = await getStatus();
|
||||
if (status.running) return status;
|
||||
} catch {
|
||||
// ChatGPT may be between processes during a restart. Keep checking until
|
||||
// it opens or the launch timeout expires.
|
||||
}
|
||||
await wait(CHATGPT_OPEN_POLL_INTERVAL_MS);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function selectionsEqual(
|
||||
left: string[] | null | undefined,
|
||||
right: string[] | null | undefined,
|
||||
): boolean {
|
||||
left ??= [];
|
||||
right ??= [];
|
||||
return (
|
||||
left.length === right.length &&
|
||||
left.every((model, index) => model === right[index])
|
||||
);
|
||||
}
|
||||
|
||||
function normalizeSettings(
|
||||
settings: ModelsSettings & {
|
||||
selected?: string[] | null;
|
||||
available?: string[] | null;
|
||||
models?: CodexDesktopModelStatus[] | null;
|
||||
},
|
||||
): ModelsSettings {
|
||||
const available = settings.available ?? [];
|
||||
return {
|
||||
...settings,
|
||||
usesDefaults: settings.usesDefaults ?? false,
|
||||
selected: settings.selected ?? [],
|
||||
available,
|
||||
models:
|
||||
settings.models ??
|
||||
available.map((name) => ({
|
||||
name,
|
||||
displayName: name,
|
||||
selected: settings.selected?.includes(name) ?? false,
|
||||
availability: "available" as const,
|
||||
})),
|
||||
maxModels: settings.maxModels || 5,
|
||||
};
|
||||
}
|
||||
|
||||
function modelIsAvailable(model: CodexDesktopModelStatus): boolean {
|
||||
return !model.availability || model.availability === "available";
|
||||
}
|
||||
|
||||
function modelCanBeSelected(model: CodexDesktopModelStatus): boolean {
|
||||
return model.recommended || modelIsAvailable(model);
|
||||
}
|
||||
|
||||
function modelStatusLabel(model: CodexDesktopModelStatus): string | null {
|
||||
switch (model.reason) {
|
||||
case "cloud_off":
|
||||
return "Cloud models are off";
|
||||
case "sign_in_required":
|
||||
return "Sign in required";
|
||||
case "upgrade_required":
|
||||
return model.requiredPlan
|
||||
? `${model.requiredPlan[0]?.toUpperCase()}${model.requiredPlan.slice(1)} plan required`
|
||||
: "Upgrade required";
|
||||
case "verification_unavailable":
|
||||
return "Access unavailable";
|
||||
case "model_not_installed":
|
||||
return "Not installed";
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function ModelOptions({
|
||||
models,
|
||||
selected,
|
||||
maxModels,
|
||||
onToggle,
|
||||
}: {
|
||||
models: CodexDesktopModelStatus[];
|
||||
selected: string[];
|
||||
maxModels: number;
|
||||
onToggle: (model: string) => void;
|
||||
}) {
|
||||
const [query, setQuery] = useState("");
|
||||
const [highlightedIndex, setHighlightedIndex] = useState(-1);
|
||||
const searchRef = useRef<HTMLInputElement>(null);
|
||||
const optionRefs = useRef<Array<HTMLButtonElement | null>>([]);
|
||||
const selectedSet = useMemo(() => new Set(selected), [selected]);
|
||||
const normalizedQuery = query.trim().toLowerCase();
|
||||
const filtered = models.filter((model) =>
|
||||
`${model.displayName} ${model.name}`
|
||||
.toLowerCase()
|
||||
.includes(normalizedQuery),
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
searchRef.current?.focus({ preventScroll: true });
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
setHighlightedIndex(-1);
|
||||
}, [normalizedQuery]);
|
||||
|
||||
useEffect(() => {
|
||||
if (highlightedIndex < 0) return;
|
||||
optionRefs.current[highlightedIndex]?.scrollIntoView({ block: "nearest" });
|
||||
}, [highlightedIndex]);
|
||||
|
||||
const optionIsDisabled = (model: CodexDesktopModelStatus) =>
|
||||
!modelCanBeSelected(model) ||
|
||||
(!selectedSet.has(model.name) && selected.length >= maxModels);
|
||||
|
||||
const moveHighlight = (direction: -1 | 1) => {
|
||||
setHighlightedIndex((current) => {
|
||||
if (filtered.length === 0) return -1;
|
||||
const start = current < 0 ? (direction === 1 ? -1 : 0) : current;
|
||||
for (let offset = 1; offset <= filtered.length; offset += 1) {
|
||||
const candidate =
|
||||
(start + direction * offset + filtered.length) % filtered.length;
|
||||
if (!optionIsDisabled(filtered[candidate])) return candidate;
|
||||
}
|
||||
return -1;
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex items-center gap-2 border-b border-neutral-100 px-3 py-2 dark:border-neutral-700">
|
||||
<MagnifyingGlassIcon className="h-4 w-4 shrink-0 text-neutral-400" />
|
||||
<input
|
||||
ref={searchRef}
|
||||
type="text"
|
||||
value={query}
|
||||
onChange={(event) => setQuery(event.target.value)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "ArrowDown") {
|
||||
event.preventDefault();
|
||||
moveHighlight(1);
|
||||
} else if (event.key === "ArrowUp") {
|
||||
event.preventDefault();
|
||||
moveHighlight(-1);
|
||||
} else if (
|
||||
event.key === "Enter" &&
|
||||
highlightedIndex >= 0 &&
|
||||
highlightedIndex < filtered.length
|
||||
) {
|
||||
event.preventDefault();
|
||||
onToggle(filtered[highlightedIndex].name);
|
||||
}
|
||||
}}
|
||||
placeholder="Find model..."
|
||||
aria-label="Find ChatGPT model"
|
||||
role="combobox"
|
||||
aria-expanded="true"
|
||||
aria-controls="chatgpt-model-options-listbox"
|
||||
aria-activedescendant={
|
||||
highlightedIndex >= 0
|
||||
? `chatgpt-model-option-${highlightedIndex}`
|
||||
: undefined
|
||||
}
|
||||
autoCorrect="off"
|
||||
autoComplete="off"
|
||||
className="min-w-0 flex-1 border-none bg-transparent py-0.5 outline-none"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
id="chatgpt-model-options-listbox"
|
||||
role="listbox"
|
||||
aria-multiselectable="true"
|
||||
className="min-h-0 overflow-y-auto py-1"
|
||||
>
|
||||
{filtered.map((model, index) => {
|
||||
const checked = selectedSet.has(model.name);
|
||||
const disabled = optionIsDisabled(model);
|
||||
const statusLabel = modelStatusLabel(model);
|
||||
return (
|
||||
<button
|
||||
key={model.name}
|
||||
id={`chatgpt-model-option-${index}`}
|
||||
ref={(element) => {
|
||||
optionRefs.current[index] = element;
|
||||
}}
|
||||
type="button"
|
||||
role="option"
|
||||
aria-selected={checked}
|
||||
disabled={disabled}
|
||||
onClick={() => onToggle(model.name)}
|
||||
onMouseEnter={() => setHighlightedIndex(index)}
|
||||
className={`flex w-full cursor-pointer items-center gap-2 px-3 py-2 text-left hover:bg-neutral-100 focus:bg-neutral-100 focus:outline-none disabled:cursor-not-allowed disabled:opacity-40 dark:hover:bg-neutral-700/60 dark:focus:bg-neutral-700/60 ${
|
||||
highlightedIndex === index
|
||||
? "bg-neutral-100 dark:bg-neutral-700/60"
|
||||
: ""
|
||||
}`}
|
||||
>
|
||||
<span className="h-4 w-4 shrink-0">
|
||||
{checked && <CheckIcon className="h-4 w-4" />}
|
||||
</span>
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block truncate">{model.displayName}</span>
|
||||
{statusLabel && (
|
||||
<span className="mt-0.5 block truncate text-xs text-neutral-400">
|
||||
{statusLabel}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
{filtered.length === 0 && (
|
||||
<p className="px-3 py-2 text-neutral-400">No models found</p>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export const CodexDesktopModelsSettings = forwardRef<
|
||||
CodexDesktopModelsSettingsHandle,
|
||||
CodexDesktopModelsSettingsProps
|
||||
>(function CodexDesktopModelsSettings(
|
||||
{ initialSettings, accountKey, onDraftChange },
|
||||
ref,
|
||||
) {
|
||||
const normalizedInitialSettings = initialSettings
|
||||
? normalizeSettings(initialSettings)
|
||||
: null;
|
||||
const [settings, setSettings] = useState<ModelsSettings | null>(
|
||||
normalizedInitialSettings,
|
||||
);
|
||||
const [selected, setSelected] = useState<string[]>(
|
||||
normalizedInitialSettings?.selected ?? [],
|
||||
);
|
||||
const [saved, setSaved] = useState<string[]>(
|
||||
normalizedInitialSettings?.selected ?? [],
|
||||
);
|
||||
const [loading, setLoading] = useState(!initialSettings);
|
||||
const [applying, setApplying] = useState(false);
|
||||
const [resetting, setResetting] = useState(false);
|
||||
const [launchAction, setLaunchAction] = useState<"start" | "restart">(
|
||||
"start",
|
||||
);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [warning, setWarning] = useState<string | null>(null);
|
||||
const draftRef = useRef({ selected, saved });
|
||||
const accountKeyRef = useRef(accountKey);
|
||||
const statusRequestRef = useRef(0);
|
||||
const operationInFlightRef = useRef(false);
|
||||
draftRef.current = { selected, saved };
|
||||
|
||||
const applyResult = useCallback(
|
||||
(result: CodexDesktopModelsSettingsResult, preserveDraft = false) => {
|
||||
const nextSettings = normalizeSettings(result.settings);
|
||||
const keepDraft =
|
||||
preserveDraft &&
|
||||
!selectionsEqual(draftRef.current.selected, draftRef.current.saved);
|
||||
setSettings(nextSettings);
|
||||
if (!keepDraft) {
|
||||
setSelected(nextSettings.selected);
|
||||
setSaved(nextSettings.selected);
|
||||
}
|
||||
setError(result.error ?? null);
|
||||
setWarning(result.warning ?? null);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
if (!window.getCodexDesktopModelsSettings) {
|
||||
setError("ChatGPT model settings are unavailable in this Ollama build.");
|
||||
setWarning(null);
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
const request = ++statusRequestRef.current;
|
||||
try {
|
||||
const result = await window.getCodexDesktopModelsSettings();
|
||||
if (
|
||||
request === statusRequestRef.current &&
|
||||
!operationInFlightRef.current
|
||||
) {
|
||||
applyResult(result, true);
|
||||
}
|
||||
} catch {
|
||||
if (
|
||||
request === statusRequestRef.current &&
|
||||
!operationInFlightRef.current
|
||||
) {
|
||||
setError("Ollama could not load the ChatGPT model settings.");
|
||||
setWarning(null);
|
||||
}
|
||||
} finally {
|
||||
if (request === statusRequestRef.current) setLoading(false);
|
||||
}
|
||||
}, [applyResult]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!initialSettings) void refresh();
|
||||
const onFocus = () => void refresh();
|
||||
window.addEventListener("focus", onFocus);
|
||||
return () => window.removeEventListener("focus", onFocus);
|
||||
}, [initialSettings, refresh]);
|
||||
|
||||
useEffect(() => {
|
||||
if (accountKeyRef.current === accountKey) return;
|
||||
accountKeyRef.current = accountKey;
|
||||
void refresh();
|
||||
}, [accountKey, refresh]);
|
||||
|
||||
const hasChanges = !selectionsEqual(selected, saved);
|
||||
useEffect(() => {
|
||||
onDraftChange?.(hasChanges);
|
||||
}, [hasChanges, onDraftChange]);
|
||||
|
||||
const maxModels = settings?.maxModels ?? 5;
|
||||
const models = useMemo(() => {
|
||||
const catalog = [...(settings?.models ?? [])];
|
||||
const known = new Set(catalog.map((model) => model.name));
|
||||
for (const name of selected) {
|
||||
if (known.has(name)) continue;
|
||||
known.add(name);
|
||||
catalog.push({
|
||||
name,
|
||||
displayName: name,
|
||||
selected: true,
|
||||
availability: "unknown",
|
||||
reason: "verification_unavailable",
|
||||
});
|
||||
}
|
||||
return catalog;
|
||||
}, [selected, settings?.models]);
|
||||
const displayNames = useMemo(
|
||||
() => new Map(models.map((model) => [model.name, model.displayName])),
|
||||
[models],
|
||||
);
|
||||
|
||||
const toggleModel = (model: string) => {
|
||||
setError(null);
|
||||
setWarning(null);
|
||||
setSelected((current) => {
|
||||
if (current.includes(model)) {
|
||||
return current.filter((name) => name !== model);
|
||||
}
|
||||
if (current.length >= maxModels) return current;
|
||||
return [...current, model];
|
||||
});
|
||||
};
|
||||
|
||||
const applyChanges = async () => {
|
||||
if (!window.applyCodexDesktopModels) {
|
||||
setError("ChatGPT model settings are available in the Ollama macOS app.");
|
||||
return;
|
||||
}
|
||||
if (selected.length === 0) {
|
||||
setError("Choose at least one model for ChatGPT.");
|
||||
return;
|
||||
}
|
||||
if (operationInFlightRef.current) return;
|
||||
|
||||
setApplying(true);
|
||||
setLaunchAction(settings?.running ? "restart" : "start");
|
||||
setError(null);
|
||||
setWarning(null);
|
||||
operationInFlightRef.current = true;
|
||||
++statusRequestRef.current;
|
||||
try {
|
||||
const modelsToApply =
|
||||
!hasChanges && settings?.usesDefaults ? [] : selected;
|
||||
let result = await window.applyCodexDesktopModels(modelsToApply, false);
|
||||
if (result.restartConfirmationRequired) {
|
||||
applyResult(result, true);
|
||||
if (
|
||||
!window.confirm(
|
||||
result.settings.connected
|
||||
? "Restart ChatGPT to update Ollama models? Any running task will stop."
|
||||
: "Restart ChatGPT to add Ollama models? Any running task will stop.",
|
||||
)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
result = await window.applyCodexDesktopModels(modelsToApply, true);
|
||||
}
|
||||
++statusRequestRef.current;
|
||||
if (result.error) {
|
||||
setSettings(normalizeSettings(result.settings));
|
||||
setError(result.error);
|
||||
setWarning(result.warning ?? null);
|
||||
return;
|
||||
}
|
||||
applyResult(result);
|
||||
const openedStatus = await waitForChatGPTToOpen();
|
||||
if (openedStatus === null) {
|
||||
setError("ChatGPT is taking longer than expected to open. Try again.");
|
||||
return;
|
||||
}
|
||||
if (openedStatus) {
|
||||
setSettings((current) =>
|
||||
current
|
||||
? {
|
||||
...current,
|
||||
installed: openedStatus.installed,
|
||||
connected: openedStatus.connected,
|
||||
running: openedStatus.running,
|
||||
}
|
||||
: current,
|
||||
);
|
||||
}
|
||||
} catch {
|
||||
setError("Ollama could not apply the ChatGPT models.");
|
||||
} finally {
|
||||
++statusRequestRef.current;
|
||||
operationInFlightRef.current = false;
|
||||
setApplying(false);
|
||||
}
|
||||
};
|
||||
|
||||
const resetToDefaults = useCallback(async (): Promise<boolean> => {
|
||||
if (operationInFlightRef.current) return false;
|
||||
|
||||
const resetModels = window.resetCodexDesktopModels;
|
||||
if (!resetModels) {
|
||||
setError("Ollama could not reset the ChatGPT models.");
|
||||
return false;
|
||||
}
|
||||
|
||||
setResetting(true);
|
||||
setError(null);
|
||||
setWarning(null);
|
||||
operationInFlightRef.current = true;
|
||||
++statusRequestRef.current;
|
||||
try {
|
||||
const result = await resetModels();
|
||||
++statusRequestRef.current;
|
||||
if (result.error) {
|
||||
setSettings(normalizeSettings(result.settings));
|
||||
setError(result.error);
|
||||
setWarning(result.warning ?? null);
|
||||
return false;
|
||||
}
|
||||
applyResult(result);
|
||||
return true;
|
||||
} catch {
|
||||
setError("Ollama could not reset the ChatGPT models.");
|
||||
return false;
|
||||
} finally {
|
||||
++statusRequestRef.current;
|
||||
operationInFlightRef.current = false;
|
||||
setResetting(false);
|
||||
}
|
||||
}, [applyResult]);
|
||||
|
||||
useImperativeHandle(ref, () => ({ resetToDefaults }), [resetToDefaults]);
|
||||
|
||||
if (!settings?.supported && !loading && !error) return null;
|
||||
|
||||
const busy = applying || resetting;
|
||||
|
||||
return (
|
||||
<div
|
||||
aria-labelledby="chatgpt-model-settings-heading"
|
||||
className="overflow-visible rounded-xl bg-white p-4 dark:bg-neutral-800"
|
||||
>
|
||||
<div className="flex items-start space-x-3">
|
||||
<span className="mt-0.5 flex h-5 w-5 shrink-0 items-center justify-center">
|
||||
<img
|
||||
src="/launch-icons/codex.svg"
|
||||
alt=""
|
||||
className="h-5 w-5 dark:hidden"
|
||||
/>
|
||||
<img
|
||||
src="/launch-icons/codex-dark.svg"
|
||||
alt=""
|
||||
className="hidden h-5 w-5 dark:block"
|
||||
/>
|
||||
</span>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<h2
|
||||
id="chatgpt-model-settings-heading"
|
||||
className="text-sm font-medium text-neutral-900 dark:text-white"
|
||||
>
|
||||
ChatGPT
|
||||
</h2>
|
||||
<p className="mt-1 text-base/6 text-zinc-500 sm:text-sm/6 dark:text-zinc-400">
|
||||
Choose up to {maxModels} Ollama models to use in ChatGPT.
|
||||
</p>
|
||||
</div>
|
||||
<div className="shrink-0">
|
||||
<Button
|
||||
type="button"
|
||||
color="white"
|
||||
onClick={() => void applyChanges()}
|
||||
disabled={loading || busy || selected.length === 0}
|
||||
>
|
||||
{applying && (
|
||||
<ArrowPathIcon data-slot="icon" className="animate-spin" />
|
||||
)}
|
||||
{applying
|
||||
? launchAction === "restart"
|
||||
? "Restarting…"
|
||||
: "Starting…"
|
||||
: settings?.running
|
||||
? hasChanges
|
||||
? "Save & restart ChatGPT"
|
||||
: "Restart ChatGPT"
|
||||
: hasChanges
|
||||
? "Save & start ChatGPT"
|
||||
: "Start ChatGPT"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 w-full max-w-xl">
|
||||
<Popover className="relative w-full">
|
||||
<div
|
||||
data-testid="chatgpt-model-picker"
|
||||
className="relative flex min-h-10 w-full flex-wrap items-center gap-1.5 rounded-lg bg-neutral-50 px-2 py-1.5 ring-1 ring-inset ring-neutral-200 hover:bg-neutral-100 dark:bg-neutral-700 dark:ring-neutral-600 dark:hover:bg-neutral-600"
|
||||
>
|
||||
<PopoverButton
|
||||
aria-label="Add ChatGPT model"
|
||||
disabled={loading || busy}
|
||||
className="absolute inset-0 rounded-lg outline-none focus-visible:ring-2 focus-visible:ring-blue-500 disabled:cursor-not-allowed"
|
||||
>
|
||||
<span className="sr-only">Choose ChatGPT models</span>
|
||||
</PopoverButton>
|
||||
<div className="pointer-events-none relative z-10 flex min-w-0 flex-1 flex-wrap items-center gap-1.5">
|
||||
{selected.map((model) => (
|
||||
<span
|
||||
key={model}
|
||||
className="pointer-events-none inline-flex max-w-full items-stretch overflow-hidden rounded-md bg-neutral-200/70 text-sm text-neutral-700 dark:bg-neutral-600 dark:text-neutral-100"
|
||||
>
|
||||
<span className="min-w-0 py-1 pl-2 pr-1">
|
||||
<span className="block truncate">
|
||||
{displayNames.get(model) ?? model}
|
||||
</span>
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={`Remove ${model}`}
|
||||
disabled={busy}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
toggleModel(model);
|
||||
}}
|
||||
className="pointer-events-auto inline-flex shrink-0 items-center px-1.5 hover:bg-neutral-200 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500 disabled:opacity-50 dark:hover:bg-neutral-500"
|
||||
>
|
||||
<XMarkIcon className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</span>
|
||||
))}
|
||||
{selected.length === 0 && (
|
||||
<span className="px-1 py-1 text-sm text-neutral-400">
|
||||
{loading ? "Loading models…" : "Select models"}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<PopoverPanel
|
||||
anchor={{ to: "bottom start", gap: 8, padding: 8 }}
|
||||
data-testid="chatgpt-model-options"
|
||||
className="z-50 flex w-[var(--button-width)] max-w-[calc(100vw-1rem)] flex-col overflow-hidden rounded-2xl border border-neutral-100 bg-white text-[15px] text-neutral-800 shadow-xl shadow-black/5 [--anchor-max-height:19rem] dark:border-neutral-600/40 dark:bg-neutral-800 dark:text-white"
|
||||
>
|
||||
<ModelOptions
|
||||
models={models}
|
||||
selected={selected}
|
||||
maxModels={maxModels}
|
||||
onToggle={toggleModel}
|
||||
/>
|
||||
</PopoverPanel>
|
||||
</Popover>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<p
|
||||
role="alert"
|
||||
className="mt-3 w-full max-w-xl text-xs leading-5 text-red-600 dark:text-red-400"
|
||||
>
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
{warning && (
|
||||
<p
|
||||
role="status"
|
||||
className="mt-3 w-full max-w-xl text-xs leading-5 text-zinc-500 dark:text-zinc-400"
|
||||
>
|
||||
{warning}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,543 @@
|
||||
import { CodexConnectedIntro } from "./CodexConnectedIntro";
|
||||
import type { IntegrationStatus } from "@/api";
|
||||
import { INTEGRATION_ICONS } from "@/lib/launchCommands";
|
||||
import type {
|
||||
CodexDesktopActionResult,
|
||||
CodexDesktopStatus,
|
||||
} from "@/types/webview";
|
||||
import { ArrowPathIcon, CommandLineIcon } from "@heroicons/react/24/outline";
|
||||
import {
|
||||
useMutation,
|
||||
useMutationState,
|
||||
useQueryClient,
|
||||
} from "@tanstack/react-query";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
|
||||
export const CODEX_DESKTOP_INSTALL_TIMEOUT_MS = 120_000;
|
||||
const acknowledgmentKey = ["codex-desktop-acknowledgment"];
|
||||
|
||||
const connectionProgress = {
|
||||
idle: null,
|
||||
installing: {
|
||||
label: "Downloading…",
|
||||
description: "Ollama is downloading the ChatGPT installer…",
|
||||
},
|
||||
"waiting-for-install": {
|
||||
label: "Finish installing…",
|
||||
description:
|
||||
"Finish installing ChatGPT. Ollama will connect it automatically.",
|
||||
},
|
||||
connecting: {
|
||||
label: "Connecting…",
|
||||
description: "Connecting ChatGPT to Ollama…",
|
||||
},
|
||||
saving: {
|
||||
label: "Saving…",
|
||||
description: "Saving your progress…",
|
||||
},
|
||||
disconnecting: {
|
||||
label: "Disconnecting…",
|
||||
description: "Restoring ChatGPT’s usual connection…",
|
||||
},
|
||||
} as const;
|
||||
|
||||
type CodexConnectPhase = keyof typeof connectionProgress;
|
||||
|
||||
interface CodexDesktopRowProps {
|
||||
integration: IntegrationStatus;
|
||||
initialStatus?: CodexDesktopStatus;
|
||||
}
|
||||
|
||||
function CodexIcon({ integration }: { integration: IntegrationStatus }) {
|
||||
const icon = INTEGRATION_ICONS[integration.id];
|
||||
return (
|
||||
<div className="flex h-10 w-10 shrink-0 items-center justify-center overflow-hidden rounded-xl bg-transparent">
|
||||
{icon ? (
|
||||
<>
|
||||
<img
|
||||
src={icon.src}
|
||||
alt=""
|
||||
className={`${icon.className ?? "h-7 w-7"} rounded-sm object-contain ${icon.darkSrc ? "dark:hidden" : ""}`}
|
||||
/>
|
||||
{icon.darkSrc && (
|
||||
<img
|
||||
src={icon.darkSrc}
|
||||
alt=""
|
||||
className={`${icon.className ?? "h-7 w-7"} hidden rounded-sm object-contain dark:block`}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<CommandLineIcon className="h-6 w-6 stroke-[1.5] text-neutral-700 dark:text-neutral-300" />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function codexDesktopDescription(
|
||||
status: CodexDesktopStatus | null,
|
||||
defaultDescription: string,
|
||||
): string {
|
||||
if (!status?.connected) return defaultDescription;
|
||||
const requestCount = status.requests ?? 0;
|
||||
return `Connected to Ollama · ${requestCount} ${requestCount === 1 ? "request" : "requests"} this session`;
|
||||
}
|
||||
|
||||
export function CodexDesktopRow({
|
||||
integration,
|
||||
initialStatus,
|
||||
}: CodexDesktopRowProps) {
|
||||
const [status, setStatus] = useState<CodexDesktopStatus | null>(
|
||||
initialStatus ?? null,
|
||||
);
|
||||
const [phase, setPhase] = useState<CodexConnectPhase>("idle");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [notice, setNotice] = useState<string | null>(null);
|
||||
const [showIntro, setShowIntro] = useState(false);
|
||||
const introRestartConfirmed = useRef(false);
|
||||
const used = useRef(false);
|
||||
const mounted = useRef(true);
|
||||
const operationInFlight = useRef(false);
|
||||
const statusRequest = useRef(0);
|
||||
const queryClient = useQueryClient();
|
||||
const acknowledgment = useMutation({
|
||||
mutationKey: acknowledgmentKey,
|
||||
// Keep the save and its retry available across Apps page navigation.
|
||||
gcTime: Infinity,
|
||||
retry: false,
|
||||
networkMode: "always",
|
||||
mutationFn: async () => {
|
||||
if (!window.markCodexDesktopIntegrationUsed)
|
||||
throw new Error("Acknowledgment is unavailable");
|
||||
const saveError = await window.markCodexDesktopIntegrationUsed();
|
||||
if (saveError) throw new Error(saveError);
|
||||
},
|
||||
});
|
||||
const acknowledgmentStates = useMutationState({
|
||||
filters: { mutationKey: acknowledgmentKey, exact: true },
|
||||
select: (mutation) => mutation.state.status,
|
||||
});
|
||||
const acknowledgmentStatus =
|
||||
acknowledgmentStates[acknowledgmentStates.length - 1];
|
||||
const savingAcknowledgment = acknowledgmentStates.includes("pending");
|
||||
const acknowledgmentFailed =
|
||||
acknowledgmentStates.includes("error") &&
|
||||
acknowledgmentStatus !== "success" &&
|
||||
!status?.used &&
|
||||
!used.current;
|
||||
|
||||
const beginOperation = useCallback(
|
||||
(nextPhase: CodexConnectPhase) => {
|
||||
if (
|
||||
!mounted.current ||
|
||||
operationInFlight.current ||
|
||||
queryClient.isMutating({ mutationKey: acknowledgmentKey })
|
||||
)
|
||||
return false;
|
||||
operationInFlight.current = true;
|
||||
++statusRequest.current;
|
||||
setPhase(nextPhase);
|
||||
setError(null);
|
||||
setNotice(null);
|
||||
return true;
|
||||
},
|
||||
[queryClient],
|
||||
);
|
||||
|
||||
const finishOperation = useCallback(
|
||||
(nextPhase: CodexConnectPhase = "idle") => {
|
||||
operationInFlight.current = false;
|
||||
if (mounted.current) setPhase(nextPhase);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
mounted.current = true;
|
||||
return () => {
|
||||
mounted.current = false;
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (acknowledgmentStatus !== "success") return;
|
||||
used.current = true;
|
||||
setStatus((current) =>
|
||||
current && !current.used ? { ...current, used: true } : current,
|
||||
);
|
||||
}, [acknowledgmentStatus]);
|
||||
|
||||
const refreshStatus = useCallback(async () => {
|
||||
if (operationInFlight.current || !window.getCodexDesktopStatus) return;
|
||||
const request = ++statusRequest.current;
|
||||
const isCurrent = () =>
|
||||
mounted.current &&
|
||||
request === statusRequest.current &&
|
||||
!operationInFlight.current;
|
||||
try {
|
||||
const next = await window.getCodexDesktopStatus();
|
||||
if (!isCurrent()) return;
|
||||
setStatus(next);
|
||||
if (next.used) {
|
||||
used.current = true;
|
||||
}
|
||||
setError(null);
|
||||
setNotice(null);
|
||||
} catch {
|
||||
if (isCurrent())
|
||||
setError("Ollama could not read the ChatGPT connection status.");
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!initialStatus) void refreshStatus();
|
||||
const onFocus = () => void refreshStatus();
|
||||
window.addEventListener("focus", onFocus);
|
||||
return () => window.removeEventListener("focus", onFocus);
|
||||
}, [initialStatus, refreshStatus]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!status?.connected || !window.getCodexDesktopRequestCount) return;
|
||||
|
||||
let active = true;
|
||||
let checking = false;
|
||||
const refreshRequestCount = async () => {
|
||||
if (!active || checking || document.visibilityState === "hidden") return;
|
||||
checking = true;
|
||||
try {
|
||||
const requests = await window.getCodexDesktopRequestCount?.();
|
||||
if (!active || requests === undefined) return;
|
||||
setStatus((current) => {
|
||||
if (!current || current.requests === requests) return current;
|
||||
return { ...current, requests };
|
||||
});
|
||||
} catch {
|
||||
// The next interval or window-focus refresh can recover the count.
|
||||
} finally {
|
||||
checking = false;
|
||||
}
|
||||
};
|
||||
|
||||
void refreshRequestCount();
|
||||
const interval = window.setInterval(refreshRequestCount, 1000);
|
||||
return () => {
|
||||
active = false;
|
||||
window.clearInterval(interval);
|
||||
};
|
||||
}, [status?.connected]);
|
||||
|
||||
useEffect(() => {
|
||||
if (phase !== "waiting-for-install") return;
|
||||
|
||||
let active = true;
|
||||
let checking = false;
|
||||
let completing = false;
|
||||
const checkForInstall = async () => {
|
||||
if (
|
||||
!active ||
|
||||
checking ||
|
||||
completing ||
|
||||
operationInFlight.current ||
|
||||
!window.getCodexDesktopStatus ||
|
||||
!window.setCodexDesktopConnected
|
||||
) {
|
||||
return;
|
||||
}
|
||||
checking = true;
|
||||
try {
|
||||
const next = await window.getCodexDesktopStatus();
|
||||
if (!active || !mounted.current) return;
|
||||
setStatus(next);
|
||||
if (!next.installed) return;
|
||||
if (!beginOperation("connecting")) return;
|
||||
completing = true;
|
||||
|
||||
if (next.running) {
|
||||
setError(
|
||||
"ChatGPT is installed. Turn on the switch to restart it with Ollama models.",
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!next.used && !used.current) {
|
||||
introRestartConfirmed.current = false;
|
||||
setShowIntro(true);
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await window.setCodexDesktopConnected(true, false);
|
||||
if (!mounted.current) return;
|
||||
setStatus(result.status);
|
||||
if (result.restartConfirmationRequired) {
|
||||
setError(
|
||||
"ChatGPT is installed. Turn on the switch to restart it with Ollama models.",
|
||||
);
|
||||
} else if (result.error || !result.status.connected) {
|
||||
setError(
|
||||
result.error || "Ollama could not add its models to ChatGPT.",
|
||||
);
|
||||
} else {
|
||||
setNotice("Ollama models added alongside Codex models");
|
||||
}
|
||||
} catch {
|
||||
if (!mounted.current || (!active && !completing)) return;
|
||||
setPhase("idle");
|
||||
setError("Ollama could not finish connecting ChatGPT.");
|
||||
} finally {
|
||||
checking = false;
|
||||
if (completing) finishOperation();
|
||||
}
|
||||
};
|
||||
|
||||
void checkForInstall();
|
||||
const interval = window.setInterval(checkForInstall, 1000);
|
||||
const timeout = window.setTimeout(() => {
|
||||
if (!active || completing) return;
|
||||
active = false;
|
||||
setPhase("idle");
|
||||
setError("ChatGPT installation wasn’t detected. Try again.");
|
||||
}, CODEX_DESKTOP_INSTALL_TIMEOUT_MS);
|
||||
return () => {
|
||||
active = false;
|
||||
window.clearInterval(interval);
|
||||
window.clearTimeout(timeout);
|
||||
};
|
||||
}, [phase, beginOperation, finishOperation]);
|
||||
|
||||
const connected = status?.connected ?? false;
|
||||
const installed = status?.installed ?? integration.installed ?? false;
|
||||
const pending = phase !== "idle" || savingAcknowledgment;
|
||||
const displayedConnected =
|
||||
phase === "disconnecting"
|
||||
? false
|
||||
: connected ||
|
||||
showIntro ||
|
||||
phase === "installing" ||
|
||||
phase === "waiting-for-install" ||
|
||||
phase === "connecting";
|
||||
const progress = connectionProgress[savingAcknowledgment ? "saving" : phase];
|
||||
const statusLabel =
|
||||
progress?.label ?? (!connected && !installed ? "Download & connect" : null);
|
||||
const actionError =
|
||||
error ??
|
||||
(acknowledgmentFailed
|
||||
? "Ollama couldn’t save your progress. Please try again."
|
||||
: null);
|
||||
const description =
|
||||
actionError ??
|
||||
notice ??
|
||||
progress?.description ??
|
||||
codexDesktopDescription(status, integration.description);
|
||||
|
||||
const saveAcknowledgment = async (): Promise<boolean> => {
|
||||
if (queryClient.isMutating({ mutationKey: acknowledgmentKey }))
|
||||
return false;
|
||||
try {
|
||||
await acknowledgment.mutateAsync();
|
||||
used.current = true;
|
||||
if (mounted.current) {
|
||||
setStatus((current) =>
|
||||
current ? { ...current, used: true } : current,
|
||||
);
|
||||
}
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
const retryAcknowledgment = async () => {
|
||||
if (pending || !acknowledgmentFailed || !beginOperation("saving")) return;
|
||||
try {
|
||||
await saveAcknowledgment();
|
||||
} finally {
|
||||
finishOperation();
|
||||
if (mounted.current) void refreshStatus();
|
||||
}
|
||||
};
|
||||
|
||||
const toggleConnection = async (fromIntro = false) => {
|
||||
const enabled = fromIntro || !connected;
|
||||
const nextPhase = enabled
|
||||
? installed
|
||||
? "connecting"
|
||||
: "installing"
|
||||
: "disconnecting";
|
||||
if (pending || (showIntro && !fromIntro) || !beginOperation(nextPhase))
|
||||
return;
|
||||
let finalPhase: CodexConnectPhase = "idle";
|
||||
let restartConfirmed = fromIntro && introRestartConfirmed.current;
|
||||
if (fromIntro) {
|
||||
setShowIntro(false);
|
||||
introRestartConfirmed.current = false;
|
||||
}
|
||||
try {
|
||||
if (!window.setCodexDesktopConnected) {
|
||||
setError("The ChatGPT integration is unavailable.");
|
||||
return;
|
||||
}
|
||||
if (enabled && !installed) {
|
||||
if (!window.installCodexDesktop || !window.getCodexDesktopStatus) {
|
||||
setError("Ollama could not install ChatGPT.");
|
||||
return;
|
||||
}
|
||||
const installResult = await window.installCodexDesktop();
|
||||
if (!mounted.current) return;
|
||||
if (installResult === "opened") finalPhase = "waiting-for-install";
|
||||
else if (installResult !== "cancelled")
|
||||
setError("Ollama could not install ChatGPT.");
|
||||
return;
|
||||
}
|
||||
if (fromIntro || (enabled && !status?.used && !used.current)) {
|
||||
if (!window.getCodexDesktopStatus) {
|
||||
setError("Ollama could not read the ChatGPT connection status.");
|
||||
return;
|
||||
}
|
||||
const liveStatus = await window.getCodexDesktopStatus();
|
||||
if (!mounted.current) return;
|
||||
setStatus(liveStatus);
|
||||
if (liveStatus.running && !restartConfirmed) {
|
||||
restartConfirmed = window.confirm(
|
||||
"Restart ChatGPT to add Ollama models? Any running task will stop.",
|
||||
);
|
||||
if (!restartConfirmed) return;
|
||||
}
|
||||
|
||||
if (!fromIntro && !liveStatus.used && !used.current) {
|
||||
introRestartConfirmed.current = restartConfirmed;
|
||||
setShowIntro(true);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
let result: CodexDesktopActionResult =
|
||||
await window.setCodexDesktopConnected(enabled, restartConfirmed);
|
||||
|
||||
setStatus(result.status);
|
||||
if (result.restartConfirmationRequired) {
|
||||
if (!mounted.current) return;
|
||||
// Keep focus-driven status refreshes from discarding this operation
|
||||
// while the native confirmation dialog temporarily owns focus.
|
||||
if (
|
||||
!window.confirm(
|
||||
enabled
|
||||
? "Restart ChatGPT to add Ollama models? Any running task will stop."
|
||||
: "Restart ChatGPT to remove Ollama models? Any running task will stop.",
|
||||
)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
result = await window.setCodexDesktopConnected(enabled, true);
|
||||
setStatus(result.status);
|
||||
}
|
||||
|
||||
if (result.restartConfirmationRequired) return;
|
||||
if (result.error) {
|
||||
setError(result.error);
|
||||
return;
|
||||
}
|
||||
if (result.status.connected !== enabled) {
|
||||
setError(
|
||||
enabled
|
||||
? "Ollama could not add its models to ChatGPT."
|
||||
: "Ollama could not remove its models from ChatGPT.",
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (fromIntro) {
|
||||
setPhase("saving");
|
||||
if (!(await saveAcknowledgment())) return;
|
||||
}
|
||||
if (enabled) {
|
||||
setNotice("Ollama models added alongside Codex models");
|
||||
} else {
|
||||
setNotice("Ollama models removed · Codex models remain available");
|
||||
}
|
||||
} catch {
|
||||
setError(
|
||||
nextPhase === "installing"
|
||||
? "Ollama could not install ChatGPT."
|
||||
: enabled
|
||||
? "Ollama could not add its models to ChatGPT."
|
||||
: "Ollama could not remove its models from ChatGPT.",
|
||||
);
|
||||
} finally {
|
||||
finishOperation(finalPhase);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex min-h-18 items-center justify-between gap-4 bg-white px-4 py-3 dark:bg-neutral-900">
|
||||
<div className="flex min-w-0 items-center gap-3">
|
||||
<CodexIcon integration={integration} />
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm font-medium text-neutral-950 dark:text-neutral-100">
|
||||
ChatGPT (Desktop)
|
||||
</p>
|
||||
<p
|
||||
role={actionError ? "alert" : notice ? "status" : undefined}
|
||||
className="truncate text-xs leading-5 text-neutral-500 dark:text-neutral-400"
|
||||
>
|
||||
{description}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="ml-auto flex shrink-0 items-center gap-2.5">
|
||||
{acknowledgmentFailed && (
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Retry saving progress"
|
||||
disabled={pending}
|
||||
onClick={() => void retryAcknowledgment()}
|
||||
className="text-xs font-medium text-neutral-700 hover:underline disabled:cursor-wait disabled:opacity-50 dark:text-neutral-300"
|
||||
>
|
||||
Retry
|
||||
</button>
|
||||
)}
|
||||
{statusLabel && (
|
||||
<span
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
className="inline-flex items-center gap-1.5 whitespace-nowrap text-xs text-neutral-500 dark:text-neutral-400"
|
||||
>
|
||||
{pending && <ArrowPathIcon className="h-3.5 w-3.5 animate-spin" />}
|
||||
{statusLabel}
|
||||
</span>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
role="switch"
|
||||
aria-checked={displayedConnected}
|
||||
aria-busy={pending || undefined}
|
||||
aria-label={
|
||||
showIntro
|
||||
? "Finish connecting ChatGPT"
|
||||
: connected
|
||||
? "Remove Ollama models from ChatGPT"
|
||||
: pending
|
||||
? "Connecting ChatGPT"
|
||||
: "Add Ollama models to ChatGPT"
|
||||
}
|
||||
title={
|
||||
connected
|
||||
? "Remove Ollama models"
|
||||
: installed
|
||||
? "Add Ollama models"
|
||||
: "Install ChatGPT and add Ollama models"
|
||||
}
|
||||
disabled={pending || showIntro}
|
||||
onClick={() => void toggleConnection()}
|
||||
className={`relative inline-flex h-5 w-9 shrink-0 items-center rounded-full transition-colors focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-neutral-500 disabled:cursor-wait disabled:opacity-50 ${displayedConnected ? "bg-neutral-950 dark:bg-white" : "bg-neutral-300 dark:bg-neutral-700"}`}
|
||||
>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className={`inline-block h-4 w-4 rounded-full bg-white shadow-sm transition-transform ${pending ? "animate-pulse" : ""} ${displayedConnected ? "translate-x-4.5 dark:bg-neutral-900" : "translate-x-0.5"}`}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
{showIntro && (
|
||||
<CodexConnectedIntro onDone={() => void toggleConnection(true)} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -18,7 +18,7 @@ export default function Logo({
|
||||
viewBox="0 0 3400 3400"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className="select-none"
|
||||
className={showBackground ? "select-none" : "select-none dark:invert"}
|
||||
>
|
||||
{showBackground && (
|
||||
<circle cx="1700" cy="1700" r="1700" fill="white" />
|
||||
|
||||
@@ -273,7 +273,7 @@ function ToolRoleContent({
|
||||
);
|
||||
}
|
||||
return (
|
||||
// collapsable tool result with raw json
|
||||
// collapsible tool result with raw json
|
||||
<div className="space-y-2">
|
||||
{content && !rawToolResult && (
|
||||
<pre className="text-xs whitespace-pre-wrap overflow-x-auto bg-neutral-100 dark:bg-neutral-800 text-neutral-800 dark:text-neutral-200 p-2 rounded-md max-h-40">
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import { QueryClient } from "@tanstack/react-query";
|
||||
import { act, create, type ReactTestRenderer } from "react-test-renderer";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
ClaudeConnectedIntro,
|
||||
FIRST_MODEL_COMMAND,
|
||||
@@ -25,6 +26,24 @@ import {
|
||||
} from "@/lib/onboarding";
|
||||
import type { IntegrationStatuses } from "@/api";
|
||||
|
||||
let queryClient: QueryClient;
|
||||
beforeEach(() => {
|
||||
queryClient = new QueryClient();
|
||||
});
|
||||
afterEach(() => queryClient.clear());
|
||||
|
||||
vi.mock("@tanstack/react-query", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("@tanstack/react-query")>();
|
||||
return Object.assign({}, actual, {
|
||||
useQueryClient: () => queryClient,
|
||||
useMutation: (options: Parameters<typeof actual.useMutation>[0]) =>
|
||||
actual.useMutation(options, queryClient),
|
||||
useMutationState: (
|
||||
options: Parameters<typeof actual.useMutationState>[0],
|
||||
) => actual.useMutationState(options, queryClient),
|
||||
});
|
||||
});
|
||||
|
||||
describe("Onboarding", () => {
|
||||
it("explains what Ollama is before asking the user to choose a path", () => {
|
||||
const html = renderToStaticMarkup(<IntroScreen onContinue={vi.fn()} />);
|
||||
@@ -33,6 +52,8 @@ describe("Onboarding", () => {
|
||||
expect(html.indexOf('alt="Ollama waving"')).toBeLessThan(
|
||||
html.indexOf("Welcome to Ollama!"),
|
||||
);
|
||||
expect(html).toMatch(/<main class="light-only [^"]*bg-white/);
|
||||
expect(html).not.toMatch(/alt="Ollama waving" class="[^"]*dark:/);
|
||||
expect(html).toContain(
|
||||
"Run open models with your coding agents so you can spend less while keeping your data private.",
|
||||
);
|
||||
@@ -60,7 +81,7 @@ describe("Onboarding", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("hides the Claude application on Windows", () => {
|
||||
it("hides the Claude and ChatGPT desktop integrations on Windows", () => {
|
||||
vi.stubGlobal("window", {
|
||||
OLLAMA_PLATFORM: "windows",
|
||||
innerHeight: 660,
|
||||
@@ -83,12 +104,21 @@ describe("Onboarding", () => {
|
||||
description: "Anthropic's coding tool with subagents",
|
||||
command: "ollama launch claude",
|
||||
},
|
||||
{
|
||||
id: "chatgpt",
|
||||
name: "ChatGPT",
|
||||
description: "Use Ollama models in ChatGPT",
|
||||
installed: true,
|
||||
command: "ollama launch chatgpt",
|
||||
},
|
||||
]}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(html).not.toContain('id="desktop-heading"');
|
||||
expect(html).not.toContain("Use Ollama models in Claude Desktop");
|
||||
expect(html).not.toContain("Use Ollama models in ChatGPT");
|
||||
expect(html).not.toContain("ollama launch chatgpt");
|
||||
expect(html).toContain('id="terminal-heading"');
|
||||
expect(html).toContain("ollama launch claude");
|
||||
} finally {
|
||||
@@ -212,7 +242,10 @@ describe("Onboarding", () => {
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
const claudeSwitch = () => renderer!.root.findByProps({ role: "switch" });
|
||||
const claudeSwitch = () =>
|
||||
renderer!.root
|
||||
.findAllByProps({ role: "switch" })
|
||||
.find((node) => String(node.props["aria-label"]).endsWith("Claude"))!;
|
||||
let clickResult!: Promise<void>;
|
||||
await act(async () => {
|
||||
clickResult = claudeSwitch().props.onClick();
|
||||
@@ -224,9 +257,11 @@ describe("Onboarding", () => {
|
||||
expect(claudeSwitch().props["aria-busy"]).toBe(true);
|
||||
expect(claudeSwitch().props.disabled).toBe(true);
|
||||
expect(claudeSwitch().props.className).toContain("disabled:opacity-50");
|
||||
expect(renderer.root.findByProps({ role: "status" }).children).toContain(
|
||||
"Downloading…",
|
||||
);
|
||||
expect(
|
||||
renderer.root
|
||||
.findAllByProps({ role: "status" })
|
||||
.some((node) => node.children.includes("Downloading…")),
|
||||
).toBe(true);
|
||||
expect(
|
||||
renderer.root.findAll(
|
||||
(node) =>
|
||||
@@ -245,9 +280,11 @@ describe("Onboarding", () => {
|
||||
expect(claudeSwitch().props["aria-busy"]).toBe(true);
|
||||
expect(claudeSwitch().props.disabled).toBe(true);
|
||||
expect(claudeSwitch().props.className).toContain("disabled:opacity-50");
|
||||
expect(renderer.root.findByProps({ role: "status" }).children).toContain(
|
||||
"Finish installing…",
|
||||
);
|
||||
expect(
|
||||
renderer.root
|
||||
.findAllByProps({ role: "status" })
|
||||
.some((node) => node.children.includes("Finish installing…")),
|
||||
).toBe(true);
|
||||
expect(
|
||||
renderer.root.findAll(
|
||||
(node) =>
|
||||
@@ -331,7 +368,10 @@ describe("Onboarding", () => {
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
const claudeSwitch = () => renderer!.root.findByProps({ role: "switch" });
|
||||
const claudeSwitch = () =>
|
||||
renderer!.root
|
||||
.findAllByProps({ role: "switch" })
|
||||
.find((node) => String(node.props["aria-label"]).endsWith("Claude"))!;
|
||||
expect(claudeSwitch().props["aria-checked"]).toBe(false);
|
||||
expect(claudeSwitch().props["aria-busy"]).toBeUndefined();
|
||||
expect(claudeSwitch().props.disabled).toBe(false);
|
||||
@@ -468,7 +508,7 @@ describe("Onboarding", () => {
|
||||
const integrations: IntegrationStatuses = [
|
||||
{
|
||||
id: "claude-desktop",
|
||||
name: "Claude",
|
||||
name: "Claude Code (Desktop)",
|
||||
description: "Use Ollama models in Claude Desktop",
|
||||
installed: true,
|
||||
action: "connect",
|
||||
@@ -483,7 +523,7 @@ describe("Onboarding", () => {
|
||||
},
|
||||
{
|
||||
id: "codex",
|
||||
name: "Codex",
|
||||
name: "Codex CLI",
|
||||
description: "OpenAI's open-source coding agent",
|
||||
installed: true,
|
||||
action: "copy",
|
||||
@@ -513,6 +553,22 @@ describe("Onboarding", () => {
|
||||
action: "copy",
|
||||
command: "ollama launch droid",
|
||||
},
|
||||
{
|
||||
id: "dsh",
|
||||
name: "DeepSeek Harness",
|
||||
description: "DeepSeek's open-source agent harness",
|
||||
installed: false,
|
||||
action: "copy",
|
||||
command: "ollama launch dsh",
|
||||
},
|
||||
{
|
||||
id: "cline",
|
||||
name: "Cline",
|
||||
description: "Autonomous coding agent",
|
||||
installed: false,
|
||||
action: "copy",
|
||||
command: "ollama launch cline",
|
||||
},
|
||||
{
|
||||
id: "terminal",
|
||||
name: "Terminal",
|
||||
@@ -532,9 +588,10 @@ describe("Onboarding", () => {
|
||||
expect(html).not.toContain(
|
||||
"Connect Claude, or copy a command to run in your terminal.",
|
||||
);
|
||||
expect(html).toContain("Claude");
|
||||
expect(html).toContain("Claude Code (Desktop)");
|
||||
expect(html).toContain("Use Ollama models in Claude Desktop");
|
||||
expect(html).toContain("Claude Code");
|
||||
expect(html).toContain("Codex CLI");
|
||||
expect(html).not.toContain("Search apps");
|
||||
expect(html).not.toContain('type="search"');
|
||||
expect(html).toContain("Desktop");
|
||||
@@ -548,18 +605,18 @@ describe("Onboarding", () => {
|
||||
expect(html).not.toContain(">Command</th>");
|
||||
expect(html).toContain("ollama launch claude");
|
||||
expect(html).not.toContain("Installed");
|
||||
expect(html).not.toContain("Not installed");
|
||||
expect(html).toContain("Use Ollama models in ChatGPT");
|
||||
expect(html).toContain('aria-label="Connect Claude"');
|
||||
expect(html).toContain('role="switch"');
|
||||
expect(html).toContain('aria-checked="false"');
|
||||
expect(html).not.toContain("Inactive");
|
||||
expect(html).not.toContain("Download & connect");
|
||||
expect(html).toContain("Download & connect");
|
||||
expect(html).not.toContain("Active");
|
||||
expect(html).toContain("bg-transparent");
|
||||
expect(html).toContain('aria-label="Copy OpenCode command"');
|
||||
expect(html).toContain('aria-label="Copy Terminal command"');
|
||||
expect(html).not.toContain(">Copy command</button>");
|
||||
expect(html).not.toContain("ChatGPT");
|
||||
expect(html).toContain("ChatGPT (Desktop)");
|
||||
expect(html).toContain("OpenCode");
|
||||
expect(html).toContain("Terminal");
|
||||
expect(html).toContain("overflow-y-auto");
|
||||
@@ -569,6 +626,14 @@ describe("Onboarding", () => {
|
||||
expect(html).not.toContain("inert");
|
||||
expect(html).toContain("/launch-icons/claude.svg");
|
||||
expect(html).toContain("/launch-icons/claude-code.svg");
|
||||
expect(html).toContain("/launch-icons/codex-color.svg");
|
||||
expect(html).toMatch(
|
||||
/src="\/launch-icons\/cline\.svg"[^>]*class="[^"]*dark:invert/,
|
||||
);
|
||||
expect(html).toContain("/launch-icons/deepseek-harness.svg");
|
||||
expect(html).not.toMatch(
|
||||
/src="\/launch-icons\/deepseek-harness\.svg"[^>]*class="[^"]*dark:invert/,
|
||||
);
|
||||
expect(html).not.toContain("<table");
|
||||
expect(html).not.toContain("<footer");
|
||||
expect(html).not.toContain("Command copied. Run it in your terminal.");
|
||||
@@ -580,6 +645,40 @@ describe("Onboarding", () => {
|
||||
expect(html).not.toContain('viewBox="0 0 3400 3400"');
|
||||
});
|
||||
|
||||
it("places ChatGPT directly below Claude instead of in Terminal", () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<ConnectAppsScreen
|
||||
initialIntegrations={[
|
||||
{
|
||||
id: "claude-desktop",
|
||||
name: "Claude",
|
||||
description: "Use Ollama models in Claude Desktop",
|
||||
installed: true,
|
||||
},
|
||||
{
|
||||
id: "codex",
|
||||
name: "Codex CLI",
|
||||
description: "OpenAI's coding agent",
|
||||
command: "ollama launch codex",
|
||||
},
|
||||
]}
|
||||
initialCodexStatus={{
|
||||
supported: true,
|
||||
installed: true,
|
||||
connected: false,
|
||||
running: false,
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(html.indexOf("Use Ollama models in Claude Desktop")).toBeLessThan(
|
||||
html.indexOf(">ChatGPT (Desktop)</p>"),
|
||||
);
|
||||
expect(html).toContain('aria-label="Add Ollama models to ChatGPT"');
|
||||
expect(html).not.toContain('aria-label="Copy ChatGPT command"');
|
||||
expect(html).toContain('aria-label="Copy Codex CLI command"');
|
||||
});
|
||||
|
||||
it("keeps connected Claude in Desktop without an idle status", () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<ConnectAppsScreen
|
||||
@@ -605,7 +704,7 @@ describe("Onboarding", () => {
|
||||
},
|
||||
{
|
||||
id: "codex",
|
||||
name: "Codex",
|
||||
name: "Codex CLI",
|
||||
description: "OpenAI's open-source coding agent",
|
||||
installed: true,
|
||||
action: "copy",
|
||||
@@ -732,8 +831,11 @@ describe("Onboarding", () => {
|
||||
expect(html).toContain('aria-label="Connect Claude"');
|
||||
expect(html).toContain("Download & connect");
|
||||
expect(html).not.toContain("Inactive");
|
||||
expect(html).not.toContain("Not installed");
|
||||
expect(html).not.toContain('disabled=""');
|
||||
const claudeButton = html.match(
|
||||
/<button[^>]*aria-label="Connect Claude"[^>]*>/,
|
||||
)?.[0];
|
||||
expect(claudeButton).toBeDefined();
|
||||
expect(claudeButton).not.toContain('disabled=""');
|
||||
});
|
||||
|
||||
it("uses branded icons for the remaining launcher integrations", () => {
|
||||
@@ -793,6 +895,7 @@ describe("Onboarding", () => {
|
||||
);
|
||||
|
||||
expect(html).toContain("Create an account");
|
||||
expect(html).toMatch(/<main class="light-only [^"]*bg-white/);
|
||||
expect(html).toContain(
|
||||
"Create your account for access to faster, larger open models.",
|
||||
);
|
||||
@@ -829,6 +932,7 @@ describe("Onboarding", () => {
|
||||
);
|
||||
|
||||
expect(html).toContain("Run Ollama");
|
||||
expect(html).toMatch(/<main class="light-only [^"]*bg-white/);
|
||||
expect(html).toContain(FIRST_MODEL_COMMAND);
|
||||
expect(html).not.toContain("Finish");
|
||||
expect(html).not.toContain("Sign in");
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import CopyButton from "@/components/CopyButton";
|
||||
import { CodexDesktopRow } from "@/components/CodexDesktopRow";
|
||||
import Logo from "@/components/Logo";
|
||||
import { nextOnboardingStep, type OnboardingStep } from "@/lib/onboarding";
|
||||
import {
|
||||
@@ -22,6 +23,7 @@ import { isWindowsPlatform } from "@/lib/platform";
|
||||
import type {
|
||||
ClaudeDesktopActionResult,
|
||||
ClaudeDesktopStatus,
|
||||
CodexDesktopStatus,
|
||||
} from "@/types/webview";
|
||||
import { copyTextToClipboard } from "@/utils/clipboard";
|
||||
import {
|
||||
@@ -96,6 +98,7 @@ interface RunOllamaScreenProps {
|
||||
interface ConnectAppsScreenProps {
|
||||
initialIntegrations?: IntegrationStatuses;
|
||||
initialClaudeStatus?: ClaudeDesktopStatus;
|
||||
initialCodexStatus?: CodexDesktopStatus;
|
||||
}
|
||||
|
||||
function TitleBar({ onSignIn }: { onSignIn?: () => void }) {
|
||||
@@ -173,7 +176,7 @@ export function IntroScreen({
|
||||
onRetryCompletion?: () => void;
|
||||
}) {
|
||||
return (
|
||||
<main className="flex h-screen w-full flex-col overflow-hidden bg-white text-neutral-950">
|
||||
<main className="light-only flex h-screen w-full flex-col overflow-hidden bg-white text-neutral-950">
|
||||
<TitleBar />
|
||||
|
||||
<section className="flex min-h-0 flex-1 items-center justify-center overflow-y-auto px-6 pb-10">
|
||||
@@ -263,7 +266,7 @@ export function WelcomeScreen({
|
||||
onRetryCompletion,
|
||||
}: WelcomeScreenProps) {
|
||||
return (
|
||||
<main className="flex min-h-screen w-full flex-col bg-white text-neutral-950">
|
||||
<main className="light-only flex min-h-screen w-full flex-col bg-white text-neutral-950">
|
||||
<TitleBar onSignIn={isAuthenticated ? undefined : onSignIn} />
|
||||
<OnboardingCard>
|
||||
<OnboardingIcon />
|
||||
@@ -315,7 +318,7 @@ export function RunOllamaScreen({
|
||||
onRetryCompletion,
|
||||
}: RunOllamaScreenProps) {
|
||||
return (
|
||||
<main className="flex min-h-screen w-full flex-col bg-white text-neutral-950">
|
||||
<main className="light-only flex min-h-screen w-full flex-col bg-white text-neutral-950">
|
||||
<TitleBar />
|
||||
<OnboardingCard>
|
||||
<OnboardingIcon compact />
|
||||
@@ -331,7 +334,7 @@ export function RunOllamaScreen({
|
||||
content={FIRST_MODEL_COMMAND}
|
||||
size="md"
|
||||
title="Copy command to clipboard"
|
||||
className="shrink-0 text-neutral-400 hover:!bg-transparent hover:!text-neutral-400 dark:hover:!bg-transparent"
|
||||
className="shrink-0 text-neutral-400 hover:!bg-transparent hover:!text-neutral-400"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -360,13 +363,22 @@ function LaunchCommandIcon({ item }: { item: IntegrationStatus }) {
|
||||
return (
|
||||
<div className="flex h-10 w-10 shrink-0 items-center justify-center overflow-hidden rounded-xl bg-transparent">
|
||||
{icon ? (
|
||||
<img
|
||||
src={icon.src}
|
||||
alt=""
|
||||
className={`${icon.className ?? "h-7 w-7"} rounded-sm object-contain`}
|
||||
/>
|
||||
<>
|
||||
<img
|
||||
src={icon.src}
|
||||
alt=""
|
||||
className={`${icon.className ?? "h-7 w-7"} rounded-sm object-contain ${icon.darkSrc ? "dark:hidden" : ""}`}
|
||||
/>
|
||||
{icon.darkSrc && (
|
||||
<img
|
||||
src={icon.darkSrc}
|
||||
alt=""
|
||||
className={`${icon.className ?? "h-7 w-7"} hidden rounded-sm object-contain dark:block`}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<CommandLineIcon className="h-6 w-6 stroke-[1.5] text-neutral-700" />
|
||||
<CommandLineIcon className="h-6 w-6 stroke-[1.5] text-neutral-700 dark:text-neutral-300" />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
@@ -374,13 +386,13 @@ function LaunchCommandIcon({ item }: { item: IntegrationStatus }) {
|
||||
|
||||
export function ClaudeConnectedIntro({ onDone }: { onDone: () => void }) {
|
||||
return (
|
||||
<div className="claude-connected-backdrop fixed inset-0 z-50 flex items-center justify-center bg-black/20 p-6">
|
||||
<div className="claude-connected-backdrop fixed inset-0 z-50 flex items-center justify-center bg-black/20 p-6 dark:bg-black/50">
|
||||
<section
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="claude-connected-title"
|
||||
aria-describedby="claude-connected-description"
|
||||
className="claude-connected-dialog relative w-full max-w-md overflow-hidden rounded-2xl bg-white font-sans shadow-2xl ring-1 ring-black/10"
|
||||
className="claude-connected-dialog relative w-full max-w-md overflow-hidden rounded-2xl bg-white font-sans shadow-2xl ring-1 ring-black/10 dark:bg-neutral-800 dark:ring-white/10"
|
||||
>
|
||||
<img
|
||||
src="/claude-connected.png"
|
||||
@@ -393,13 +405,13 @@ export function ClaudeConnectedIntro({ onDone }: { onDone: () => void }) {
|
||||
<div className="p-6">
|
||||
<h2
|
||||
id="claude-connected-title"
|
||||
className="font-rounded text-lg font-medium leading-6 text-neutral-950"
|
||||
className="font-rounded text-lg font-medium leading-6 text-neutral-950 dark:text-neutral-100"
|
||||
>
|
||||
Easily access Ollama models in your Claude
|
||||
</h2>
|
||||
<p
|
||||
id="claude-connected-description"
|
||||
className="mt-2 text-[13px] leading-5 text-neutral-500"
|
||||
className="mt-2 text-[13px] leading-5 text-neutral-500 dark:text-neutral-400"
|
||||
>
|
||||
Ollama models now show up in Claude so you can pick the right model
|
||||
for the task.
|
||||
@@ -408,7 +420,7 @@ export function ClaudeConnectedIntro({ onDone }: { onDone: () => void }) {
|
||||
<button
|
||||
type="button"
|
||||
autoFocus
|
||||
className="rounded-full bg-neutral-100 px-6 py-2 text-sm font-normal text-neutral-950 transition-colors hover:bg-neutral-200 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-neutral-500"
|
||||
className="rounded-full bg-neutral-100 px-6 py-2 text-sm font-normal text-neutral-950 transition-colors hover:bg-neutral-200 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-neutral-500 dark:bg-white dark:hover:bg-neutral-100"
|
||||
onClick={onDone}
|
||||
>
|
||||
Continue
|
||||
@@ -423,6 +435,7 @@ export function ClaudeConnectedIntro({ onDone }: { onDone: () => void }) {
|
||||
export function ConnectAppsScreen({
|
||||
initialIntegrations,
|
||||
initialClaudeStatus,
|
||||
initialCodexStatus,
|
||||
}: ConnectAppsScreenProps) {
|
||||
const isWindows = isWindowsPlatform();
|
||||
const [copiedCommand, setCopiedCommand] = useState<string | null>(null);
|
||||
@@ -879,9 +892,18 @@ export function ConnectAppsScreen({
|
||||
const claudeIntegration = isWindows
|
||||
? undefined
|
||||
: integrationStatuses?.find((item) => item.id === "claude-desktop");
|
||||
const codexIntegration = isWindows
|
||||
? undefined
|
||||
: (integrationStatuses?.find((item) => item.id === "chatgpt") ?? {
|
||||
id: "chatgpt",
|
||||
name: "ChatGPT (Desktop)",
|
||||
description: "Use Ollama models in ChatGPT",
|
||||
installed: false,
|
||||
});
|
||||
const launchIntegrations =
|
||||
integrationStatuses?.filter(
|
||||
(item) => item.id !== "claude-desktop" && item.command,
|
||||
(item) =>
|
||||
item.id !== "claude-desktop" && item.id !== "chatgpt" && item.command,
|
||||
) ?? [];
|
||||
const claudeConnected = claudeStatus?.connected ?? false;
|
||||
const claudeConfigured = claudeStatus
|
||||
@@ -931,19 +953,21 @@ export function ConnectAppsScreen({
|
||||
<div className="flex min-w-0 items-center gap-3">
|
||||
<LaunchCommandIcon item={item} />
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm font-medium text-neutral-950">{item.name}</p>
|
||||
<p className="truncate text-xs leading-5 text-neutral-500">
|
||||
<p className="text-sm font-medium text-neutral-950 dark:text-neutral-100">
|
||||
{item.name}
|
||||
</p>
|
||||
<p className="truncate text-xs leading-5 text-neutral-500 dark:text-neutral-400">
|
||||
{item.description}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="ml-auto flex min-w-0 shrink-0 items-center overflow-hidden rounded-lg bg-neutral-100 pl-3">
|
||||
<code className="block flex-1 whitespace-nowrap py-2 pr-2 font-mono text-[13px] text-neutral-500">
|
||||
<div className="ml-auto flex min-w-0 shrink-0 items-center overflow-hidden rounded-lg bg-neutral-100 pl-3 dark:bg-neutral-800">
|
||||
<code className="block flex-1 whitespace-nowrap py-2 pr-2 font-mono text-[13px] text-neutral-500 dark:text-neutral-400">
|
||||
{item.command}
|
||||
</code>
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex h-8 w-8 shrink-0 items-center justify-center rounded-lg text-neutral-500 transition-colors hover:text-neutral-950 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-neutral-500"
|
||||
className="inline-flex h-8 w-8 shrink-0 items-center justify-center rounded-lg text-neutral-500 transition-colors hover:text-neutral-950 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-neutral-500 dark:text-neutral-400 dark:hover:text-neutral-100"
|
||||
onClick={() => copyLaunchCommand(item)}
|
||||
aria-label={
|
||||
copied
|
||||
@@ -963,16 +987,16 @@ export function ConnectAppsScreen({
|
||||
);
|
||||
};
|
||||
const claudeRow = claudeIntegration ? (
|
||||
<div className="flex min-h-18 items-center justify-between gap-4 bg-white px-4 py-3">
|
||||
<div className="flex min-h-18 items-center justify-between gap-4 bg-white px-4 py-3 dark:bg-neutral-900">
|
||||
<div className="flex min-w-0 items-center gap-3">
|
||||
<LaunchCommandIcon item={claudeIntegration} />
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm font-medium text-neutral-950">
|
||||
<p className="text-sm font-medium text-neutral-950 dark:text-neutral-100">
|
||||
{claudeIntegration.name}
|
||||
</p>
|
||||
<p
|
||||
role={claudeGuidance ? "alert" : undefined}
|
||||
className="truncate text-xs leading-5 text-neutral-500"
|
||||
className="truncate text-xs leading-5 text-neutral-500 dark:text-neutral-400"
|
||||
>
|
||||
{claudeGuidance ??
|
||||
(claudeConnected
|
||||
@@ -996,7 +1020,7 @@ export function ConnectAppsScreen({
|
||||
<span
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
className="inline-flex items-center gap-1.5 whitespace-nowrap text-xs text-neutral-500"
|
||||
className="inline-flex items-center gap-1.5 whitespace-nowrap text-xs text-neutral-500 dark:text-neutral-400"
|
||||
>
|
||||
{isConnectingClaude && (
|
||||
<ArrowPathIcon className="h-3.5 w-3.5 animate-spin" />
|
||||
@@ -1019,11 +1043,11 @@ export function ConnectAppsScreen({
|
||||
title={claudeConfigured ? "Disconnect" : "Connect"}
|
||||
disabled={isConnectingClaude}
|
||||
onClick={connectClaude}
|
||||
className={`relative inline-flex h-5 w-9 shrink-0 items-center rounded-full transition-colors focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-neutral-500 disabled:cursor-wait disabled:opacity-50 ${claudeToggleConfigured ? "bg-neutral-950" : "bg-neutral-300"}`}
|
||||
className={`relative inline-flex h-5 w-9 shrink-0 items-center rounded-full transition-colors focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-neutral-500 disabled:cursor-wait disabled:opacity-50 ${claudeToggleConfigured ? "bg-neutral-950 dark:bg-white" : "bg-neutral-300 dark:bg-neutral-700"}`}
|
||||
>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className={`inline-block h-4 w-4 rounded-full bg-white shadow-sm transition-transform ${isConnectingClaude ? "animate-pulse" : ""} ${claudeToggleConfigured ? "translate-x-4.5" : "translate-x-0.5"}`}
|
||||
className={`inline-block h-4 w-4 rounded-full bg-white shadow-sm transition-transform ${isConnectingClaude ? "animate-pulse" : ""} ${claudeToggleConfigured ? "translate-x-4.5 dark:bg-neutral-900" : "translate-x-0.5"}`}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
@@ -1031,21 +1055,29 @@ export function ConnectAppsScreen({
|
||||
) : null;
|
||||
|
||||
return (
|
||||
<main className="flex min-h-0 w-full flex-1 flex-col overflow-hidden bg-white text-neutral-950">
|
||||
<main className="flex min-h-0 w-full flex-1 flex-col overflow-hidden bg-white text-neutral-950 dark:bg-neutral-900 dark:text-neutral-100">
|
||||
<div className="flex min-h-0 flex-1 flex-col overflow-y-auto overscroll-contain p-6">
|
||||
<section className="min-h-0 flex-1">
|
||||
<div className="mx-auto w-full max-w-4xl text-left">
|
||||
{integrationStatuses ? (
|
||||
<div className="space-y-7 pb-4 pt-2">
|
||||
{claudeIntegration && (
|
||||
{(claudeIntegration || codexIntegration) && (
|
||||
<section aria-labelledby="desktop-heading">
|
||||
<h2
|
||||
id="desktop-heading"
|
||||
className="px-4 text-xs font-medium uppercase tracking-wider text-neutral-400"
|
||||
className="px-4 text-xs font-medium uppercase tracking-wider text-neutral-400 dark:text-neutral-500"
|
||||
>
|
||||
Desktop
|
||||
</h2>
|
||||
<div className="mt-2 bg-white">{claudeRow}</div>
|
||||
<div className="mt-2 space-y-2 bg-white dark:bg-neutral-900">
|
||||
{claudeRow}
|
||||
{codexIntegration && (
|
||||
<CodexDesktopRow
|
||||
integration={codexIntegration}
|
||||
initialStatus={initialCodexStatus}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
@@ -1053,11 +1085,11 @@ export function ConnectAppsScreen({
|
||||
<section aria-labelledby="terminal-heading">
|
||||
<h2
|
||||
id="terminal-heading"
|
||||
className="px-4 text-xs font-medium uppercase tracking-wider text-neutral-400"
|
||||
className="px-4 text-xs font-medium uppercase tracking-wider text-neutral-400 dark:text-neutral-500"
|
||||
>
|
||||
Terminal
|
||||
</h2>
|
||||
<div className="mt-2 bg-white">
|
||||
<div className="mt-2 bg-white dark:bg-neutral-900">
|
||||
<div className="space-y-2">
|
||||
{launchIntegrations.map(launchIntegrationRow)}
|
||||
</div>
|
||||
@@ -1065,18 +1097,20 @@ export function ConnectAppsScreen({
|
||||
</section>
|
||||
)}
|
||||
|
||||
{!claudeIntegration && launchIntegrations.length === 0 && (
|
||||
<p className="py-12 text-center text-sm text-neutral-400">
|
||||
No apps found.
|
||||
</p>
|
||||
)}
|
||||
{!claudeIntegration &&
|
||||
!codexIntegration &&
|
||||
launchIntegrations.length === 0 && (
|
||||
<p className="py-12 text-center text-sm text-neutral-400 dark:text-neutral-500">
|
||||
No apps found.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
) : statusError ? (
|
||||
<p role="alert" className="mt-8 text-sm text-red-600">
|
||||
Couldn't load integrations.
|
||||
</p>
|
||||
) : (
|
||||
<p className="mt-8 text-sm text-neutral-400">
|
||||
<p className="mt-8 text-sm text-neutral-400 dark:text-neutral-500">
|
||||
Checking integrations…
|
||||
</p>
|
||||
)}
|
||||
|
||||
@@ -7,10 +7,12 @@ import Settings from "./Settings";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
resetClaudeMappings: vi.fn(),
|
||||
resetChatGPTModels: vi.fn(),
|
||||
updateSettings: vi.fn(),
|
||||
updateCloudSetting: vi.fn(),
|
||||
setShowAppsInMenu: vi.fn(),
|
||||
refetchUser: vi.fn(),
|
||||
disconnectUser: vi.fn(),
|
||||
isWindows: false,
|
||||
queryClient: {
|
||||
cancelQueries: vi.fn().mockResolvedValue(undefined),
|
||||
@@ -32,16 +34,32 @@ vi.mock("@/components/ClaudeDesktopModelsSettings", () => ({
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock("@/components/CodexDesktopModelsSettings", () => ({
|
||||
CodexDesktopModelsSettings: forwardRef(
|
||||
function MockCodexDesktopSettings(_props, ref) {
|
||||
useImperativeHandle(ref, () => ({
|
||||
resetToDefaults: mocks.resetChatGPTModels,
|
||||
}));
|
||||
return <section aria-label="ChatGPT settings" />;
|
||||
},
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock("@/hooks/useUser", () => ({
|
||||
useUser: () => ({
|
||||
user: { name: "Paid user", email: "paid@example.com", plan: "pro" },
|
||||
user: {
|
||||
id: "paid-user-id",
|
||||
name: "Paid user",
|
||||
email: "paid@example.com",
|
||||
plan: "pro",
|
||||
},
|
||||
isAuthenticated: true,
|
||||
refreshUser: vi.fn(),
|
||||
isRefreshing: false,
|
||||
refetchUser: mocks.refetchUser,
|
||||
fetchConnectUrl: vi.fn(),
|
||||
isLoading: false,
|
||||
disconnectUser: vi.fn(),
|
||||
disconnectUser: mocks.disconnectUser,
|
||||
}),
|
||||
}));
|
||||
|
||||
@@ -150,6 +168,8 @@ describe("Settings reset interactions", () => {
|
||||
source: "none",
|
||||
});
|
||||
mocks.setShowAppsInMenu.mockResolvedValue(undefined);
|
||||
mocks.resetChatGPTModels.mockResolvedValue(true);
|
||||
mocks.disconnectUser.mockResolvedValue(undefined);
|
||||
|
||||
vi.stubGlobal("window", {
|
||||
addEventListener: vi.fn(),
|
||||
@@ -160,6 +180,7 @@ describe("Settings reset interactions", () => {
|
||||
setShowAppsInMenu: mocks.setShowAppsInMenu,
|
||||
open: vi.fn(),
|
||||
confirm: vi.fn(() => true),
|
||||
location: { reload: vi.fn() },
|
||||
OLLAMA_TOOLS: false,
|
||||
});
|
||||
vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
|
||||
@@ -193,6 +214,7 @@ describe("Settings reset interactions", () => {
|
||||
expect(settingsFieldset.props["aria-busy"]).toBe(true);
|
||||
expect(textContent(resetButton)).toContain("Resetting…");
|
||||
expect(renderer!.root.findAllByType(Badge)).toHaveLength(0);
|
||||
expect(mocks.resetChatGPTModels).toHaveBeenCalledOnce();
|
||||
|
||||
await act(async () => {
|
||||
pendingClaudeReset.resolve(true);
|
||||
@@ -212,7 +234,7 @@ describe("Settings reset interactions", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("hides Claude Desktop settings and skips its reset on Windows", async () => {
|
||||
it("hides Claude and ChatGPT desktop settings on Windows", async () => {
|
||||
mocks.isWindows = true;
|
||||
|
||||
let renderer;
|
||||
@@ -225,6 +247,9 @@ describe("Settings reset interactions", () => {
|
||||
expect(
|
||||
renderer!.root.findAllByProps({ "aria-label": "Claude settings" }),
|
||||
).toHaveLength(0);
|
||||
expect(
|
||||
renderer!.root.findAllByProps({ "aria-label": "ChatGPT settings" }),
|
||||
).toHaveLength(0);
|
||||
|
||||
const resetButton = renderer!.root
|
||||
.findAllByType("button")
|
||||
@@ -237,6 +262,37 @@ describe("Settings reset interactions", () => {
|
||||
});
|
||||
|
||||
expect(mocks.resetClaudeMappings).not.toHaveBeenCalled();
|
||||
expect(mocks.resetChatGPTModels).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
await act(async () => {
|
||||
renderer?.unmount();
|
||||
await Promise.resolve();
|
||||
});
|
||||
vi.unstubAllGlobals();
|
||||
}
|
||||
});
|
||||
|
||||
it("reloads Settings after signing out", async () => {
|
||||
let renderer;
|
||||
try {
|
||||
await act(async () => {
|
||||
renderer = create(<Settings />);
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
const signOutButton = renderer!.root
|
||||
.findAllByType("button")
|
||||
.find((button) => textContent(button) === "Sign out");
|
||||
if (!signOutButton) throw new Error("Sign out button not found");
|
||||
|
||||
await act(async () => {
|
||||
signOutButton.props.onClick();
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(mocks.disconnectUser).toHaveBeenCalledOnce();
|
||||
expect(window.location.reload).toHaveBeenCalledOnce();
|
||||
} finally {
|
||||
await act(async () => {
|
||||
renderer?.unmount();
|
||||
|
||||
@@ -23,6 +23,7 @@ describe("Settings defaults", () => {
|
||||
const updateSettings = vi.fn(() => settingsUpdate.promise);
|
||||
const updateCloud = vi.fn().mockResolvedValue(undefined);
|
||||
const updateShowAppsInMenu = vi.fn().mockResolvedValue(undefined);
|
||||
const resetChatGPTModels = vi.fn().mockResolvedValue(true);
|
||||
let resolveClaudeReset!: (succeeded: boolean) => void;
|
||||
const claudeReset = new Promise<boolean>((resolve) => {
|
||||
resolveClaudeReset = resolve;
|
||||
@@ -34,6 +35,7 @@ describe("Settings defaults", () => {
|
||||
updateSettings,
|
||||
updateCloud,
|
||||
updateShowAppsInMenu,
|
||||
resetChatGPTModels,
|
||||
resetClaudeMappings,
|
||||
currentSettings: currentSettings({
|
||||
Expose: true,
|
||||
@@ -47,6 +49,7 @@ describe("Settings defaults", () => {
|
||||
await vi.waitFor(() => expect(updateSettings).toHaveBeenCalledOnce());
|
||||
expect(updateCloud).toHaveBeenCalledWith(true);
|
||||
expect(updateShowAppsInMenu).not.toHaveBeenCalled();
|
||||
expect(resetChatGPTModels).not.toHaveBeenCalled();
|
||||
expect(resetClaudeMappings).not.toHaveBeenCalled();
|
||||
expect(onSaved).not.toHaveBeenCalled();
|
||||
|
||||
@@ -60,6 +63,7 @@ describe("Settings defaults", () => {
|
||||
settingsUpdate.resolve();
|
||||
await vi.waitFor(() => expect(resetClaudeMappings).toHaveBeenCalledOnce());
|
||||
expect(updateShowAppsInMenu).toHaveBeenCalledWith(true);
|
||||
expect(resetChatGPTModels).toHaveBeenCalledOnce();
|
||||
expect(onSaved).not.toHaveBeenCalled();
|
||||
|
||||
resolveClaudeReset(true);
|
||||
@@ -73,6 +77,9 @@ describe("Settings defaults", () => {
|
||||
updateShowAppsInMenu.mock.invocationCallOrder[0],
|
||||
);
|
||||
expect(updateShowAppsInMenu.mock.invocationCallOrder[0]).toBeLessThan(
|
||||
resetChatGPTModels.mock.invocationCallOrder[0],
|
||||
);
|
||||
expect(resetChatGPTModels.mock.invocationCallOrder[0]).toBeLessThan(
|
||||
resetClaudeMappings.mock.invocationCallOrder[0],
|
||||
);
|
||||
expect(resetClaudeMappings.mock.invocationCallOrder[0]).toBeLessThan(
|
||||
@@ -88,6 +95,7 @@ describe("Settings defaults", () => {
|
||||
updateSettings: vi.fn().mockResolvedValue(undefined),
|
||||
updateCloud,
|
||||
updateShowAppsInMenu,
|
||||
resetChatGPTModels: vi.fn().mockResolvedValue(true),
|
||||
resetClaudeMappings: vi.fn().mockResolvedValue(true),
|
||||
currentSettings: currentSettings(),
|
||||
currentShowAppsInMenu: true,
|
||||
@@ -110,6 +118,7 @@ describe("Settings defaults", () => {
|
||||
updateSettings: vi.fn().mockResolvedValue(undefined),
|
||||
updateCloud,
|
||||
updateShowAppsInMenu: vi.fn().mockResolvedValue(undefined),
|
||||
resetChatGPTModels: vi.fn().mockResolvedValue(true),
|
||||
resetClaudeMappings: vi.fn().mockResolvedValue(true),
|
||||
currentSettings: currentSettings(),
|
||||
currentShowAppsInMenu: true,
|
||||
@@ -131,6 +140,7 @@ describe("Settings defaults", () => {
|
||||
updateSettings: vi.fn().mockResolvedValue(undefined),
|
||||
updateCloud,
|
||||
updateShowAppsInMenu: vi.fn().mockResolvedValue(undefined),
|
||||
resetChatGPTModels: vi.fn().mockResolvedValue(true),
|
||||
resetClaudeMappings: vi.fn().mockResolvedValue(true),
|
||||
currentSettings: currentSettings(),
|
||||
currentShowAppsInMenu: true,
|
||||
@@ -144,6 +154,7 @@ describe("Settings defaults", () => {
|
||||
it("restores Cloud and leaves Claude untouched when settings fail", async () => {
|
||||
const updateCloud = vi.fn().mockResolvedValue(undefined);
|
||||
const updateShowAppsInMenu = vi.fn().mockResolvedValue(undefined);
|
||||
const resetChatGPTModels = vi.fn().mockResolvedValue(true);
|
||||
const resetClaudeMappings = vi.fn().mockResolvedValue(true);
|
||||
const onSaved = vi.fn();
|
||||
|
||||
@@ -152,6 +163,7 @@ describe("Settings defaults", () => {
|
||||
updateSettings: vi.fn().mockRejectedValue(new Error("restart failed")),
|
||||
updateCloud,
|
||||
updateShowAppsInMenu,
|
||||
resetChatGPTModels,
|
||||
resetClaudeMappings,
|
||||
currentSettings: currentSettings({
|
||||
Expose: true,
|
||||
@@ -164,6 +176,7 @@ describe("Settings defaults", () => {
|
||||
).rejects.toThrow("restart failed");
|
||||
|
||||
expect(updateCloud.mock.calls).toEqual([[true], [false]]);
|
||||
expect(resetChatGPTModels).not.toHaveBeenCalled();
|
||||
expect(resetClaudeMappings).not.toHaveBeenCalled();
|
||||
expect(updateShowAppsInMenu).not.toHaveBeenCalled();
|
||||
expect(onSaved).not.toHaveBeenCalled();
|
||||
@@ -178,6 +191,7 @@ describe("Settings defaults", () => {
|
||||
updateSettings: vi.fn().mockResolvedValue(undefined),
|
||||
updateCloud: vi.fn().mockRejectedValue(new Error("cloud failed")),
|
||||
updateShowAppsInMenu,
|
||||
resetChatGPTModels: vi.fn().mockResolvedValue(true),
|
||||
resetClaudeMappings: vi.fn().mockResolvedValue(true),
|
||||
currentSettings: currentSettings(),
|
||||
currentShowAppsInMenu: true,
|
||||
@@ -205,6 +219,7 @@ describe("Settings defaults", () => {
|
||||
updateSettings,
|
||||
updateCloud,
|
||||
updateShowAppsInMenu,
|
||||
resetChatGPTModels: vi.fn().mockResolvedValue(true),
|
||||
resetClaudeMappings: vi.fn().mockResolvedValue(false),
|
||||
currentSettings: previousSettings,
|
||||
currentShowAppsInMenu: false,
|
||||
@@ -219,4 +234,33 @@ describe("Settings defaults", () => {
|
||||
expect(updateShowAppsInMenu.mock.calls).toEqual([[true], [false]]);
|
||||
expect(onSaved).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("stops before Claude and rolls settings back when ChatGPT cannot be reset", async () => {
|
||||
const onSaved = vi.fn();
|
||||
const updateSettings = vi.fn().mockResolvedValue(undefined);
|
||||
const updateCloud = vi.fn().mockResolvedValue(undefined);
|
||||
const updateShowAppsInMenu = vi.fn().mockResolvedValue(undefined);
|
||||
const resetClaudeMappings = vi.fn().mockResolvedValue(true);
|
||||
const previousSettings = currentSettings({ Expose: true });
|
||||
|
||||
await expect(
|
||||
applySettingsDefaults({
|
||||
updateSettings,
|
||||
updateCloud,
|
||||
updateShowAppsInMenu,
|
||||
resetChatGPTModels: vi.fn().mockResolvedValue(false),
|
||||
resetClaudeMappings,
|
||||
currentSettings: previousSettings,
|
||||
currentShowAppsInMenu: false,
|
||||
cloudSource: "config",
|
||||
onSaved,
|
||||
}),
|
||||
).rejects.toThrow("ChatGPT models could not be reset");
|
||||
|
||||
expect(resetClaudeMappings).not.toHaveBeenCalled();
|
||||
expect(updateCloud.mock.calls).toEqual([[true], [false]]);
|
||||
expect(updateSettings.mock.calls[1][0]).toBe(previousSettings);
|
||||
expect(updateShowAppsInMenu.mock.calls).toEqual([[true], [false]]);
|
||||
expect(onSaved).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -10,6 +10,10 @@ import {
|
||||
ClaudeDesktopModelsSettings,
|
||||
type ClaudeDesktopModelsSettingsHandle,
|
||||
} from "@/components/ClaudeDesktopModelsSettings";
|
||||
import {
|
||||
CodexDesktopModelsSettings,
|
||||
type CodexDesktopModelsSettingsHandle,
|
||||
} from "@/components/CodexDesktopModelsSettings";
|
||||
import {
|
||||
WifiIcon,
|
||||
FolderIcon,
|
||||
@@ -55,6 +59,7 @@ interface SettingsDefaultsActions {
|
||||
updateSettings: (settings: SettingsType) => Promise<unknown>;
|
||||
updateCloud: (enabled: boolean) => Promise<unknown>;
|
||||
updateShowAppsInMenu: (visible: boolean) => Promise<unknown>;
|
||||
resetChatGPTModels: () => Promise<boolean>;
|
||||
resetClaudeMappings: () => Promise<boolean>;
|
||||
currentSettings: SettingsType;
|
||||
currentShowAppsInMenu: boolean;
|
||||
@@ -74,6 +79,7 @@ export async function applySettingsDefaults({
|
||||
updateSettings,
|
||||
updateCloud,
|
||||
updateShowAppsInMenu,
|
||||
resetChatGPTModels,
|
||||
resetClaudeMappings,
|
||||
currentSettings,
|
||||
currentShowAppsInMenu,
|
||||
@@ -105,8 +111,12 @@ export async function applySettingsDefaults({
|
||||
await updateShowAppsInMenu(true);
|
||||
rollbacks.push(() => updateShowAppsInMenu(currentShowAppsInMenu));
|
||||
|
||||
// Apply Claude last so no later settings failure can leave its mappings
|
||||
// reset while the rest of the page rolls back.
|
||||
// Reset app-specific model settings only after the rest of the page has
|
||||
// succeeded, because those native profile changes cannot be rolled back
|
||||
// with the settings API.
|
||||
if (!(await resetChatGPTModels())) {
|
||||
throw new Error("ChatGPT models could not be reset");
|
||||
}
|
||||
if (!(await resetClaudeMappings())) {
|
||||
throw new Error("Claude model mappings could not be reset");
|
||||
}
|
||||
@@ -137,14 +147,16 @@ export default function Settings() {
|
||||
const [resettingToDefaults, setResettingToDefaults] = useState(false);
|
||||
const [resetError, setResetError] = useState<string | null>(null);
|
||||
const [hasClaudeDraftChanges, setHasClaudeDraftChanges] = useState(false);
|
||||
const [hasCodexDraftChanges, setHasCodexDraftChanges] = useState(false);
|
||||
const claudeModelsSettingsRef =
|
||||
useRef<ClaudeDesktopModelsSettingsHandle>(null);
|
||||
const codexModelsSettingsRef = useRef<CodexDesktopModelsSettingsHandle>(null);
|
||||
const savedConfirmationTimeoutRef = useRef<number | null>(null);
|
||||
useBlocker({
|
||||
shouldBlockFn: () =>
|
||||
!window.confirm("Discard unapplied Claude routing changes?"),
|
||||
enableBeforeUnload: hasClaudeDraftChanges,
|
||||
disabled: !hasClaudeDraftChanges,
|
||||
!window.confirm("Discard unapplied app model changes?"),
|
||||
enableBeforeUnload: hasClaudeDraftChanges || hasCodexDraftChanges,
|
||||
disabled: !hasClaudeDraftChanges && !hasCodexDraftChanges,
|
||||
});
|
||||
const {
|
||||
user,
|
||||
@@ -397,6 +409,8 @@ export default function Settings() {
|
||||
updateSettingsMutation.mutateAsync(defaultSettings),
|
||||
updateCloud: requestCloudUpdate,
|
||||
updateShowAppsInMenu: updateShowAppsInMenuVisibility,
|
||||
resetChatGPTModels: async () =>
|
||||
(await codexModelsSettingsRef.current?.resetToDefaults()) ?? true,
|
||||
resetClaudeMappings: async () =>
|
||||
(await claudeModelsSettingsRef.current?.resetToDefaults()) ?? true,
|
||||
currentSettings: settings,
|
||||
@@ -449,6 +463,16 @@ export default function Settings() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleDisconnectOllamaAccount = async () => {
|
||||
setConnectionError(null);
|
||||
try {
|
||||
await disconnectUser();
|
||||
window.location.reload();
|
||||
} catch {
|
||||
setConnectionError("Failed to disconnect Ollama account");
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return null;
|
||||
}
|
||||
@@ -529,7 +553,7 @@ export default function Settings() {
|
||||
type="button"
|
||||
color="zinc"
|
||||
className="px-3 py-2 text-sm"
|
||||
onClick={() => disconnectUser()}
|
||||
onClick={() => void handleDisconnectOllamaAccount()}
|
||||
>
|
||||
Sign out
|
||||
</Button>
|
||||
@@ -756,13 +780,30 @@ export default function Settings() {
|
||||
</div>
|
||||
|
||||
{!isWindows && (
|
||||
<ClaudeDesktopModelsSettings
|
||||
ref={claudeModelsSettingsRef}
|
||||
includeCloudModels={
|
||||
isAuthenticated && cloudStatusKnown && !cloudDisabled
|
||||
}
|
||||
onDraftChange={setHasClaudeDraftChanges}
|
||||
/>
|
||||
<section
|
||||
aria-labelledby="apps-settings-heading"
|
||||
className="space-y-2"
|
||||
>
|
||||
<h2
|
||||
id="apps-settings-heading"
|
||||
className="px-1 text-xs font-medium uppercase tracking-wider text-neutral-400 dark:text-neutral-500"
|
||||
>
|
||||
Apps
|
||||
</h2>
|
||||
<ClaudeDesktopModelsSettings
|
||||
ref={claudeModelsSettingsRef}
|
||||
includeCloudModels={
|
||||
isAuthenticated && cloudStatusKnown && !cloudDisabled
|
||||
}
|
||||
onDraftChange={setHasClaudeDraftChanges}
|
||||
showSectionHeading={false}
|
||||
/>
|
||||
<CodexDesktopModelsSettings
|
||||
ref={codexModelsSettingsRef}
|
||||
accountKey={`${user?.id ?? "signed-out"}:${user?.plan ?? ""}:${cloudDisabled ? "cloud-off" : "cloud-on"}`}
|
||||
onDraftChange={setHasCodexDraftChanges}
|
||||
/>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* Agent Mode */}
|
||||
|
||||
@@ -35,8 +35,15 @@ export function useUser() {
|
||||
|
||||
const disconnectMutation = useMutation({
|
||||
mutationFn: disconnectUser,
|
||||
onSuccess: () => {
|
||||
onMutate: async () => {
|
||||
await queryClient.cancelQueries({ queryKey: ["user"] });
|
||||
const previousUser = queryClient.getQueryData(["user"]);
|
||||
queryClient.setQueryData(["user"], null);
|
||||
|
||||
return { previousUser };
|
||||
},
|
||||
onError: (_error, _variables, context) => {
|
||||
queryClient.setQueryData(["user"], context?.previousUser);
|
||||
},
|
||||
});
|
||||
|
||||
@@ -54,6 +61,6 @@ export function useUser() {
|
||||
refetchUser: userQuery.refetch,
|
||||
fetchConnectUrl: connectUrlQuery.refetch,
|
||||
connectUrl: connectUrlQuery.data,
|
||||
disconnectUser: disconnectMutation.mutate,
|
||||
disconnectUser: disconnectMutation.mutateAsync,
|
||||
};
|
||||
}
|
||||
@@ -2,8 +2,13 @@
|
||||
@plugin "@tailwindcss/typography";
|
||||
@import "katex/dist/katex.min.css";
|
||||
|
||||
/* Retain component class names while making the dark variant unreachable. */
|
||||
@custom-variant dark (@media not all);
|
||||
@custom-variant dark {
|
||||
@media (prefers-color-scheme: dark) {
|
||||
&:not(.light-only, .light-only *) {
|
||||
@slot;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@theme {
|
||||
--font-sans: ui-sans-serif, system-ui, "Segoe UI", sans-serif;
|
||||
@@ -13,13 +18,11 @@
|
||||
|
||||
@layer base {
|
||||
:root {
|
||||
color-scheme: light;
|
||||
color-scheme: light dark;
|
||||
}
|
||||
|
||||
html,
|
||||
body,
|
||||
#root {
|
||||
background-color: #fff;
|
||||
.light-only {
|
||||
color-scheme: light;
|
||||
}
|
||||
|
||||
a[href],
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
export interface IntegrationIcon {
|
||||
src: string;
|
||||
darkSrc?: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
@@ -13,12 +14,24 @@ export const INTEGRATION_ICONS: Record<string, IntegrationIcon> = {
|
||||
src: "/launch-icons/opencode.svg",
|
||||
className: "h-7 w-7 rounded",
|
||||
},
|
||||
codex: { src: "/launch-icons/codex.svg" },
|
||||
copilot: { src: "/launch-icons/copilot.svg" },
|
||||
codex: {
|
||||
src: "/launch-icons/codex-color.svg",
|
||||
},
|
||||
chatgpt: {
|
||||
src: "/launch-icons/codex.svg",
|
||||
darkSrc: "/launch-icons/codex-dark.svg",
|
||||
},
|
||||
copilot: {
|
||||
src: "/launch-icons/copilot.svg",
|
||||
darkSrc: "/launch-icons/copilot-dark.svg",
|
||||
},
|
||||
droid: { src: "/launch-icons/droid.svg" },
|
||||
dsh: { src: "/launch-icons/deepseek-harness.svg" },
|
||||
pi: { src: "/launch-icons/pi.svg" },
|
||||
cline: { src: "/launch-icons/cline.svg" },
|
||||
cline: {
|
||||
src: "/launch-icons/cline.svg",
|
||||
className: "h-7 w-7 dark:invert",
|
||||
},
|
||||
omp: { src: "/launch-icons/oh-my-pi.svg" },
|
||||
pool: { src: "/launch-icons/poolside.svg" },
|
||||
qwen: { src: "/launch-icons/qwen-code.svg" },
|
||||
|
||||
Vendored
+74
@@ -61,7 +61,61 @@ interface ClaudeDesktopActionResult {
|
||||
restartConfirmationRequired?: boolean;
|
||||
}
|
||||
|
||||
interface CodexDesktopStatus {
|
||||
used?: boolean;
|
||||
supported: boolean;
|
||||
installed: boolean;
|
||||
connected: boolean;
|
||||
running: boolean;
|
||||
model?: string;
|
||||
models?: string[];
|
||||
maxModels?: number;
|
||||
requests?: number;
|
||||
}
|
||||
|
||||
interface CodexDesktopActionResult {
|
||||
status: CodexDesktopStatus;
|
||||
error?: string;
|
||||
restartConfirmationRequired?: boolean;
|
||||
}
|
||||
|
||||
interface CodexDesktopModelsSettings {
|
||||
supported: boolean;
|
||||
installed: boolean;
|
||||
connected: boolean;
|
||||
running: boolean;
|
||||
usesDefaults: boolean;
|
||||
selected: string[];
|
||||
available: string[];
|
||||
models?: CodexDesktopModelStatus[];
|
||||
maxModels: number;
|
||||
}
|
||||
|
||||
interface CodexDesktopModelStatus {
|
||||
name: string;
|
||||
displayName: string;
|
||||
description?: string;
|
||||
recommended?: boolean;
|
||||
selected: boolean;
|
||||
availability?: "unknown" | "available" | "unavailable";
|
||||
reason?:
|
||||
| "cloud_off"
|
||||
| "sign_in_required"
|
||||
| "upgrade_required"
|
||||
| "verification_unavailable"
|
||||
| "model_not_installed";
|
||||
requiredPlan?: string;
|
||||
}
|
||||
|
||||
interface CodexDesktopModelsSettingsResult {
|
||||
settings: CodexDesktopModelsSettings;
|
||||
error?: string;
|
||||
warning?: string;
|
||||
restartConfirmationRequired?: boolean;
|
||||
}
|
||||
|
||||
type ClaudeDesktopInstallResult = "opened" | "cancelled" | "failed";
|
||||
type CodexDesktopInstallResult = "opened" | "cancelled" | "failed";
|
||||
|
||||
interface WebviewAPI {
|
||||
selectFile: () => Promise<ImageData | null>;
|
||||
@@ -85,6 +139,20 @@ declare global {
|
||||
) => Promise<ClaudeDesktopActionResult>;
|
||||
prepareClaudeDesktopConnection?: () => Promise<ClaudeDesktopActionResult>;
|
||||
openClaudeDesktop?: () => Promise<string>;
|
||||
markCodexDesktopIntegrationUsed?: () => Promise<string>;
|
||||
getCodexDesktopStatus?: () => Promise<CodexDesktopStatus>;
|
||||
getCodexDesktopRequestCount?: () => Promise<number>;
|
||||
setCodexDesktopConnected?: (
|
||||
enabled: boolean,
|
||||
restartConfirmed: boolean,
|
||||
) => Promise<CodexDesktopActionResult>;
|
||||
installCodexDesktop?: () => Promise<CodexDesktopInstallResult>;
|
||||
getCodexDesktopModelsSettings?: () => Promise<CodexDesktopModelsSettingsResult>;
|
||||
applyCodexDesktopModels?: (
|
||||
models: string[],
|
||||
restartConfirmed: boolean,
|
||||
) => Promise<CodexDesktopModelsSettingsResult>;
|
||||
resetCodexDesktopModels?: () => Promise<CodexDesktopModelsSettingsResult>;
|
||||
installClaudeDesktop?: () => Promise<ClaudeDesktopInstallResult>;
|
||||
getShowAppsInMenu?: () => Promise<boolean>;
|
||||
setShowAppsInMenu?: (visible: boolean) => Promise<void>;
|
||||
@@ -129,6 +197,12 @@ export type {
|
||||
ClaudeDesktopMappingStatus,
|
||||
ClaudeDesktopModelStatus,
|
||||
ClaudeDesktopStatus,
|
||||
CodexDesktopActionResult,
|
||||
CodexDesktopInstallResult,
|
||||
CodexDesktopModelsSettings,
|
||||
CodexDesktopModelStatus,
|
||||
CodexDesktopModelsSettingsResult,
|
||||
CodexDesktopStatus,
|
||||
ContextMenuItem,
|
||||
ContextMenuResult,
|
||||
ImageData,
|
||||
|
||||
@@ -1,197 +0,0 @@
|
||||
//go:build windows || darwin
|
||||
|
||||
package ui
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"sync"
|
||||
)
|
||||
|
||||
const maxFeatureFlagResponseSize = 4096
|
||||
|
||||
var errFeatureFlagUnavailable = errors.New("feature flag unavailable")
|
||||
|
||||
type featureFlagResult struct {
|
||||
ready chan struct{}
|
||||
value any
|
||||
}
|
||||
|
||||
type featureFlagService struct {
|
||||
mu sync.Mutex
|
||||
results map[string]*featureFlagResult
|
||||
fetch func(context.Context, string) (any, error)
|
||||
cloudDisabled func() (bool, error)
|
||||
}
|
||||
|
||||
func newFeatureFlagService(
|
||||
fetch func(context.Context, string) (any, error),
|
||||
cloudDisabled func() (bool, error),
|
||||
) *featureFlagService {
|
||||
return &featureFlagService{
|
||||
results: make(map[string]*featureFlagResult),
|
||||
fetch: fetch,
|
||||
cloudDisabled: cloudDisabled,
|
||||
}
|
||||
}
|
||||
|
||||
func (s *featureFlagService) resolve(ctx context.Context, key string, defaultValue any) any {
|
||||
if !validFeatureFlagKey(key) {
|
||||
return defaultValue
|
||||
}
|
||||
|
||||
s.mu.Lock()
|
||||
result, ok := s.results[key]
|
||||
if ok {
|
||||
s.mu.Unlock()
|
||||
select {
|
||||
case <-result.ready:
|
||||
return result.value
|
||||
case <-ctx.Done():
|
||||
return defaultValue
|
||||
}
|
||||
}
|
||||
result = &featureFlagResult{ready: make(chan struct{})}
|
||||
s.results[key] = result
|
||||
s.mu.Unlock()
|
||||
|
||||
result.value = defaultValue
|
||||
disabled, err := s.cloudDisabled()
|
||||
if err == nil && !disabled {
|
||||
value, err := s.fetch(ctx, key)
|
||||
if err == nil {
|
||||
switch defaultValue.(type) {
|
||||
case bool:
|
||||
if _, ok := value.(bool); ok {
|
||||
result.value = value
|
||||
}
|
||||
case string:
|
||||
if _, ok := value.(string); ok {
|
||||
result.value = value
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
close(result.ready)
|
||||
return result.value
|
||||
}
|
||||
|
||||
func validFeatureFlagKey(key string) bool {
|
||||
if key == "" || len(key) > 128 {
|
||||
return false
|
||||
}
|
||||
for _, r := range key {
|
||||
switch {
|
||||
case r >= 'a' && r <= 'z':
|
||||
case r >= 'A' && r <= 'Z':
|
||||
case r >= '0' && r <= '9':
|
||||
case r == '-', r == '_', r == '.', r == ':':
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func (s *Server) featureFlagResolver() *featureFlagService {
|
||||
s.featureFlagsMu.Lock()
|
||||
defer s.featureFlagsMu.Unlock()
|
||||
if s.featureFlags == nil {
|
||||
s.featureFlags = newFeatureFlagService(
|
||||
s.fetchFeatureFlag,
|
||||
func() (bool, error) {
|
||||
if s.Store == nil {
|
||||
return false, errFeatureFlagUnavailable
|
||||
}
|
||||
return s.Store.CloudDisabled()
|
||||
},
|
||||
)
|
||||
}
|
||||
return s.featureFlags
|
||||
}
|
||||
|
||||
// FeatureFlagBool returns one session-stable boolean value or defaultValue.
|
||||
func (s *Server) FeatureFlagBool(ctx context.Context, key string, defaultValue bool) bool {
|
||||
valueBool, ok := s.featureFlagResolver().resolve(ctx, key, defaultValue).(bool)
|
||||
if !ok {
|
||||
return defaultValue
|
||||
}
|
||||
return valueBool
|
||||
}
|
||||
|
||||
// FeatureFlagString returns one session-stable string value or defaultValue.
|
||||
func (s *Server) FeatureFlagString(ctx context.Context, key, defaultValue string) string {
|
||||
valueString, ok := s.featureFlagResolver().resolve(ctx, key, defaultValue).(string)
|
||||
if !ok {
|
||||
return defaultValue
|
||||
}
|
||||
return valueString
|
||||
}
|
||||
|
||||
func (s *Server) fetchFeatureFlag(ctx context.Context, key string) (any, error) {
|
||||
if !validFeatureFlagKey(key) {
|
||||
return nil, errFeatureFlagUnavailable
|
||||
}
|
||||
resp, err := s.doSelfSigned(ctx, http.MethodGet, "/api/app/feature-flags/"+url.PathEscape(key))
|
||||
if err != nil {
|
||||
return nil, errFeatureFlagUnavailable
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, errFeatureFlagUnavailable
|
||||
}
|
||||
|
||||
body, err := io.ReadAll(io.LimitReader(resp.Body, maxFeatureFlagResponseSize+1))
|
||||
if err != nil || len(body) > maxFeatureFlagResponseSize {
|
||||
return nil, errFeatureFlagUnavailable
|
||||
}
|
||||
var response struct {
|
||||
Value any `json:"value"`
|
||||
}
|
||||
decoder := json.NewDecoder(bytes.NewReader(body))
|
||||
decoder.DisallowUnknownFields()
|
||||
if err := decoder.Decode(&response); err != nil {
|
||||
return nil, errFeatureFlagUnavailable
|
||||
}
|
||||
if err := decoder.Decode(&struct{}{}); !errors.Is(err, io.EOF) {
|
||||
return nil, errFeatureFlagUnavailable
|
||||
}
|
||||
|
||||
switch value := response.Value.(type) {
|
||||
case bool, string:
|
||||
return value, nil
|
||||
}
|
||||
return nil, errFeatureFlagUnavailable
|
||||
}
|
||||
|
||||
func (s *Server) getFeatureFlag(w http.ResponseWriter, r *http.Request) error {
|
||||
key := r.PathValue("key")
|
||||
defaultValue := r.URL.Query().Get("default")
|
||||
var value any
|
||||
switch r.URL.Query().Get("type") {
|
||||
case "boolean":
|
||||
if defaultValue != "true" && defaultValue != "false" {
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
return nil
|
||||
}
|
||||
fallback, _ := strconv.ParseBool(defaultValue)
|
||||
value = s.FeatureFlagBool(r.Context(), key, fallback)
|
||||
case "string":
|
||||
value = s.FeatureFlagString(r.Context(), key, defaultValue)
|
||||
default:
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
return nil
|
||||
}
|
||||
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
return json.NewEncoder(w).Encode(struct {
|
||||
Value any `json:"value"`
|
||||
}{Value: value})
|
||||
}
|
||||
@@ -1,298 +0,0 @@
|
||||
//go:build windows || darwin
|
||||
|
||||
package ui
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/ed25519"
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"encoding/pem"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
|
||||
"golang.org/x/crypto/ssh"
|
||||
)
|
||||
|
||||
func TestFeatureFlagsTypedValues(t *testing.T) {
|
||||
server := &Server{
|
||||
featureFlags: newFeatureFlagService(
|
||||
func(_ context.Context, key string) (any, error) {
|
||||
switch key {
|
||||
case "enabled":
|
||||
return true, nil
|
||||
case "mode":
|
||||
return "compact", nil
|
||||
case "wrong-type":
|
||||
return "yes", nil
|
||||
default:
|
||||
return nil, errors.New("unavailable")
|
||||
}
|
||||
},
|
||||
func() (bool, error) { return false, nil },
|
||||
),
|
||||
}
|
||||
|
||||
if got := server.FeatureFlagBool(t.Context(), "enabled", false); !got {
|
||||
t.Fatal("FeatureFlagBool() = false, want true")
|
||||
}
|
||||
if got := server.FeatureFlagString(t.Context(), "mode", "standard"); got != "compact" {
|
||||
t.Fatalf("FeatureFlagString() = %q, want compact", got)
|
||||
}
|
||||
if got := server.FeatureFlagBool(t.Context(), "wrong-type", false); got {
|
||||
t.Fatal("FeatureFlagBool() accepted a string value")
|
||||
}
|
||||
if got := server.FeatureFlagString(t.Context(), "missing", "standard"); got != "standard" {
|
||||
t.Fatalf("FeatureFlagString() = %q, want fallback", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFeatureFlagsResolveOncePerSession(t *testing.T) {
|
||||
var calls atomic.Int32
|
||||
started := make(chan struct{})
|
||||
release := make(chan struct{})
|
||||
server := &Server{
|
||||
featureFlags: newFeatureFlagService(
|
||||
func(context.Context, string) (any, error) {
|
||||
if calls.Add(1) == 1 {
|
||||
close(started)
|
||||
}
|
||||
<-release
|
||||
return true, nil
|
||||
},
|
||||
func() (bool, error) { return false, nil },
|
||||
),
|
||||
}
|
||||
|
||||
const callers = 20
|
||||
results := make(chan bool, callers)
|
||||
var wg sync.WaitGroup
|
||||
for range callers {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
results <- server.FeatureFlagBool(t.Context(), "shared", false)
|
||||
}()
|
||||
}
|
||||
<-started
|
||||
close(release)
|
||||
wg.Wait()
|
||||
close(results)
|
||||
for result := range results {
|
||||
if !result {
|
||||
t.Fatal("FeatureFlagBool() = false, want true")
|
||||
}
|
||||
}
|
||||
if got := calls.Load(); got != 1 {
|
||||
t.Fatalf("remote calls = %d, want 1", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFeatureFlagsCacheFailures(t *testing.T) {
|
||||
for _, tt := range []struct {
|
||||
name string
|
||||
cloudDisabled func() (bool, error)
|
||||
}{
|
||||
{name: "cloud off", cloudDisabled: func() (bool, error) { return true, nil }},
|
||||
{name: "cloud status unavailable", cloudDisabled: func() (bool, error) { return false, errors.New("unavailable") }},
|
||||
{name: "remote unavailable", cloudDisabled: func() (bool, error) { return false, nil }},
|
||||
} {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
var calls atomic.Int32
|
||||
server := &Server{
|
||||
featureFlags: newFeatureFlagService(
|
||||
func(context.Context, string) (any, error) {
|
||||
calls.Add(1)
|
||||
return nil, errors.New("unavailable")
|
||||
},
|
||||
tt.cloudDisabled,
|
||||
),
|
||||
}
|
||||
|
||||
if got := server.FeatureFlagBool(t.Context(), "enabled", true); !got {
|
||||
t.Fatal("first call did not return its fallback")
|
||||
}
|
||||
if got := server.FeatureFlagBool(t.Context(), "enabled", false); !got {
|
||||
t.Fatal("second call did not return the session-stable fallback")
|
||||
}
|
||||
wantCalls := int32(1)
|
||||
if tt.name != "remote unavailable" {
|
||||
wantCalls = 0
|
||||
}
|
||||
if got := calls.Load(); got != wantCalls {
|
||||
t.Fatalf("remote calls = %d, want %d", got, wantCalls)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestFeatureFlagsRejectInvalidKeysWithoutFetching(t *testing.T) {
|
||||
var calls atomic.Int32
|
||||
server := &Server{
|
||||
featureFlags: newFeatureFlagService(
|
||||
func(context.Context, string) (any, error) {
|
||||
calls.Add(1)
|
||||
return true, nil
|
||||
},
|
||||
func() (bool, error) { return false, nil },
|
||||
),
|
||||
}
|
||||
|
||||
for _, key := range []string{"", "flags/all", "has space", strings.Repeat("x", 129)} {
|
||||
if got := server.FeatureFlagBool(t.Context(), key, false); got {
|
||||
t.Fatalf("FeatureFlagBool(%q) = true, want fallback", key)
|
||||
}
|
||||
}
|
||||
if got := calls.Load(); got != 0 {
|
||||
t.Fatalf("remote calls = %d, want 0", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFeatureFlagLocalAPI(t *testing.T) {
|
||||
server := &Server{
|
||||
Dev: true,
|
||||
featureFlags: newFeatureFlagService(
|
||||
func(_ context.Context, key string) (any, error) {
|
||||
if key == "enabled" {
|
||||
return true, nil
|
||||
}
|
||||
return "compact", nil
|
||||
},
|
||||
func() (bool, error) { return false, nil },
|
||||
),
|
||||
}
|
||||
|
||||
for _, tt := range []struct {
|
||||
path string
|
||||
want any
|
||||
}{
|
||||
{path: "/api/v1/feature-flags/enabled?type=boolean&default=false", want: true},
|
||||
{path: "/api/v1/feature-flags/mode?type=string&default=standard", want: "compact"},
|
||||
} {
|
||||
rr := httptest.NewRecorder()
|
||||
server.Handler().ServeHTTP(rr, httptest.NewRequest(http.MethodGet, tt.path, nil))
|
||||
if rr.Code != http.StatusOK {
|
||||
t.Fatalf("%s status = %d, want %d", tt.path, rr.Code, http.StatusOK)
|
||||
}
|
||||
var response struct {
|
||||
Value any `json:"value"`
|
||||
}
|
||||
if err := json.NewDecoder(rr.Body).Decode(&response); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if response.Value != tt.want {
|
||||
t.Fatalf("%s value = %v, want %v", tt.path, response.Value, tt.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestFetchFeatureFlag(t *testing.T) {
|
||||
home := t.TempDir()
|
||||
t.Setenv("HOME", home)
|
||||
writeFeatureFlagTestKey(t, home)
|
||||
|
||||
remote := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet || r.URL.Path != "/api/app/feature-flags/enabled" {
|
||||
t.Fatalf("request = %s %s, want signed feature lookup", r.Method, r.URL.Path)
|
||||
}
|
||||
verifyFeatureFlagRequest(t, r)
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
fmt.Fprint(w, `{"value":true}`)
|
||||
}))
|
||||
defer remote.Close()
|
||||
|
||||
previous := OllamaDotCom
|
||||
OllamaDotCom = remote.URL
|
||||
t.Cleanup(func() { OllamaDotCom = previous })
|
||||
|
||||
value, err := (&Server{}).fetchFeatureFlag(t.Context(), "enabled")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if value != true {
|
||||
t.Fatalf("value = %v, want true", value)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFetchFeatureFlagRejectsUnexpectedResponses(t *testing.T) {
|
||||
home := t.TempDir()
|
||||
t.Setenv("HOME", home)
|
||||
writeFeatureFlagTestKey(t, home)
|
||||
|
||||
for _, body := range []string{
|
||||
`{"value":null}`,
|
||||
`{"value":1}`,
|
||||
`{"value":{"enabled":true}}`,
|
||||
`{"value":true,"reason":"forced"}`,
|
||||
`{"value":true}{"value":false}`,
|
||||
} {
|
||||
t.Run(body, func(t *testing.T) {
|
||||
remote := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
fmt.Fprint(w, body)
|
||||
}))
|
||||
defer remote.Close()
|
||||
|
||||
previous := OllamaDotCom
|
||||
OllamaDotCom = remote.URL
|
||||
defer func() { OllamaDotCom = previous }()
|
||||
|
||||
if _, err := (&Server{}).fetchFeatureFlag(t.Context(), "enabled"); err == nil {
|
||||
t.Fatal("fetchFeatureFlag() accepted an unexpected response")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func writeFeatureFlagTestKey(t *testing.T, home string) {
|
||||
t.Helper()
|
||||
_, privateKey, err := ed25519.GenerateKey(rand.Reader)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
block, err := ssh.MarshalPrivateKey(privateKey, "")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
keyPath := filepath.Join(home, ".ollama", "id_ed25519")
|
||||
if err := os.MkdirAll(filepath.Dir(keyPath), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(keyPath, pem.EncodeToMemory(block), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func verifyFeatureFlagRequest(t *testing.T, req *http.Request) {
|
||||
t.Helper()
|
||||
keyData, signatureData, ok := strings.Cut(req.Header.Get("Authorization"), ":")
|
||||
if !ok {
|
||||
t.Fatal("request is missing its public-key signature")
|
||||
}
|
||||
keyData = strings.TrimPrefix(keyData, "Bearer ")
|
||||
publicKeyData, err := base64.StdEncoding.DecodeString(keyData)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
publicKey, err := ssh.ParsePublicKey(publicKeyData)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
signature, err := base64.StdEncoding.DecodeString(signatureData)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
challenge := []byte(req.Method + "," + req.URL.RequestURI())
|
||||
if err := publicKey.Verify(challenge, &ssh.Signature{Format: publicKey.Type(), Blob: signature}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
+6
-4
@@ -115,8 +115,6 @@ type Server struct {
|
||||
UpdateAvailableFunc func()
|
||||
IntegrationInstalled func(string) bool
|
||||
ListCloudModels func(context.Context) (*api.ListResponse, error)
|
||||
featureFlagsMu sync.Mutex
|
||||
featureFlags *featureFlagService
|
||||
}
|
||||
|
||||
func (s *Server) log() *slog.Logger {
|
||||
@@ -297,7 +295,6 @@ func (s *Server) Handler() http.Handler {
|
||||
mux.Handle("POST /api/v1/settings", handle(s.settings))
|
||||
mux.Handle("GET /api/v1/cloud", handle(s.getCloudSetting))
|
||||
mux.Handle("POST /api/v1/cloud", handle(s.cloudSetting))
|
||||
mux.Handle("GET /api/v1/feature-flags/{key}", handle(s.getFeatureFlag))
|
||||
mux.Handle("GET /api/v1/models/cloud", handle(s.getCloudModels))
|
||||
mux.Handle("GET /api/v1/integrations", handle(s.getIntegrationStatuses))
|
||||
|
||||
@@ -342,7 +339,7 @@ func (s *Server) getIntegrationStatuses(w http.ResponseWriter, _ *http.Request)
|
||||
claudeDesktopInstalled := isInstalled("claude-desktop")
|
||||
statuses = append(statuses, integrationStatus{
|
||||
ID: "claude-desktop",
|
||||
Name: "Claude",
|
||||
Name: "Claude Code (Desktop)",
|
||||
Description: "Use Ollama models in Claude Desktop",
|
||||
Installed: &claudeDesktopInstalled,
|
||||
Action: "connect",
|
||||
@@ -1564,6 +1561,11 @@ func (s *Server) settings(w http.ResponseWriter, r *http.Request) error {
|
||||
if err := s.Store.SetSettings(settings); err != nil {
|
||||
return fmt.Errorf("failed to save settings: %w", err)
|
||||
}
|
||||
saved, err := s.Store.Settings()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to load saved settings: %w", err)
|
||||
}
|
||||
settings.CodexDesktopUsed = saved.CodexDesktopUsed
|
||||
|
||||
// Handle auto-update toggle changes
|
||||
if old.AutoUpdateEnabled != settings.AutoUpdateEnabled {
|
||||
|
||||
+155
-8
@@ -17,6 +17,7 @@ import (
|
||||
|
||||
"github.com/ollama/ollama/api"
|
||||
"github.com/ollama/ollama/app/store"
|
||||
"github.com/ollama/ollama/app/ui/responses"
|
||||
"github.com/ollama/ollama/app/updater"
|
||||
"github.com/ollama/ollama/cmd/launch"
|
||||
)
|
||||
@@ -135,6 +136,7 @@ func TestGetIntegrationStatuses(t *testing.T) {
|
||||
|
||||
var got []struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Installed *bool `json:"installed"`
|
||||
Action string `json:"action"`
|
||||
Command string `json:"command"`
|
||||
@@ -146,7 +148,7 @@ func TestGetIntegrationStatuses(t *testing.T) {
|
||||
if len(got) < 5 {
|
||||
t.Fatalf("got %d integrations, want the full registry", len(got))
|
||||
}
|
||||
if got[0].ID != "claude-desktop" || got[0].Action != "connect" || got[0].Command != "" {
|
||||
if got[0].ID != "claude-desktop" || got[0].Name != "Claude Code (Desktop)" || got[0].Action != "connect" || got[0].Command != "" {
|
||||
t.Fatalf("first integration = %+v, want command-free Claude Desktop connect", got[0])
|
||||
}
|
||||
wantPrefix := []string{"claude-desktop", "claude", "codex", "openclaw", "opencode", "hermes", "hermes-desktop", "droid", "pi", "cline"}
|
||||
@@ -997,7 +999,7 @@ func TestSettingsToggleAutoUpdateOn_WithPendingUpdate_ShowsNotification(t *testi
|
||||
}
|
||||
}
|
||||
|
||||
func TestSettingsToggleAutoUpdateOn_NoPendingUpdate_TriggersCheck(t *testing.T) {
|
||||
func TestSettingsToggleAutoUpdateOn_NoPendingUpdate_DoesNotNotify(t *testing.T) {
|
||||
testStore := &store.Store{
|
||||
DBPath: filepath.Join(t.TempDir(), "db.sqlite"),
|
||||
}
|
||||
@@ -1027,12 +1029,6 @@ func TestSettingsToggleAutoUpdateOn_NoPendingUpdate_TriggersCheck(t *testing.T)
|
||||
}}
|
||||
defer upd.Store.Close()
|
||||
|
||||
// Initialize the checkNow channel by starting (and immediately stopping) the checker
|
||||
// so TriggerImmediateCheck doesn't panic on nil channel
|
||||
ctx, cancel := context.WithCancel(t.Context())
|
||||
upd.StartBackgroundUpdaterChecker(ctx, func(string) error { return nil })
|
||||
defer cancel()
|
||||
|
||||
var notificationCalled atomic.Bool
|
||||
server := &Server{
|
||||
Store: testStore,
|
||||
@@ -1066,3 +1062,154 @@ func TestSettingsToggleAutoUpdateOn_NoPendingUpdate_TriggersCheck(t *testing.T)
|
||||
t.Fatal("UpdateAvailableFunc should not be called when there is no pending update")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSettingsPreservesCodexDesktopUsedWhenOmitted(t *testing.T) {
|
||||
testStore := &store.Store{
|
||||
DBPath: filepath.Join(t.TempDir(), "db.sqlite"),
|
||||
}
|
||||
defer testStore.Close()
|
||||
|
||||
settings, err := testStore.Settings()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := testStore.MarkCodexDesktopUsed(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
payload, err := json.Marshal(settings)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var fields map[string]any
|
||||
if err := json.Unmarshal(payload, &fields); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
delete(fields, "CodexDesktopUsed")
|
||||
payload, err = json.Marshal(fields)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
server := &Server{Store: testStore, Restart: func() {}}
|
||||
req := httptest.NewRequest("POST", "/api/v1/settings", bytes.NewReader(payload))
|
||||
rr := httptest.NewRecorder()
|
||||
if err := server.settings(rr, req); err != nil {
|
||||
t.Fatalf("settings() error = %v", err)
|
||||
}
|
||||
|
||||
saved, err := testStore.Settings()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !saved.CodexDesktopUsed {
|
||||
t.Fatal("expected CodexDesktopUsed to be preserved")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSettingsPreservesCodexDesktopUsedWithStaleValue(t *testing.T) {
|
||||
testStore := &store.Store{
|
||||
DBPath: filepath.Join(t.TempDir(), "db.sqlite"),
|
||||
}
|
||||
defer testStore.Close()
|
||||
|
||||
settings, err := testStore.Settings()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := testStore.MarkCodexDesktopUsed(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
payload, err := json.Marshal(settings)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var fields map[string]any
|
||||
if err := json.Unmarshal(payload, &fields); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
fields["CodexDesktopUsed"] = false
|
||||
payload, err = json.Marshal(fields)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
server := &Server{Store: testStore, Restart: func() {}}
|
||||
req := httptest.NewRequest("POST", "/api/v1/settings", bytes.NewReader(payload))
|
||||
rr := httptest.NewRecorder()
|
||||
if err := server.settings(rr, req); err != nil {
|
||||
t.Fatalf("settings() error = %v", err)
|
||||
}
|
||||
|
||||
saved, err := testStore.Settings()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !saved.CodexDesktopUsed {
|
||||
t.Fatal("expected CodexDesktopUsed to be preserved")
|
||||
}
|
||||
}
|
||||
|
||||
type settingsBodyReadHook struct {
|
||||
io.Reader
|
||||
onRead func()
|
||||
}
|
||||
|
||||
func (r *settingsBodyReadHook) Read(p []byte) (int, error) {
|
||||
if r.onRead != nil {
|
||||
onRead := r.onRead
|
||||
r.onRead = nil
|
||||
onRead()
|
||||
}
|
||||
return r.Reader.Read(p)
|
||||
}
|
||||
|
||||
func TestSettingsPreservesConcurrentCodexDesktopAcknowledgment(t *testing.T) {
|
||||
testStore := &store.Store{DBPath: filepath.Join(t.TempDir(), "db.sqlite")}
|
||||
defer testStore.Close()
|
||||
|
||||
settings, err := testStore.Settings()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
settings.Browser = !settings.Browser
|
||||
payload, err := json.Marshal(settings)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
body := &settingsBodyReadHook{
|
||||
Reader: bytes.NewReader(payload),
|
||||
onRead: func() {
|
||||
// The handler has read the old settings but has not saved the request yet.
|
||||
if err := testStore.MarkCodexDesktopUsed(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
},
|
||||
}
|
||||
server := &Server{Store: testStore, Restart: func() {}}
|
||||
req := httptest.NewRequest("POST", "/api/v1/settings", body)
|
||||
rr := httptest.NewRecorder()
|
||||
if err := server.settings(rr, req); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
saved, err := testStore.Settings()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !saved.CodexDesktopUsed {
|
||||
t.Error("overlapping settings save erased the acknowledgment")
|
||||
}
|
||||
if saved.Browser != settings.Browser {
|
||||
t.Error("overlapping acknowledgment lost the requested setting")
|
||||
}
|
||||
var response responses.SettingsResponse
|
||||
if err := json.Unmarshal(rr.Body.Bytes(), &response); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !response.Settings.CodexDesktopUsed {
|
||||
t.Error("settings response returned a stale acknowledgment")
|
||||
}
|
||||
}
|
||||
+14
-1
@@ -353,11 +353,23 @@ func (u *Updater) TriggerImmediateCheck() {
|
||||
}
|
||||
|
||||
func (u *Updater) StartBackgroundUpdaterChecker(ctx context.Context, cb func(string) error) {
|
||||
u.startBackgroundUpdaterChecker(ctx, cb)
|
||||
}
|
||||
|
||||
func (u *Updater) startBackgroundUpdaterChecker(ctx context.Context, cb func(string) error) <-chan struct{} {
|
||||
u.checkNow = make(chan struct{}, 1)
|
||||
u.checkNow <- struct{}{} // Trigger first check after initial delay
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
defer close(done)
|
||||
// Don't blast an update message immediately after startup
|
||||
time.Sleep(UpdateCheckInitialDelay)
|
||||
initialDelay := time.NewTimer(UpdateCheckInitialDelay)
|
||||
defer initialDelay.Stop()
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-initialDelay.C:
|
||||
}
|
||||
slog.Info("beginning update checker", "interval", UpdateCheckInterval)
|
||||
ticker := time.NewTicker(UpdateCheckInterval)
|
||||
defer ticker.Stop()
|
||||
@@ -406,4 +418,5 @@ func (u *Updater) StartBackgroundUpdaterChecker(ctx context.Context, cb func(str
|
||||
}
|
||||
}
|
||||
}()
|
||||
return done
|
||||
}
|
||||
@@ -149,7 +149,7 @@ func DoUpgrade(interactive bool) error {
|
||||
}
|
||||
}
|
||||
|
||||
// Get ready to try to unwind a partial upgade failure during unzip
|
||||
// Get ready to try to unwind a partial upgrade failure during unzip
|
||||
// If something goes wrong, we attempt to put the old version back.
|
||||
anyFailures := false
|
||||
defer func() {
|
||||
|
||||
+38
-13
@@ -190,6 +190,20 @@ func TestDownloadNewReleaseDoesNotUseRawETagAsPathComponent(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// stopChecker cancels the background update checker and waits for its
|
||||
// goroutine to return. Tests must join it before returning: the goroutine
|
||||
// reads package-level knobs (UpdateCheckURLBase, UpdateCheckInterval, ...)
|
||||
// that the next test rewrites.
|
||||
func stopChecker(t *testing.T, cancel context.CancelFunc, done <-chan struct{}) {
|
||||
t.Helper()
|
||||
cancel()
|
||||
select {
|
||||
case <-done:
|
||||
case <-time.After(10 * time.Second):
|
||||
t.Error("background update checker did not stop")
|
||||
}
|
||||
}
|
||||
|
||||
// waitDownloadIdle blocks until no download is in flight, so staged-file
|
||||
// handles close before t.TempDir cleanup removes the stage directory. After
|
||||
// the context is cancelled a new download can't write (it aborts at the HEAD
|
||||
@@ -289,11 +303,12 @@ func TestBackgroundCheckerSkipsAlreadyStagedETagDownload(t *testing.T) {
|
||||
defer cancel()
|
||||
|
||||
callbacks := make(chan string, 4)
|
||||
updater.StartBackgroundUpdaterChecker(ctx, func(ver string) error {
|
||||
checkerDone := updater.startBackgroundUpdaterChecker(ctx, func(ver string) error {
|
||||
callbacks <- ver
|
||||
return nil
|
||||
})
|
||||
t.Cleanup(updater.waitDownloadIdle)
|
||||
defer updater.waitDownloadIdle()
|
||||
defer stopChecker(t, cancel, checkerDone)
|
||||
|
||||
for range 2 {
|
||||
select {
|
||||
@@ -334,10 +349,16 @@ func TestBackgoundChecker(t *testing.T) {
|
||||
UpdateStageDir = t.TempDir()
|
||||
haveUpdate := false
|
||||
verified := false
|
||||
done := make(chan int)
|
||||
// Buffered + non-blocking send: the checker keeps calling cb every
|
||||
// UpdateCheckInterval, and a blocking send would wedge its goroutine once
|
||||
// the test stops receiving.
|
||||
done := make(chan int, 1)
|
||||
cb := func(ver string) error {
|
||||
haveUpdate = true
|
||||
done <- 0
|
||||
select {
|
||||
case done <- 0:
|
||||
default:
|
||||
}
|
||||
return nil
|
||||
}
|
||||
stallTimer := time.NewTimer(5 * time.Second)
|
||||
@@ -381,8 +402,9 @@ func TestBackgoundChecker(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
updater.StartBackgroundUpdaterChecker(ctx, cb)
|
||||
t.Cleanup(updater.waitDownloadIdle)
|
||||
checkerDone := updater.startBackgroundUpdaterChecker(ctx, cb)
|
||||
defer updater.waitDownloadIdle()
|
||||
defer stopChecker(t, cancel, checkerDone)
|
||||
select {
|
||||
case <-stallTimer.C:
|
||||
t.Fatal("stalled")
|
||||
@@ -440,12 +462,13 @@ func TestAutoUpdateDisabledSkipsDownload(t *testing.T) {
|
||||
}
|
||||
|
||||
cb := func(ver string) error {
|
||||
t.Fatal("callback should not be called when auto-update is disabled")
|
||||
t.Error("callback should not be called when auto-update is disabled")
|
||||
return nil
|
||||
}
|
||||
|
||||
updater.StartBackgroundUpdaterChecker(ctx, cb)
|
||||
t.Cleanup(updater.waitDownloadIdle)
|
||||
checkerDone := updater.startBackgroundUpdaterChecker(ctx, cb)
|
||||
defer updater.waitDownloadIdle()
|
||||
defer stopChecker(t, cancel, checkerDone)
|
||||
|
||||
// Wait enough time for multiple check cycles
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
@@ -507,8 +530,9 @@ func TestAutoUpdateReenabledDownloadsUpdate(t *testing.T) {
|
||||
return nil
|
||||
}
|
||||
|
||||
upd.StartBackgroundUpdaterChecker(ctx, cb)
|
||||
t.Cleanup(upd.waitDownloadIdle)
|
||||
checkerDone := upd.startBackgroundUpdaterChecker(ctx, cb)
|
||||
defer upd.waitDownloadIdle()
|
||||
defer stopChecker(t, cancel, checkerDone)
|
||||
|
||||
// Wait for a few cycles with auto-update disabled - no download should happen
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
@@ -641,8 +665,9 @@ func TestTriggerImmediateCheck(t *testing.T) {
|
||||
return nil
|
||||
}
|
||||
|
||||
updater.StartBackgroundUpdaterChecker(ctx, cb)
|
||||
t.Cleanup(updater.waitDownloadIdle)
|
||||
checkerDone := updater.startBackgroundUpdaterChecker(ctx, cb)
|
||||
defer updater.waitDownloadIdle()
|
||||
defer stopChecker(t, cancel, checkerDone)
|
||||
|
||||
// Wait for the initial check that fires after the initial delay
|
||||
select {
|
||||
|
||||
+13
-3
@@ -345,7 +345,7 @@ WEBVIEW_API void webview_unbind(webview_t w, const char *name);
|
||||
* @param seq The sequence number of the binding call. Pass along the value
|
||||
* received in the binding handler (see webview_bind()).
|
||||
* @param status A status of zero tells the JS side that the binding call was
|
||||
* succesful; any other value indicates an error.
|
||||
* successful; any other value indicates an error.
|
||||
* @param result The result of the binding call to be returned to the JS side.
|
||||
* This must either be a valid JSON value or an empty string for
|
||||
* the primitive JS value @c undefined.
|
||||
@@ -2535,9 +2535,19 @@ inline SIZE make_window_frame_size(HWND window, int width, int height,
|
||||
return {frame_width, frame_height};
|
||||
}
|
||||
|
||||
inline bool is_dark_theme_enabled() {
|
||||
constexpr auto *sub_key =
|
||||
L"SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Themes\\Personalize";
|
||||
reg_key key(HKEY_CURRENT_USER, sub_key, 0, KEY_READ);
|
||||
if (!key.is_open()) {
|
||||
// Default is light theme
|
||||
return false;
|
||||
}
|
||||
return key.query_uint(L"AppsUseLightTheme", 1) == 0;
|
||||
}
|
||||
|
||||
inline void apply_window_theme(HWND window) {
|
||||
// Ollama uses a light-only application appearance.
|
||||
constexpr bool dark_theme_enabled = false;
|
||||
auto dark_theme_enabled = is_dark_theme_enabled();
|
||||
|
||||
// Use "immersive dark mode" on systems that support it.
|
||||
// Changes the color of the window's title bar (light or dark).
|
||||
|
||||
@@ -104,7 +104,7 @@ func (t *winTray) wndProc(hWnd windows.Handle, message uint32, wParam, lParam ui
|
||||
}
|
||||
err = t.wcex.unregister()
|
||||
if err != nil {
|
||||
slog.Error(fmt.Sprintf("failed to uregister windo %s", err))
|
||||
slog.Error(fmt.Sprintf("failed to unregister window %s", err))
|
||||
}
|
||||
case WM_DESTROY:
|
||||
// slog.Debug("XXX WM_DESTROY triggered")
|
||||
|
||||
@@ -3,10 +3,8 @@ package auth
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/ed25519"
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
"encoding/pem"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
@@ -20,48 +18,6 @@ import (
|
||||
|
||||
const defaultPrivateKey = "id_ed25519"
|
||||
|
||||
// EnsureKeypair creates the default signing keypair when it does not exist.
|
||||
func EnsureKeypair(out io.Writer) error {
|
||||
home, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
privKeyPath := filepath.Join(home, ".ollama", defaultPrivateKey)
|
||||
pubKeyPath := privKeyPath + ".pub"
|
||||
if _, err := os.Stat(privKeyPath); !os.IsNotExist(err) {
|
||||
return nil
|
||||
}
|
||||
|
||||
fmt.Fprintf(out, "Couldn't find '%s'. Generating new private key.\n", privKeyPath)
|
||||
cryptoPublicKey, cryptoPrivateKey, err := ed25519.GenerateKey(rand.Reader)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
privateKeyBytes, err := ssh.MarshalPrivateKey(cryptoPrivateKey, "")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Dir(privKeyPath), 0o755); err != nil {
|
||||
return fmt.Errorf("could not create directory %w", err)
|
||||
}
|
||||
if err := os.WriteFile(privKeyPath, pem.EncodeToMemory(privateKeyBytes), 0o600); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
sshPublicKey, err := ssh.NewPublicKey(cryptoPublicKey)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
publicKeyBytes := ssh.MarshalAuthorizedKey(sshPublicKey)
|
||||
if err := os.WriteFile(pubKeyPath, publicKeyBytes, 0o644); err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Fprintf(out, "Your new public key is: \n\n%s\n", publicKeyBytes)
|
||||
return nil
|
||||
}
|
||||
|
||||
func GetPublicKey() (string, error) {
|
||||
home, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
|
||||
@@ -1,40 +0,0 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestEnsureKeypair(t *testing.T) {
|
||||
home := t.TempDir()
|
||||
t.Setenv("HOME", home)
|
||||
t.Setenv("USERPROFILE", home)
|
||||
|
||||
var output bytes.Buffer
|
||||
if err := EnsureKeypair(&output); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if output.Len() == 0 {
|
||||
t.Fatal("EnsureKeypair() did not report the generated public key")
|
||||
}
|
||||
|
||||
for _, name := range []string{"id_ed25519", "id_ed25519.pub"} {
|
||||
if _, err := os.Stat(filepath.Join(home, ".ollama", name)); err != nil {
|
||||
t.Fatalf("generated key %s: %v", name, err)
|
||||
}
|
||||
}
|
||||
if _, err := Sign(context.Background(), []byte("request")); err != nil {
|
||||
t.Fatalf("Sign() after EnsureKeypair(): %v", err)
|
||||
}
|
||||
|
||||
output.Reset()
|
||||
if err := EnsureKeypair(&output); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if output.Len() != 0 {
|
||||
t.Fatal("EnsureKeypair() replaced an existing key")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
foreach(_variable IN ITEMS GO_EXECUTABLE SOURCE_DIR BINARY_DIR OUTPUT_DIR TARGETS)
|
||||
if(NOT DEFINED ${_variable})
|
||||
message(FATAL_ERROR "${_variable} is required")
|
||||
endif()
|
||||
endforeach()
|
||||
if(NOT TARGETS)
|
||||
message(FATAL_ERROR "At least one GOOS/GOARCH target is required")
|
||||
endif()
|
||||
|
||||
execute_process(
|
||||
COMMAND "${GO_EXECUTABLE}" tool dist list
|
||||
OUTPUT_VARIABLE _supported_targets
|
||||
OUTPUT_STRIP_TRAILING_WHITESPACE
|
||||
COMMAND_ERROR_IS_FATAL ANY)
|
||||
string(REPLACE "\r" "" _supported_targets "${_supported_targets}")
|
||||
string(REPLACE "\n" ";" _supported_targets "${_supported_targets}")
|
||||
|
||||
set(_version v2.0.1)
|
||||
set(_tool_dir "${BINARY_DIR}/go-licenses-${_version}")
|
||||
set(_tool "${_tool_dir}/go-licenses")
|
||||
if(CMAKE_HOST_WIN32)
|
||||
string(APPEND _tool ".exe")
|
||||
endif()
|
||||
|
||||
if(NOT EXISTS "${_tool}")
|
||||
file(MAKE_DIRECTORY "${_tool_dir}")
|
||||
execute_process(
|
||||
COMMAND "${CMAKE_COMMAND}" -E env
|
||||
--unset=GOOS --unset=GOARCH --unset=GOARM --unset=GOAMD64
|
||||
"GOBIN=${_tool_dir}" "GOFLAGS="
|
||||
"${GO_EXECUTABLE}" install "github.com/google/go-licenses/v2@${_version}"
|
||||
WORKING_DIRECTORY "${SOURCE_DIR}"
|
||||
COMMAND_ERROR_IS_FATAL ANY)
|
||||
endif()
|
||||
|
||||
set(_staging_dir "${BINARY_DIR}/go-license-files")
|
||||
file(REMOVE_RECURSE "${_staging_dir}")
|
||||
|
||||
foreach(_target IN LISTS TARGETS)
|
||||
list(FIND _supported_targets "${_target}" _target_index)
|
||||
if(_target_index EQUAL -1)
|
||||
message(FATAL_ERROR "Unsupported Go license target '${_target}'; expected a GOOS/GOARCH from 'go tool dist list'")
|
||||
endif()
|
||||
string(REPLACE "/" ";" _target_parts "${_target}")
|
||||
list(GET _target_parts 0 _goos)
|
||||
list(GET _target_parts 1 _goarch)
|
||||
|
||||
set(_packages .)
|
||||
if(_goos STREQUAL "darwin" OR _goos STREQUAL "windows")
|
||||
list(APPEND _packages ./app/cmd/app)
|
||||
endif()
|
||||
|
||||
set(_target_staging_dir "${BINARY_DIR}/go-license-files-${_goos}-${_goarch}")
|
||||
message(STATUS "Collecting Go licenses for ${_target}")
|
||||
execute_process(
|
||||
COMMAND "${CMAKE_COMMAND}" -E env
|
||||
"GOOS=${_goos}" "GOARCH=${_goarch}" "CGO_ENABLED=1"
|
||||
"${_tool}" save ${_packages}
|
||||
--save_path "${_target_staging_dir}" --force
|
||||
--ignore github.com/apache/arrow/go/arrow
|
||||
WORKING_DIRECTORY "${SOURCE_DIR}"
|
||||
COMMAND_ERROR_IS_FATAL ANY)
|
||||
|
||||
file(COPY "${_target_staging_dir}/" DESTINATION "${_staging_dir}")
|
||||
endforeach()
|
||||
|
||||
# Arrow's aggregate license includes a license that go-licenses cannot classify.
|
||||
execute_process(
|
||||
COMMAND "${GO_EXECUTABLE}" list -m -f "{{.Dir}}" github.com/apache/arrow/go/arrow
|
||||
WORKING_DIRECTORY "${SOURCE_DIR}"
|
||||
OUTPUT_VARIABLE _arrow_dir
|
||||
OUTPUT_STRIP_TRAILING_WHITESPACE
|
||||
COMMAND_ERROR_IS_FATAL ANY)
|
||||
if(NOT EXISTS "${_arrow_dir}/LICENSE.txt")
|
||||
message(FATAL_ERROR "failed to locate the Apache Arrow license")
|
||||
endif()
|
||||
|
||||
set(_arrow_output_dir "${_staging_dir}/github.com/apache/arrow/go/arrow")
|
||||
file(MAKE_DIRECTORY "${_arrow_output_dir}")
|
||||
file(COPY "${_arrow_dir}/LICENSE.txt" DESTINATION "${_arrow_output_dir}")
|
||||
|
||||
file(GLOB_RECURSE _license_files
|
||||
LIST_DIRECTORIES FALSE
|
||||
RELATIVE "${_staging_dir}"
|
||||
"${_staging_dir}/*")
|
||||
list(SORT _license_files)
|
||||
|
||||
file(MAKE_DIRECTORY "${OUTPUT_DIR}")
|
||||
set(_output "${OUTPUT_DIR}/GO_LICENSE")
|
||||
file(WRITE "${_output}" "Go licenses for Ollama and its dependencies.\n")
|
||||
foreach(_license_file IN LISTS _license_files)
|
||||
file(READ "${_staging_dir}/${_license_file}" _license_text)
|
||||
file(APPEND "${_output}"
|
||||
"\n================================================================================\n"
|
||||
"${_license_file}\n"
|
||||
"================================================================================\n"
|
||||
"${_license_text}\n")
|
||||
endforeach()
|
||||
+37
-14
@@ -169,17 +169,6 @@ if(OLLAMA_MLX_BACKENDS)
|
||||
list(APPEND _mlx_source_targets ollama-mlx-source)
|
||||
endif()
|
||||
|
||||
# Temporary MLX-C carry patch: regenerated bindings for force_fused and the
|
||||
# thread-local compile cache, carried until they merge upstream into
|
||||
# ml-explore/mlx-c. Then bump MLX_C_VERSION and delete mlx/compat/.
|
||||
find_package(Git REQUIRED)
|
||||
set(OLLAMA_MLX_C_COMPAT_PATCH_COMMAND
|
||||
${CMAKE_COMMAND}
|
||||
-DPATCH_DIR=${CMAKE_SOURCE_DIR}/mlx/compat
|
||||
-DPATCH_LABEL=mlx/compat
|
||||
-P ${CMAKE_SOURCE_DIR}/cmake/apply-git-patches.cmake
|
||||
CACHE INTERNAL "MLX-C carry patch")
|
||||
|
||||
if(DEFINED "FETCHCONTENT_SOURCE_DIR_MLX-C" AND NOT "${FETCHCONTENT_SOURCE_DIR_MLX-C}" STREQUAL "")
|
||||
get_filename_component(OLLAMA_MLX_C_SOURCE_DIR
|
||||
"${FETCHCONTENT_SOURCE_DIR_MLX-C}" ABSOLUTE BASE_DIR "${CMAKE_SOURCE_DIR}")
|
||||
@@ -199,9 +188,7 @@ if(OLLAMA_MLX_BACKENDS)
|
||||
CONFIGURE_COMMAND ""
|
||||
BUILD_COMMAND ""
|
||||
INSTALL_COMMAND ""
|
||||
PATCH_COMMAND ${OLLAMA_MLX_C_COMPAT_PATCH_COMMAND}
|
||||
USES_TERMINAL_DOWNLOAD TRUE
|
||||
USES_TERMINAL_PATCH TRUE)
|
||||
USES_TERMINAL_DOWNLOAD TRUE)
|
||||
list(APPEND _mlx_source_targets ollama-mlx-c-source)
|
||||
endif()
|
||||
# XGrammar has no pre-fetch: without an override each variant's build
|
||||
@@ -568,6 +555,42 @@ endfunction()
|
||||
|
||||
find_program(GO_EXECUTABLE go)
|
||||
|
||||
if(GO_EXECUTABLE)
|
||||
if(NOT DEFINED OLLAMA_GO_LICENSE_TARGETS)
|
||||
execute_process(
|
||||
COMMAND "${GO_EXECUTABLE}" env GOOS
|
||||
OUTPUT_VARIABLE _go_license_goos
|
||||
OUTPUT_STRIP_TRAILING_WHITESPACE
|
||||
COMMAND_ERROR_IS_FATAL ANY)
|
||||
execute_process(
|
||||
COMMAND "${GO_EXECUTABLE}" env GOARCH
|
||||
OUTPUT_VARIABLE _go_license_goarch
|
||||
OUTPUT_STRIP_TRAILING_WHITESPACE
|
||||
COMMAND_ERROR_IS_FATAL ANY)
|
||||
set(OLLAMA_GO_LICENSE_TARGETS "${_go_license_goos}/${_go_license_goarch}" CACHE STRING
|
||||
"Semicolon-separated GOOS/GOARCH targets included in GO_LICENSE")
|
||||
endif()
|
||||
|
||||
add_custom_target(ollama-go-license
|
||||
COMMAND ${CMAKE_COMMAND}
|
||||
"-DGO_EXECUTABLE=${GO_EXECUTABLE}"
|
||||
"-DSOURCE_DIR=${CMAKE_SOURCE_DIR}"
|
||||
"-DBINARY_DIR=${CMAKE_BINARY_DIR}"
|
||||
"-DOUTPUT_DIR=${OLLAMA_PAYLOAD_INSTALL_PREFIX}/${OLLAMA_LIB_DIR}"
|
||||
"-DTARGETS=${OLLAMA_GO_LICENSE_TARGETS}"
|
||||
-P "${CMAKE_SOURCE_DIR}/cmake/generate_go_license.cmake"
|
||||
BYPRODUCTS "${OLLAMA_PAYLOAD_INSTALL_PREFIX}/${OLLAMA_LIB_DIR}/GO_LICENSE"
|
||||
COMMENT "Collecting Go licenses"
|
||||
VERBATIM)
|
||||
else()
|
||||
add_custom_target(ollama-go-license
|
||||
COMMAND ${CMAKE_COMMAND} -E echo
|
||||
"Go executable not found. Install Go or set GO_EXECUTABLE to collect Go licenses."
|
||||
COMMAND ${CMAKE_COMMAND} -E false
|
||||
COMMENT "Collecting Go licenses"
|
||||
VERBATIM)
|
||||
endif()
|
||||
|
||||
if(OLLAMA_MLX_BACKENDS)
|
||||
if(GO_EXECUTABLE AND (NOT APPLE OR CMAKE_SYSTEM_PROCESSOR STREQUAL CMAKE_HOST_SYSTEM_PROCESSOR))
|
||||
add_custom_target(ollama-mlx-generate-wrappers
|
||||
|
||||
@@ -128,8 +128,8 @@ list(APPEND MLX_RUNTIME_DIRS ${CMAKE_BINARY_DIR}/_deps/openblas-src/bin)
|
||||
# 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)
|
||||
# Non-link-time deps stay explicit; link-time deps derive from the mlx target.
|
||||
set(MLX_INCLUDE_REGEXES cublas cublasLt cudart cufft nvrtc nvrtc-builtins cudnn nccl cusolver cusparse nv[Jj]it[Ll]ink openblas gfortran)
|
||||
# On Windows, also include dl.dll (dlfcn-win32 POSIX emulation layer).
|
||||
if(WIN32)
|
||||
list(APPEND MLX_INCLUDE_REGEXES "^dl\\.dll$")
|
||||
@@ -145,17 +145,17 @@ install(TARGETS mlx mlxc ollama_xgrammar
|
||||
)
|
||||
install(FILES
|
||||
"${xgrammar_SOURCE_DIR}/LICENSE"
|
||||
DESTINATION ${OLLAMA_INSTALL_DIR}
|
||||
DESTINATION ${OLLAMA_LIB_DIR}
|
||||
RENAME XGRAMMAR_LICENSE
|
||||
COMPONENT MLX)
|
||||
install(FILES
|
||||
"${xgrammar_SOURCE_DIR}/NOTICE"
|
||||
DESTINATION ${OLLAMA_INSTALL_DIR}
|
||||
DESTINATION ${OLLAMA_LIB_DIR}
|
||||
RENAME XGRAMMAR_NOTICE
|
||||
COMPONENT MLX)
|
||||
install(FILES
|
||||
"${xgrammar_SOURCE_DIR}/3rdparty/dlpack/LICENSE"
|
||||
DESTINATION ${OLLAMA_INSTALL_DIR}
|
||||
DESTINATION ${OLLAMA_LIB_DIR}
|
||||
RENAME DLPACK_LICENSE
|
||||
COMPONENT MLX)
|
||||
file(READ "${xgrammar_SOURCE_DIR}/3rdparty/picojson/picojson.h" _picojson_header LIMIT 4096)
|
||||
@@ -168,8 +168,46 @@ string(SUBSTRING "${_picojson_header}" 0 ${_picojson_license_end} _picojson_lice
|
||||
file(WRITE "${CMAKE_BINARY_DIR}/PICOJSON_LICENSE" "${_picojson_license}\n")
|
||||
install(FILES
|
||||
"${CMAKE_BINARY_DIR}/PICOJSON_LICENSE"
|
||||
DESTINATION ${OLLAMA_INSTALL_DIR}
|
||||
DESTINATION ${OLLAMA_LIB_DIR}
|
||||
COMPONENT MLX)
|
||||
get_target_property(_mlx_license_source mlx SOURCE_DIR)
|
||||
if(NOT _mlx_license_source OR _mlx_license_source MATCHES "-NOTFOUND$")
|
||||
message(FATAL_ERROR "MLX source directory not found for license install")
|
||||
endif()
|
||||
install(FILES
|
||||
"${_mlx_license_source}/LICENSE"
|
||||
DESTINATION ${OLLAMA_LIB_DIR}
|
||||
RENAME MLX_LICENSE
|
||||
COMPONENT MLX)
|
||||
get_target_property(_mlxc_license_source mlxc SOURCE_DIR)
|
||||
if(NOT _mlxc_license_source OR _mlxc_license_source MATCHES "-NOTFOUND$")
|
||||
message(FATAL_ERROR "MLX-C source directory not found for license install")
|
||||
endif()
|
||||
install(FILES
|
||||
"${_mlxc_license_source}/LICENSE"
|
||||
DESTINATION ${OLLAMA_LIB_DIR}
|
||||
RENAME MLX_C_LICENSE
|
||||
COMPONENT MLX)
|
||||
# Ship LICENSE/NOTICE/COPYING from every fetched MLX dependency.
|
||||
install(CODE "
|
||||
file(GLOB _dep_dirs
|
||||
LIST_DIRECTORIES true
|
||||
\"${CMAKE_BINARY_DIR}/_deps/*-src\")
|
||||
foreach(_dep \${_dep_dirs})
|
||||
get_filename_component(_dep_name \${_dep} NAME)
|
||||
string(REGEX REPLACE \"-src$\" \"\" _dep_name \"\${_dep_name}\")
|
||||
string(TOUPPER \"\${_dep_name}\" _dep_name)
|
||||
string(REGEX REPLACE \"[^A-Z0-9]\" \"_\" _dep_name \"\${_dep_name}\")
|
||||
file(GLOB _lics \"\${_dep}/LICENSE*\" \"\${_dep}/COPYING*\" \"\${_dep}/NOTICE*\")
|
||||
foreach(_lic \${_lics})
|
||||
get_filename_component(_lic_name \${_lic} NAME)
|
||||
file(INSTALL DESTINATION \"$ENV{DESTDIR}\${CMAKE_INSTALL_PREFIX}/${OLLAMA_LIB_DIR}\"
|
||||
TYPE FILE FILES \"\${_lic}\"
|
||||
RENAME \"\${_dep_name}_\${_lic_name}\")
|
||||
endforeach()
|
||||
endforeach()
|
||||
" COMPONENT MLX)
|
||||
|
||||
install(RUNTIME_DEPENDENCY_SET mlx_runtime_deps
|
||||
DIRECTORIES ${MLX_RUNTIME_DIRS}
|
||||
PRE_INCLUDE_REGEXES ${MLX_INCLUDE_REGEXES}
|
||||
@@ -376,8 +414,7 @@ if(WIN32 AND TARGET dl)
|
||||
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).
|
||||
# dlopen'd runtime libs, derived from MLX's dynamic.c/delayload.cpp
|
||||
if(CUDAToolkit_FOUND)
|
||||
file(GLOB MLX_CUDA_LIBS
|
||||
"${CUDAToolkit_LIBRARY_DIR}/libcudart.so*"
|
||||
@@ -386,11 +423,20 @@ if(CUDAToolkit_FOUND)
|
||||
"${CUDAToolkit_LIBRARY_DIR}/libnvrtc.so*"
|
||||
"${CUDAToolkit_LIBRARY_DIR}/libnvrtc-builtins.so*"
|
||||
"${CUDAToolkit_LIBRARY_DIR}/libcufft.so*"
|
||||
"${CUDAToolkit_LIBRARY_DIR}/libcusolver.so*"
|
||||
"${CUDAToolkit_LIBRARY_DIR}/libcusparse.so*"
|
||||
"${CUDAToolkit_LIBRARY_DIR}/libnvJitLink.so*"
|
||||
"${CUDAToolkit_LIBRARY_DIR}/libcudnn*.so*")
|
||||
if(WIN32)
|
||||
file(GLOB MLX_CUDA_DLLS
|
||||
"${CUDAToolkit_BIN_DIR}/nvrtc-builtins64_*.dll"
|
||||
"${CUDAToolkit_BIN_DIR}/x64/nvrtc-builtins64_*.dll")
|
||||
"${CUDAToolkit_BIN_DIR}/x64/nvrtc-builtins64_*.dll"
|
||||
"${CUDAToolkit_BIN_DIR}/cusolver64_*.dll"
|
||||
"${CUDAToolkit_BIN_DIR}/x64/cusolver64_*.dll"
|
||||
"${CUDAToolkit_BIN_DIR}/cusparse64_*.dll"
|
||||
"${CUDAToolkit_BIN_DIR}/x64/cusparse64_*.dll"
|
||||
"${CUDAToolkit_BIN_DIR}/nvJitLink_*.dll"
|
||||
"${CUDAToolkit_BIN_DIR}/x64/nvJitLink_*.dll")
|
||||
list(APPEND MLX_CUDA_LIBS ${MLX_CUDA_DLLS})
|
||||
endif()
|
||||
find_library(MLX_CUDNN_LIBRARY NAMES cudnn HINTS "$ENV{CUDNN_LIBRARY_PATH}")
|
||||
|
||||
+39
-17
@@ -36,10 +36,11 @@ type flagOptions struct {
|
||||
}
|
||||
|
||||
type Metrics struct {
|
||||
Model string
|
||||
Step string
|
||||
Count int
|
||||
Duration time.Duration
|
||||
Model string
|
||||
Step string
|
||||
Count int
|
||||
CachedPromptCount *int
|
||||
Duration time.Duration
|
||||
}
|
||||
|
||||
type ModelInfo struct {
|
||||
@@ -194,7 +195,7 @@ func outputFormatHeader(w io.Writer, format string, verbose bool) {
|
||||
fmt.Fprintf(w, "goarch: %s\n", runtime.GOARCH)
|
||||
}
|
||||
case "csv":
|
||||
headings := []string{"NAME", "STEP", "COUNT", "NS_PER_COUNT", "TOKEN_PER_SEC"}
|
||||
headings := []string{"NAME", "STEP", "COUNT", "NS_PER_COUNT", "TOKEN_PER_SEC", "CACHED_PROMPT_COUNT"}
|
||||
fmt.Fprintln(w, strings.Join(headings, ","))
|
||||
}
|
||||
}
|
||||
@@ -221,14 +222,21 @@ func OutputMetrics(w io.Writer, format string, metrics []Metrics, verbose bool)
|
||||
case "benchstat":
|
||||
for _, m := range metrics {
|
||||
if m.Step == "generate" || m.Step == "prefill" {
|
||||
var promptCounts string
|
||||
if m.Step == "prefill" {
|
||||
promptCounts = fmt.Sprintf(" %d processed-prompt-token", m.Count)
|
||||
if m.CachedPromptCount != nil {
|
||||
promptCounts += fmt.Sprintf(" %d cached-prompt-token", *m.CachedPromptCount)
|
||||
}
|
||||
}
|
||||
if m.Count > 0 {
|
||||
nsPerToken := float64(m.Duration.Nanoseconds()) / float64(m.Count)
|
||||
tokensPerSec := float64(m.Count) / (float64(m.Duration.Nanoseconds()) + 1e-12) * 1e9
|
||||
fmt.Fprintf(w, "BenchmarkModel/name=%s/step=%s 1 %.2f ns/token %.2f token/sec\n",
|
||||
m.Model, m.Step, nsPerToken, tokensPerSec)
|
||||
fmt.Fprintf(w, "BenchmarkModel/name=%s/step=%s 1 %.2f ns/token %.2f token/sec%s\n",
|
||||
m.Model, m.Step, nsPerToken, tokensPerSec, promptCounts)
|
||||
} else {
|
||||
fmt.Fprintf(w, "BenchmarkModel/name=%s/step=%s 1 0 ns/token 0 token/sec\n",
|
||||
m.Model, m.Step)
|
||||
fmt.Fprintf(w, "BenchmarkModel/name=%s/step=%s 1 0 ns/token 0 token/sec%s\n",
|
||||
m.Model, m.Step, promptCounts)
|
||||
}
|
||||
} else if m.Step == "ttft" {
|
||||
fmt.Fprintf(w, "BenchmarkModel/name=%s/step=ttft 1 %d ns/op\n",
|
||||
@@ -240,6 +248,10 @@ func OutputMetrics(w io.Writer, format string, metrics []Metrics, verbose bool)
|
||||
}
|
||||
case "csv":
|
||||
for _, m := range metrics {
|
||||
cachedPromptCount := ""
|
||||
if m.CachedPromptCount != nil {
|
||||
cachedPromptCount = fmt.Sprint(*m.CachedPromptCount)
|
||||
}
|
||||
if m.Step == "generate" || m.Step == "prefill" {
|
||||
var nsPerToken float64
|
||||
var tokensPerSec float64
|
||||
@@ -247,9 +259,9 @@ func OutputMetrics(w io.Writer, format string, metrics []Metrics, verbose bool)
|
||||
nsPerToken = float64(m.Duration.Nanoseconds()) / float64(m.Count)
|
||||
tokensPerSec = float64(m.Count) / (float64(m.Duration.Nanoseconds()) + 1e-12) * 1e9
|
||||
}
|
||||
fmt.Fprintf(w, "%s,%s,%d,%.2f,%.2f\n", m.Model, m.Step, m.Count, nsPerToken, tokensPerSec)
|
||||
fmt.Fprintf(w, "%s,%s,%d,%.2f,%.2f,%s\n", m.Model, m.Step, m.Count, nsPerToken, tokensPerSec, cachedPromptCount)
|
||||
} else {
|
||||
fmt.Fprintf(w, "%s,%s,1,%d,0\n", m.Model, m.Step, m.Duration.Nanoseconds())
|
||||
fmt.Fprintf(w, "%s,%s,1,%d,0,%s\n", m.Model, m.Step, m.Duration.Nanoseconds(), cachedPromptCount)
|
||||
}
|
||||
}
|
||||
default:
|
||||
@@ -428,12 +440,17 @@ func BenchmarkModel(fOpt flagOptions) error {
|
||||
}
|
||||
}
|
||||
|
||||
cachedPromptCount := 0
|
||||
if responseMetrics.PromptEvalCachedCount != nil {
|
||||
cachedPromptCount = *responseMetrics.PromptEvalCachedCount
|
||||
}
|
||||
metrics := []Metrics{
|
||||
{
|
||||
Model: model,
|
||||
Step: "prefill",
|
||||
Count: responseMetrics.PromptEvalCount,
|
||||
Duration: responseMetrics.PromptEvalDuration,
|
||||
Model: model,
|
||||
Step: "prefill",
|
||||
Count: max(0, responseMetrics.PromptEvalCount-cachedPromptCount),
|
||||
CachedPromptCount: responseMetrics.PromptEvalCachedCount,
|
||||
Duration: responseMetrics.PromptEvalDuration,
|
||||
},
|
||||
{
|
||||
Model: model,
|
||||
@@ -464,8 +481,13 @@ func BenchmarkModel(fOpt flagOptions) error {
|
||||
OutputMetrics(out, *fOpt.format, metrics, *fOpt.verbose)
|
||||
|
||||
if *fOpt.debug && *fOpt.promptTokens > 0 {
|
||||
fmt.Fprintf(os.Stderr, "Generated prompt targeting ~%d tokens (actual: %d)\n",
|
||||
*fOpt.promptTokens, responseMetrics.PromptEvalCount)
|
||||
if responseMetrics.PromptEvalCachedCount == nil {
|
||||
fmt.Fprintf(os.Stderr, "Generated prompt targeting ~%d tokens (actual: %d, cached: unavailable)\n",
|
||||
*fOpt.promptTokens, responseMetrics.PromptEvalCount)
|
||||
} else {
|
||||
fmt.Fprintf(os.Stderr, "Generated prompt targeting ~%d tokens (actual: %d, cached: %d)\n",
|
||||
*fOpt.promptTokens, responseMetrics.PromptEvalCount, cachedPromptCount)
|
||||
}
|
||||
}
|
||||
|
||||
if *fOpt.keepAlive > 0 {
|
||||
|
||||
+71
-3
@@ -4,6 +4,7 @@ import (
|
||||
"bytes"
|
||||
"crypto/rand"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
@@ -15,6 +16,10 @@ import (
|
||||
"github.com/ollama/ollama/api"
|
||||
)
|
||||
|
||||
func testIntPtr(v int) *int {
|
||||
return &v
|
||||
}
|
||||
|
||||
func createTestFlagOptions() flagOptions {
|
||||
models := "test-model"
|
||||
format := "benchstat"
|
||||
@@ -1083,7 +1088,7 @@ func TestBenchmarkModel_CSVFormat(t *testing.T) {
|
||||
}
|
||||
})
|
||||
|
||||
if !strings.Contains(output, "NAME,STEP,COUNT,NS_PER_COUNT,TOKEN_PER_SEC") {
|
||||
if !strings.Contains(output, "NAME,STEP,COUNT,NS_PER_COUNT,TOKEN_PER_SEC,CACHED_PROMPT_COUNT") {
|
||||
t.Errorf("Expected CSV header, got: %s", output)
|
||||
}
|
||||
if !strings.Contains(output, "test-model,prefill,") {
|
||||
@@ -1094,6 +1099,35 @@ func TestBenchmarkModel_CSVFormat(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestBenchmarkModel_PrefillExcludesCachedTokens(t *testing.T) {
|
||||
fOpt := createTestFlagOptions()
|
||||
format := "csv"
|
||||
fOpt.format = &format
|
||||
responses := defaultGenerateResponses()
|
||||
responses[len(responses)-1].PromptEvalCachedCount = testIntPtr(4)
|
||||
|
||||
server := createMockOllamaServer(t, mockServerOptions{generateResponses: responses})
|
||||
defer server.Close()
|
||||
t.Setenv("OLLAMA_HOST", server.URL)
|
||||
|
||||
output := captureOutput(func() {
|
||||
if err := BenchmarkModel(fOpt); err != nil {
|
||||
t.Errorf("BenchmarkModel: %v", err)
|
||||
}
|
||||
})
|
||||
wantPrefix := fmt.Sprintf("test-model,prefill,%d,", responses[len(responses)-1].PromptEvalCount-*responses[len(responses)-1].PromptEvalCachedCount)
|
||||
var prefillRow string
|
||||
for row := range strings.SplitSeq(output, "\n") {
|
||||
if strings.HasPrefix(row, wantPrefix) {
|
||||
prefillRow = row
|
||||
break
|
||||
}
|
||||
}
|
||||
if prefillRow == "" || !strings.HasSuffix(prefillRow, ",4") {
|
||||
t.Errorf("prefill row did not exclude cached prompt tokens:\n%s", output)
|
||||
}
|
||||
}
|
||||
|
||||
// --- Unit tests for helper functions ---
|
||||
|
||||
func TestGeneratePromptForTokenCount(t *testing.T) {
|
||||
@@ -1188,7 +1222,7 @@ func TestBuildGenerateRequest_VariesByEpoch(t *testing.T) {
|
||||
func TestOutputMetrics_Benchstat(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
metrics := []Metrics{
|
||||
{Model: "m1", Step: "prefill", Count: 10, Duration: 100 * time.Millisecond},
|
||||
{Model: "m1", Step: "prefill", Count: 10, CachedPromptCount: testIntPtr(4), Duration: 100 * time.Millisecond},
|
||||
{Model: "m1", Step: "generate", Count: 50, Duration: 500 * time.Millisecond},
|
||||
{Model: "m1", Step: "ttft", Count: 1, Duration: 50 * time.Millisecond},
|
||||
{Model: "m1", Step: "load", Count: 1, Duration: 50 * time.Millisecond},
|
||||
@@ -1214,6 +1248,9 @@ func TestOutputMetrics_Benchstat(t *testing.T) {
|
||||
if !strings.Contains(output, "token/sec") {
|
||||
t.Errorf("Expected token/sec metric for throughput lines, got: %s", output)
|
||||
}
|
||||
if !strings.Contains(output, "10 processed-prompt-token 4 cached-prompt-token") {
|
||||
t.Errorf("Expected prompt cache counts on prefill line, got: %s", output)
|
||||
}
|
||||
for _, line := range strings.Split(strings.TrimSpace(output), "\n") {
|
||||
if !strings.HasPrefix(line, "Benchmark") {
|
||||
continue
|
||||
@@ -1242,6 +1279,37 @@ func TestOutputMetrics_BenchstatFormat(t *testing.T) {
|
||||
if !strings.Contains(output, "ns/token") {
|
||||
t.Errorf("Expected ns/token unit for prefill, got: %s", output)
|
||||
}
|
||||
if strings.Contains(output, "cached-prompt-token") {
|
||||
t.Errorf("unexpected cached prompt count when unavailable: %s", output)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOutputMetrics_CachedPromptAvailability(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
count *int
|
||||
want string
|
||||
}{
|
||||
{name: "unavailable", want: "m1,prefill,10,10000000.00,100.00,"},
|
||||
{name: "zero", count: testIntPtr(0), want: "m1,prefill,10,10000000.00,100.00,0"},
|
||||
{name: "positive", count: testIntPtr(4), want: "m1,prefill,10,10000000.00,100.00,4"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
OutputMetrics(&buf, "csv", []Metrics{{
|
||||
Model: "m1",
|
||||
Step: "prefill",
|
||||
Count: 10,
|
||||
CachedPromptCount: tt.count,
|
||||
Duration: 100 * time.Millisecond,
|
||||
}}, false)
|
||||
if got := strings.TrimSpace(buf.String()); got != tt.want {
|
||||
t.Errorf("output = %q, want %q", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestOutputModelInfo(t *testing.T) {
|
||||
@@ -1403,7 +1471,7 @@ func TestOutputFormatHeader(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
outputFormatHeader(&buf, "csv", false)
|
||||
output := buf.String()
|
||||
if !strings.Contains(output, "NAME,STEP,COUNT") {
|
||||
if output != "NAME,STEP,COUNT,NS_PER_COUNT,TOKEN_PER_SEC,CACHED_PROMPT_COUNT\n" {
|
||||
t.Errorf("Expected CSV header, got: %s", output)
|
||||
}
|
||||
})
|
||||
|
||||
+47
-2
@@ -3,7 +3,10 @@ package cmd
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"crypto/ed25519"
|
||||
"crypto/rand"
|
||||
"encoding/json"
|
||||
"encoding/pem"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
@@ -30,11 +33,11 @@ import (
|
||||
"github.com/olekukonko/tablewriter"
|
||||
"github.com/pkg/browser"
|
||||
"github.com/spf13/cobra"
|
||||
"golang.org/x/crypto/ssh"
|
||||
"golang.org/x/sync/errgroup"
|
||||
"golang.org/x/term"
|
||||
|
||||
"github.com/ollama/ollama/api"
|
||||
"github.com/ollama/ollama/auth"
|
||||
"github.com/ollama/ollama/cmd/config"
|
||||
"github.com/ollama/ollama/cmd/launch"
|
||||
"github.com/ollama/ollama/cmd/tui"
|
||||
@@ -2028,7 +2031,49 @@ func RunServer(_ *cobra.Command, _ []string) error {
|
||||
}
|
||||
|
||||
func initializeKeypair() error {
|
||||
return auth.EnsureKeypair(os.Stdout)
|
||||
home, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
privKeyPath := filepath.Join(home, ".ollama", "id_ed25519")
|
||||
pubKeyPath := filepath.Join(home, ".ollama", "id_ed25519.pub")
|
||||
|
||||
_, err = os.Stat(privKeyPath)
|
||||
if os.IsNotExist(err) {
|
||||
fmt.Printf("Couldn't find '%s'. Generating new private key.\n", privKeyPath)
|
||||
cryptoPublicKey, cryptoPrivateKey, err := ed25519.GenerateKey(rand.Reader)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
privateKeyBytes, err := ssh.MarshalPrivateKey(cryptoPrivateKey, "")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := os.MkdirAll(filepath.Dir(privKeyPath), 0o755); err != nil {
|
||||
return fmt.Errorf("could not create directory %w", err)
|
||||
}
|
||||
|
||||
if err := os.WriteFile(privKeyPath, pem.EncodeToMemory(privateKeyBytes), 0o600); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
sshPublicKey, err := ssh.NewPublicKey(cryptoPublicKey)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
publicKeyBytes := ssh.MarshalAuthorizedKey(sshPublicKey)
|
||||
|
||||
if err := os.WriteFile(pubKeyPath, publicKeyBytes, 0o644); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
fmt.Printf("Your new public key is: \n\n%s\n", publicKeyBytes)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func checkServerHeartbeat(cmd *cobra.Command, _ []string) error {
|
||||
|
||||
+2
-19
@@ -18,7 +18,7 @@ import (
|
||||
// Codex implements Runner for Codex integration
|
||||
type Codex struct{}
|
||||
|
||||
func (c *Codex) String() string { return "Codex" }
|
||||
func (c *Codex) String() string { return "Codex CLI" }
|
||||
|
||||
const (
|
||||
codexProfileName = "ollama-launch"
|
||||
@@ -30,6 +30,7 @@ const (
|
||||
codexRootModelKey = "model"
|
||||
codexRootModelProviderKey = "model_provider"
|
||||
codexRootModelCatalogJSONKey = "model_catalog_json"
|
||||
codexRootOpenAIBaseURLKey = "openai_base_url"
|
||||
)
|
||||
|
||||
func (c *Codex) args(model, modelCatalogPath string, extra []string) ([]string, error) {
|
||||
@@ -389,24 +390,6 @@ func codexValidateProfileConfigText(config codexParsedConfig, profileName, model
|
||||
return nil
|
||||
}
|
||||
|
||||
func codexUpsertSection(text, header string, lines []string) string {
|
||||
block := strings.Join(append([]string{header}, lines...), "\n") + "\n"
|
||||
|
||||
if targetPath, ok := codexTableHeaderPath(header); ok {
|
||||
if start, end, found := codexSectionRange(text, targetPath); found {
|
||||
return text[:start] + block + text[end:]
|
||||
}
|
||||
}
|
||||
|
||||
if text != "" && !strings.HasSuffix(text, "\n") {
|
||||
text += "\n"
|
||||
}
|
||||
if text != "" {
|
||||
text += "\n"
|
||||
}
|
||||
return text + block
|
||||
}
|
||||
|
||||
func codexRemoveSection(text, header string) string {
|
||||
targetPath, ok := codexTableHeaderPath(header)
|
||||
if !ok {
|
||||
|
||||
+1571
-218
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,245 @@
|
||||
package launch
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"io/fs"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
type codexAppRequestCursor struct {
|
||||
mu sync.Mutex
|
||||
start time.Time
|
||||
filterKey string
|
||||
files map[string]int64
|
||||
models map[string]string
|
||||
count uint64
|
||||
}
|
||||
|
||||
var codexAppRequests codexAppRequestCursor
|
||||
|
||||
func resetCodexAppRequestCountAt(path string) error {
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
|
||||
return err
|
||||
}
|
||||
start := time.Now().UTC()
|
||||
if err := os.WriteFile(path, []byte(start.Format(time.RFC3339Nano)+"\n"), 0o600); err != nil {
|
||||
return err
|
||||
}
|
||||
codexAppRequests.mu.Lock()
|
||||
codexAppRequests.start = start
|
||||
codexAppRequests.filterKey = ""
|
||||
codexAppRequests.files = make(map[string]int64)
|
||||
codexAppRequests.models = make(map[string]string)
|
||||
codexAppRequests.count = 0
|
||||
codexAppRequests.mu.Unlock()
|
||||
return nil
|
||||
}
|
||||
|
||||
func resetCodexAppRegularProfileRequestCount() error {
|
||||
path, err := codexAppRegularProfileSessionStartPath()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return resetCodexAppRequestCountAt(path)
|
||||
}
|
||||
|
||||
func codexAppRegularProfileRequestCount() uint64 {
|
||||
startPath, err := codexAppRegularProfileSessionStartPath()
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
data, err := os.ReadFile(startPath)
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
start, err := time.Parse(time.RFC3339Nano, string(bytes.TrimSpace(data)))
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
configPath, err := codexConfigPath()
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
// The regular profile contains native Codex and Ollama turns. Filter its
|
||||
// user-prompt count by the Ollama-only routing catalog; the proxy's raw API
|
||||
// counter would overcount prompts that need multiple tool-loop requests.
|
||||
return codexAppRequests.scan(
|
||||
filepath.Join(filepath.Dir(configPath), "sessions"),
|
||||
start,
|
||||
codexAppRegularProfileRoutingModels(configPath),
|
||||
)
|
||||
}
|
||||
|
||||
func codexAppRegularProfileSessionStartPath() (string, error) {
|
||||
home, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return filepath.Join(home, ".ollama", "launch", "chatgpt-session-start"), nil
|
||||
}
|
||||
|
||||
func codexAppRegularProfileRoutingModels(configPath string) map[string]struct{} {
|
||||
models := make(map[string]struct{})
|
||||
data, err := os.ReadFile(codexAppRoutingCatalogPathForConfig(configPath))
|
||||
if err != nil {
|
||||
return models
|
||||
}
|
||||
var catalog struct {
|
||||
Models []struct {
|
||||
Slug string `json:"slug"`
|
||||
} `json:"models"`
|
||||
}
|
||||
if json.Unmarshal(data, &catalog) != nil {
|
||||
return models
|
||||
}
|
||||
for _, model := range catalog.Models {
|
||||
if key := codexAppCatalogModelKey(strings.TrimSpace(model.Slug)); key != "" {
|
||||
models[key] = struct{}{}
|
||||
}
|
||||
}
|
||||
return models
|
||||
}
|
||||
|
||||
func (c *codexAppRequestCursor) scan(root string, start time.Time, allowedModels map[string]struct{}) uint64 {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
return c.scanLocked(root, start, allowedModels, true)
|
||||
}
|
||||
|
||||
func (c *codexAppRequestCursor) scanLocked(root string, start time.Time, allowedModels map[string]struct{}, retryOnTruncate bool) uint64 {
|
||||
filterKey := codexAppRequestModelFilterKey(allowedModels)
|
||||
if !c.start.Equal(start) || c.filterKey != filterKey || c.files == nil || c.models == nil {
|
||||
c.start = start
|
||||
c.filterKey = filterKey
|
||||
c.files = make(map[string]int64)
|
||||
c.models = make(map[string]string)
|
||||
c.count = 0
|
||||
}
|
||||
|
||||
paths := make([]string, 0)
|
||||
_ = filepath.WalkDir(root, func(path string, entry fs.DirEntry, _ error) error {
|
||||
// Request counting is best-effort; skip paths that disappear or cannot
|
||||
// be inspected while the session directory is changing.
|
||||
if entry == nil || entry.IsDir() || filepath.Ext(path) != ".jsonl" {
|
||||
return nil
|
||||
}
|
||||
paths = append(paths, path)
|
||||
return nil
|
||||
})
|
||||
slices.Sort(paths)
|
||||
for _, path := range paths {
|
||||
info, err := os.Stat(path)
|
||||
if err != nil || info.ModTime().Before(start) {
|
||||
continue
|
||||
}
|
||||
offset := c.files[path]
|
||||
if info.Size() < offset {
|
||||
c.start = start
|
||||
c.filterKey = filterKey
|
||||
c.files = make(map[string]int64)
|
||||
c.models = make(map[string]string)
|
||||
c.count = 0
|
||||
if retryOnTruncate {
|
||||
return c.scanLocked(root, start, allowedModels, false)
|
||||
}
|
||||
return 0
|
||||
}
|
||||
file, err := os.Open(path)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
if _, err := file.Seek(offset, io.SeekStart); err != nil {
|
||||
_ = file.Close()
|
||||
continue
|
||||
}
|
||||
reader := bufio.NewReader(file)
|
||||
model := c.models[path]
|
||||
for {
|
||||
line, readErr := reader.ReadBytes('\n')
|
||||
if readErr != nil {
|
||||
break
|
||||
}
|
||||
offset += int64(len(line))
|
||||
if turnModel, ok := codexAppLineTurnModel(line); ok {
|
||||
model = codexAppCatalogModelKey(turnModel)
|
||||
}
|
||||
if codexAppLineIsUserRequest(line, start) && codexAppRequestModelAllowed(model, allowedModels) {
|
||||
c.count++
|
||||
}
|
||||
}
|
||||
_ = file.Close()
|
||||
c.files[path] = offset
|
||||
c.models[path] = model
|
||||
}
|
||||
return c.count
|
||||
}
|
||||
|
||||
func codexAppRequestModelFilterKey(models map[string]struct{}) string {
|
||||
if models == nil {
|
||||
return "*"
|
||||
}
|
||||
keys := make([]string, 0, len(models))
|
||||
for model := range models {
|
||||
keys = append(keys, model)
|
||||
}
|
||||
slices.Sort(keys)
|
||||
return strings.Join(keys, "\x00")
|
||||
}
|
||||
|
||||
func codexAppRequestModelAllowed(model string, allowedModels map[string]struct{}) bool {
|
||||
if allowedModels == nil {
|
||||
return true
|
||||
}
|
||||
_, ok := allowedModels[model]
|
||||
return ok
|
||||
}
|
||||
|
||||
func codexAppLineTurnModel(line []byte) (string, bool) {
|
||||
if !bytes.Contains(line, []byte(`"type":"turn_context"`)) || !bytes.Contains(line, []byte(`"model"`)) {
|
||||
return "", false
|
||||
}
|
||||
var event struct {
|
||||
Type string `json:"type"`
|
||||
Payload struct {
|
||||
Model string `json:"model"`
|
||||
} `json:"payload"`
|
||||
}
|
||||
if json.Unmarshal(line, &event) != nil || event.Type != "turn_context" {
|
||||
return "", false
|
||||
}
|
||||
model := strings.TrimSpace(event.Payload.Model)
|
||||
return model, model != ""
|
||||
}
|
||||
|
||||
func codexAppLineIsUserRequest(line []byte, start time.Time) bool {
|
||||
if !bytes.Contains(line, []byte(`"role":"user"`)) || !bytes.Contains(line, []byte(`"user.text"`)) {
|
||||
return false
|
||||
}
|
||||
var event struct {
|
||||
Timestamp time.Time `json:"timestamp"`
|
||||
Type string `json:"type"`
|
||||
Payload struct {
|
||||
Type string `json:"type"`
|
||||
Role string `json:"role"`
|
||||
Metadata struct {
|
||||
ContentItemKinds []string `json:"content_item_kinds"`
|
||||
} `json:"internal_chat_message_metadata_passthrough"`
|
||||
} `json:"payload"`
|
||||
}
|
||||
if err := json.Unmarshal(line, &event); err != nil {
|
||||
return false
|
||||
}
|
||||
return !event.Timestamp.Before(start) &&
|
||||
event.Type == "response_item" &&
|
||||
event.Payload.Type == "message" &&
|
||||
event.Payload.Role == "user" &&
|
||||
slices.Contains(event.Payload.Metadata.ContentItemKinds, "user.text")
|
||||
}
|
||||
+1783
-179
File diff suppressed because it is too large.
Load diff
@@ -57,7 +57,7 @@ func TestIntegrationLookup(t *testing.T) {
|
||||
{"claude mixed case", "Claude", true, "Claude Code"},
|
||||
{"claude desktop", "claude-desktop", true, "Claude Desktop"},
|
||||
{"claude desktop alias", "claude-app", true, "Claude Desktop"},
|
||||
{"codex", "codex", true, "Codex"},
|
||||
{"codex", "codex", true, "Codex CLI"},
|
||||
{"chatgpt", "chatgpt", true, "ChatGPT"},
|
||||
{"codex app legacy alias", "codex-app", true, "ChatGPT"},
|
||||
{"codex app desktop alias", "codex-desktop", true, "ChatGPT"},
|
||||
|
||||
+23
-4
@@ -257,6 +257,7 @@ type ModelItem struct {
|
||||
RequiredPlan string
|
||||
ToolCapable bool
|
||||
Capabilities []modelpkg.Capability
|
||||
Thinking *api.ModelRecommendationThinking
|
||||
Size int64
|
||||
Details api.ModelDetails
|
||||
}
|
||||
@@ -330,7 +331,7 @@ Examples:
|
||||
},
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
policy := defaultLaunchPolicy(isInteractiveSession(), yesFlag)
|
||||
// reset when done to make sure state doens't leak between launches
|
||||
// reset when done to make sure state doesn't leak between launches
|
||||
restoreConfirmPolicy := withLaunchConfirmPolicy(policy.confirmPolicy())
|
||||
defer restoreConfirmPolicy()
|
||||
|
||||
@@ -769,7 +770,7 @@ func (c *launcherClient) launchEditorIntegration(ctx context.Context, name strin
|
||||
var launchModels []LaunchModel
|
||||
liveConfigMatches := slices.Equal(editor.Models(), models)
|
||||
if needsConfigure || req.ModelOverride != "" || !savedMatchesModels(saved, models) || !liveConfigMatches {
|
||||
launchModels = c.modelInventory().Resolve(ctx, models)
|
||||
launchModels = c.resolveRunModels(ctx, models)
|
||||
if err := prepareEditorIntegration(name, editor, launchModels); err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -806,7 +807,7 @@ func (c *launcherClient) launchManagedSingleIntegration(ctx context.Context, nam
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := prepareManagedSingleIntegration(name, managed, target, c.modelInventory().Resolve(ctx, configureModels)); err != nil {
|
||||
if err := prepareManagedSingleIntegration(name, managed, target, c.resolveRunModels(ctx, configureModels)); err != nil {
|
||||
return err
|
||||
}
|
||||
if refresher, ok := managed.(ManagedRuntimeRefresher); ok {
|
||||
@@ -1224,6 +1225,7 @@ func (c *launcherClient) requestRecommendations(ctx context.Context) ([]ModelIte
|
||||
VRAMBytes: rec.VRAMBytes,
|
||||
MaxOutputTokens: rec.MaxOutputTokens,
|
||||
RequiredPlan: strings.TrimSpace(rec.RequiredPlan),
|
||||
Thinking: rec.Thinking.Clone(),
|
||||
Details: api.ModelDetails{
|
||||
ContextLength: rec.ContextLength,
|
||||
},
|
||||
@@ -1443,7 +1445,24 @@ func hasLocalModel(inventory []LaunchModel, name string) bool {
|
||||
}
|
||||
|
||||
func (c *launcherClient) resolveRunModels(ctx context.Context, models []string) []LaunchModel {
|
||||
return c.modelInventory().Resolve(ctx, models)
|
||||
recommendations := c.recommendations(ctx)
|
||||
resolved := c.modelInventory().Resolve(ctx, models)
|
||||
byName := make(map[string]*api.ModelRecommendationThinking, len(recommendations))
|
||||
for _, recommendation := range recommendations {
|
||||
if recommendation.Thinking != nil {
|
||||
byName[launchModelRecommendationKey(recommendation.Name)] = recommendation.Thinking
|
||||
}
|
||||
}
|
||||
for i := range resolved {
|
||||
if thinking := byName[launchModelRecommendationKey(resolved[i].Name)]; thinking != nil {
|
||||
resolved[i].Thinking = thinking.Clone()
|
||||
}
|
||||
}
|
||||
return resolved
|
||||
}
|
||||
|
||||
func launchModelRecommendationKey(name string) string {
|
||||
return strings.ToLower(strings.TrimSuffix(strings.TrimSpace(name), ":latest"))
|
||||
}
|
||||
|
||||
func runIntegration(runner Runner, modelName string, models []LaunchModel, args []string) error {
|
||||
|
||||
@@ -46,6 +46,33 @@ func (r *launcherEditorRunner) Models() []string {
|
||||
return append([]string(nil), r.models...)
|
||||
}
|
||||
|
||||
func TestResolveRunModelsCarriesRecommendationThinkingMetadata(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.URL.Path {
|
||||
case "/api/experimental/model-recommendations":
|
||||
fmt.Fprint(w, `{"recommendations":[{"model":"deepseek-v4-flash:cloud","description":"Coding","context_length":1048576,"max_output_tokens":65536,"thinking":{"values":[false,true,"max"],"default":true}}]}`)
|
||||
case "/api/tags":
|
||||
fmt.Fprint(w, `{"models":[{"name":"deepseek-v4-flash:cloud","remote_model":"deepseek-v4-flash"}]}`)
|
||||
default:
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
}))
|
||||
defer server.Close()
|
||||
t.Setenv("OLLAMA_HOST", server.URL)
|
||||
|
||||
client, err := newLauncherClient(defaultLaunchPolicy(false, false))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
models := client.resolveRunModels(context.Background(), []string{"deepseek-v4-flash:cloud"})
|
||||
if len(models) != 1 || models[0].Thinking == nil {
|
||||
t.Fatalf("resolved models = %#v, want recommendation thinking metadata", models)
|
||||
}
|
||||
if !slices.Equal(models[0].Thinking.Values, []any{false, true, "max"}) || models[0].Thinking.Default != true {
|
||||
t.Fatalf("thinking = %#v, want exact endpoint values/default", models[0].Thinking)
|
||||
}
|
||||
}
|
||||
|
||||
type launcherSingleRunner struct {
|
||||
ranModel string
|
||||
}
|
||||
|
||||
@@ -17,6 +17,7 @@ type LaunchModel struct {
|
||||
Remote bool
|
||||
ToolCapable bool
|
||||
Capabilities []modelpkg.Capability
|
||||
Thinking *api.ModelRecommendationThinking
|
||||
ContextLength int
|
||||
MaxOutputTokens int
|
||||
EmbeddingLength int
|
||||
@@ -167,6 +168,7 @@ func launchModelMatches(candidate, name string) bool {
|
||||
|
||||
func cloneLaunchModel(model LaunchModel) LaunchModel {
|
||||
model.Capabilities = append([]modelpkg.Capability(nil), model.Capabilities...)
|
||||
model.Thinking = model.Thinking.Clone()
|
||||
model.Details.Families = append([]string(nil), model.Details.Families...)
|
||||
return model
|
||||
}
|
||||
|
||||
+22
-5
@@ -530,6 +530,10 @@ func (p *Pi) Paths() []string {
|
||||
return paths
|
||||
}
|
||||
|
||||
func piBaseURL() string {
|
||||
return strings.TrimRight(envconfig.Host().String(), "/") + "/v1"
|
||||
}
|
||||
|
||||
func (p *Pi) Edit(models []LaunchModel) error {
|
||||
if len(models) == 0 {
|
||||
return nil
|
||||
@@ -557,11 +561,16 @@ func (p *Pi) Edit(models []LaunchModel) error {
|
||||
|
||||
ollama, ok := providers["ollama"].(map[string]any)
|
||||
if !ok {
|
||||
ollama = map[string]any{
|
||||
"baseUrl": envconfig.Host().String() + "/v1",
|
||||
"api": "openai-completions",
|
||||
"apiKey": "ollama",
|
||||
}
|
||||
ollama = map[string]any{}
|
||||
}
|
||||
|
||||
ollama["baseUrl"] = piBaseURL()
|
||||
|
||||
if _, exists := ollama["api"]; !exists {
|
||||
ollama["api"] = "openai-completions"
|
||||
}
|
||||
if _, exists := ollama["apiKey"]; !exists {
|
||||
ollama["apiKey"] = "ollama"
|
||||
}
|
||||
|
||||
existingModels, ok := ollama["models"].([]any)
|
||||
@@ -652,6 +661,14 @@ func (p *Pi) Models() []string {
|
||||
|
||||
providers, _ := config["providers"].(map[string]any)
|
||||
ollama, _ := providers["ollama"].(map[string]any)
|
||||
|
||||
// Returning nil on host drift forces launchEditorIntegration to call Edit.
|
||||
if configured, _ := ollama["baseUrl"].(string); configured != "" {
|
||||
if strings.TrimRight(configured, "/") != strings.TrimRight(piBaseURL(), "/") {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
models, _ := ollama["models"].([]any)
|
||||
|
||||
var result []string
|
||||
|
||||
+75
-3
@@ -984,7 +984,7 @@ func TestPiEdit(t *testing.T) {
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("updates existing config preserving ollama provider settings", func(t *testing.T) {
|
||||
t.Run("updates existing config overwriting baseUrl with current host", func(t *testing.T) {
|
||||
cleanup()
|
||||
os.MkdirAll(configDir, 0o755)
|
||||
|
||||
@@ -1013,9 +1013,12 @@ func TestPiEdit(t *testing.T) {
|
||||
providers := cfg["providers"].(map[string]any)
|
||||
ollama := providers["ollama"].(map[string]any)
|
||||
|
||||
if ollama["baseUrl"] != "http://custom:8080/v1" {
|
||||
t.Errorf("Custom baseUrl not preserved, got %v", ollama["baseUrl"])
|
||||
// baseUrl must be overwritten to match OLLAMA_HOST (the test server)
|
||||
expectedBaseURL := strings.TrimRight(srv.URL, "/") + "/v1"
|
||||
if ollama["baseUrl"] != expectedBaseURL {
|
||||
t.Errorf("baseUrl = %v, want %v", ollama["baseUrl"], expectedBaseURL)
|
||||
}
|
||||
// User-customized api and apiKey are preserved
|
||||
if ollama["api"] != "custom-api" {
|
||||
t.Errorf("Custom api not preserved, got %v", ollama["api"])
|
||||
}
|
||||
@@ -1569,6 +1572,75 @@ func TestPiModels(t *testing.T) {
|
||||
t.Errorf("Models() = %v, want nil for corrupt config", models)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("returns nil when baseUrl does not match OLLAMA_HOST", func(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
setTestHome(t, tmpDir)
|
||||
|
||||
configDir := filepath.Join(tmpDir, ".pi", "agent")
|
||||
if err := os.MkdirAll(configDir, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
config := `{
|
||||
"providers": {
|
||||
"ollama": {
|
||||
"baseUrl": "http://remote-host:9999/v1",
|
||||
"models": [
|
||||
{"id": "llama3.2"},
|
||||
{"id": "qwen3:8b"}
|
||||
]
|
||||
}
|
||||
}
|
||||
}`
|
||||
configPath := filepath.Join(configDir, "models.json")
|
||||
if err := os.WriteFile(configPath, []byte(config), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// OLLAMA_HOST defaults to 127.0.0.1:11434, which differs from the
|
||||
// baseUrl in the config, so Models() should return nil.
|
||||
models := pi.Models()
|
||||
if models != nil {
|
||||
t.Errorf("Models() = %v, want nil when baseUrl is stale", models)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("returns models when baseUrl matches OLLAMA_HOST", func(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
defer srv.Close()
|
||||
t.Setenv("OLLAMA_HOST", srv.URL)
|
||||
|
||||
tmpDir := t.TempDir()
|
||||
setTestHome(t, tmpDir)
|
||||
|
||||
configDir := filepath.Join(tmpDir, ".pi", "agent")
|
||||
if err := os.MkdirAll(configDir, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
expectedBaseURL := strings.TrimRight(srv.URL, "/") + "/v1"
|
||||
config := fmt.Sprintf(`{
|
||||
"providers": {
|
||||
"ollama": {
|
||||
"baseUrl": "%s",
|
||||
"models": [
|
||||
{"id": "llama3.2"},
|
||||
{"id": "qwen3:8b"}
|
||||
]
|
||||
}
|
||||
}
|
||||
}`, expectedBaseURL)
|
||||
configPath := filepath.Join(configDir, "models.json")
|
||||
if err := os.WriteFile(configPath, []byte(config), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
models := pi.Models()
|
||||
if len(models) != 2 {
|
||||
t.Errorf("Models() returned %d models, want 2", len(models))
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestIsPiOllamaModel(t *testing.T) {
|
||||
|
||||
@@ -98,7 +98,7 @@ var integrationSpecs = []*IntegrationSpec{
|
||||
Name: chatGPTIntegrationName,
|
||||
Runner: &CodexApp{},
|
||||
Aliases: []string{codexAppIntegrationName, "codex-desktop", "codex-gui"},
|
||||
Description: "Complete work with ChatGPT",
|
||||
Description: "Use Ollama models in ChatGPT",
|
||||
Install: IntegrationInstallSpec{
|
||||
CheckInstalled: func() bool {
|
||||
return codexAppInstalled()
|
||||
|
||||
@@ -39,6 +39,13 @@ func buildTestIntegrationAliases() map[string]bool {
|
||||
func setTestHome(t *testing.T, dir string) {
|
||||
t.Helper()
|
||||
setLaunchTestHome(t, dir)
|
||||
oldNativeCatalog := codexAppNativeCatalog
|
||||
codexAppNativeCatalog = func(string) ([]byte, error) {
|
||||
return []byte(`{"models":[{"slug":"gpt-5.6-sol","display_name":"GPT-5.6-Sol","description":"Native Codex test model","priority":10,"supported_in_api":true,"base_instructions":"You are Codex, an agent based on GPT-5. Work carefully."}]}`), nil
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
codexAppNativeCatalog = oldNativeCatalog
|
||||
})
|
||||
}
|
||||
|
||||
func testLaunchModels(names ...string) []LaunchModel {
|
||||
|
||||
+14
-1
@@ -582,9 +582,17 @@ func metricsSummaryLines(metrics *api.Metrics) []string {
|
||||
if metrics.PromptEvalCount > 0 {
|
||||
lines = append(lines, fmt.Sprintf("prompt eval count: %d token(s)", metrics.PromptEvalCount))
|
||||
}
|
||||
cached := 0
|
||||
if metrics.PromptEvalCachedCount != nil {
|
||||
cached = *metrics.PromptEvalCachedCount
|
||||
}
|
||||
if cached > 0 {
|
||||
lines = append(lines, fmt.Sprintf("prompt eval cached: %d token(s)", cached))
|
||||
}
|
||||
if metrics.PromptEvalDuration > 0 {
|
||||
lines = append(lines, fmt.Sprintf("prompt eval duration: %s", metrics.PromptEvalDuration))
|
||||
lines = append(lines, fmt.Sprintf("prompt eval rate: %.2f tokens/s", float64(metrics.PromptEvalCount)/metrics.PromptEvalDuration.Seconds()))
|
||||
uncached := max(0, metrics.PromptEvalCount-cached)
|
||||
lines = append(lines, fmt.Sprintf("prompt eval rate: %.2f tokens/s", float64(uncached)/metrics.PromptEvalDuration.Seconds()))
|
||||
}
|
||||
if metrics.EvalCount > 0 {
|
||||
lines = append(lines, fmt.Sprintf("eval count: %d token(s)", metrics.EvalCount))
|
||||
@@ -597,9 +605,14 @@ func metricsSummaryLines(metrics *api.Metrics) []string {
|
||||
}
|
||||
|
||||
func metricsEmpty(metrics api.Metrics) bool {
|
||||
cached := 0
|
||||
if metrics.PromptEvalCachedCount != nil {
|
||||
cached = *metrics.PromptEvalCachedCount
|
||||
}
|
||||
return metrics.TotalDuration <= 0 &&
|
||||
metrics.LoadDuration <= 0 &&
|
||||
metrics.PromptEvalCount <= 0 &&
|
||||
cached <= 0 &&
|
||||
metrics.PromptEvalDuration <= 0 &&
|
||||
metrics.EvalCount <= 0 &&
|
||||
metrics.EvalDuration <= 0
|
||||
|
||||
@@ -17,6 +17,24 @@ import (
|
||||
"github.com/ollama/ollama/api"
|
||||
)
|
||||
|
||||
func testIntPtr(v int) *int {
|
||||
return &v
|
||||
}
|
||||
|
||||
func TestMetricsSummaryLinesCachedPromptTokens(t *testing.T) {
|
||||
lines := metricsSummaryLines(&api.Metrics{
|
||||
PromptEvalCount: 10,
|
||||
PromptEvalCachedCount: testIntPtr(4),
|
||||
PromptEvalDuration: time.Second,
|
||||
})
|
||||
got := strings.Join(lines, "\n")
|
||||
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(got, want) {
|
||||
t.Errorf("summary missing %q:\n%s", want, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestChatAssistantEntryHasNoLabel(t *testing.T) {
|
||||
m := chatModel{}
|
||||
|
||||
|
||||
+1
-1
@@ -24,7 +24,7 @@ func launcherTestState() *launch.LauncherState {
|
||||
},
|
||||
"codex": {
|
||||
Name: "codex",
|
||||
DisplayName: "Codex",
|
||||
DisplayName: "Codex CLI",
|
||||
Description: "OpenAI's open-source coding agent",
|
||||
Selectable: true,
|
||||
Changeable: true,
|
||||
|
||||
@@ -199,13 +199,9 @@ var (
|
||||
cudaRuntimeDirRegex = regexp.MustCompile(`^cuda_v(\d+)$`)
|
||||
)
|
||||
|
||||
// parseLlamaServerDevices parses the combined output of llama-server discovery.
|
||||
// It extracts device info, ROCm gfx targets, CUDA compute capabilities, and
|
||||
// CUDA compiled architecture lists.
|
||||
func parseLlamaServerDevices(output string, libDirs []string) []ml.DeviceInfo {
|
||||
return parseLlamaServerDevicesWithNative(output, "", libDirs, nil)
|
||||
}
|
||||
|
||||
// parseLlamaServerDevicesWithNative parses the combined output of llama-server
|
||||
// discovery. It extracts device info, ROCm gfx targets, CUDA compute
|
||||
// capabilities, and CUDA compiled architecture lists.
|
||||
func parseLlamaServerDevicesWithNative(output, nativeOutput string, libDirs []string, nativeDevices []nativeProbeDevice) []ml.DeviceInfo {
|
||||
combined := output
|
||||
if nativeOutput != "" {
|
||||
|
||||
@@ -243,7 +243,7 @@ Available devices:
|
||||
if tt.libDirs == nil {
|
||||
tt.libDirs = []string{"/lib/ollama"}
|
||||
}
|
||||
devices := parseLlamaServerDevices(tt.output, tt.libDirs)
|
||||
devices := parseLlamaServerDevicesWithNative(tt.output, "", tt.libDirs, nil)
|
||||
if len(devices) != len(tt.want) {
|
||||
t.Fatalf("got %d devices, want %d", len(devices), len(tt.want))
|
||||
}
|
||||
@@ -357,7 +357,7 @@ Available devices:
|
||||
libDirs = []string{"/lib/ollama"}
|
||||
}
|
||||
|
||||
got := parseLlamaServerDevices(tt.output, libDirs)
|
||||
got := parseLlamaServerDevicesWithNative(tt.output, "", libDirs, nil)
|
||||
if len(got) != len(tt.want) {
|
||||
t.Fatalf("got %d devices, want %d", len(got), len(tt.want))
|
||||
}
|
||||
|
||||
+2
-1
@@ -100,7 +100,8 @@ The final response in the stream also includes additional data about the generat
|
||||
- `total_duration`: time spent generating the response
|
||||
- `load_duration`: time spent in nanoseconds loading the model
|
||||
- `prompt_eval_count`: number of tokens in the prompt
|
||||
- `prompt_eval_duration`: time spent in nanoseconds evaluating the prompt
|
||||
- `prompt_eval_cached_count`: number of prompt tokens read from the cache
|
||||
- `prompt_eval_duration`: time spent in nanoseconds evaluating uncached prompt tokens
|
||||
- `eval_count`: number of tokens in the response
|
||||
- `eval_duration`: time in nanoseconds spent generating the response
|
||||
- `context`: an encoding of the conversation used in this response, this can be sent in the next request to keep a conversational memory
|
||||
|
||||
+4
-2
@@ -6,8 +6,9 @@ Ollama's API responses include metrics that can be used for measuring performanc
|
||||
|
||||
* `total_duration`: How long the response took to generate
|
||||
* `load_duration`: How long the model took to load
|
||||
* `prompt_eval_count`: How many input tokens were processed
|
||||
* `prompt_eval_duration`: How long it took to evaluate the prompt
|
||||
* `prompt_eval_count`: How many input tokens were in the prompt
|
||||
* `prompt_eval_cached_count`: How many prompt tokens were read from the cache
|
||||
* `prompt_eval_duration`: How long it took to evaluate the uncached prompt tokens
|
||||
* `eval_count`: How many output tokens were processes
|
||||
* `eval_duration`: How long it took to generate the output tokens
|
||||
|
||||
@@ -27,6 +28,7 @@ For endpoints that return usage metrics, the response body will include the usag
|
||||
"total_duration": 174560334,
|
||||
"load_duration": 101397084,
|
||||
"prompt_eval_count": 11,
|
||||
"prompt_eval_cached_count": 8,
|
||||
"prompt_eval_duration": 13074791,
|
||||
"eval_count": 18,
|
||||
"eval_duration": 52479709
|
||||
|
||||
@@ -7,7 +7,7 @@ Vision models accept images alongside text so the model can describe, classify,
|
||||
## Quick start
|
||||
|
||||
```shell
|
||||
ollama run gemma4 ./image.png whats in this image?
|
||||
ollama run gemma4 ./image.png what is in this image?
|
||||
```
|
||||
|
||||
|
||||
|
||||
+12
-3
@@ -147,9 +147,12 @@ components:
|
||||
prompt_eval_count:
|
||||
type: integer
|
||||
description: Number of input tokens in the prompt
|
||||
prompt_eval_cached_count:
|
||||
type: integer
|
||||
description: Number of prompt tokens read from the cache
|
||||
prompt_eval_duration:
|
||||
type: integer
|
||||
description: Time spent evaluating the prompt in nanoseconds
|
||||
description: Time spent evaluating uncached prompt tokens in nanoseconds
|
||||
eval_count:
|
||||
type: integer
|
||||
description: Number of output tokens generated in the response
|
||||
@@ -191,9 +194,12 @@ components:
|
||||
prompt_eval_count:
|
||||
type: integer
|
||||
description: Number of input tokens in the prompt
|
||||
prompt_eval_cached_count:
|
||||
type: integer
|
||||
description: Number of prompt tokens read from the cache
|
||||
prompt_eval_duration:
|
||||
type: integer
|
||||
description: Time spent evaluating the prompt in nanoseconds
|
||||
description: Time spent evaluating uncached prompt tokens in nanoseconds
|
||||
eval_count:
|
||||
type: integer
|
||||
description: Number of output tokens generated in the response
|
||||
@@ -352,9 +358,12 @@ components:
|
||||
prompt_eval_count:
|
||||
type: integer
|
||||
description: Number of tokens in the prompt
|
||||
prompt_eval_cached_count:
|
||||
type: integer
|
||||
description: Number of prompt tokens read from the cache
|
||||
prompt_eval_duration:
|
||||
type: integer
|
||||
description: Time spent evaluating the prompt in nanoseconds
|
||||
description: Time spent evaluating uncached prompt tokens in nanoseconds
|
||||
eval_count:
|
||||
type: integer
|
||||
description: Number of tokens generated in the response
|
||||
|
||||
@@ -26,6 +26,15 @@ func value[T any](v Value, kinds ...reflect.Kind) (t T) {
|
||||
return
|
||||
}
|
||||
|
||||
func valueOK[T any](v Value, kinds ...reflect.Kind) (t T, ok bool) {
|
||||
vv := reflect.ValueOf(v.value)
|
||||
if !vv.IsValid() || !slices.Contains(kinds, vv.Kind()) {
|
||||
return t, false
|
||||
}
|
||||
|
||||
return vv.Convert(reflect.TypeOf(t)).Interface().(T), true
|
||||
}
|
||||
|
||||
func values[T any](v Value, kinds ...reflect.Kind) (ts []T) {
|
||||
switch vv := reflect.ValueOf(v.value); vv.Kind() {
|
||||
case reflect.Slice:
|
||||
@@ -44,6 +53,12 @@ func (v Value) Int() int64 {
|
||||
return value[int64](v, reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64)
|
||||
}
|
||||
|
||||
// IntOK converts a signed integer value to int64 and reports whether the
|
||||
// underlying type was signed.
|
||||
func (v Value) IntOK() (int64, bool) {
|
||||
return valueOK[int64](v, reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64)
|
||||
}
|
||||
|
||||
// Ints returns Value as a signed integer slice. If it is not a signed integer slice, it returns nil.
|
||||
func (v Value) Ints() (i64s []int64) {
|
||||
return values[int64](v, reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64)
|
||||
@@ -54,6 +69,12 @@ func (v Value) Uint() uint64 {
|
||||
return value[uint64](v, reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64)
|
||||
}
|
||||
|
||||
// UintOK converts an unsigned integer value to uint64 and reports whether the
|
||||
// underlying type was unsigned.
|
||||
func (v Value) UintOK() (uint64, bool) {
|
||||
return valueOK[uint64](v, reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64)
|
||||
}
|
||||
|
||||
// Uints returns Value as a unsigned integer slice. If it is not a unsigned integer slice, it returns nil.
|
||||
func (v Value) Uints() (u64s []uint64) {
|
||||
return values[uint64](v, reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64)
|
||||
@@ -64,6 +85,12 @@ func (v Value) Float() float64 {
|
||||
return value[float64](v, reflect.Float32, reflect.Float64)
|
||||
}
|
||||
|
||||
// FloatOK converts a float value to float64 and reports whether the underlying
|
||||
// type was a float.
|
||||
func (v Value) FloatOK() (float64, bool) {
|
||||
return valueOK[float64](v, reflect.Float32, reflect.Float64)
|
||||
}
|
||||
|
||||
// Floats returns Value as a float slice. If it is not a float slice, it returns nil.
|
||||
func (v Value) Floats() (f64s []float64) {
|
||||
return values[float64](v, reflect.Float32, reflect.Float64)
|
||||
|
||||
@@ -62,7 +62,7 @@ require (
|
||||
github.com/kr/text v0.2.0 // indirect
|
||||
github.com/lucasb-eyer/go-colorful v1.2.0 // indirect
|
||||
github.com/mailru/easyjson v0.7.7 // indirect
|
||||
github.com/mattn/go-localereader v0.0.1 // indirect
|
||||
github.com/mattn/go-localereader v0.0.2-0.20220822084737-6bae6c923850 // indirect
|
||||
github.com/mattn/go-pointer v0.0.1 // indirect
|
||||
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect
|
||||
github.com/muesli/cancelreader v0.2.2 // indirect
|
||||
|
||||
@@ -170,8 +170,8 @@ github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0
|
||||
github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc=
|
||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4=
|
||||
github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88=
|
||||
github.com/mattn/go-localereader v0.0.2-0.20220822084737-6bae6c923850 h1:3wTbcd6IAtyC45hpBhisgmnAl/+twARH7FyVSh5NbcY=
|
||||
github.com/mattn/go-localereader v0.0.2-0.20220822084737-6bae6c923850/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88=
|
||||
github.com/mattn/go-pointer v0.0.1 h1:n+XhsuGeVO6MEAp7xyEukFINEa+Quek5psIR/ylA6o0=
|
||||
github.com/mattn/go-pointer v0.0.1/go.mod h1:2zXcozF6qYGgmsG+SeTZz3oAbFLdD3OWqnUbNvJZAlc=
|
||||
github.com/mattn/go-runewidth v0.0.9/go.mod h1:H031xJmbD/WCDINGzjvQ9THkh0rPKHF+m2gUSrubnMI=
|
||||
|
||||
@@ -340,7 +340,7 @@ func TestHarmonyParserStreaming(t *testing.T) {
|
||||
},
|
||||
},
|
||||
{
|
||||
desc: "message with channel and recipient (receipient before channel)",
|
||||
desc: "message with channel and recipient (recipient before channel)",
|
||||
steps: []step{
|
||||
{
|
||||
input: "<|start|>assistant to=functions.calc<|channel|>commentary<|message|>",
|
||||
|
||||
@@ -413,7 +413,7 @@ func runAPIShowModel(t *testing.T) {
|
||||
}
|
||||
// llama3 omits system
|
||||
verifyModelDetails(t, resp.Details)
|
||||
// llama3 ommits messages
|
||||
// llama3 omits messages
|
||||
if len(resp.ModelInfo) == 0 {
|
||||
t.Errorf("%s missing model_info: %#v", modelName, resp)
|
||||
}
|
||||
|
||||
Loaded 100 of 253 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user