Compare commits

..
Author SHA1 Message Date
Parth Sareen 86f7292934 openai: accept plaintext-labeled Codex agent messages (#18329) 2026-09-08 17:09:48 -07:00
Parth Sareen cd1c5a145d openai: finalize responses at the web search limit (#18328) 2026-09-08 16:27:20 -07:00
Eva H 9160b3c0b8 app: add a first-use ChatGPT connection intro (#18321) 2026-09-08 15:44:21 -07:00
Eva H 3e02feac8d app: align ChatGPT connection status copy with Claude (#18325) 2026-09-08 15:22:56 -07:00
Parth Sareen 1b45aa19da server/openai: retry compaction after context overflow (#18324) 2026-09-08 14:42:03 -07:00
Eva H 34afa1a0c8 app: open Codex by default for Ollama launches (#18323) 2026-09-08 13:40:20 -07:00
Daniel Hiltgen b5d373f340 fix data races in progress and sched (#18319)
progress: join the render loop in stop and do the final writes after the
goroutine exits, so Stop/StopAndClear cannot race an in-flight render on
the shared bufio.Writer.

sched: read the unload-mutable fields in runnerRef.LogValue only under a
successful refMu.TryLock and omit them when contended, since slog resolves
it on goroutines that may already hold refMu.
2026-09-08 13:13:59 -07:00
Parth Sareen 9ef6c19341 app: preserve Codex configuration across host changes (#18247) 2026-09-08 12:42:37 -07:00
Jeffrey Morgan 3b5ab1fcfc openai: use tsc_ prefix for tool search call IDs (#18296) 2026-09-08 10:55:23 -07:00
Parth Sareen 83ed7d9965 app: harden Codex desktop proxy handling (#18244) 2026-09-04 17:48:55 -07:00
Parth Sareen b043d891c2 openai: preserve images through response compaction (#18245) 2026-09-04 17:48:45 -07:00
Parth Sareen 43e667004a app: route Codex auto review through selected model (#18246) 2026-09-04 17:48:37 -07:00
Daniel Hiltgen d3efc63263 ci: bound and cache the macOS MLX payload build (#18240)
Avoid unbounded parallel builds on small runners, and cache the payloads for repeated runs.
2026-09-04 14:38:05 -07:00
Eva H 87b9f9e95a app: add Ollama to ChatGPT Desktop (#18236) 2026-09-04 14:04:48 -07:00
Parth Sareen 3f77cb6dfb openai: add Codex compaction support (#18224) 2026-09-04 13:07:33 -07:00
Parth Sareen cf8b605b06 openai: add client tool search support (#18223) 2026-09-04 13:07:23 -07:00
Jesse Gross b68365a0a4 mlxrunner: compile structured output as xgrammar structural tags
The runner compiled a format as a JSON Schema, the one grammar kind its
xgrammar binding exposed. A structural tag holds a schema as one node of
a larger tree and also expresses what a schema cannot: free text around
constrained spans, a thinking region that closes before constrained
content, tool calls pinned to their schemas.

The runner now compiles structural tags only; its client wraps the API's
formats into one, which compiles to the same grammar as before. The JSON
token and vocabulary caps go with it: neither bounds compile cost, which
follows the grammar's state count. The byte and nesting caps stay.
2026-09-03 15:49:34 -07:00
Jesse Gross 4986e92379 mlxrunner: enable speculative decoding under structured output
A structured-output request could not use a model's draft head: it
decoded one token at a time, at roughly half the speculative throughput
on a dense 27B MTP model.

The grammar is enforced during verification instead: each draft
position's logits are masked before rejection sampling, so an invalid
draft is never accepted and every emitted token obeys the grammar.
Drafts stay unconstrained; constraining the draft chain would stall its
pipelined forwards.

Speculative steps also now dispatch the drafts before the host builds
the verification graph, worth 4-8% end to end at a fixed draft depth on
MTP models, with or without a grammar.
2026-09-03 15:49:34 -07:00
Daniel Hiltgen 59fe23d85c ci: rebuild MLX macOS test payloads the release can't supply (#18219)
* ci: rebuild MLX macOS test payloads the release can't supply

The MLX unit test dependency payload can go stale when a PR changes the
xgrammar wrapper (release dylib lacks the new symbols) or bumps MLX pins (no
matching release, so MLX tests silently skipped). Compare the matched tag's
build rules and wrapper sources against the checkout, rebuilding only
libollama_xgrammar.dylib on drift; with no matching release, build one Metal
variant at the platform default.

* address comments
2026-09-03 14:17:21 -07:00
Jesse Gross b79067b0db gemma4: image and audio input support
Safetensors gemma4 imports served by the MLX engine now answer image
and audio chats. Images run through both vision architectures: the
transformer tower (26B, 31B, e-series) and the 12B's encoder-free
unified embedder. Audio arrives through the same intake the ollama
API already accepts for gemma4 GGUFs — WAV bytes in the images field,
OpenAI input_audio parts, and /v1/audio/transcriptions uploads — with
the e2b/e4b checkpoints running clips through their conformer audio
encoder and the 12b unified checkpoint embedding the raw waveform
directly. Clips longer than 30 seconds are split evenly into chunks
of at most 30 seconds, cut at pauses, and encoded independently.

Each modality serves only checkpoints that carry it: 26B/31B have no
audio config and reject audio input, and checkpoints with an
unrecognized vision architecture still load as text-only models and
reject image requests.

The server previously hid the vision and audio capabilities for
gemma4 safetensors because the engine served neither. Both
suppressions are removed, and existing imports start advertising the
capabilities without re-importing since import already records them.
2026-09-02 15:07:28 -07:00
Jesse Gross 3ffc9a682a mlxrunner: shared audio decoding and chunking for audio models
Audio-capable models need mono PCM at their expected sample rate
before model-specific feature extraction. Like the image decoder set
in base, the supported audio containers are decided once here so
every model accepts the same formats: WAV, covering integer and float
PCM, extensible headers, and multi-channel downmix. Anything else is
rejected as unrecognized; supporting another container later means
one new decoder here, with no model or runner changes.

Input at other sample rates is resampled through a band-limiting
filter, so mismatched rates degrade gracefully instead of aliasing.
Decoded clips are capped at ten minutes: the declared rate comes from
an untrusted header, and the cap is what keeps a small file claiming
an absurdly low rate from resampling into an enormous allocation.

Models whose encoder takes clips only up to a fixed length split
longer clips with Split. Chunks are sized evenly, and each cut moves
to the quietest point within a few seconds of its even share, so a
boundary lands on a pause where the clip has one instead of severing
a word between two independently encoded chunks. The even sizing
bounds the search so no chunk can end up over the limit.
2026-09-02 15:07:28 -07:00
Jesse Gross ba064c3662 mlxrunner: check every mlx-c call in the MLX bindings
The bindings captured MLX error messages but checked almost no calls,
so a failure continued with a null output and surfaced later as zero
results, skipped evals, or an unrelated crash.

Wrap every call in mlxCheck. Paths that return an error or disable a
GPU kernel backend use mlxError instead, and the lookups where a
non-zero status means a miss read the buffer first and then treat the
status as data.

Also free the string handles behind Array.String and the log values
after the call that fills them; they were freed before it and leaked.
2026-09-02 15:05:54 -07:00
Jesse Gross c36adebc20 mlxrunner: capture MLX errors in a single buffer read after every call
MLX runs on one goroutine locked to its OS thread, so the thread-local
error buffers and closure-based check helpers defended against a
calling pattern that is already invalid.

Replace them with a single buffer that the handler fills and Go reads
after every call. mlxError returns the captured message; mlxCheck
panics on it and passes the call's result through, so a checked call
is one expression. Only an int status carries a failure signal, which
lets a message next to a zero status be reported as an earlier
unchecked call.

Fix two tests that relied on errors being dropped: the laguna
mixed-precision fixture used an unsupported quantization group size,
and the compile callback test expected the callback's own panic.
2026-09-02 15:05:54 -07:00
Daniel Hiltgen 882387a57b mlx: fix mtp_test unit test (#18203) 2026-09-02 11:17:16 -07:00
Daniel Hiltgen b1d1ccc957 llama.cpp: version bump b10760 (#18199) 2026-09-02 09:46:04 -07:00
Daniel Hiltgen 855f4bf989 Report cached prompt tokens (#17943)
* Report cached prompt tokens

Add prompt_eval_cached_count to native responses and expose equivalent cached-token fields through the OpenAI- and Anthropic-compatible APIs. Keep prompt_eval_count as the logical input total while excluding cache hits from CLI and benchmark prefill rates. Surface processed and cached prompt counts in benchmark output.

Collect cache counts from llama-server and MLX, preserve coherent metrics across two-pass structured generation.

Fixes #8008

Related to #15758

* review comments
2026-09-02 09:30:44 -07:00
Daniel Hiltgen e5e4377115 ci: wire up MLX unit tests for PR runs (#17022)
* ci: wire up MLX unit tests for PR runs

Download the latest Darwin release payload matching the current MLX and MLX-C revisions so macOS PR tests can exercise MLX without rebuilding it. If no matching release exists after a pin bump, leave MLX tests skipped until the next release.

Add whole-tree race coverage and smoke-run committed benchmarks. Verify generated UI types, and stabilize tests exposed by the broader CI coverage.

* review comments

* mlx: run tests on one pinned worker

Keep MLX tests and benchmarks on a shared pinned thread while preserving Fatal, Skip, and Cleanup semantics. Also clean stale CI payloads and ensure updater workers shut down cleanly.

* addres comments
2026-09-01 16:59:36 -07:00
Daniel Hiltgen 5ec5804360 llama.cpp: version bump b10729 (#18160)
* llama.cpp: version bump b10729

Regenerate the compat hooks patch for b10729: upstream removed the
whole-tensor load_data_for read (last consumer was llama-quantize,
which now reads slabs via load_data_range). Keep the existing hook
surface (constructor, skip loops, load_all_data, mtmd/clip) unchanged
and add maybe_load_text_tensor_range, which materializes a text load
op's output once per tensor and serves the new (offset, size) slab
reads from that cache.

* address comments
2026-09-01 16:24:15 -07:00
Daniel Hiltgen 3ba380d0be MLX, MLX-C: version bump (#18080)
* MLX, MLX-C: version bump

* resolve comments

* address comments
2026-09-01 16:24:04 -07:00
Daniel Hiltgen f348c7e3f5 Honor model generation defaults (#16471)
* Honor model generation defaults

Model-authored sampler defaults from GGUF metadata and HF generation_config.json were ignored, so built-in Ollama defaults could override model intent unless parameters were set in the Modelfile or request. The fix parses those defaults into model config and applies them before Modelfile/request options, preserving the expected precedence order.

* review comments

* address comments
2026-09-01 14:52:07 -07:00
Daniel Hiltgen 205a042690 build: go deps (#18161)
* build: go deps

Gather go dep licenses

* address comments
2026-09-01 12:59:48 -07:00
Daniel Hiltgen ef117cfcc0 mlx: dedup dependency files (#18159)
Put license files in lib/ollama so runner dirs don't duplicate them, and
package in the base package(s).
2026-08-31 17:12:46 -07:00
Marcel PetrickandPatrick Devine e37a00a8fa fix(docs): correct typos found during code review (#17579)
* fix(docs): correct typos found during code review

Non-functional changes only:
- Fixed minor spelling mistakes in comments
- Corrected typos in user-facing strings
- No variables, logic, or functional code was modified.

Signed-off-by: Marcel Petrick <mail@marcelpetrick.it>

* fix additional typos and shell-unsafe example in docs

---------

Co-authored-by: Patrick Devine <patrick@ollama.com>
2026-08-31 08:59:55 -07:00
Daniel Hiltgen f96e7aa051 lint fix (#18081) 2026-08-27 17:01:54 -07:00
Daniel Hiltgen 68793119df Clean up dead code (#17381)
Largely from the llama-server work.
2026-08-27 14:33:51 -07:00
Eva H f4025ed1fe app: list account cloud models for Claude (#18077) 2026-08-27 13:22:11 -07:00
Parth Sareen 39f7f91563 app: synchronize macOS app handoff (#18056) 2026-08-26 23:15:54 -07:00
Parth Sareen d366f4868a proxy: continue requests when the model catalog changes (#18058) 2026-08-26 19:39:05 -07:00
Eva H a67fe8c537 app: restore system dark mode (#18049) 2026-08-26 22:34:51 -04:00
Parth Sareen 13f2fb8c99 app: consume Claude recommendation mappings (#18042) 2026-08-26 15:55:01 -07:00
Jesse Gross 3b96a8972a mlx: remove the text-only gemma3 model
The MLX gemma3 port implements only the text stack, while gemma3 as GGUF
runs on llama-server with vision. Once MLX takes priority for
architectures both engines support, a registered gemma3 would route the
model to the engine that cannot serve images. No gemma3 safetensors
manifests were ever published, so removing the architecture affects no
existing installs and keeps gemma3 on llama-server.
2026-08-26 14:01:50 -07:00
Daniel Hiltgen 91cf995996 launch: respect OLLAMA_HOST when configuring Pi (#18039)
Pi's Edit() only set baseUrl when creating a new ollama provider entry.
On subsequent launches it preserved whatever baseUrl was already in
~/.pi/agent/models.json, so switching OLLAMA_HOST to a remote server had
no effect — Pi would still connect to localhost.

Edit() now ensures baseUrl reflects the current OLLAMA_HOST. Models()
returns nil when the stored baseUrl no longer matches, so the launcher
only calls Edit() when the host has actually drifted. User-customized api
and apiKey fields are still preserved.
2026-08-26 13:24:05 -07:00
253 changed files with 35991 additions and 9172 deletions

No files matched your search

+366
View File
@@ -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
+18
View File
@@ -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 ;;
+15 -1
View File
@@ -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
+82 -1
View File
@@ -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
View File
@@ -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
View File
@@ -1 +1 @@
b10630
b10760
+1 -1
View File
@@ -1 +1 @@
fba4470b89073180056c9ea46c443051375f7399
c74db5307cc8ce122f48d97ef951b30578674e7f
+1 -1
View File
@@ -1 +1 @@
c793734eb715dbcfdb1ced58e348ec53c2d7ed85
37c26e5755da637255d57ea34b4879196a485301
+44 -16
View File
@@ -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,
},
},
})
+74 -6
View File
@@ -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 {
+74
View File
@@ -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
View File
@@ -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 {
+19
View File
@@ -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
View File
@@ -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
View File
@@ -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()
}
+14 -2
View File
@@ -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
View File
@@ -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 couldnt 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 couldnt 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
View File
@@ -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")
+2 -1
View File
@@ -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() {}
+81
View File
@@ -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 "Couldnt refresh available models. Your saved models are unchanged."
}
return "Couldnt 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
}
+8 -1
View File
@@ -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

+1 -1
View File
@@ -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
View File
@@ -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)
+35
View File
@@ -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")
}
}
+11
View File
@@ -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
+55
View File
@@ -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()
+3 -3
View File
@@ -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

-41
View File
@@ -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();
-32
View File
@@ -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 ChatGPTs 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 wasnt 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 couldnt 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>
);
}
+1 -1
View File
@@ -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" />
+1 -1
View File
@@ -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">
+123 -19
View File
@@ -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 &amp; connect");
expect(html).toContain("Download &amp; 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 &amp; 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");
+73 -39
View File
@@ -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&apos;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();
});
});
+54 -13
View File
@@ -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 */}
+9 -2
View File
@@ -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,
};
}
+10 -7
View File
@@ -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],
+16 -3
View File
@@ -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" },
+74
View File
@@ -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,
-197
View File
@@ -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})
}
-298
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
}
+1 -1
View File
@@ -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
View File
@@ -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
View File
@@ -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).
+1 -1
View File
@@ -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")
-44
View File
@@ -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 {
-40
View File
@@ -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")
}
}
+98
View File
@@ -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
View File
@@ -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
+55 -9
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
File diff suppressed because it is too large. Load diff
+245
View File
@@ -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
View File
File diff suppressed because it is too large. Load diff
+1 -1
View File
@@ -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
View File
@@ -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 {
+27
View File
@@ -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
}
+2
View File
@@ -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
View File
@@ -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
View File
@@ -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) {
+1 -1
View File
@@ -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()
+7
View File
@@ -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
View File
@@ -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
+18
View File
@@ -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
View File
@@ -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,
+3 -7
View File
@@ -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 != "" {
+2 -2
View File
@@ -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
View File
@@ -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
View File
@@ -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
+1 -1
View File
@@ -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
View File
@@ -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
+27
View File
@@ -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)
+1 -1
View File
@@ -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
+2 -2
View File
@@ -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=
+1 -1
View File
@@ -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|>",
+1 -1
View File
@@ -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