diff --git a/README.md b/README.md index 236a2463..b9a11196 100644 --- a/README.md +++ b/README.md @@ -64,8 +64,8 @@ CCS gives you one stable command surface while letting you switch between: - multiple Claude subscriptions and isolated account contexts - OAuth providers like Codex, xAI/Grok, Kiro, Claude, Kimi, and more, with legacy Copilot compatibility for existing setups -- API and local-model profiles like GLM, Kimi, OpenRouter, Ollama, llama.cpp, - Novita, Fireworks AI, and Alibaba Coding Plan +- API and local-model profiles like GLM, Kimi, OpenRouter, OrcaRouter, Ollama, + llama.cpp, Novita, Fireworks AI, and Alibaba Coding Plan Qwen Code account linking is not available in the bundled CLIProxy runtime yet; use an API-key Qwen profile such as Alibaba Coding Plan for Qwen models. diff --git a/docker/README.md b/docker/README.md index 6c3ba275..53e4a46d 100644 --- a/docker/README.md +++ b/docker/README.md @@ -79,6 +79,32 @@ The `ccs docker` flow uses the integrated assets in this directory: - `docker/supervisord.conf` - `docker/entrypoint-integrated.sh` +### Optional Host Continuity Timers + +For a persistent single-host stack, install the host reconciliation and safe-update scripts with +their systemd units: + +```bash +sudo install -d /opt/cliproxy +sudo install -m 0755 docker/host/ccs-cliproxy-{reconcile,safe-update}.sh /opt/cliproxy/ +sudo install -m 0644 docker/host/systemd/ccs-cliproxy-{reconcile,update}.{service,timer} /etc/systemd/system/ +sudo systemctl daemon-reload +sudo systemctl enable --now ccs-cliproxy-reconcile.timer ccs-cliproxy-update.timer + +systemctl list-timers 'ccs-cliproxy-*' +journalctl -u ccs-cliproxy-reconcile.service -u ccs-cliproxy-update.service +``` + +By default, reconciliation expects `/root/.ccs/docker/docker-compose.integrated.yml` with Compose project +`docker`. Keep those defaults when adopting an existing stack so its `docker_ccs_home` and +`docker_ccs_logs` volumes remain attached. The scripts support environment overrides for the +container, Compose paths and project, lock file, and logs. + +The updater downloads and verifies a replacement while the current proxy remains online, then +stops it only for the atomic swap and startup check. A failed update restores the previous binary +and version. Reconciliation probes the dashboard on port 3000 and CLIProxy on port 8317, then +escalates from supervised-process restart to container restart and finally Compose force-recreate. + ### Network Binding and Dashboard Auth The integrated Docker stack publishes the dashboard and CLIProxy ports on `127.0.0.1` by default. This keeps the services reachable from the Docker host and SSH tunnels without exposing them on every host interface. @@ -575,9 +601,11 @@ docker exec ccs-cliproxy supervisorctl -c /etc/supervisord.conf restart cliproxy docker exec ccs-cliproxy grep "client load complete" /var/log/ccs/cliproxy.log ``` -### ETXTBSY Error on First Boot +### ETXTBSY During CLIProxy Update -On first container start, you may see `ETXTBSY: text file is busy` in dashboard logs. This is a known race condition where the dashboard tries to update the CLIProxy binary while it's already running. The dashboard recovers automatically on the next attempt. No action needed. +Current releases stage and verify CLIProxy updates before replacing the live binary atomically, so +`ETXTBSY: text file is busy` is not an expected first-boot condition. Upgrade older installations +that still report this error. ### Debug Mode diff --git a/docker/host/ccs-cliproxy-reconcile.sh b/docker/host/ccs-cliproxy-reconcile.sh new file mode 100755 index 00000000..074d2f90 --- /dev/null +++ b/docker/host/ccs-cliproxy-reconcile.sh @@ -0,0 +1,102 @@ +#!/usr/bin/env bash + +set -Eeuo pipefail + +container_name="${CCS_CLIPROXY_CONTAINER:-ccs-cliproxy}" +compose_dir="${CCS_CLIPROXY_COMPOSE_DIR:-/root/.ccs/docker}" +compose_file="${CCS_CLIPROXY_COMPOSE_FILE:-$compose_dir/docker-compose.integrated.yml}" +compose_project="${CCS_CLIPROXY_COMPOSE_PROJECT:-docker}" +lock_file="${CCS_CLIPROXY_LOCK_FILE:-/run/lock/ccs-cliproxy-maintenance.lock}" +log_file="${CCS_CLIPROXY_RECONCILE_LOG:-/var/log/ccs-cliproxy-reconcile.log}" + +log() { + printf '[%s] %s\n' "$(date -Is)" "$*" | tee -a "$log_file" +} + +exec 9>"$lock_file" +if ! flock -n 9; then + exit 0 +fi + +probe() { + docker exec "$container_name" sh -c \ + 'curl -fsS --max-time 2 http://127.0.0.1:3000/ >/dev/null && curl -fsS --max-time 2 http://127.0.0.1:8317/ >/dev/null' \ + >/dev/null 2>&1 +} + +wait_for_health() { + attempts=0 + while [ "$attempts" -lt 30 ]; do + if probe; then + return 0 + fi + attempts=$((attempts + 1)) + sleep 2 + done + return 1 +} + +compose_up() { + cd "$compose_dir" + docker compose --project-name "$compose_project" --project-directory "$compose_dir" \ + -f "$compose_file" up -d --no-build || \ + docker compose --project-name "$compose_project" --project-directory "$compose_dir" \ + -f "$compose_file" up -d --build +} + +compose_recreate() { + cd "$compose_dir" + docker compose --project-name "$compose_project" --project-directory "$compose_dir" \ + -f "$compose_file" up -d --force-recreate --no-build || \ + docker compose --project-name "$compose_project" --project-directory "$compose_dir" \ + -f "$compose_file" up -d --force-recreate --build +} + +running_state="$(docker inspect --format '{{.State.Running}}' "$container_name" 2>/dev/null || true)" + +if [ -z "$running_state" ]; then + log "Container $container_name is missing; recreating from $compose_file" + compose_up + wait_for_health + log "Container $container_name was recreated and passed both health probes" + exit 0 +fi + +if [ "$running_state" != 'true' ]; then + log "Container $container_name is stopped; restoring the Compose service" + compose_up + wait_for_health + log "Container $container_name is running and passed both health probes" + exit 0 +fi + +if probe; then + exit 0 +fi + +sleep 10 +if probe; then + exit 0 +fi + +log 'Dashboard or proxy failed two consecutive probes; restarting supervised processes' +if docker exec "$container_name" \ + supervisorctl -c /etc/supervisord.conf restart ccs-dashboard cliproxy; then + if wait_for_health; then + log 'Supervised processes recovered and passed both health probes' + exit 0 + fi +fi + +log 'Supervisor recovery failed; restarting the container' +if docker restart "$container_name" >/dev/null; then + if wait_for_health; then + log "Container $container_name recovered and passed both health probes" + exit 0 + fi +fi + +log "Container $container_name is still unhealthy; recreating it from $compose_file" +compose_recreate +wait_for_health +log "Container $container_name was recreated and passed both health probes" diff --git a/docker/host/ccs-cliproxy-safe-update.sh b/docker/host/ccs-cliproxy-safe-update.sh new file mode 100755 index 00000000..202ea93e --- /dev/null +++ b/docker/host/ccs-cliproxy-safe-update.sh @@ -0,0 +1,187 @@ +#!/usr/bin/env bash + +set -Eeuo pipefail + +container_name="${CCS_CLIPROXY_CONTAINER:-ccs-cliproxy}" +lock_file="${CCS_CLIPROXY_LOCK_FILE:-/run/lock/ccs-cliproxy-maintenance.lock}" +log_file="${CCS_CLIPROXY_UPDATE_LOG:-/var/log/ccs-cliproxy-update.log}" + +log() { + printf '[%s] %s\n' "$(date -Is)" "$*" | tee -a "$log_file" +} + +exec 9>"$lock_file" +if ! flock -n 9; then + log 'Another CLIProxy maintenance operation is active; skipping update check' + exit 0 +fi + +if ! docker inspect "$container_name" >/dev/null 2>&1; then + log "Container $container_name is missing; reconciliation must restore it before updating" + exit 1 +fi + +if [ "$(docker inspect --format '{{.State.Running}}' "$container_name")" != 'true' ]; then + log "Container $container_name is not running; reconciliation must restore it before updating" + exit 1 +fi + +status_output="$(docker exec "$container_name" ccs cliproxy --version --backend plus --verbose 2>&1)" || { + log "Unable to inspect the installed CLIProxy version: $status_output" + exit 1 +} + +if grep -qi 'Could not fetch' <<<"$status_output"; then + log "Unable to check the latest CLIProxy version: $(grep -im1 'Could not fetch' <<<"$status_output" | xargs)" + exit 1 +fi + +if ! grep -qi 'update available' <<<"$status_output"; then + exit 0 +fi + +log 'CLIProxy Plus update available; staging verified replacement while the current proxy stays online' + +if ! docker exec -i "$container_name" sh -s <<'CONTAINER_UPDATE' +set -eu + +live_dir='/root/.ccs/cliproxy/bin/plus' +live_binary="$live_dir/cli-proxy-api-plus" +live_version="$live_dir/.version" +stage_root="$(mktemp -d /root/.ccs/cliproxy/.host-update.XXXXXX)" +stage_ccs_dir="$stage_root/ccs" +stage_dir="$stage_ccs_dir/cliproxy/bin/plus" +stage_binary="$stage_dir/cli-proxy-api-plus" +stage_version="$stage_dir/.version" +backup_binary="$stage_root/previous-binary" +backup_version="$stage_root/previous-version" +install_lock_target='/root/.ccs/cliproxy/bin/.install-lifecycle-plus' +install_lock_dir="$install_lock_target.lock" +install_lock_stale_seconds=600 +install_lock_owned=0 +maintenance_started=0 + +supervisorctl_cmd() { + supervisorctl -c /etc/supervisord.conf "$@" +} + +cleanup() { + rm -rf -- "$stage_root" +} + +remove_stale_install_lock() { + lock_mtime="$( + stat -c %Y "$install_lock_dir" 2>/dev/null || stat -f %m "$install_lock_dir" 2>/dev/null + )" || return 0 + current_time="$(date +%s)" + lock_age=$((current_time - lock_mtime)) + if [ "$lock_age" -gt "$install_lock_stale_seconds" ]; then + rmdir "$install_lock_dir" 2>/dev/null || true + fi +} + +acquire_install_lock() { + mkdir -p "$install_lock_target" + attempts=0 + while ! mkdir "$install_lock_dir" 2>/dev/null; do + attempts=$((attempts + 1)) + if [ "$attempts" -ge 240 ]; then + printf '[X] Timed out waiting for CLIProxy install lifecycle lock\n' >&2 + return 1 + fi + remove_stale_install_lock + sleep 0.25 + done + install_lock_owned=1 + touch "$install_lock_dir" +} + +release_install_lock() { + if [ "$install_lock_owned" -eq 1 ]; then + rmdir "$install_lock_dir" 2>/dev/null || true + install_lock_owned=0 + fi +} + +wait_for_proxy() { + attempts=0 + while [ "$attempts" -lt 30 ]; do + if curl -fsS --max-time 2 http://127.0.0.1:8317/ >/dev/null; then + return 0 + fi + attempts=$((attempts + 1)) + sleep 2 + done + return 1 +} + +rollback() { + rollback_ok=1 + supervisorctl_cmd stop cliproxy >/dev/null 2>&1 || true + if [ -f "$backup_binary" ]; then + install -m 0755 "$backup_binary" "$live_binary.rollback" && \ + mv -f "$live_binary.rollback" "$live_binary" || rollback_ok=0 + else + rollback_ok=0 + fi + if [ -f "$backup_version" ]; then + install -m 0644 "$backup_version" "$live_version.rollback" && \ + mv -f "$live_version.rollback" "$live_version" || rollback_ok=0 + else + rollback_ok=0 + fi + supervisorctl_cmd start cliproxy >/dev/null || rollback_ok=0 + wait_for_proxy || rollback_ok=0 + [ "$rollback_ok" -eq 1 ] +} + +on_exit() { + rc=$? + trap - EXIT INT TERM HUP + rollback_failed=0 + if [ "$rc" -ne 0 ] && [ "$maintenance_started" -eq 1 ]; then + if ! rollback; then + rollback_failed=1 + printf '[X] CLIProxy rollback failed; recovery files preserved at %s\n' "$stage_root" >&2 + fi + fi + release_install_lock + if [ "$rollback_failed" -eq 0 ]; then + cleanup + else + rc=70 + fi + exit "$rc" +} + +trap on_exit EXIT +trap 'exit 130' INT +trap 'exit 143' TERM +trap 'exit 129' HUP + +mkdir -p "$stage_ccs_dir" +CCS_DIR="$stage_ccs_dir" ccs cliproxy --latest --backend plus +test -x "$stage_binary" +test -s "$stage_version" +"$stage_binary" --version >/dev/null + +acquire_install_lock +cp -p "$live_binary" "$backup_binary" +cp -p "$live_version" "$backup_version" + +maintenance_started=1 +supervisorctl_cmd stop cliproxy >/dev/null +mv -f "$stage_binary" "$live_binary" +mv -f "$stage_version" "$live_version" +chmod 0755 "$live_binary" +supervisorctl_cmd start cliproxy >/dev/null +wait_for_proxy +maintenance_started=0 +CONTAINER_UPDATE +then + log 'CLIProxy Plus update failed; rollback was attempted and reconciliation will verify service health' + exit 1 +fi + +installed_output="$(docker exec "$container_name" ccs cliproxy --version --backend plus 2>&1)" +log "CLIProxy Plus update completed and passed health verification: $(grep -m1 'Version:' <<<"$installed_output" | xargs)" diff --git a/docker/host/systemd/ccs-cliproxy-reconcile.service b/docker/host/systemd/ccs-cliproxy-reconcile.service new file mode 100644 index 00000000..b1ec3961 --- /dev/null +++ b/docker/host/systemd/ccs-cliproxy-reconcile.service @@ -0,0 +1,9 @@ +[Unit] +Description=Reconcile the CCS CLIProxy Docker service +After=docker.service +Requires=docker.service + +[Service] +Type=oneshot +ExecStart=/opt/cliproxy/ccs-cliproxy-reconcile.sh +TimeoutStartSec=10min diff --git a/docker/host/systemd/ccs-cliproxy-reconcile.timer b/docker/host/systemd/ccs-cliproxy-reconcile.timer new file mode 100644 index 00000000..9c147bea --- /dev/null +++ b/docker/host/systemd/ccs-cliproxy-reconcile.timer @@ -0,0 +1,11 @@ +[Unit] +Description=Continuously reconcile the CCS CLIProxy Docker service + +[Timer] +OnBootSec=30s +OnUnitActiveSec=30s +AccuracySec=5s +Persistent=true + +[Install] +WantedBy=timers.target diff --git a/docker/host/systemd/ccs-cliproxy-update.service b/docker/host/systemd/ccs-cliproxy-update.service new file mode 100644 index 00000000..a4bb64c3 --- /dev/null +++ b/docker/host/systemd/ccs-cliproxy-update.service @@ -0,0 +1,9 @@ +[Unit] +Description=Safely update CLIProxy Plus in ccs-cliproxy +After=docker.service +Requires=docker.service + +[Service] +Type=oneshot +ExecStart=/opt/cliproxy/ccs-cliproxy-safe-update.sh +TimeoutStartSec=10min diff --git a/docker/host/systemd/ccs-cliproxy-update.timer b/docker/host/systemd/ccs-cliproxy-update.timer new file mode 100644 index 00000000..0ac39f3a --- /dev/null +++ b/docker/host/systemd/ccs-cliproxy-update.timer @@ -0,0 +1,10 @@ +[Unit] +Description=Poll for CLIProxy Plus releases without interrupting the live binary + +[Timer] +OnBootSec=3min +OnUnitActiveSec=15min +Persistent=true + +[Install] +WantedBy=timers.target diff --git a/docs/reports/hardening-inventory.json b/docs/reports/hardening-inventory.json index 1a238299..545f40c6 100644 --- a/docs/reports/hardening-inventory.json +++ b/docs/reports/hardening-inventory.json @@ -1,30 +1,11 @@ { "scope": "src/**/*.{ts,tsx,js,jsx,mjs,cjs}", "syncFs": { - "totalOccurrences": 2471, - "filesAffected": 261, - "hotpathOccurrences": 1186, - "hotpathFilesAffected": 155, + "totalOccurrences": 2513, + "filesAffected": 263, + "hotpathOccurrences": 1183, + "hotpathFilesAffected": 156, "topHotpathFiles": [ - { - "file": "src/management/shared-manager/diverged-file-adopter.ts", - "count": 39, - "calls": [ - "closeSync", - "fsyncSync", - "linkSync", - "lstatSync", - "openSync", - "readdirSync", - "readFileSync", - "readlinkSync", - "renameSync", - "statSync", - "unlinkSync", - "writeFileSync" - ], - "markers": [] - }, { "file": "src/utils/browser/mcp-installer.ts", "count": 32, @@ -57,6 +38,26 @@ ], "markers": [] }, + { + "file": "src/management/shared-manager/diverged-file-adopter.ts", + "count": 29, + "calls": [ + "chmodSync", + "closeSync", + "fsyncSync", + "linkSync", + "lstatSync", + "openSync", + "readdirSync", + "readFileSync", + "readlinkSync", + "renameSync", + "statSync", + "unlinkSync", + "writeFileSync" + ], + "markers": [] + }, { "file": "src/utils/claude-symlink-manager.ts", "count": 27, @@ -229,20 +230,17 @@ "markers": [] }, { - "file": "src/management/shared-manager/diverged-file-adopter.ts", - "count": 39, + "file": "src/cliproxy/binary/__tests__/binary-installer-atomic.test.ts", + "count": 41, "calls": [ - "closeSync", - "fsyncSync", - "linkSync", - "lstatSync", - "openSync", + "existsSync", + "mkdirSync", + "mkdtempSync", "readdirSync", "readFileSync", - "readlinkSync", "renameSync", - "statSync", - "unlinkSync", + "rmdirSync", + "rmSync", "writeFileSync" ], "markers": [] @@ -497,16 +495,16 @@ }, "maintainability": { "typedErrors": { - "totalThrows": 454, - "typedThrows": 83, - "plainThrows": 311, + "totalThrows": 455, + "typedThrows": 89, + "plainThrows": 306, "otherThrows": 60, - "adoptionRatio": 0.1828, + "adoptionRatio": 0.1956, "topSubdomainsByThrows": [ { "subdomain": "web-server", - "count": 103, - "typed": 16, + "count": 104, + "typed": 17, "plain": 45 }, { @@ -579,8 +577,8 @@ }, "loggerCoverage": { "filesWithCreateLogger": 65, - "totalSourceFiles": 764, - "coverageRatio": 0.0851, + "totalSourceFiles": 765, + "coverageRatio": 0.085, "subdomainsWithZeroCreateLogger": [ "api", "bin", @@ -652,9 +650,9 @@ ] }, "hotpathConsoleErrors": { - "totalOccurrences": 592, + "totalOccurrences": 590, "exemptOccurrences": 326, - "hotpathOccurrences": 266, + "hotpathOccurrences": 264, "filesAffected": 81, "topFiles": [ { @@ -720,7 +718,7 @@ ] }, "largeFiles": { - "countOver400": 91, + "countOver400": 94, "countOver600": 42, "topOver400": [ { @@ -741,7 +739,7 @@ }, { "file": "src/web-server/model-pricing.ts", - "loc": 1127 + "loc": 1138 }, { "file": "src/cliproxy/config/generator.ts", @@ -780,8 +778,8 @@ "loc": 939 }, { - "file": "src/cliproxy/accounts/registry.ts", - "loc": 871 + "file": "src/cliproxy/model-catalog.ts", + "loc": 895 } ] } diff --git a/docs/reports/hardening-inventory.md b/docs/reports/hardening-inventory.md index 6928164e..a8aa1818 100644 --- a/docs/reports/hardening-inventory.md +++ b/docs/reports/hardening-inventory.md @@ -6,10 +6,10 @@ Scope: `src/**/*.{ts,tsx,js,jsx,mjs,cjs}` | Metric | Value | |---|---:| -| Sync fs occurrences (all) | 2471 | -| Sync fs files affected (all) | 261 | -| Sync fs occurrences (runtime hotpaths) | 1186 | -| Sync fs files affected (runtime hotpaths) | 155 | +| Sync fs occurrences (all) | 2513 | +| Sync fs files affected (all) | 263 | +| Sync fs occurrences (runtime hotpaths) | 1183 | +| Sync fs files affected (runtime hotpaths) | 156 | | Legacy shim markers | 465 | | Legacy shim files affected | 176 | @@ -17,9 +17,9 @@ Scope: `src/**/*.{ts,tsx,js,jsx,mjs,cjs}` | File | Sync Calls | API Names | |---|---:|---| -| `src/management/shared-manager/diverged-file-adopter.ts` | 39 | closeSync, fsyncSync, linkSync, lstatSync, openSync, readdirSync, readFileSync, readlinkSync, renameSync, statSync, unlinkSync, writeFileSync | | `src/utils/browser/mcp-installer.ts` | 32 | chmodSync, copyFileSync, existsSync, mkdirSync, readFileSync, renameSync, statSync, unlinkSync, writeFileSync | | `src/utils/image-analysis/mcp-installer.ts` | 30 | chmodSync, copyFileSync, existsSync, mkdirSync, readFileSync, renameSync, statSync, unlinkSync, writeFileSync | +| `src/management/shared-manager/diverged-file-adopter.ts` | 29 | chmodSync, closeSync, fsyncSync, linkSync, lstatSync, openSync, readdirSync, readFileSync, readlinkSync, renameSync, statSync, unlinkSync, writeFileSync | | `src/utils/claude-symlink-manager.ts` | 27 | copyFileSync, existsSync, lstatSync, mkdirSync, readdirSync, readlinkSync, renameSync, rmSync, statSync, symlinkSync, unlinkSync | | `src/cliproxy/config/env-builder.ts` | 25 | existsSync, mkdirSync, readFileSync, writeFileSync | | `src/management/shared-manager/migrations.ts` | 25 | copyFileSync, cpSync, existsSync, lstatSync, mkdirSync, readdirSync, symlinkSync, unlinkSync, writeFileSync | @@ -56,13 +56,13 @@ Scope: `src/**/*.{ts,tsx,js,jsx,mjs,cjs}` | Metric | Value | |---|---:| -| typed-error adoption (typed/total throws) | 18.3% (83/454) | +| typed-error adoption (typed/total throws) | 19.6% (89/455) | | typed-error adoption (P4 locked subdomains) | 93.3% (28/30), target 40% | -| hotpath console.error/warn occurrences | 266 (592 total, 326 CLI-UX exempt) | +| hotpath console.error/warn occurrences | 264 (590 total, 326 CLI-UX exempt) | | hotpath console.error/warn files | 81 | -| files with createLogger | 65/764 | +| files with createLogger | 65/765 | | subdomains with zero createLogger | 15 (api, bin, channels, cliproxy, cliproxy/accounts, cliproxy/ai-providers, cliproxy/binary, cliproxy/config, cliproxy/management, cliproxy/sync, cliproxy/types, config, dispatcher, shared, types) | -| files > 400 LOC | 91 | +| files > 400 LOC | 94 | | files > 600 LOC | 42 | ### Top Hotpath console.error/warn Files @@ -93,7 +93,7 @@ Scope: `src/**/*.{ts,tsx,js,jsx,mjs,cjs}` | `src/web-server/routes/cliproxy-auth-routes.ts` | 1531 | | `src/cliproxy/auth/oauth-handler.ts` | 1510 | | `src/cursor/cursor-executor.ts` | 1234 | -| `src/web-server/model-pricing.ts` | 1127 | +| `src/web-server/model-pricing.ts` | 1138 | | `src/cliproxy/config/generator.ts` | 1109 | | `src/cliproxy/auth/oauth-process.ts` | 1048 | | `src/cliproxy/config/env-builder.ts` | 1045 | @@ -103,5 +103,5 @@ Scope: `src/**/*.{ts,tsx,js,jsx,mjs,cjs}` | `src/cliproxy/quota/quota-manager.ts` | 954 | | `src/web-server/services/codex-dashboard-service.ts` | 940 | | `src/glmt/glmt-proxy.ts` | 939 | -| `src/cliproxy/accounts/registry.ts` | 871 | +| `src/cliproxy/model-catalog.ts` | 895 | diff --git a/eslint-rules/throw-error-baseline.json b/eslint-rules/throw-error-baseline.json index 9fa25647..60d84fa8 100644 --- a/eslint-rules/throw-error-baseline.json +++ b/eslint-rules/throw-error-baseline.json @@ -8,12 +8,12 @@ "src/channels/official-channels-store.ts:291", "src/channels/official-channels-store.ts:296", "src/channels/official-channels-store.ts:299", - "src/cliproxy/accounts/drain-order.ts:131", - "src/cliproxy/accounts/drain-order.ts:139", - "src/cliproxy/accounts/drain-order.ts:246", - "src/cliproxy/accounts/drain-order.ts:253", - "src/cliproxy/accounts/drain-order.ts:279", - "src/cliproxy/accounts/drain-order.ts:342", + "src/cliproxy/accounts/drain-order.ts:149", + "src/cliproxy/accounts/drain-order.ts:157", + "src/cliproxy/accounts/drain-order.ts:264", + "src/cliproxy/accounts/drain-order.ts:271", + "src/cliproxy/accounts/drain-order.ts:297", + "src/cliproxy/accounts/drain-order.ts:360", "src/cliproxy/accounts/registry.ts:521", "src/cliproxy/accounts/registry.ts:536", "src/cliproxy/accounts/registry.ts:544", @@ -38,10 +38,10 @@ "src/cliproxy/binary/installer.ts:74", "src/cliproxy/binary/installer.ts:88", "src/cliproxy/binary/lifecycle.ts:137", - "src/cliproxy/binary/platform-detector.ts:153", - "src/cliproxy/binary/platform-detector.ts:160", + "src/cliproxy/binary/platform-detector.ts:152", + "src/cliproxy/binary/platform-detector.ts:159", "src/cliproxy/binary/verifier.ts:55", - "src/cliproxy/binary/version-checker.ts:108", + "src/cliproxy/binary/version-checker.ts:118", "src/cliproxy/config/base-config-loader.ts:54", "src/cliproxy/config/base-config-loader.ts:66", "src/cliproxy/config/base-config-loader.ts:81", @@ -59,9 +59,9 @@ "src/cliproxy/executor/proxy-resolver.ts:177", "src/cliproxy/executor/session-bridge.ts:147", "src/cliproxy/proxy/https-tunnel-proxy.ts:61", - "src/cliproxy/routing/routing-strategy.ts:480", - "src/cliproxy/routing/routing-strategy.ts:488", - "src/cliproxy/routing/routing-strategy.ts:708", + "src/cliproxy/routing/routing-strategy.ts:490", + "src/cliproxy/routing/routing-strategy.ts:498", + "src/cliproxy/routing/routing-strategy.ts:718", "src/cliproxy/services/remote-auth-fetcher.ts:139", "src/cliproxy/services/remote-auth-fetcher.ts:150", "src/cliproxy/services/remote-auth-fetcher.ts:152", @@ -220,13 +220,13 @@ "src/shared/claude-extension-setup.ts:247", "src/shared/claude-extension-setup.ts:257", "src/shared/claude-extension-setup.ts:317", - "src/shared/provider-preset-catalog.ts:339", - "src/shared/provider-preset-catalog.ts:342", - "src/shared/provider-preset-catalog.ts:351", - "src/shared/provider-preset-catalog.ts:354", - "src/shared/provider-preset-catalog.ts:357", - "src/shared/provider-preset-catalog.ts:362", - "src/shared/provider-preset-catalog.ts:365", + "src/shared/provider-preset-catalog.ts:356", + "src/shared/provider-preset-catalog.ts:359", + "src/shared/provider-preset-catalog.ts:368", + "src/shared/provider-preset-catalog.ts:371", + "src/shared/provider-preset-catalog.ts:374", + "src/shared/provider-preset-catalog.ts:379", + "src/shared/provider-preset-catalog.ts:382", "src/shared/toml-object.ts:23", "src/targets/codex-adapter.ts:103", "src/targets/codex-adapter.ts:275", diff --git a/package.json b/package.json index 448ba081..8a2d66fb 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@kaitranntt/ccs", - "version": "8.9.0", + "version": "8.9.0-dev.11", "description": "Claude Codex Switch - Instant profile switching between Claude, GLM, Kimi, and more", "keywords": [ "cli", @@ -19,6 +19,9 @@ "url": "git+https://github.com/kaitranntt/ccs.git" }, "license": "MIT", + "publishConfig": { + "access": "public" + }, "author": { "name": "Tam Nhu Tran (Kai)", "email": "kaitranntt@users.noreply.github.com" diff --git a/scripts/dev-release.sh b/scripts/dev-release.sh index de01bde0..fd5607e8 100755 --- a/scripts/dev-release.sh +++ b/scripts/dev-release.sh @@ -115,7 +115,7 @@ PACKAGE_NAME=$(jq -r '.name' package.json) if npm view "${PACKAGE_NAME}@${VERSION}" version >/dev/null 2>&1; then log_info "npm already has ${PACKAGE_NAME}@${VERSION}, skipping publish" else - npm publish --tag dev + npm publish --tag dev --access public log_info "Published to npm with @dev tag" fi diff --git a/src/cliproxy/__tests__/model-catalog-compat.test.ts b/src/cliproxy/__tests__/model-catalog-compat.test.ts index aaaa01d6..e0c17565 100644 --- a/src/cliproxy/__tests__/model-catalog-compat.test.ts +++ b/src/cliproxy/__tests__/model-catalog-compat.test.ts @@ -46,21 +46,62 @@ describe('model-catalog compatibility lookups', () => { expect(PROVIDER_TO_CHANNEL.xai).toBe('xai'); }); - it('merges live xAI model metadata while preserving the static coding default', () => { + it('merges live Grok 4.6 metadata while preserving the static coding default', () => { const catalog = mergeCatalog('xai', [ { id: 'grok-build-0.1', display_name: 'Grok Build 0.1' }, { - id: 'grok-4.5', - display_name: 'Grok 4.5', - thinking: { levels: ['low', 'medium', 'high'] }, + id: 'grok-4.6', + display_name: 'Grok 4.6', + context_length: 500_000, + thinking: { + levels: ['low', 'medium', 'high', 'xhigh'], + zero_allowed: false, + }, }, ]); expect(catalog?.defaultModel).toBe('grok-build-0.1'); - expect(catalog?.models[1]?.thinking).toMatchObject({ - type: 'levels', - levels: ['low', 'medium', 'high'], + expect(catalog?.models[1]).toMatchObject({ + id: 'grok-4.6', + contextWindow: 500_000, + thinking: { + type: 'levels', + levels: ['low', 'medium', 'high', 'xhigh'], + zeroAllowed: false, + }, }); + expect(catalog?.models[1]?.extendedContext).toBeUndefined(); + }); + + it('preserves static zeroAllowed when live level metadata omits it', () => { + const catalog = mergeCatalog('xai', [ + { + id: 'grok-4.3', + display_name: 'Grok 4.3', + thinking: { levels: ['none', 'low', 'medium', 'high'] }, + }, + ]); + + expect(catalog?.models[0]?.thinking).toMatchObject({ + type: 'levels', + levels: ['none', 'low', 'medium', 'high'], + zeroAllowed: true, + }); + }); + + it('prefers explicit live zeroAllowed over static level metadata', () => { + const catalog = mergeCatalog('xai', [ + { + id: 'grok-4.3', + display_name: 'Grok 4.3', + thinking: { + levels: ['low', 'medium', 'high'], + zero_allowed: false, + }, + }, + ]); + + expect(catalog?.models[0]?.thinking?.zeroAllowed).toBe(false); }); it('does not export extended-context capability from live xAI context length', () => { diff --git a/src/cliproxy/__tests__/model-catalog.test.js b/src/cliproxy/__tests__/model-catalog.test.js index ca020e79..e8044d46 100644 --- a/src/cliproxy/__tests__/model-catalog.test.js +++ b/src/cliproxy/__tests__/model-catalog.test.js @@ -77,6 +77,7 @@ describe('Model Catalog', () => { const ids = MODEL_CATALOG.xai.models.map((model) => model.id); assert.deepStrictEqual(ids, [ 'grok-build-0.1', + 'grok-4.6', 'grok-4.5', 'grok-4.3', 'grok-4.20-0309-reasoning', @@ -88,6 +89,18 @@ describe('Model Catalog', () => { ]); }); + it('includes Grok 4.6 with upstream reasoning metadata', () => { + const { MODEL_CATALOG } = modelCatalog; + const grok46 = MODEL_CATALOG.xai.models.find((model) => model.id === 'grok-4.6'); + + assert(grok46, 'Should include grok-4.6'); + assert.strictEqual(grok46.name, 'Grok 4.6'); + assert.strictEqual(grok46.contextWindow, 500000); + assert.strictEqual(grok46.thinking.type, 'levels'); + assert.deepStrictEqual(grok46.thinking.levels, ['low', 'medium', 'high', 'xhigh']); + assert.strictEqual(grok46.thinking.zeroAllowed, false); + }); + it('does not expose Claude [1m] suffix support for xAI model IDs', () => { const { MODEL_CATALOG, supportsExtendedContext } = modelCatalog; diff --git a/src/cliproxy/__tests__/thinking-validator.test.ts b/src/cliproxy/__tests__/thinking-validator.test.ts index b6a47489..41be1a1a 100644 --- a/src/cliproxy/__tests__/thinking-validator.test.ts +++ b/src/cliproxy/__tests__/thinking-validator.test.ts @@ -60,6 +60,14 @@ describe('Thinking Validator', () => { expect(result.warning).toBeUndefined(); }); + it('should treat max as a distinct top tier on Claude Fable 5.1', () => { + // Fable 5.1 has always-on adaptive thinking steered only by effort level. + const result = validateThinking('claude', 'claude-fable-5-1', 'max'); + expect(result.valid).toBe(true); + expect(result.value).toBe('max'); + expect(result.warning).toBeUndefined(); + }); + it('should treat max as a distinct top tier on Claude Fable 5', () => { // Fable 5 shares Opus 4.8's adaptive thinking surface; max must remain // distinct from xhigh. diff --git a/src/cliproxy/accounts/__tests__/drain-order.test.ts b/src/cliproxy/accounts/__tests__/drain-order.test.ts index a8942d28..cc397a65 100644 --- a/src/cliproxy/accounts/__tests__/drain-order.test.ts +++ b/src/cliproxy/accounts/__tests__/drain-order.test.ts @@ -933,3 +933,36 @@ describe('fetchProxyAuthCooldowns', () => { }); }); }); + +describe('DRAIN_ORDER_MIN_VERSION and isDrainOrderSupported (#1724)', () => { + it('defines backend-specific minimum versions for drain-order priorities', async () => { + const { DRAIN_ORDER_MIN_VERSION } = await loadDrainOrder(); + expect(DRAIN_ORDER_MIN_VERSION).toEqual({ + original: '6.6.106', + plus: '6.6.107-0', + }); + }); + + it('evaluates original backend boundary correctly', async () => { + const { isDrainOrderSupported } = await loadDrainOrder(); + expect(isDrainOrderSupported('original', '6.6.105')).toBe(false); + expect(isDrainOrderSupported('original', '6.6.106')).toBe(true); + expect(isDrainOrderSupported('original', '6.7.0')).toBe(true); + }); + + it('evaluates plus backend boundary with fork suffixes correctly', async () => { + const { isDrainOrderSupported } = await loadDrainOrder(); + expect(isDrainOrderSupported('plus', '6.6.105-9')).toBe(false); + expect(isDrainOrderSupported('plus', '6.6.106-0')).toBe(false); + expect(isDrainOrderSupported('plus', '6.6.107-0')).toBe(true); + expect(isDrainOrderSupported('plus', '7.2.127-7')).toBe(true); + }); + + it('rejects invalid and empty version strings', async () => { + const { isDrainOrderSupported } = await loadDrainOrder(); + expect(isDrainOrderSupported('original', '')).toBe(false); + expect(isDrainOrderSupported('original', 'invalid')).toBe(false); + expect(isDrainOrderSupported('plus', '')).toBe(false); + expect(isDrainOrderSupported('plus', 'invalid')).toBe(false); + }); +}); diff --git a/src/cliproxy/accounts/drain-order.ts b/src/cliproxy/accounts/drain-order.ts index 6f1e82ce..ec2ee993 100644 --- a/src/cliproxy/accounts/drain-order.ts +++ b/src/cliproxy/accounts/drain-order.ts @@ -21,11 +21,29 @@ import { } from '../proxy/proxy-target-resolver'; import { loadDrainOrderConfig } from './registry'; import type { AccountTier } from './types'; -import type { CLIProxyProvider } from '../types'; +import type { CLIProxyBackend, CLIProxyProvider } from '../types'; +import { + type CLIProxyBackendMinVersions, + meetsBackendMinimumVersion, +} from '../binary/version-checker'; /** Minimum valid priority value. Management layer treats 0 as delete. */ export const MIN_PRIORITY = 1; +/** + * Minimum CLIProxy version that supports drain-order priority selection. + * Original: v6.6.106 + * Plus: v6.6.107-0 + */ +export const DRAIN_ORDER_MIN_VERSION: CLIProxyBackendMinVersions = { + original: '6.6.106', + plus: '6.6.107-0', +}; + +export function isDrainOrderSupported(backend: CLIProxyBackend, installedVersion: string): boolean { + return meetsBackendMinimumVersion(installedVersion, backend, DRAIN_ORDER_MIN_VERSION); +} + /** Management API path for patching auth file fields */ const AUTH_FILES_FIELDS_PATH = '/v0/management/auth-files/fields'; diff --git a/src/cliproxy/binary-manager.ts b/src/cliproxy/binary-manager.ts index e35753a9..6698fc61 100644 --- a/src/cliproxy/binary-manager.ts +++ b/src/cliproxy/binary-manager.ts @@ -41,6 +41,7 @@ import { import type { CLIProxyBackend } from './types'; import { getVersionListCachePath } from './binary/version-cache'; import { loadOrCreateUnifiedConfig } from '../config/config-loader-facade'; +import { withInstallLifecycleLock } from './binary/install-lifecycle-lock'; export const CLIPROXY_DELETED_PLUS_REPO = 'router-for-me/CLIProxyAPIPlus'; export const CLIPROXY_PLUS_FALLBACK_TRACKING_URL = 'https://github.com/kaitranntt/ccs/issues/1062'; @@ -176,6 +177,14 @@ function getBackendBinDir(backend: CLIProxyBackend = DEFAULT_BACKEND): string { return `${baseDir}/${backend}`; } +export async function withCliproxyInstallLifecycleLock( + backend: CLIProxyBackend, + operation: () => Promise +): Promise { + const lockTarget = path.join(getBinDir(), `.install-lifecycle-${backend}`); + return withInstallLifecycleLock(lockTarget, operation); +} + /** Default configuration (uses backend from config.yaml or defaults to `DEFAULT_BACKEND`) */ function createDefaultConfig(backend: CLIProxyBackend = DEFAULT_BACKEND): BinaryManagerConfig { const backendConfig = BACKEND_CONFIG[backend]; @@ -186,6 +195,7 @@ function createDefaultConfig(backend: CLIProxyBackend = DEFAULT_BACKEND): Binary maxRetries: 3, verbose: false, forceVersion: false, + replaceExisting: false, skipAutoUpdate: false, allowInstall: true, backend, // Pass backend for installer to use correct download URL @@ -317,6 +327,7 @@ interface InstallCliproxyVersionDeps { formatInfo?: typeof info; formatWarn?: typeof warn; getInstalledVersion?: typeof getInstalledCliproxyVersion; + withInstallLifecycleLockFn?: typeof withCliproxyInstallLifecycleLock; } /** Install a specific version of CLIProxyAPI */ @@ -329,42 +340,41 @@ export async function installCliproxyVersion( const configuredBackend = backend ?? getConfiguredOrDefaultBackend(); const effectiveBackend = resolveLocalBackend(configuredBackend, { notifyOnPlus: true }); const manager = - deps.createManager?.({ version, verbose, forceVersion: true }, effectiveBackend) ?? - new BinaryManager({ version, verbose, forceVersion: true }, effectiveBackend); + deps.createManager?.( + { version, verbose, forceVersion: true, replaceExisting: true }, + effectiveBackend + ) ?? + new BinaryManager( + { version, verbose, forceVersion: true, replaceExisting: true }, + effectiveBackend + ); const stopProxyFn = deps.stopProxyFn ?? stopProxy; const waitForPortFreeFn = deps.waitForPortFreeFn ?? waitForPortFree; const formatInfo = deps.formatInfo ?? info; const formatWarn = deps.formatWarn ?? warn; - const getInstalledVersion = deps.getInstalledVersion ?? getInstalledCliproxyVersion; + const withLifecycleLock = deps.withInstallLifecycleLockFn ?? withCliproxyInstallLifecycleLock; - // Always attempt a best-effort stop first so we also catch untracked proxies - // that are running without a session lock. - if (verbose) console.log(formatInfo('Stopping running CLIProxy before update...')); - const result = await stopProxyFn(); - if (result.stopped) { - const stoppedPort = result.port ?? resolveLifecyclePort(); - // Wait for port to be fully released - const portFree = await waitForPortFreeFn(stoppedPort, 5000); - if (!portFree && verbose) { - console.log(formatWarn('Port did not free up in time, proceeding anyway...')); + await withLifecycleLock(effectiveBackend, async () => { + // Always attempt a best-effort stop first so we also catch untracked proxies + // that are running without a session lock. + if (verbose) console.log(formatInfo('Stopping running CLIProxy before update...')); + const result = await stopProxyFn(); + if (result.stopped) { + const stoppedPort = result.port ?? resolveLifecyclePort(); + const portFree = await waitForPortFreeFn(stoppedPort, 5000); + if (!portFree && verbose) { + console.log(formatWarn('Port did not free up in time, proceeding anyway...')); + } + } else if (verbose && result.error && result.error !== 'No active CLIProxy session found') { + console.log(formatWarn(`Could not stop proxy: ${result.error}`)); } - } else if (verbose && result.error && result.error !== 'No active CLIProxy session found') { - console.log(formatWarn(`Could not stop proxy: ${result.error}`)); - } - if (manager.isBinaryInstalled()) { - const label = effectiveBackend === 'plus' ? 'CLIProxy Plus' : 'CLIProxy'; - if (verbose) - console.log( - formatInfo(`Removing existing ${label} v${getInstalledVersion(effectiveBackend)}`) - ); - manager.deleteBinary(); - } - await manager.ensureBinary(); + await manager.ensureBinary(); - if (verbose) { - console.log(formatInfo('New version will be active on next CLIProxy command')); - } + if (verbose) { + console.log(formatInfo('New version will be active on next CLIProxy command')); + } + }); } /** Fetch the latest CLIProxyAPI version from GitHub API */ diff --git a/src/cliproxy/binary/__tests__/binary-installer-atomic.test.ts b/src/cliproxy/binary/__tests__/binary-installer-atomic.test.ts new file mode 100644 index 00000000..3a791ad3 --- /dev/null +++ b/src/cliproxy/binary/__tests__/binary-installer-atomic.test.ts @@ -0,0 +1,359 @@ +import { afterEach, beforeEach, describe, expect, it } from 'bun:test'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import { getExecutableName } from '../platform-detector'; +import { downloadAndInstall } from '../installer'; +import { ensureBinary } from '../lifecycle'; +import { withInstallLifecycleLock } from '../install-lifecycle-lock'; + +describe('atomic binary installation', () => { + let binPath: string; + + beforeEach(() => { + binPath = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-atomic-installer-')); + }); + + afterEach(() => { + fs.rmSync(binPath, { recursive: true, force: true }); + }); + + function stagingDirectories(): string[] { + return fs.readdirSync(binPath).filter((entry) => entry.startsWith('.cliproxy-install-')); + } + + it('preserves the installed binary and version when verification fails', async () => { + const binaryPath = path.join(binPath, getExecutableName('original')); + const versionPath = path.join(binPath, '.version'); + fs.writeFileSync(binaryPath, 'old-binary'); + fs.writeFileSync(versionPath, '6.6.80'); + + await expect( + downloadAndInstall( + { + version: '6.7.1', + releaseUrl: 'https://example.invalid', + binPath, + maxRetries: 1, + verbose: false, + forceVersion: true, + skipAutoUpdate: false, + allowInstall: true, + backend: 'original', + }, + false, + { + downloadWithRetryFn: async (_url, archivePath) => { + fs.writeFileSync(archivePath, 'downloaded-archive'); + return { success: true, filePath: archivePath, retries: 0 }; + }, + verifyChecksumFn: async () => ({ + valid: false, + expected: 'expected-checksum', + actual: 'actual-checksum', + }), + extractArchiveFn: async () => { + throw new Error('extract should not run'); + }, + } + ) + ).rejects.toThrow('Checksum mismatch'); + + expect(fs.readFileSync(binaryPath, 'utf8')).toBe('old-binary'); + expect(fs.readFileSync(versionPath, 'utf8')).toBe('6.6.80'); + expect(stagingDirectories()).toEqual([]); + }); + + it('atomically swaps the verified staged binary and then records its version', async () => { + const binaryName = getExecutableName('original'); + const binaryPath = path.join(binPath, binaryName); + const versionPath = path.join(binPath, '.version'); + fs.writeFileSync(binaryPath, 'old-binary'); + fs.writeFileSync(versionPath, '6.6.80'); + + await downloadAndInstall( + { + version: '6.7.1', + releaseUrl: 'https://example.invalid', + binPath, + maxRetries: 1, + verbose: false, + forceVersion: true, + replaceExisting: true, + skipAutoUpdate: false, + allowInstall: true, + backend: 'original', + }, + false, + { + downloadWithRetryFn: async (_url, archivePath) => { + fs.writeFileSync(archivePath, 'downloaded-archive'); + return { success: true, filePath: archivePath, retries: 0 }; + }, + verifyChecksumFn: async () => ({ + valid: true, + expected: 'checksum', + actual: 'checksum', + }), + extractArchiveFn: async (_archivePath, destination) => { + fs.writeFileSync(path.join(destination, binaryName), 'new-binary'); + }, + } + ); + + expect(fs.readFileSync(binaryPath, 'utf8')).toBe('new-binary'); + expect(fs.readFileSync(versionPath, 'utf8')).toBe('6.7.1'); + expect(stagingDirectories()).toEqual([]); + }); + + it('preserves the installed binary and version when the atomic swap fails', async () => { + const binaryName = getExecutableName('original'); + const binaryPath = path.join(binPath, binaryName); + const versionPath = path.join(binPath, '.version'); + fs.writeFileSync(binaryPath, 'old-binary'); + fs.writeFileSync(versionPath, '6.6.80'); + + await expect( + downloadAndInstall( + { + version: '6.7.1', + releaseUrl: 'https://example.invalid', + binPath, + maxRetries: 1, + verbose: false, + forceVersion: true, + skipAutoUpdate: false, + allowInstall: true, + backend: 'original', + }, + false, + { + downloadWithRetryFn: async (_url, archivePath) => { + fs.writeFileSync(archivePath, 'downloaded-archive'); + return { success: true, filePath: archivePath, retries: 0 }; + }, + verifyChecksumFn: async () => ({ + valid: true, + expected: 'checksum', + actual: 'checksum', + }), + extractArchiveFn: async (_archivePath, destination) => { + fs.writeFileSync(path.join(destination, binaryName), 'new-binary'); + }, + renameSyncFn: () => { + throw Object.assign(new Error('replacement blocked'), { code: 'EPERM' }); + }, + } + ) + ).rejects.toThrow('replacement blocked'); + + expect(fs.readFileSync(binaryPath, 'utf8')).toBe('old-binary'); + expect(fs.readFileSync(versionPath, 'utf8')).toBe('6.6.80'); + expect(stagingDirectories()).toEqual([]); + }); + + it('installs a forced version even when an older binary already exists', async () => { + const binaryPath = path.join(binPath, getExecutableName('original')); + fs.writeFileSync(binaryPath, 'old-binary'); + let installs = 0; + + const resolvedPath = await ensureBinary( + { + version: '6.7.1', + releaseUrl: 'https://example.invalid', + binPath, + maxRetries: 1, + verbose: false, + forceVersion: true, + replaceExisting: true, + skipAutoUpdate: false, + allowInstall: true, + backend: 'original', + }, + { + downloadAndInstallFn: async () => { + installs += 1; + }, + } + ); + + expect(resolvedPath).toBe(binaryPath); + expect(installs).toBe(1); + }); + + it('reuses an existing pinned binary during runtime bootstrap', async () => { + const binaryPath = path.join(binPath, getExecutableName('original')); + fs.writeFileSync(binaryPath, 'pinned-binary'); + let installs = 0; + + const resolvedPath = await ensureBinary( + { + version: '6.6.80', + releaseUrl: 'https://example.invalid', + binPath, + maxRetries: 1, + verbose: false, + forceVersion: true, + replaceExisting: false, + skipAutoUpdate: false, + allowInstall: false, + backend: 'original', + }, + { + downloadAndInstallFn: async () => { + installs += 1; + }, + } + ); + + expect(resolvedPath).toBe(binaryPath); + expect(installs).toBe(0); + }); + + it('waits for an externally held compatible install lifecycle lock', async () => { + const lockTarget = path.join(binPath, '.install-lifecycle-plus'); + fs.mkdirSync(lockTarget, { recursive: true }); + fs.mkdirSync(`${lockTarget}.lock`); + let entered = false; + + const operation = withInstallLifecycleLock(lockTarget, async () => { + entered = true; + }); + + await Bun.sleep(50); + expect(entered).toBe(false); + fs.rmdirSync(`${lockTarget}.lock`); + await operation; + expect(entered).toBe(true); + }); + + it('restores the previous binary when publishing the version marker fails', async () => { + const binaryName = getExecutableName('original'); + const binaryPath = path.join(binPath, binaryName); + const versionPath = path.join(binPath, '.version'); + fs.writeFileSync(binaryPath, 'old-binary'); + fs.writeFileSync(versionPath, '6.6.80'); + let renames = 0; + + await expect( + downloadAndInstall( + { + version: '6.7.1', + releaseUrl: 'https://example.invalid', + binPath, + maxRetries: 1, + verbose: false, + forceVersion: true, + skipAutoUpdate: false, + allowInstall: true, + backend: 'original', + }, + false, + { + downloadWithRetryFn: async (_url, archivePath) => { + fs.writeFileSync(archivePath, 'downloaded-archive'); + return { success: true, filePath: archivePath, retries: 0 }; + }, + verifyChecksumFn: async () => ({ + valid: true, + expected: 'checksum', + actual: 'checksum', + }), + extractArchiveFn: async (_archivePath, destination) => { + fs.writeFileSync(path.join(destination, binaryName), 'new-binary'); + }, + renameSyncFn: (source, destination) => { + renames += 1; + if (renames === 2) throw new Error('version marker blocked'); + fs.renameSync(source, destination); + }, + } + ) + ).rejects.toThrow('version marker blocked'); + + expect(fs.readFileSync(binaryPath, 'utf8')).toBe('old-binary'); + expect(fs.readFileSync(versionPath, 'utf8')).toBe('6.6.80'); + expect(stagingDirectories()).toEqual([]); + }); + + it('serializes concurrent installs so the binary and version stay paired', async () => { + const binaryName = getExecutableName('original'); + const binaryPath = path.join(binPath, binaryName); + const versionPath = path.join(binPath, '.version'); + + const install = (version: string) => + downloadAndInstall( + { + version, + releaseUrl: 'https://example.invalid', + binPath, + maxRetries: 1, + verbose: false, + forceVersion: true, + skipAutoUpdate: false, + allowInstall: true, + backend: 'original', + }, + false, + { + downloadWithRetryFn: async (_url, archivePath) => { + fs.writeFileSync(archivePath, 'downloaded-archive'); + return { success: true, filePath: archivePath, retries: 0 }; + }, + verifyChecksumFn: async () => ({ + valid: true, + expected: 'checksum', + actual: 'checksum', + }), + extractArchiveFn: async (_archivePath, destination) => { + fs.writeFileSync(path.join(destination, binaryName), `binary-${version}`); + }, + } + ); + + await Promise.all([install('6.7.1'), install('6.7.2')]); + + const installedVersion = fs.readFileSync(versionPath, 'utf8'); + expect(fs.readFileSync(binaryPath, 'utf8')).toBe(`binary-${installedVersion}`); + expect(stagingDirectories()).toEqual([]); + }); + + it('removes staging residue left by an interrupted prior install', async () => { + const stalePath = path.join(binPath, '.cliproxy-install-stale'); + fs.mkdirSync(stalePath); + fs.writeFileSync(path.join(stalePath, 'partial-archive'), 'partial'); + const binaryName = getExecutableName('original'); + + await downloadAndInstall( + { + version: '6.7.1', + releaseUrl: 'https://example.invalid', + binPath, + maxRetries: 1, + verbose: false, + forceVersion: true, + skipAutoUpdate: false, + allowInstall: true, + backend: 'original', + }, + false, + { + downloadWithRetryFn: async (_url, archivePath) => { + fs.writeFileSync(archivePath, 'downloaded-archive'); + return { success: true, filePath: archivePath, retries: 0 }; + }, + verifyChecksumFn: async () => ({ + valid: true, + expected: 'checksum', + actual: 'checksum', + }), + extractArchiveFn: async (_archivePath, destination) => { + fs.writeFileSync(path.join(destination, binaryName), 'new-binary'); + }, + } + ); + + expect(fs.existsSync(stalePath)).toBe(false); + expect(stagingDirectories()).toEqual([]); + }); +}); diff --git a/src/cliproxy/binary/__tests__/binary-manager-install.test.ts b/src/cliproxy/binary/__tests__/binary-manager-install.test.ts index ec80035b..626360e3 100644 --- a/src/cliproxy/binary/__tests__/binary-manager-install.test.ts +++ b/src/cliproxy/binary/__tests__/binary-manager-install.test.ts @@ -33,6 +33,7 @@ describe('installCliproxyVersion', () => { ); await binaryManager.installCliproxyVersion('6.7.1', false, 'plus', { + withInstallLifecycleLockFn: async (_backend, operation) => operation(), createManager: (_config: unknown, backend: string) => { seenBackend = backend; return { @@ -147,6 +148,7 @@ describe('installCliproxyVersion', () => { const binaryManager = await import(`../../binary-manager?binary-manager-install=${Date.now()}`); await binaryManager.installCliproxyVersion('6.7.1', false, 'plus', { + withInstallLifecycleLockFn: async (_backend, operation) => operation(), createManager: () => ({ isBinaryInstalled: () => false, deleteBinary: () => { @@ -176,6 +178,39 @@ describe('installCliproxyVersion', () => { expect(calls.ensureBinary).toBe(1); }); + it('keeps the installed binary in place while the replacement is prepared', async () => { + const calls = { + deleteBinary: 0, + ensureBinary: 0, + }; + + const binaryManager = await import( + `../../binary-manager?binary-manager-atomic-install=${Date.now()}` + ); + + await binaryManager.installCliproxyVersion('6.7.1', false, 'plus', { + withInstallLifecycleLockFn: async (_backend, operation) => operation(), + createManager: () => ({ + isBinaryInstalled: () => true, + deleteBinary: () => { + calls.deleteBinary += 1; + }, + ensureBinary: async () => { + calls.ensureBinary += 1; + return '/tmp/ccs-bin/plus/cli-proxy-api-plus'; + }, + }), + stopProxyFn: async () => ({ stopped: false, error: 'No active CLIProxy session found' }), + waitForPortFreeFn: async () => true, + formatInfo: (message: string) => message, + formatWarn: (message: string) => message, + getInstalledVersion: () => '6.6.80', + }); + + expect(calls.deleteBinary).toBe(0); + expect(calls.ensureBinary).toBe(1); + }); + it('waits for the port that was actually stopped before continuing install', async () => { let waitedPort: number | undefined; @@ -184,6 +219,7 @@ describe('installCliproxyVersion', () => { ); await binaryManager.installCliproxyVersion('6.7.1', false, 'plus', { + withInstallLifecycleLockFn: async (_backend, operation) => operation(), createManager: () => ({ isBinaryInstalled: () => false, deleteBinary: () => undefined, diff --git a/src/cliproxy/binary/__tests__/version-checker-suffix.test.ts b/src/cliproxy/binary/__tests__/version-checker-suffix.test.ts index 186942b0..c20c75ae 100644 --- a/src/cliproxy/binary/__tests__/version-checker-suffix.test.ts +++ b/src/cliproxy/binary/__tests__/version-checker-suffix.test.ts @@ -1,6 +1,11 @@ import { describe, expect, it } from 'bun:test'; -import { compareCliproxyVersions, isNewerVersion } from '../version-checker'; +import { + type CLIProxyBackendMinVersions, + compareCliproxyVersions, + isNewerVersion, + meetsBackendMinimumVersion, +} from '../version-checker'; describe('cliproxy version comparison', () => { it('treats missing fork release suffix as zero', () => { @@ -19,3 +24,38 @@ describe('cliproxy version comparison', () => { expect(isNewerVersion('7.1.31-99', '7.1.32-0')).toBe(false); }); }); + +describe('meetsBackendMinimumVersion', () => { + const testMinimums: CLIProxyBackendMinVersions = { + original: '7.2.104', + plus: '7.2.105-0', + }; + + it('evaluates original backend boundary correctly', () => { + expect(meetsBackendMinimumVersion('7.2.103', 'original', testMinimums)).toBe(false); + expect(meetsBackendMinimumVersion('7.2.104', 'original', testMinimums)).toBe(true); + expect(meetsBackendMinimumVersion('7.2.105', 'original', testMinimums)).toBe(true); + }); + + it('evaluates plus backend boundary with fork suffixes correctly', () => { + expect(meetsBackendMinimumVersion('7.2.104-9', 'plus', testMinimums)).toBe(false); + expect(meetsBackendMinimumVersion('7.2.105-0', 'plus', testMinimums)).toBe(true); + expect(meetsBackendMinimumVersion('7.2.105-1', 'plus', testMinimums)).toBe(true); + }); + + it('treats missing fork suffix as equal to -0', () => { + const minWithSuffix: CLIProxyBackendMinVersions = { + original: '6.8.34-0', + plus: '6.8.34-0', + }; + expect(meetsBackendMinimumVersion('6.8.34', 'original', minWithSuffix)).toBe(true); + expect(meetsBackendMinimumVersion('6.8.34', 'plus', minWithSuffix)).toBe(true); + }); + + it('rejects empty and invalid version strings against real minimums', () => { + expect(meetsBackendMinimumVersion('', 'original', testMinimums)).toBe(false); + expect(meetsBackendMinimumVersion('not-a-version', 'original', testMinimums)).toBe(false); + expect(meetsBackendMinimumVersion('', 'plus', testMinimums)).toBe(false); + expect(meetsBackendMinimumVersion('not-a-version', 'plus', testMinimums)).toBe(false); + }); +}); diff --git a/src/cliproxy/binary/install-lifecycle-lock.ts b/src/cliproxy/binary/install-lifecycle-lock.ts new file mode 100644 index 00000000..afa534b2 --- /dev/null +++ b/src/cliproxy/binary/install-lifecycle-lock.ts @@ -0,0 +1,37 @@ +import { AsyncLocalStorage } from 'async_hooks'; +import * as fs from 'fs'; +import * as path from 'path'; +import * as lockfile from 'proper-lockfile'; + +const heldInstallLocks = new AsyncLocalStorage>(); + +export async function withInstallLifecycleLock( + lockTarget: string, + operation: () => Promise +): Promise { + const resolvedTarget = path.resolve(lockTarget); + const held = heldInstallLocks.getStore(); + if (held?.has(resolvedTarget)) return operation(); + + fs.mkdirSync(resolvedTarget, { recursive: true }); + const release = await lockfile.lock(resolvedTarget, { + stale: 10 * 60 * 1000, + retries: { retries: 60, factor: 1, minTimeout: 250, maxTimeout: 250 }, + }); + const nextHeld = new Set(held); + nextHeld.add(resolvedTarget); + let operationError: unknown; + + try { + return await heldInstallLocks.run(nextHeld, operation); + } catch (error) { + operationError = error; + throw error; + } finally { + try { + await release(); + } catch (error) { + if (!operationError) throw error; + } + } +} diff --git a/src/cliproxy/binary/installer.ts b/src/cliproxy/binary/installer.ts index 68be9dca..d79914c4 100644 --- a/src/cliproxy/binary/installer.ts +++ b/src/cliproxy/binary/installer.ts @@ -5,6 +5,7 @@ import * as fs from 'fs'; import * as path from 'path'; +import * as lockfile from 'proper-lockfile'; import { BinaryManagerConfig } from '../types'; import { detectPlatform, @@ -16,66 +17,71 @@ import { import { downloadWithRetry } from './downloader'; import { verifyChecksum, computeChecksum } from './verifier'; import { extractArchive } from './extractor'; -import { writeInstalledVersion } from './version-cache'; import { ProgressIndicator } from '../../utils/progress-indicator'; import { ok } from '../../utils/ui'; +import { BinaryError, NetworkError } from '../../errors/error-types'; + +interface DownloadAndInstallDeps { + downloadWithRetryFn?: typeof downloadWithRetry; + verifyChecksumFn?: typeof verifyChecksum; + extractArchiveFn?: typeof extractArchive; + renameSyncFn?: typeof fs.renameSync; +} /** * Download and install the binary */ export async function downloadAndInstall( config: BinaryManagerConfig, - verbose = false + verbose = false, + deps: DownloadAndInstallDeps = {} ): Promise { const backend = config.backend ?? DEFAULT_BACKEND; const platform = detectPlatform(config.version, backend); const downloadUrl = getDownloadUrl(config.version, backend); const checksumsUrl = getChecksumsUrl(config.version, backend); const backendLabel = backend === 'plus' ? 'CLIProxy Plus' : 'CLIProxy'; + const downloadWithRetryFn = deps.downloadWithRetryFn ?? downloadWithRetry; + const verifyChecksumFn = deps.verifyChecksumFn ?? verifyChecksum; + const extractArchiveFn = deps.extractArchiveFn ?? extractArchive; + const renameSyncFn = deps.renameSyncFn ?? fs.renameSync; fs.mkdirSync(config.binPath, { recursive: true }); - - // Delete existing binary before install to prevent mismatched binaries. - // Abort if binary is currently running (ETXTBSY) — cannot replace in-use binary. - // Happens in Docker when dashboard tries to update while bootstrap's instance is active. - const existingBinary = path.join(config.binPath, getExecutableName(backend)); - if (fs.existsSync(existingBinary)) { - try { - fs.unlinkSync(existingBinary); - if (verbose) console.error(`[cliproxy] Removed existing binary: ${existingBinary}`); - } catch (error: unknown) { - const code = - error instanceof Error && 'code' in error ? (error as { code: string }).code : ''; - // ETXTBSY: Linux-specific error when unlinking a running executable. - // EBUSY on Windows may mean something different (mount point, etc.), - // so only treat ETXTBSY as "binary in use" to avoid misleading messages. - if (code === 'ETXTBSY') { - if (verbose) - console.error(`[cliproxy] Binary is running, cannot replace: ${existingBinary}`); - throw new Error( - 'CLIProxy binary is currently running and cannot be replaced. Restart the container to apply the update.' - ); - } - throw error; - } - } - - const archivePath = path.join(config.binPath, `cliproxy-archive.${platform.extension}`); + const releaseLock = await lockfile.lock(config.binPath, { + stale: 10 * 60 * 1000, + retries: { retries: 60, factor: 1, minTimeout: 250, maxTimeout: 250 }, + }); + let stagingPath: string | undefined; const spinner = new ProgressIndicator(`Downloading ${backendLabel} v${config.version}`); - spinner.start(); + let installError: unknown; try { - const result = await downloadWithRetry(downloadUrl, archivePath, { + for (const entry of fs.readdirSync(config.binPath)) { + if (entry.startsWith('.cliproxy-install-')) { + fs.rmSync(path.join(config.binPath, entry), { recursive: true, force: true }); + } + } + stagingPath = fs.mkdtempSync(path.join(config.binPath, '.cliproxy-install-')); + const archivePath = path.join(stagingPath, `cliproxy-archive.${platform.extension}`); + const stagedBinary = path.join(stagingPath, getExecutableName(backend)); + const stagedVersion = path.join(stagingPath, '.version'); + const installedBinary = path.join(config.binPath, getExecutableName(backend)); + const installedVersion = path.join(config.binPath, '.version'); + const backupBinary = path.join(stagingPath, '.previous-binary'); + const hadInstalledBinary = fs.existsSync(installedBinary); + spinner.start(); + + const result = await downloadWithRetryFn(downloadUrl, archivePath, { maxRetries: config.maxRetries, verbose, }); if (!result.success) { spinner.fail('Download failed'); - throw new Error(result.error || 'Download failed after retries'); + throw new NetworkError(result.error || 'Download failed after retries', downloadUrl); } spinner.update('Verifying checksum'); - const checksumResult = await verifyChecksum( + const checksumResult = await verifyChecksumFn( archivePath, platform.binaryName, checksumsUrl, @@ -84,29 +90,64 @@ export async function downloadAndInstall( if (!checksumResult.valid) { spinner.fail('Checksum mismatch'); - fs.unlinkSync(archivePath); - throw new Error( + throw new BinaryError( `Checksum mismatch for ${platform.binaryName}\nExpected: ${checksumResult.expected}\n` + - `Actual: ${checksumResult.actual}\n\nManual download: ${downloadUrl}` + `Actual: ${checksumResult.actual}\n\nManual download: ${downloadUrl}`, + stagedBinary ); } spinner.update('Extracting binary'); - await extractArchive(archivePath, config.binPath, platform.extension, verbose, backend); - spinner.succeed(`${backendLabel} ready`); - fs.unlinkSync(archivePath); - - const binaryPath = path.join(config.binPath, getExecutableName(backend)); - if (platform.os !== 'windows' && fs.existsSync(binaryPath)) { - fs.chmodSync(binaryPath, 0o755); - if (verbose) console.error(`[cliproxy] Set executable permissions: ${binaryPath}`); + await extractArchiveFn(archivePath, stagingPath, platform.extension, verbose, backend); + if (!fs.existsSync(stagedBinary) || !fs.statSync(stagedBinary).isFile()) { + throw new BinaryError( + `Extracted archive did not contain ${getExecutableName(backend)}`, + stagedBinary + ); } - writeInstalledVersion(config.binPath, config.version); + if (platform.os !== 'windows') { + fs.chmodSync(stagedBinary, 0o755); + if (verbose) console.error(`[cliproxy] Set executable permissions: ${stagedBinary}`); + } + + fs.writeFileSync(stagedVersion, config.version, 'utf8'); + if (hadInstalledBinary) { + fs.copyFileSync(installedBinary, backupBinary); + if (platform.os !== 'windows') fs.chmodSync(backupBinary, 0o755); + } + + renameSyncFn(stagedBinary, installedBinary); + try { + renameSyncFn(stagedVersion, installedVersion); + } catch (error) { + if (hadInstalledBinary) { + renameSyncFn(backupBinary, installedBinary); + } else { + fs.unlinkSync(installedBinary); + } + throw error; + } + spinner.succeed(`${backendLabel} ready`); console.log(ok(`${backendLabel} v${config.version} installed successfully`)); } catch (error) { + installError = error; spinner.fail('Installation failed'); throw error; + } finally { + let cleanupError: unknown; + try { + if (stagingPath) fs.rmSync(stagingPath, { recursive: true, force: true }); + } catch (error) { + cleanupError = error; + } finally { + try { + await releaseLock(); + } catch (error) { + cleanupError ??= error; + } + } + if (!installError && cleanupError) throw cleanupError; } } @@ -124,7 +165,10 @@ export function deleteBinary(binPath: string, verbose = false, backend?: CLIProx const code = error instanceof Error && 'code' in error ? (error as { code: string }).code : ''; if (code === 'ETXTBSY') { - throw new Error('CLIProxy binary is currently running and cannot be deleted.'); + throw new BinaryError( + 'CLIProxy binary is currently running and cannot be deleted.', + binaryPath + ); } throw error; } diff --git a/src/cliproxy/binary/lifecycle.ts b/src/cliproxy/binary/lifecycle.ts index f16b103d..b77eaa80 100644 --- a/src/cliproxy/binary/lifecycle.ts +++ b/src/cliproxy/binary/lifecycle.ts @@ -11,10 +11,11 @@ import { isNewerVersion, isVersionFaulty, } from './version-checker'; -import { downloadAndInstall, deleteBinary, getBinaryPath } from './installer'; +import { downloadAndInstall, getBinaryPath } from './installer'; import { info, warn } from '../../utils/ui'; import { isCliproxyRunning } from '../services/stats-fetcher'; import { resolveLifecyclePort } from '../config/port-manager'; +import { BinaryError } from '../../errors/error-types'; import { CLIPROXY_MAX_STABLE_VERSION, CLIPROXY_FAULTY_RANGE, @@ -93,7 +94,7 @@ async function handleAutoUpdate(config: BinaryManagerConfig, verbose: boolean): } else { console.log(info(updateMsg)); console.log(info(`Updating ${backendLabel}...`)); - deleteBinary(config.binPath, verbose, backend); + // The installer stages and validates the replacement before atomically swapping it. config.version = targetVersion; await downloadAndInstall(config, verbose); } @@ -103,17 +104,31 @@ async function handleAutoUpdate(config: BinaryManagerConfig, verbose: boolean): * Ensure binary is available (download if missing, update if outdated) * @returns Path to executable binary */ -export async function ensureBinary(config: BinaryManagerConfig): Promise { +interface EnsureBinaryDeps { + downloadAndInstallFn?: typeof downloadAndInstall; +} + +export async function ensureBinary( + config: BinaryManagerConfig, + deps: EnsureBinaryDeps = {} +): Promise { const verbose = config.verbose; const backend: CLIProxyBackend = config.backend ?? DEFAULT_BACKEND; const binaryPath = getBinaryPath(config.binPath, backend); + const downloadAndInstallFn = deps.downloadAndInstallFn ?? downloadAndInstall; // Binary exists - check for updates unless forceVersion if (fs.existsSync(binaryPath)) { log(`Binary exists: ${binaryPath}`, verbose); + if (config.replaceExisting) { + log(`Force version mode: installing specified version ${config.version}`, verbose); + await downloadAndInstallFn(config, verbose); + return binaryPath; + } + if (config.forceVersion) { - log('Force version mode: skipping auto-update', verbose); + log(`Pinned version mode: using installed version ${config.version}`, verbose); return binaryPath; } @@ -134,9 +149,10 @@ export async function ensureBinary(config: BinaryManagerConfig): Promise // Binary missing if (!config.allowInstall) { - throw new Error( + throw new BinaryError( `${getBackendLabel(backend)} binary is not installed locally. ` + - 'Run "ccs cliproxy install" when you have network access.' + 'Run "ccs cliproxy install" when you have network access.', + binaryPath ); } @@ -161,6 +177,6 @@ export async function ensureBinary(config: BinaryManagerConfig): Promise log(`Force version mode: using specified version ${config.version}`, verbose); } - await downloadAndInstall(config, verbose); + await downloadAndInstallFn(config, verbose); return binaryPath; } diff --git a/src/cliproxy/binary/platform-detector.ts b/src/cliproxy/binary/platform-detector.ts index fdb78d83..d9e5347b 100644 --- a/src/cliproxy/binary/platform-detector.ts +++ b/src/cliproxy/binary/platform-detector.ts @@ -84,7 +84,10 @@ const RELEASE_ARCH_MAP: Record = { }; const PLUS_NO_PLUGIN_ASSET_MIN_VERSION = '7.1.68-0'; -const PLUS_AARCH64_ASSET_MIN_VERSION = '7.1.45-1'; +const AARCH64_ASSET_MIN_VERSION: Readonly> = { + original: '6.9.48', + plus: '6.10.0-0', +}; export function mapNodeArchToReleaseArch(nodeArch: string): SupportedArch | undefined { const arch = ARCH_MAP[nodeArch]; @@ -121,11 +124,7 @@ function getReleaseArchForBackend( publicArch: SupportedArch, releaseArch: SupportedArch ): SupportedArch { - if ( - backend === 'plus' && - publicArch === 'arm64' && - !isAtLeastVersion(version, PLUS_AARCH64_ASSET_MIN_VERSION) - ) { + if (publicArch === 'arm64' && !isAtLeastVersion(version, AARCH64_ASSET_MIN_VERSION[backend])) { return 'arm64'; } diff --git a/src/cliproxy/binary/version-checker.ts b/src/cliproxy/binary/version-checker.ts index f4e7c547..946dfc78 100644 --- a/src/cliproxy/binary/version-checker.ts +++ b/src/cliproxy/binary/version-checker.ts @@ -21,6 +21,8 @@ import { } from '../binary/platform-detector'; import type { CLIProxyBackend } from '../types'; +export type CLIProxyBackendMinVersions = Readonly>; + interface FetchLatestVersionDeps { fetchJsonFn?: typeof fetchJson; } @@ -78,6 +80,14 @@ export function isNewerVersion(latest: string, current: string): boolean { return compareCliproxyVersions(latest, current) > 0; } +export function meetsBackendMinimumVersion( + installedVersion: string, + backend: CLIProxyBackend, + minimumByBackend: CLIProxyBackendMinVersions +): boolean { + return compareCliproxyVersions(installedVersion, minimumByBackend[backend]) >= 0; +} + /** * Check if version is within the faulty range (v81-85) * @returns true if version has known critical bugs diff --git a/src/cliproxy/config/__tests__/backend-selection.test.js b/src/cliproxy/config/__tests__/backend-selection.test.js index e48d1091..116b3ddb 100644 --- a/src/cliproxy/config/__tests__/backend-selection.test.js +++ b/src/cliproxy/config/__tests__/backend-selection.test.js @@ -86,13 +86,102 @@ describe('Backend Selection', () => { ); }); }); - it('generates correct binary name for original backend', () => { const info = platformDetector.detectPlatform('6.6.51', 'original'); assert(info.binaryName.startsWith('CLIProxyAPI_6.6.51_')); assert(!info.binaryName.includes('CLIProxyAPIPlus')); }); + it('selects arm64 vs aarch64 release assets across original and plus boundaries', () => { + // Original pre-rename boundaries (<= 6.9.47) use arm64 + for (const version of [undefined, '6.9.45', '6.9.47']) { + const effectiveVersion = + version || platformDetector.BACKEND_CONFIG.original.fallbackVersion; + withMockedProcessPlatform('darwin', 'arm64', () => { + const info = platformDetector.detectPlatform(version, 'original'); + assert.strictEqual(info.arch, 'arm64'); + assert.strictEqual( + info.binaryName, + `CLIProxyAPI_${effectiveVersion}_darwin_arm64.tar.gz` + ); + assert.strictEqual( + platformDetector.getDownloadUrl(version, 'original'), + `https://github.com/router-for-me/CLIProxyAPI/releases/download/v${effectiveVersion}/CLIProxyAPI_${effectiveVersion}_darwin_arm64.tar.gz` + ); + }); + + withMockedProcessPlatform('linux', 'arm64', () => { + const info = platformDetector.detectPlatform(version, 'original'); + assert.strictEqual(info.arch, 'arm64'); + assert.strictEqual(info.binaryName, `CLIProxyAPI_${effectiveVersion}_linux_arm64.tar.gz`); + assert.strictEqual( + platformDetector.getDownloadUrl(version, 'original'), + `https://github.com/router-for-me/CLIProxyAPI/releases/download/v${effectiveVersion}/CLIProxyAPI_${effectiveVersion}_linux_arm64.tar.gz` + ); + }); + + withMockedProcessPlatform('win32', 'arm64', () => { + const info = platformDetector.detectPlatform(version, 'original'); + assert.strictEqual(info.arch, 'arm64'); + assert.strictEqual(info.binaryName, `CLIProxyAPI_${effectiveVersion}_windows_arm64.zip`); + assert.strictEqual( + platformDetector.getDownloadUrl(version, 'original'), + `https://github.com/router-for-me/CLIProxyAPI/releases/download/v${effectiveVersion}/CLIProxyAPI_${effectiveVersion}_windows_arm64.zip` + ); + }); + } + + // Original post-rename boundary (>= 6.9.48) uses aarch64 while public arch remains arm64 + withMockedProcessPlatform('darwin', 'arm64', () => { + const info = platformDetector.detectPlatform('6.9.48', 'original'); + assert.strictEqual(info.arch, 'arm64'); + assert.strictEqual(info.binaryName, 'CLIProxyAPI_6.9.48_darwin_aarch64.tar.gz'); + assert.strictEqual( + platformDetector.getDownloadUrl('6.9.48', 'original'), + 'https://github.com/router-for-me/CLIProxyAPI/releases/download/v6.9.48/CLIProxyAPI_6.9.48_darwin_aarch64.tar.gz' + ); + }); + + // Plus fallback (undefined) and explicit pre-rename (< 6.10.0-0) use arm64 + for (const version of [undefined, '6.9.45-0']) { + const effectiveVersion = version || platformDetector.BACKEND_CONFIG.plus.fallbackVersion; + withMockedProcessPlatform('darwin', 'arm64', () => { + const info = platformDetector.detectPlatform(version, 'plus'); + assert.strictEqual(info.arch, 'arm64'); + assert.strictEqual( + info.binaryName, + `CLIProxyAPIPlus_${effectiveVersion}_darwin_arm64.tar.gz` + ); + assert.strictEqual( + platformDetector.getDownloadUrl(version, 'plus'), + `https://github.com/kaitranntt/CLIProxyAPIPlus/releases/download/v${effectiveVersion}/CLIProxyAPIPlus_${effectiveVersion}_darwin_arm64.tar.gz` + ); + }); + } + + // Plus post-rename (>= 6.10.0-0) uses aarch64 + withMockedProcessPlatform('darwin', 'arm64', () => { + const info = platformDetector.detectPlatform('6.10.0-0', 'plus'); + assert.strictEqual(info.arch, 'arm64'); + assert.strictEqual(info.binaryName, 'CLIProxyAPIPlus_6.10.0-0_darwin_aarch64.tar.gz'); + assert.strictEqual( + platformDetector.getDownloadUrl('6.10.0-0', 'plus'), + 'https://github.com/kaitranntt/CLIProxyAPIPlus/releases/download/v6.10.0-0/CLIProxyAPIPlus_6.10.0-0_darwin_aarch64.tar.gz' + ); + }); + + // x64 remains amd64 for both backends + withMockedProcessPlatform('linux', 'x64', () => { + assert.strictEqual( + platformDetector.detectPlatform('6.9.45', 'original').binaryName, + 'CLIProxyAPI_6.9.45_linux_amd64.tar.gz' + ); + assert.strictEqual( + platformDetector.detectPlatform('6.9.45-0', 'plus').binaryName, + 'CLIProxyAPIPlus_6.9.45-0_linux_amd64.tar.gz' + ); + }); + }); it('uses static no-plugin assets when the integrated Linux image opts in', () => { withEnvironmentVariable('CCS_CLIPROXY_NO_PLUGIN_ASSET', '1', () => { withMockedProcessPlatform('linux', 'x64', () => { diff --git a/src/cliproxy/config/extended-context-config.ts b/src/cliproxy/config/extended-context-config.ts index 98115888..98fb26c9 100644 --- a/src/cliproxy/config/extended-context-config.ts +++ b/src/cliproxy/config/extended-context-config.ts @@ -10,10 +10,11 @@ */ import type { CLIProxyProvider } from '../types'; -import { supportsExtendedContext } from '../model-catalog'; +import { getDefaultFableTierModel, supportsExtendedContext } from '../model-catalog'; import { warn } from '../../utils/ui'; import { ANTHROPIC_MODEL_ENV_KEYS, + EXTENDED_CONTEXT_MODEL_ENV_KEYS, applyExtendedContextPreferenceToAnthropicModels, applyExtendedContextSuffix as applyExtendedContextSuffixShared, hasExtendedContextSuffix, @@ -63,6 +64,39 @@ export function shouldApplyExtendedContext( return false; } +/** + * Whether a saved Anthropic tier mapping still carries a [1m] suffix after the + * per-key pass, i.e. the launch is a long-context launch. + */ +function hasSavedLongContextTier(envVars: NodeJS.ProcessEnv): boolean { + return ANTHROPIC_MODEL_ENV_KEYS.some((key) => { + const value = envVars[key]; + return typeof value === 'string' && hasExtendedContextSuffix(value); + }); +} + +/** + * Give a long-context launch a fable tier when the profile does not map one. + * + * Claude Code falls back to its own bare Fable id for `--model fable` / the + * fable tier, and behind a proxy base URL a bare natively-1M id is clamped to + * the standard 200k window. Only the [1m] suffix lifts that clamp, so a + * model-neutral claude profile that asked for 1M elsewhere would otherwise get + * a 200k Fable. An explicit mapping (dashboard "fable tier" field or settings + * file) always wins; providers without a catalog Fable model are untouched. + */ +function fillFableTierForLongContext(envVars: NodeJS.ProcessEnv, provider: CLIProxyProvider): void { + const existing = envVars.ANTHROPIC_DEFAULT_FABLE_MODEL; + if (typeof existing === 'string' && existing.trim().length > 0) { + return; + } + const fableModel = getDefaultFableTierModel(provider); + if (!fableModel || !supportsExtendedContext(provider, fableModel)) { + return; + } + envVars.ANTHROPIC_DEFAULT_FABLE_MODEL = applyExtendedContextSuffixShared(fableModel); +} + /** * Apply extended context configuration to env vars. * Modifies ANTHROPIC_MODEL and tier models with [1m] suffix. @@ -88,6 +122,9 @@ export function applyExtendedContextConfig( ), }) ); + if (extendedContextOverride) { + fillFableTierForLongContext(envVars, provider); + } return; } @@ -95,7 +132,7 @@ export function applyExtendedContextConfig( // previously saved [1m] preference just because the model is Claude — only // strip when the model no longer supports extended context. Native Gemini // models still get auto-toggled based on catalog support. - for (const key of ANTHROPIC_MODEL_ENV_KEYS) { + for (const key of EXTENDED_CONTEXT_MODEL_ENV_KEYS) { const value = envVars[key]; if (typeof value !== 'string' || value.trim().length === 0) { continue; @@ -113,4 +150,8 @@ export function applyExtendedContextConfig( envVars[key] = stripExtendedContextSuffix(value); } } + + if (hasSavedLongContextTier(envVars)) { + fillFableTierForLongContext(envVars, provider); + } } diff --git a/src/cliproxy/executor/__tests__/launch-settings.test.ts b/src/cliproxy/executor/__tests__/launch-settings.test.ts index 6080ab27..0b313b84 100644 --- a/src/cliproxy/executor/__tests__/launch-settings.test.ts +++ b/src/cliproxy/executor/__tests__/launch-settings.test.ts @@ -57,6 +57,30 @@ describe('buildLaunchSettingsOverlay', () => { expect(env.ANTHROPIC_AUTH_TOKEN).toBe('ccs-internal-managed'); }); + it('overlays the extended-context extra model keys so a saved bare value cannot clobber --1m/--no-1m', () => { + writePersisted({ + env: { + ANTHROPIC_BASE_URL: 'http://127.0.0.1:8317/api/provider/claude', + ANTHROPIC_MODEL: 'claude-opus-5[1m]', + ANTHROPIC_DEFAULT_MODEL: 'claude-opus-5[1m]', + CLAUDE_CODE_SUBAGENT_MODEL: 'claude-sonnet-5[1m]', + }, + }); + + const { settings, changed } = buildLaunchSettingsOverlay(settingsPath, { + ANTHROPIC_BASE_URL: 'http://127.0.0.1:8317/api/provider/claude', + ANTHROPIC_MODEL: 'claude-opus-5', + ANTHROPIC_DEFAULT_MODEL: 'claude-opus-5', + CLAUDE_CODE_SUBAGENT_MODEL: 'claude-sonnet-5', + } as NodeJS.ProcessEnv); + + expect(changed).toBe(true); + const env = settings.env as Record; + expect(env.ANTHROPIC_MODEL).toBe('claude-opus-5'); + expect(env.ANTHROPIC_DEFAULT_MODEL).toBe('claude-opus-5'); + expect(env.CLAUDE_CODE_SUBAGENT_MODEL).toBe('claude-sonnet-5'); + }); + it('preserves non-env settings (permissions, hooks, etc.)', () => { writePersisted({ env: { ANTHROPIC_BASE_URL: 'http://127.0.0.1:8317/api/provider/codex' }, diff --git a/src/cliproxy/executor/launch-settings.ts b/src/cliproxy/executor/launch-settings.ts index 5e33bf7e..aadbd96e 100644 --- a/src/cliproxy/executor/launch-settings.ts +++ b/src/cliproxy/executor/launch-settings.ts @@ -26,6 +26,7 @@ import * as os from 'os'; import * as path from 'path'; import { ANTHROPIC_MODEL_ENV_KEYS, ANTHROPIC_ROUTING_ENV_KEYS } from '../../utils/shell-executor'; +import { EXTRA_EXTENDED_CONTEXT_MODEL_ENV_KEYS } from '../../shared/extended-context-utils'; // SIBLING HELPER: src/utils/openai-compat-launch-settings.ts solves the same // "persisted --settings env clobbers runtime routing env" problem by STRIPPING @@ -38,9 +39,15 @@ import { ANTHROPIC_MODEL_ENV_KEYS, ANTHROPIC_ROUTING_ENV_KEYS } from '../../util * Environment keys that control provider routing/model selection and are read * by Claude from the settings `env` block. These must reflect the resolved * proxy-chain environment, not the persisted on-disk values. Reuses the - * canonical routing/model key lists from shell-executor. + * canonical routing/model key lists from shell-executor, plus the extra model + * keys the [1m] preference manages (subagent + startup default): a saved bare + * value there would otherwise clobber the resolved --1m/--no-1m result. */ -const ROUTING_ENV_KEYS = [...ANTHROPIC_ROUTING_ENV_KEYS, ...ANTHROPIC_MODEL_ENV_KEYS]; +const ROUTING_ENV_KEYS = [ + ...ANTHROPIC_ROUTING_ENV_KEYS, + ...ANTHROPIC_MODEL_ENV_KEYS, + ...EXTRA_EXTENDED_CONTEXT_MODEL_ENV_KEYS, +]; function isRecord(value: unknown): value is Record { return typeof value === 'object' && value !== null && !Array.isArray(value); diff --git a/src/cliproxy/model-catalog.ts b/src/cliproxy/model-catalog.ts index 942730bb..0b6cb2f8 100644 --- a/src/cliproxy/model-catalog.ts +++ b/src/cliproxy/model-catalog.ts @@ -323,6 +323,18 @@ export const MODEL_CATALOG: Partial> = description: 'Fast coding model for agentic software engineering workflows', contextWindow: 256000, }, + { + id: 'grok-4.6', + name: 'Grok 4.6', + description: + "SpaceXAI's smartest model built for long-running agents, interactive and visual work.", + contextWindow: 500000, + thinking: { + type: 'levels', + levels: ['low', 'medium', 'high', 'xhigh'], + zeroAllowed: false, + }, + }, { id: 'grok-4.5', name: 'Grok 4.5', @@ -519,10 +531,27 @@ export const MODEL_CATALOG: Partial> = }, extendedContext: true, }, + { + id: 'claude-fable-5-1', + name: 'Claude Fable 5.1', + description: 'Most powerful model (1M context, 128K output)', + contextWindow: 1000000, + nativeImageInput: true, + // Thinking is always on and cannot be disabled: Anthropic rejects both + // `thinking.type: "disabled"` and manual budget_tokens with 400. Depth is + // steered only through effort levels (default `high`). + thinking: { + type: 'levels', + levels: ['low', 'medium', 'high', 'xhigh', 'max'], + maxLevel: 'max', + dynamicAllowed: true, + }, + extendedContext: true, + }, { id: 'claude-fable-5', name: 'Claude Fable 5', - description: 'Most powerful model', + description: 'Previous most powerful model', contextWindow: 1000000, nativeImageInput: true, // New tier above Opus. Same adaptive-thinking surface as Opus 4.8: @@ -837,6 +866,17 @@ export function supportsExtendedContext(provider: CLIProxyProvider, modelId: str return model?.extendedContext === true; } +/** + * Catalog model that backs the Claude Code `fable` tier for a provider. + * The catalog lists the newest Fable first, so the first match is the default. + * Returns undefined for providers that serve no Fable model. + */ +export function getDefaultFableTierModel(provider: CLIProxyProvider): string | undefined { + const catalog = MODEL_CATALOG[provider]; + if (!catalog) return undefined; + return catalog.models.find((model) => model.id.toLowerCase().startsWith('claude-fable-'))?.id; +} + /** * Check if a model can read image inputs natively. */ diff --git a/src/cliproxy/routing/__tests__/routing-strategy.test.ts b/src/cliproxy/routing/__tests__/routing-strategy.test.ts index 3005620f..6a9364c1 100644 --- a/src/cliproxy/routing/__tests__/routing-strategy.test.ts +++ b/src/cliproxy/routing/__tests__/routing-strategy.test.ts @@ -2,7 +2,7 @@ import { afterEach, beforeEach, describe, expect, it, mock } from 'bun:test'; import * as fs from 'fs'; import * as os from 'os'; import * as path from 'path'; - +import type { CLIProxyBackend } from '../../types'; describe('cliproxy routing strategy service', () => { let tempHome = ''; let scopedConfigDir = ''; @@ -15,6 +15,11 @@ describe('cliproxy routing strategy service', () => { protocol: 'http' as const, isRemote: false, }; + let binaryBackend: CLIProxyBackend = 'original'; + let binaryVersion = '7.2.127-7'; + let installedVersionQueriedBackend: CLIProxyBackend | undefined = undefined; + let loggedWarnings: Array<{ event: string; message: string; context?: Record }> = + []; let responseFactory: (() => Promise) | null = null; beforeEach(async () => { @@ -28,6 +33,10 @@ describe('cliproxy routing strategy service', () => { }; responseFactory = null; originalCcsDir = process.env.CCS_DIR; + binaryBackend = 'original'; + binaryVersion = '7.2.127-7'; + installedVersionQueriedBackend = undefined; + loggedWarnings = []; originalCcsHome = process.env.CCS_HOME; process.env.CCS_DIR = scopedConfigDir; process.env.CCS_HOME = tempHome; @@ -74,6 +83,26 @@ describe('cliproxy routing strategy service', () => { }, })); + mock.module('../../binary-manager', () => ({ + getConfiguredBackend: () => binaryBackend, + getInstalledCliproxyVersion: (backend?: CLIProxyBackend) => { + installedVersionQueriedBackend = backend; + return binaryVersion; + }, + })); + + mock.module('../../../services/logging', () => ({ + createLogger: () => ({ + debug: () => {}, + info: () => {}, + warn: (event: string, message: string, context?: Record) => { + loggedWarnings.push({ event, message, context }); + }, + error: () => {}, + child: () => ({ warn: () => {} }), + }), + })); + return import(`../routing-strategy?test=${Date.now()}-${Math.random()}`); } @@ -365,4 +394,65 @@ describe('cliproxy routing strategy service', () => { expect(result.message).not.toContain('Pool routing is active'); }); }); + + describe('pool routing version compatibility (#1726)', () => { + it('defines backend-specific minimum versions for pool routing', async () => { + const mod = await loadRoutingModule(); + expect(mod.POOL_ROUTING_MIN_VERSION).toEqual({ + original: '6.8.34', + plus: '6.8.34-0', + }); + }); + + it('evaluates isPoolRoutingSupported correctly across original and plus thresholds', async () => { + const mod = await loadRoutingModule(); + expect(mod.isPoolRoutingSupported('original', '6.8.33')).toBe(false); + expect(mod.isPoolRoutingSupported('original', '6.8.34')).toBe(true); + expect(mod.isPoolRoutingSupported('original', '6.9.0')).toBe(true); + + expect(mod.isPoolRoutingSupported('plus', '6.8.33-9')).toBe(false); + expect(mod.isPoolRoutingSupported('plus', '6.8.34-0')).toBe(true); + expect(mod.isPoolRoutingSupported('plus', '7.2.127-7')).toBe(true); + + expect(mod.isPoolRoutingSupported('original', '')).toBe(false); + expect(mod.isPoolRoutingSupported('plus', 'not-a-version')).toBe(false); + }); + + it('warns with backend-specific metadata when plus binary is below minimum', async () => { + await withScopedConfig(async () => { + binaryBackend = 'plus'; + binaryVersion = '6.8.33-9'; + loggedWarnings = []; + + const mod = await loadRoutingModule(); + const result = mod.enablePoolRouting(8317); + + expect(result.changed).toBe(true); + expect(installedVersionQueriedBackend).toBe('plus'); + expect(loggedWarnings).toHaveLength(1); + expect(loggedWarnings[0].event).toBe('pool_routing.binary_below_minimum'); + expect(loggedWarnings[0].message).toContain('CLIProxy Plus'); + expect(loggedWarnings[0].context).toEqual({ + backend: 'plus', + installedVersion: '6.8.33-9', + minimumVersion: '6.8.34-0', + }); + }); + }); + + it('does not warn when plus binary meets the minimum version', async () => { + await withScopedConfig(async () => { + binaryBackend = 'plus'; + binaryVersion = '6.8.34-0'; + loggedWarnings = []; + + const mod = await loadRoutingModule(); + const result = mod.enablePoolRouting(8317); + + expect(result.changed).toBe(true); + expect(installedVersionQueriedBackend).toBe('plus'); + expect(loggedWarnings).toHaveLength(0); + }); + }); + }); }); diff --git a/src/cliproxy/routing/routing-strategy.ts b/src/cliproxy/routing/routing-strategy.ts index d025e858..3533cef0 100644 --- a/src/cliproxy/routing/routing-strategy.ts +++ b/src/cliproxy/routing/routing-strategy.ts @@ -7,10 +7,13 @@ import { getCliproxyRoutingTarget, getRoutingErrorMessage, } from './routing-strategy-http'; -import type { CliproxyRoutingStrategy } from '../types'; +import type { CLIProxyBackend, CliproxyRoutingStrategy } from '../types'; import { loadOrCreateUnifiedConfig, mutateConfig } from '../../config/config-loader-facade'; -import { getInstalledCliproxyVersion } from '../binary-manager'; -import { compareVersions } from '../../utils/update-checker'; +import { getConfiguredBackend, getInstalledCliproxyVersion } from '../binary-manager'; +import { + type CLIProxyBackendMinVersions, + meetsBackendMinimumVersion, +} from '../binary/version-checker'; import { getConfigYamlPath } from '../../config/loader/io-locks'; import { createLogger } from '../../services/logging'; @@ -46,12 +49,18 @@ export const POOL_ROUTING_VERIFIED_PROVIDERS = new Set(['claude', 'agy']); * max-retry-credentials and the cooling flip. * Older binaries silently ignore unknown keys — pool rails would appear active * but have no effect. Warn the user at enable time if below this version. - * - * NOTE: Update this constant when upstream first ships these keys. - * Current best estimate based on spec; adjust after spike Test D confirms. */ -export const POOL_ROUTING_MIN_VERSION = '6.9.45'; +export const POOL_ROUTING_MIN_VERSION: CLIProxyBackendMinVersions = { + original: '6.8.34', + plus: '6.8.34-0', +}; +export function isPoolRoutingSupported( + backend: CLIProxyBackend, + installedVersion: string +): boolean { + return meetsBackendMinimumVersion(installedVersion, backend, POOL_ROUTING_MIN_VERSION); +} /** * Pool-active override warning text. When pool routing is enabled the generator * forces fill-first/affinity/cooling and ignores the stored strategy/affinity, so @@ -205,20 +214,21 @@ export function enablePoolRouting( const preservedExplicitSetting = hasExplicitRoutingStrategy() || hasExplicitSessionAffinity(); - // Spec step 3 / architecture: assert minimum CLIProxy version at enable time. - // Stale binaries silently ignore max-retry-credentials and the cooling flip, - // so pool rails would appear active but have no effect. Warn and proceed. try { - const installedVersion = getInstalledCliproxyVersion(); - if (compareVersions(installedVersion, POOL_ROUTING_MIN_VERSION) < 0) { + const backend = getConfiguredBackend(); + const installedVersion = getInstalledCliproxyVersion(backend); + if (!isPoolRoutingSupported(backend, installedVersion)) { + const minimumVersion = POOL_ROUTING_MIN_VERSION[backend]; + const backendLabel = backend === 'plus' ? 'CLIProxy Plus' : 'CLIProxy'; logger.warn( 'pool_routing.binary_below_minimum', - `CLIProxy v${installedVersion} is older than the pool routing minimum (v${POOL_ROUTING_MIN_VERSION}). ` + + `${backendLabel} v${installedVersion} is older than the pool routing minimum (v${minimumVersion}). ` + `The max-retry-credentials and cooling keys may be silently ignored by the running binary. ` + `Run 'ccs cliproxy --latest' to update CLIProxy, then restart with 'ccs cliproxy restart'.`, { + backend, installedVersion, - minimumVersion: POOL_ROUTING_MIN_VERSION, + minimumVersion, } ); } diff --git a/src/cliproxy/services/binary-service.ts b/src/cliproxy/services/binary-service.ts index 7c91fd09..445aab1f 100644 --- a/src/cliproxy/services/binary-service.ts +++ b/src/cliproxy/services/binary-service.ts @@ -19,6 +19,7 @@ import { clearPinnedVersion, isVersionPinned, resolveLocalBackend, + withCliproxyInstallLifecycleLock, } from '../binary-manager'; import { BACKEND_CONFIG, DEFAULT_BACKEND } from '../binary/platform-detector'; import { CLIProxyBackend } from '../types'; @@ -127,14 +128,16 @@ export async function installVersion( const effectiveBackend = resolveLocalBackend(configuredBackend, { notifyOnPlus: true }); try { - await installCliproxyVersion(version, verbose, effectiveBackend); - savePinnedVersion(version, effectiveBackend); + return await withCliproxyInstallLifecycleLock(effectiveBackend, async () => { + await installCliproxyVersion(version, verbose, effectiveBackend); + savePinnedVersion(version, effectiveBackend); - return { - success: true, - version, - wasPinned: true, - }; + return { + success: true, + version, + wasPinned: true, + }; + }); } catch (error) { return { success: false, @@ -156,26 +159,28 @@ export async function installLatest( const effectiveBackend = resolveLocalBackend(configuredBackend, { notifyOnPlus: true }); try { - const latestVersion = await fetchLatestCliproxyVersion(effectiveBackend); - const currentVersion = getInstalledCliproxyVersion(effectiveBackend); - const wasPinned = isVersionPinned(effectiveBackend); + return await withCliproxyInstallLifecycleLock(effectiveBackend, async () => { + const latestVersion = await fetchLatestCliproxyVersion(effectiveBackend); + const currentVersion = getInstalledCliproxyVersion(effectiveBackend); + const wasPinned = isVersionPinned(effectiveBackend); + + if (isCLIProxyInstalled(effectiveBackend) && latestVersion === currentVersion && !wasPinned) { + return { + success: true, + version: latestVersion, + error: `Already running latest version: v${latestVersion}`, + }; + } + + await installCliproxyVersion(latestVersion, verbose, effectiveBackend); + clearPinnedVersion(effectiveBackend); - if (isCLIProxyInstalled(effectiveBackend) && latestVersion === currentVersion && !wasPinned) { return { success: true, version: latestVersion, - error: `Already running latest version: v${latestVersion}`, + wasPinned, }; - } - - await installCliproxyVersion(latestVersion, verbose, effectiveBackend); - clearPinnedVersion(effectiveBackend); - - return { - success: true, - version: latestVersion, - wasPinned, - }; + }); } catch (error) { return { success: false, diff --git a/src/cliproxy/services/catalog-cache.ts b/src/cliproxy/services/catalog-cache.ts index bfbf0560..aafc4316 100644 --- a/src/cliproxy/services/catalog-cache.ts +++ b/src/cliproxy/services/catalog-cache.ts @@ -198,6 +198,7 @@ function mapThinking(remote?: RemoteThinkingSupport): ThinkingSupport | undefine return { type: 'levels', levels: remote.levels, + zeroAllowed: remote.zero_allowed, dynamicAllowed: remote.dynamic_allowed, }; } @@ -279,6 +280,7 @@ export function mergeCatalog( const mergedThinking = remoteEntry.thinking ? { ...remoteEntry.thinking, + zeroAllowed: remoteEntry.thinking.zeroAllowed ?? staticEntry.thinking?.zeroAllowed, maxLevel: remoteEntry.thinking.maxLevel ?? staticEntry.thinking?.maxLevel, } : staticEntry.thinking; diff --git a/src/cliproxy/types/__tests__/types-backward-compat.test.ts b/src/cliproxy/types/__tests__/types-backward-compat.test.ts index 95952fb7..eef2705c 100644 --- a/src/cliproxy/types/__tests__/types-backward-compat.test.ts +++ b/src/cliproxy/types/__tests__/types-backward-compat.test.ts @@ -56,6 +56,7 @@ describe('types.ts backward compatibility', () => { maxRetries: 3, verbose: false, forceVersion: false, + replaceExisting: false, skipAutoUpdate: false, allowInstall: true, }; diff --git a/src/cliproxy/types/binary-types.ts b/src/cliproxy/types/binary-types.ts index 86bdbcb1..db7b1445 100644 --- a/src/cliproxy/types/binary-types.ts +++ b/src/cliproxy/types/binary-types.ts @@ -14,6 +14,7 @@ export interface BinaryManagerConfig { maxRetries: number; verbose: boolean; forceVersion: boolean; + replaceExisting?: boolean; skipAutoUpdate: boolean; allowInstall: boolean; backend?: CLIProxyBackend; diff --git a/src/commands/cliproxy/__tests__/order-subcommand.test.ts b/src/commands/cliproxy/__tests__/order-subcommand.test.ts index 8cc917d6..4ce07eb0 100644 --- a/src/commands/cliproxy/__tests__/order-subcommand.test.ts +++ b/src/commands/cliproxy/__tests__/order-subcommand.test.ts @@ -19,11 +19,14 @@ import * as path from 'path'; mock.module('../../../cliproxy/proxy/proxy-detector', () => ({ detectRunningProxy: async () => ({ running: false, verified: false }), + waitForProxyHealthy: async () => ({ running: false, verified: false }), + reclaimOrphanedProxy: () => {}, })); describe('handleOrderSubcommand', () => { let tempHome: string; let originalCcsHome: string | undefined; + let originalNoColor: string | undefined; let logSpy: ReturnType; let lines: string[]; @@ -35,7 +38,16 @@ describe('handleOrderSubcommand', () => { fs.mkdirSync(authDir(), { recursive: true, mode: 0o700 }); fs.writeFileSync( path.join(authDir(), fileName), - JSON.stringify({ type: 'claude', ...fields }, null, 2), + JSON.stringify({ type: 'claude', email: fileName, ...fields }, null, 2), + { mode: 0o600 } + ); + } + + function writeAgyAuthFile(fileName: string, fields: Record = {}): void { + fs.mkdirSync(authDir(), { recursive: true, mode: 0o700 }); + fs.writeFileSync( + path.join(authDir(), fileName), + JSON.stringify({ type: 'antigravity', email: fileName, ...fields }, null, 2), { mode: 0o600 } ); } @@ -47,6 +59,24 @@ describe('handleOrderSubcommand', () => { >; } + async function loadRegistry() { + return import(`../../../cliproxy/accounts/registry?order-subcommand-reg=${Date.now()}`); + } + + async function configureBackend(backend: 'original' | 'plus', version: string): Promise { + const { mutateConfig, invalidateConfigCache } = await import( + '../../../config/config-loader-facade' + ); + mutateConfig((cfg) => { + if (!cfg.cliproxy) cfg.cliproxy = {}; + cfg.cliproxy.backend = backend; + }); + invalidateConfigCache(); + const verPath = path.join(tempHome, '.ccs', 'cliproxy', 'bin', backend, '.version'); + fs.mkdirSync(path.dirname(verPath), { recursive: true }); + fs.writeFileSync(verPath, version.trim() + '\n', 'utf-8'); + } + async function registerClaude(): Promise<{ registerAccount: (provider: string, tokenFile: string, email: string) => unknown; saveDrainOrderConfig: (provider: string, config: unknown) => boolean; @@ -64,7 +94,9 @@ describe('handleOrderSubcommand', () => { beforeEach(() => { tempHome = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-order-subcommand-')); originalCcsHome = process.env.CCS_HOME; + originalNoColor = process.env.NO_COLOR; process.env.CCS_HOME = tempHome; + process.env.NO_COLOR = '1'; process.exitCode = 0; lines = []; logSpy = spyOn(console, 'log').mockImplementation((msg?: unknown) => { @@ -80,14 +112,18 @@ describe('handleOrderSubcommand', () => { } else { delete process.env.CCS_HOME; } + if (originalNoColor !== undefined) { + process.env.NO_COLOR = originalNoColor; + } else { + delete process.env.NO_COLOR; + } fs.rmSync(tempHome, { recursive: true, force: true }); }); describe('file-mode show with residual priorities', () => { it('renders selector pick order (priority desc) and flags drift instead of alphabetical order', async () => { - // Residual on-disk priorities, no stored config -> file mode + drift. - // claude-a sorts first alphabetically, but b has the higher priority, so - // the selector drains b first. The display must follow the selector. + // Explicit below-minimum version to prove show is ungated on old binaries. + await configureBackend('original', '6.6.105'); writeAuthFile('claude-a.json', { email: 'a@x.com', priority: 1 }); writeAuthFile('claude-b.json', { email: 'b@x.com', priority: 5 }); @@ -96,6 +132,7 @@ describe('handleOrderSubcommand', () => { registerAccount('claude', 'claude-b.json', 'b@x.com'); await runOrderSubcommand(['claude']); + expect(process.exitCode).toBe(0); const output = lines.join('\n'); // b@x.com (priority 5) must appear before a@x.com (priority 1). @@ -105,7 +142,6 @@ describe('handleOrderSubcommand', () => { expect(idxA).toBeGreaterThan(idxB); // Drift surfaced (same as the manual/tier branch), and the mode label no - // longer falsely claims "no priority set" under residual priorities. expect(output).toContain('Drift detected'); expect(output).toContain('residual priorities present'); expect(output).not.toContain('no priority set'); @@ -115,6 +151,8 @@ describe('handleOrderSubcommand', () => { }); it('keeps the plain "no priority set" label and no drift when there are no residuals', async () => { + // Explicit below-minimum version on Plus to prove show is ungated on old binaries. + await configureBackend('plus', '6.6.105-0'); writeAuthFile('claude-a.json', { email: 'a@x.com' }); writeAuthFile('claude-b.json', { email: 'b@x.com' }); @@ -123,6 +161,7 @@ describe('handleOrderSubcommand', () => { registerAccount('claude', 'claude-b.json', 'b@x.com'); await runOrderSubcommand(['claude']); + expect(process.exitCode).toBe(0); const output = lines.join('\n'); expect(output).toContain('no priority set'); @@ -132,6 +171,8 @@ describe('handleOrderSubcommand', () => { describe('--reset clears residual priorities (proxy stopped -> direct write)', () => { it('removes the priority field from auth files and reports per-file results', async () => { + // Explicit below-minimum version to prove reset is ungated on old binaries. + await configureBackend('original', '6.6.105'); writeAuthFile('claude-a.json', { email: 'a@x.com', priority: 4 }); writeAuthFile('claude-b.json', { email: 'b@x.com' }); // already clear @@ -141,6 +182,7 @@ describe('handleOrderSubcommand', () => { saveDrainOrderConfig('claude', { mode: 'manual', orderedIds: ['a@x.com', 'b@x.com'] }); await runOrderSubcommand(['claude', '--reset']); + expect(process.exitCode).toBe(0); // Residual priority is actually gone from disk (not just config deleted). expect('priority' in readAuthFile('claude-a.json')).toBe(false); @@ -153,16 +195,146 @@ describe('handleOrderSubcommand', () => { }); it('reports already-clear files and still resets when no priorities exist', async () => { + // Explicit below-minimum version on Plus to prove reset is ungated on old binaries. + await configureBackend('plus', '6.6.105-0'); writeAuthFile('claude-a.json', { email: 'a@x.com' }); const { registerAccount } = await registerClaude(); registerAccount('claude', 'claude-a.json', 'a@x.com'); await runOrderSubcommand(['claude', '--reset']); + expect(process.exitCode).toBe(0); const output = lines.join('\n'); expect(output).toContain('reset to file order'); expect(output).toContain('no priority set'); }); }); + + describe('binary version capability gate (#1724)', () => { + const cases: Array<{ + backend: 'original' | 'plus'; + belowMinVersion: string; + atMinVersion: string; + backendLabel: string; + requiredMinVersion: string; + }> = [ + { + backend: 'original', + belowMinVersion: '6.6.105', + atMinVersion: '6.6.106', + backendLabel: 'CLIProxy', + requiredMinVersion: '6.6.106', + }, + { + backend: 'plus', + belowMinVersion: '6.6.105-0', + atMinVersion: '6.6.107-0', + backendLabel: 'CLIProxy Plus', + requiredMinVersion: '6.6.107-0', + }, + ]; + + for (const { + backend, + belowMinVersion, + atMinVersion, + backendLabel, + requiredMinVersion, + } of cases) { + describe(`${backend} backend`, () => { + it(`refuses --set below minimum version (${belowMinVersion}) without mutating files`, async () => { + await configureBackend(backend, belowMinVersion); + writeAuthFile('claude-a.json', { email: 'a@x.com' }); + writeAuthFile('claude-b.json', { email: 'b@x.com' }); + + const { registerAccount, loadDrainOrderConfig } = await loadRegistry(); + registerAccount('claude', 'claude-a.json', 'a@x.com'); + registerAccount('claude', 'claude-b.json', 'b@x.com'); + + await runOrderSubcommand(['claude', '--set', 'a@x.com,b@x.com']); + + expect(process.exitCode).toBe(1); + const output = lines.join('\n'); + expect(output).toContain('[X]'); + expect(output).toContain( + `${backendLabel} v${belowMinVersion} does not support drain-order priorities (requires v${requiredMinVersion} or newer).` + ); + expect(output).toContain( + `Run 'ccs cliproxy --latest' to update, then restart with 'ccs cliproxy restart'.` + ); + expect(output).not.toContain('Set priorities'); + expect('priority' in readAuthFile('claude-a.json')).toBe(false); + expect('priority' in readAuthFile('claude-b.json')).toBe(false); + expect(loadDrainOrderConfig('claude')).toBeUndefined(); + }); + + it(`refuses --by-tier below minimum version (${belowMinVersion}) without mutating files`, async () => { + await configureBackend(backend, belowMinVersion); + writeAgyAuthFile('antigravity-a.json', { email: 'a@x.com' }); + writeAgyAuthFile('antigravity-b.json', { email: 'b@x.com' }); + + const { registerAccount, setAccountTier, loadDrainOrderConfig } = await loadRegistry(); + registerAccount('agy', 'antigravity-a.json', 'a@x.com'); + registerAccount('agy', 'antigravity-b.json', 'b@x.com'); + setAccountTier('agy', 'a@x.com', 'pro'); + setAccountTier('agy', 'b@x.com', 'free'); + + await runOrderSubcommand(['agy', '--by-tier']); + + expect(process.exitCode).toBe(1); + const output = lines.join('\n'); + expect(output).toContain('[X]'); + expect(output).toContain( + `${backendLabel} v${belowMinVersion} does not support drain-order priorities (requires v${requiredMinVersion} or newer).` + ); + expect(output).toContain( + `Run 'ccs cliproxy --latest' to update, then restart with 'ccs cliproxy restart'.` + ); + expect(output).not.toContain('Set priorities'); + expect('priority' in readAuthFile('antigravity-a.json')).toBe(false); + expect('priority' in readAuthFile('antigravity-b.json')).toBe(false); + expect(loadDrainOrderConfig('agy')).toBeUndefined(); + }); + + it(`applies and persists --set at minimum version (${atMinVersion})`, async () => { + await configureBackend(backend, atMinVersion); + writeAuthFile('claude-a.json', { email: 'a@x.com' }); + writeAuthFile('claude-b.json', { email: 'b@x.com' }); + + const { registerAccount, loadDrainOrderConfig } = await loadRegistry(); + registerAccount('claude', 'claude-a.json', 'a@x.com'); + registerAccount('claude', 'claude-b.json', 'b@x.com'); + + await runOrderSubcommand(['claude', '--set', 'a@x.com,b@x.com']); + + expect(process.exitCode).toBe(0); + const output = lines.join('\n'); + expect(output).toContain('Set priorities for 2 account(s).'); + expect('priority' in readAuthFile('claude-a.json')).toBe(true); + expect(loadDrainOrderConfig('claude')?.mode).toBe('manual'); + }); + + it(`applies and persists --by-tier at minimum version (${atMinVersion})`, async () => { + await configureBackend(backend, atMinVersion); + writeAgyAuthFile('antigravity-a.json', { email: 'a@x.com' }); + writeAgyAuthFile('antigravity-b.json', { email: 'b@x.com' }); + + const { registerAccount, setAccountTier, loadDrainOrderConfig } = await loadRegistry(); + registerAccount('agy', 'antigravity-a.json', 'a@x.com'); + registerAccount('agy', 'antigravity-b.json', 'b@x.com'); + setAccountTier('agy', 'a@x.com', 'pro'); + setAccountTier('agy', 'b@x.com', 'free'); + + await runOrderSubcommand(['agy', '--by-tier']); + + expect(process.exitCode).toBe(0); + const output = lines.join('\n'); + expect(output).toContain('Set priorities for 2 account(s).'); + expect('priority' in readAuthFile('antigravity-a.json')).toBe(true); + expect(loadDrainOrderConfig('agy')?.mode).toBe('tier'); + }); + }); + } + }); }); diff --git a/src/commands/cliproxy/order-subcommand.ts b/src/commands/cliproxy/order-subcommand.ts index b2b50061..29d3c6cf 100644 --- a/src/commands/cliproxy/order-subcommand.ts +++ b/src/commands/cliproxy/order-subcommand.ts @@ -12,6 +12,7 @@ import { initUI, header, subheader, color, dim, ok, fail, warn, info } from '../ import { extractOption, hasAnyFlag } from '../arg-extractor'; import { saveDrainOrderConfig, clearDrainOrderConfig } from '../../cliproxy/accounts/registry'; import { getProviderAccounts } from '../../cliproxy/accounts/query'; +import { getConfiguredBackend, getInstalledCliproxyVersion } from '../../cliproxy/binary-manager'; import { computeManualDrainOrder, computeTierDrainOrder, @@ -19,6 +20,8 @@ import { resolveEffectiveDrainOrder, clearDrainOrderPriorities, tieBreakKey, + DRAIN_ORDER_MIN_VERSION, + isDrainOrderSupported, type DrainOrderEntry, type DrainOrderInput, } from '../../cliproxy/accounts/drain-order'; @@ -45,6 +48,27 @@ function formatTierLabel(tier: AccountTier | undefined, tierDerived: boolean): s return tierDerived ? label : dim(tier); } +function ensureDrainOrderBinarySupport(): boolean { + const backend = getConfiguredBackend(); + const installedVersion = getInstalledCliproxyVersion(backend); + if (!isDrainOrderSupported(backend, installedVersion)) { + const minimumVersion = DRAIN_ORDER_MIN_VERSION[backend]; + const backendLabel = backend === 'plus' ? 'CLIProxy Plus' : 'CLIProxy'; + console.log( + fail( + `${backendLabel} v${installedVersion} does not support drain-order priorities (requires v${minimumVersion} or newer).` + ) + ); + console.log( + info(`Run 'ccs cliproxy --latest' to update, then restart with 'ccs cliproxy restart'.`) + ); + console.log(''); + process.exitCode = 1; + return false; + } + return true; +} + function printOrderTable( entries: DrainOrderEntry[], showCurrentPriority: boolean, @@ -281,6 +305,9 @@ async function handleOrderByTier(provider: CLIProxyProvider): Promise { const entries = computeTierDrainOrder(accounts); + if (!ensureDrainOrderBinarySupport()) { + return; + } console.log(subheader('Computed tier-based drain order:')); printOrderTable(entries, false); console.log(''); @@ -404,6 +431,10 @@ async function handleOrderSet(provider: CLIProxyProvider, setArg: string): Promi return; } + if (!ensureDrainOrderBinarySupport()) { + return; + } + console.log(subheader('Computed manual drain order:')); printOrderTable(entries, false); console.log(''); diff --git a/src/commands/cliproxy/quota-subcommand/format-helpers.ts b/src/commands/cliproxy/quota-subcommand/format-helpers.ts index a46116f5..c74fee6f 100644 --- a/src/commands/cliproxy/quota-subcommand/format-helpers.ts +++ b/src/commands/cliproxy/quota-subcommand/format-helpers.ts @@ -14,7 +14,7 @@ export function formatQuotaBar(percentage: number): string { const clampedPct = Math.max(0, Math.min(100, percentage)); const filled = Math.round((clampedPct / 100) * width); const empty = width - filled; - const filledChar = clampedPct > 50 ? '█' : clampedPct > 10 ? '▓' : '░'; + const filledChar = clampedPct > 50 ? '#' : clampedPct > 10 ? '+' : '-'; return `[${filledChar.repeat(filled)}${' '.repeat(empty)}]`; } diff --git a/src/commands/cliproxy/quota-subcommand/handlers.ts b/src/commands/cliproxy/quota-subcommand/handlers.ts index 84886720..a4e78574 100644 --- a/src/commands/cliproxy/quota-subcommand/handlers.ts +++ b/src/commands/cliproxy/quota-subcommand/handlers.ts @@ -138,7 +138,7 @@ export async function handleDoctor(verbose = false): Promise { for (const model of quota.models) { const bar = formatQuotaBar(model.percentage); - console.log(` ${model.name.padEnd(20)} ${bar} ${model.percentage.toFixed(0)}%`); + console.log(` ${model.name.padEnd(20)} ${bar} ${model.percentage.toFixed(0)}% remaining`); } console.log(''); } diff --git a/src/commands/cliproxy/quota-subcommand/sections/claude.ts b/src/commands/cliproxy/quota-subcommand/sections/claude.ts index 88b19f8e..a2d50d68 100644 --- a/src/commands/cliproxy/quota-subcommand/sections/claude.ts +++ b/src/commands/cliproxy/quota-subcommand/sections/claude.ts @@ -93,7 +93,7 @@ export function displayClaudeQuotaSection( ? dim(' [warning]') : ''; console.log( - ` ${getClaudeWindowDisplayLabel(window).padEnd(24)} ${bar} ${window.remainingPercent.toFixed(0)}%${statusLabel}${resetLabel}` + ` ${getClaudeWindowDisplayLabel(window).padEnd(24)} ${bar} ${window.remainingPercent.toFixed(0)}% remaining${statusLabel}${resetLabel}` ); } console.log(''); diff --git a/src/commands/cliproxy/quota-subcommand/sections/codex.ts b/src/commands/cliproxy/quota-subcommand/sections/codex.ts index e2811734..fdc5d1ed 100644 --- a/src/commands/cliproxy/quota-subcommand/sections/codex.ts +++ b/src/commands/cliproxy/quota-subcommand/sections/codex.ts @@ -94,7 +94,7 @@ export function displayCodexQuotaSection( const resetValue = formatCodexWindowReset(window); const resetLabel = resetValue ? dim(` Resets ${resetValue}`) : ''; console.log( - ` ${getCodexWindowDisplayLabel(window, orderedWindows).padEnd(24)} ${bar} ${window.remainingPercent.toFixed(0)}%${resetLabel}` + ` ${getCodexWindowDisplayLabel(window, orderedWindows).padEnd(24)} ${bar} ${window.remainingPercent.toFixed(0)}% remaining${resetLabel}` ); } console.log(''); diff --git a/src/commands/cliproxy/quota-subcommand/sections/gemini-cli.ts b/src/commands/cliproxy/quota-subcommand/sections/gemini-cli.ts index 287d34e4..f95cb8f3 100644 --- a/src/commands/cliproxy/quota-subcommand/sections/gemini-cli.ts +++ b/src/commands/cliproxy/quota-subcommand/sections/gemini-cli.ts @@ -67,7 +67,7 @@ export function displayGeminiCliQuotaSection( ? dim(` Resets ${formatResetTimeISO(bucket.resetTime)}`) : ''; console.log( - ` ${bucket.label.padEnd(24)} ${bar} ${bucket.remainingPercent.toFixed(0)}%${tokenLabel}${amountLabel}${resetLabel}` + ` ${bucket.label.padEnd(24)} ${bar} ${bucket.remainingPercent.toFixed(0)}% remaining${tokenLabel}${amountLabel}${resetLabel}` ); } console.log(''); diff --git a/src/delegation/delegation-handler.ts b/src/delegation/delegation-handler.ts index df9f4c6a..58994328 100644 --- a/src/delegation/delegation-handler.ts +++ b/src/delegation/delegation-handler.ts @@ -35,6 +35,17 @@ function isFlag(arg: string): boolean { return arg.startsWith('-'); } +/** + * Write to stdout and wait for the flush to complete. + * process.exit() right after a large piped write truncates output at the + * pipe buffer (~64 KiB), so delegation results must be drained first. + */ +function writeStdoutAndFlush(text: string): Promise { + return new Promise((resolve) => { + process.stdout.write(text + '\n', () => resolve()); + }); +} + function isInlineCcsValueFlag(arg: string): boolean { return Array.from(CCS_FLAGS_WITH_VALUE).some( (flag) => flag.startsWith('--') && arg.startsWith(`${flag}=`) @@ -147,7 +158,7 @@ export class DelegationHandler { // 5. Format and display results const formatted = await ResultFormatter.format(result); - console.log(formatted); + await writeStdoutAndFlush(formatted); // 6. Exit with proper code process.exit(result.exitCode || 0); @@ -192,7 +203,7 @@ export class DelegationHandler { }); const formatted = await ResultFormatter.format(result); - console.log(formatted); + await writeStdoutAndFlush(formatted); process.exit(result.exitCode || 0); } diff --git a/src/management/shared-manager/diverged-file-adopter.ts b/src/management/shared-manager/diverged-file-adopter.ts index 43829a8f..fb075b5b 100644 --- a/src/management/shared-manager/diverged-file-adopter.ts +++ b/src/management/shared-manager/diverged-file-adopter.ts @@ -137,41 +137,92 @@ function validateManagedJson(filePath: string, content: Buffer): boolean { } } -function atomicWriteFile(targetPath: string, content: Buffer, mode: number): void { - const tempPath = `${targetPath}.ccs-write-${process.pid}-${Date.now()}-${adoptionClaimSequence++}`; +/** + * Identity of the canonical inode as it was read. Publication compares it + * again immediately before replacing the file, so a concurrent writer is + * detected instead of silently overwritten. + */ +interface CanonicalIdentity { + ino: number; + mtimeMs: number; + size: number; +} + +function canonicalIdentityOf(stats: fs.Stats): CanonicalIdentity { + return { ino: stats.ino, mtimeMs: stats.mtimeMs, size: stats.size }; +} + +function canonicalIdentityMatches( + expected: CanonicalIdentity | null, + current: CanonicalIdentity | null +): boolean { + if (!expected || !current) return expected === current; + return ( + expected.ino === current.ino && + expected.mtimeMs === current.mtimeMs && + expected.size === current.size + ); +} + +function tempWritePath(targetPath: string): string { + return `${targetPath}.ccs-write-${process.pid}-${Date.now()}-${adoptionClaimSequence++}`; +} + +/** + * Write content into a fresh temp file and make it durable, so whatever + * publishes it under its final name publishes complete bytes. + */ +function writeDurableTempFile(tempPath: string, content: Buffer, mode: number): void { let descriptor: number | null = null; try { descriptor = fs.openSync(tempPath, 'wx', mode); fs.fchmodSync(descriptor, mode); fs.writeFileSync(descriptor, content); fs.fsyncSync(descriptor); - fs.closeSync(descriptor); - descriptor = null; - fs.linkSync(tempPath, targetPath); - fs.unlinkSync(tempPath); - } catch (err) { + } finally { if (descriptor !== null) { fs.closeSync(descriptor); } - try { - fs.unlinkSync(tempPath); - } catch { - // The temp file may not have been created or may already have been renamed. - } + } +} + +function discardTempFile(tempPath: string): void { + try { + fs.unlinkSync(tempPath); + } catch { + // The temp file may not have been created or may already have been renamed. + } +} + +/** + * Create a file only if the path is free, writing the content atomically. + * + * link() is an atomic no-replace operation, so concurrent CCS processes + * cannot overwrite each other's sidecar artifacts. + */ +function createFileNoReplace(targetPath: string, content: Buffer, mode: number): void { + const tempPath = tempWritePath(targetPath); + try { + writeDurableTempFile(tempPath, content, mode); + fs.linkSync(tempPath, targetPath); + fs.unlinkSync(tempPath); + } catch (err) { + discardTempFile(tempPath); throw err; } } -function publishBackupNoReplace(sourcePath: string, canonicalPath: string): string { - const basePath = `${canonicalPath}.bak-ccs-adopt`; - const content = fs.readFileSync(sourcePath); - const mode = fs.statSync(sourcePath).mode & 0o777; +/** + * Publish a sidecar next to a managed file, never replacing an existing one. + * Numbered suffixes keep every concurrent writer's artifact recoverable. + */ +function publishSidecarNoReplace(basePath: string, content: Buffer, mode: number): string { let sequence = 0; while (true) { - const backupPath = sequence === 0 ? basePath : `${basePath}-${sequence}`; + const sidecarPath = sequence === 0 ? basePath : `${basePath}-${sequence}`; try { - atomicWriteFile(backupPath, content, mode); - return backupPath; + createFileNoReplace(sidecarPath, content, mode); + return sidecarPath; } catch (err) { if ((err as NodeJS.ErrnoException).code !== 'EEXIST') throw err; sequence++; @@ -179,43 +230,69 @@ function publishBackupNoReplace(sourcePath: string, canonicalPath: string): stri } } -function publishAdoptedRecoveryNoReplace(sourcePath: string, divergedPath: string): string { - const basePath = `${divergedPath}.ccs-adopted-recovery`; - const content = fs.readFileSync(sourcePath); - const mode = fs.statSync(sourcePath).mode & 0o777; - let sequence = 0; - while (true) { - const recoveryPath = sequence === 0 ? basePath : `${basePath}-${sequence}`; - try { - atomicWriteFile(recoveryPath, content, mode); - return recoveryPath; - } catch (err) { - if ((err as NodeJS.ErrnoException).code !== 'EEXIST') throw err; - sequence++; - } - } -} - -function restoreCanonicalClaim(claimPath: string, writePath: string, canonicalPath: string): void { +/** + * Publish adopted content onto the canonical path by replacement. + * + * The canonical path is never emptied: a fully written temp file is renamed + * over it, so no window exists in which Claude Code or a second `ccs` can + * observe the path as missing and seed an empty placeholder there. + * + * A compare-and-swap guard runs as late as possible - after the temp file is + * durable, immediately before the rename. When the canonical inode changed + * since it was read, publication is refused rather than clobbering a writer + * that got there first; the adopted bytes stay in the sidecars the caller + * published. A pure chmod is not a content change, so the mode the inode + * carries at publication time wins. + * + * The guard is read-then-act, not atomic: POSIX offers no compare-and-swap + * rename, so a writer landing between the check and the rename is still + * overwritten. That is a narrowing, not a guarantee - the window shrinks from + * the ~100 ms the old claim-and-republish path left open to two adjacent + * syscalls, and the pre-image sidecar the caller published keeps even that + * outcome recoverable. Do not build stricter guarantees on top of it. + */ +function publishCanonicalContent( + writePath: string, + content: Buffer, + mode: number, + expected: CanonicalIdentity | null +): void { + const tempPath = tempWritePath(writePath); try { - fs.linkSync(claimPath, writePath); - fs.unlinkSync(claimPath); + writeDurableTempFile(tempPath, content, mode); + + const currentStats = getLstatSync(writePath); + const current = currentStats?.isFile() ? canonicalIdentityOf(currentStats) : null; + if (!canonicalIdentityMatches(expected, current)) { + // EEXIST is this file's marker for "a race was detected", the same code + // the reappeared-path and concurrent-replacement guards raise. It does + // not mean a no-replace link() or open('wx') hit an existing path. + throw Object.assign(new TypeError(`Canonical file changed during adoption: ${writePath}`), { + code: 'EEXIST', + }); + } + + const publishMode = currentStats ? currentStats.mode & 0o777 : mode; + if (publishMode !== mode) { + fs.chmodSync(tempPath, publishMode); + } + fs.renameSync(tempPath, writePath); } catch (err) { - if ((err as NodeJS.ErrnoException).code !== 'EEXIST') throw err; - publishBackupNoReplace(claimPath, canonicalPath); - fs.unlinkSync(claimPath); + discardTempFile(tempPath); + throw err; } } function getCanonicalFile(canonicalPath: string): { content: Buffer | null; + identity: CanonicalIdentity | null; mode: number; mtimeMs: number | null; writePath: string; } { const canonicalLstat = getLstatSync(canonicalPath); if (!canonicalLstat) { - return { content: null, mode: 0o600, mtimeMs: null, writePath: canonicalPath }; + return { content: null, identity: null, mode: 0o600, mtimeMs: null, writePath: canonicalPath }; } let writePath = canonicalPath; @@ -230,8 +307,12 @@ function getCanonicalFile(canonicalPath: string): { }); } + // The identity describes the inode as of the stat above, taken before the + // content read: a write landing in between leaves us holding newer bytes + // than the identity describes, and publication fails closed. return { - content: fs.readFileSync(canonicalPath), + content: fs.readFileSync(writePath), + identity: canonicalIdentityOf(canonicalStats), mode: canonicalStats.mode & 0o777, mtimeMs: canonicalStats.mtimeMs, writePath, @@ -260,8 +341,6 @@ export function adoptDivergedFileContent( throw err; } - let canonicalClaimPath: string | null = null; - let canonicalWritePath: string | null = null; let divergencePreserved = false; try { const claimedStats = fs.lstatSync(claimPath); @@ -288,24 +367,12 @@ export function adoptDivergedFileContent( } const canonical = getCanonicalFile(canonicalPath); - let current = canonical.content; - let publishMode = canonical.mode; - if (current) { - canonicalWritePath = canonical.writePath; - canonicalClaimPath = `${canonical.writePath}.ccs-canonical-claim-${process.pid}-${Date.now()}-${adoptionClaimSequence++}`; - fs.renameSync(canonical.writePath, canonicalClaimPath); - const currentStats = fs.statSync(canonicalClaimPath); - publishMode = currentStats.mode & 0o777; - current = fs.readFileSync(canonicalClaimPath); - if (diverged.equals(current)) { - restoreCanonicalClaim(canonicalClaimPath, canonical.writePath, canonicalPath); - canonicalClaimPath = null; + if (canonical.content) { + if (diverged.equals(canonical.content)) { fs.unlinkSync(claimPath); return 'claimed'; } - if (claimedStats.mtimeMs <= currentStats.mtimeMs) { - restoreCanonicalClaim(canonicalClaimPath, canonical.writePath, canonicalPath); - canonicalClaimPath = null; + if (claimedStats.mtimeMs <= (canonical.mtimeMs ?? 0)) { preserveClaim( claimPath, divergedPath, @@ -313,14 +380,17 @@ export function adoptDivergedFileContent( ); return 'claimed'; } + // Publish the pre-image before the canonical file is replaced, so an + // interruption mid-publication still leaves the old content recoverable. + publishSidecarNoReplace(`${canonicalPath}.bak-ccs-adopt`, canonical.content, canonical.mode); } + publishSidecarNoReplace( + `${divergedPath}.ccs-adopted-recovery`, + diverged, + claimedStats.mode & 0o777 + ); - if (canonicalClaimPath) { - publishBackupNoReplace(canonicalClaimPath, canonicalPath); - } - publishAdoptedRecoveryNoReplace(claimPath, divergedPath); - - atomicWriteFile(canonical.writePath, diverged, publishMode); + publishCanonicalContent(canonical.writePath, diverged, canonical.mode, canonical.identity); if (!fs.readFileSync(canonicalPath).equals(diverged)) { throw Object.assign( new TypeError(`Canonical adoption postcondition failed: ${canonicalPath}`), @@ -329,10 +399,6 @@ export function adoptDivergedFileContent( } ); } - if (canonicalClaimPath) { - fs.unlinkSync(canonicalClaimPath); - canonicalClaimPath = null; - } if (getLstatSync(divergedPath)) { preserveClaim(claimPath, divergedPath, `Concurrent replacement detected at ${divergedPath}`); divergencePreserved = true; @@ -346,10 +412,6 @@ export function adoptDivergedFileContent( ); return 'claimed'; } catch (err) { - if (canonicalClaimPath && canonicalWritePath) { - restoreCanonicalClaim(canonicalClaimPath, canonicalWritePath, canonicalPath); - canonicalClaimPath = null; - } if (divergencePreserved) { throw err; } diff --git a/src/shared/extended-context-utils.ts b/src/shared/extended-context-utils.ts index 57468cef..ebed49c9 100644 --- a/src/shared/extended-context-utils.ts +++ b/src/shared/extended-context-utils.ts @@ -14,8 +14,43 @@ export const ANTHROPIC_MODEL_ENV_KEYS = [ export type AnthropicModelEnvKey = (typeof ANTHROPIC_MODEL_ENV_KEYS)[number]; +/** + * Model env keys outside the Anthropic tier mappings that still carry a plain + * model id, so the extended-context preference has to cover them too. Kept + * separate from ANTHROPIC_MODEL_ENV_KEYS, which also drives routing, model-id + * normalization and profile validation. + * + * ANTHROPIC_DEFAULT_MODEL is Claude Code's lowest-priority startup model + * (after --model, ANTHROPIC_MODEL and the settings `model` field), so it needs + * the same [1m] treatment whenever a profile carries it. + */ +export const EXTRA_EXTENDED_CONTEXT_MODEL_ENV_KEYS = [ + 'CLAUDE_CODE_SUBAGENT_MODEL', + 'ANTHROPIC_DEFAULT_MODEL', +] as const; + +/** Every model env key the [1m] preference is applied to. */ +export const EXTENDED_CONTEXT_MODEL_ENV_KEYS = [ + ...ANTHROPIC_MODEL_ENV_KEYS, + ...EXTRA_EXTENDED_CONTEXT_MODEL_ENV_KEYS, +] as const; + +export type ExtendedContextModelEnvKey = (typeof EXTENDED_CONTEXT_MODEL_ENV_KEYS)[number]; + const ANTHROPIC_MODEL_ENV_KEY_SET = new Set(ANTHROPIC_MODEL_ENV_KEYS); +const EXTENDED_CONTEXT_MODEL_ENV_KEY_SET = new Set(EXTENDED_CONTEXT_MODEL_ENV_KEYS); + +/* + * Why every key, including ANTHROPIC_DEFAULT_FABLE_MODEL, carries the suffix: + * Claude Code only grants a natively-1M model (Fable, Opus 5) its full window + * without the suffix when ANTHROPIC_BASE_URL is unset or points at + * api.anthropic.com. Behind any proxy (CLIProxy, headroom, ...) a bare id is + * clamped to 200k even when the backend advertises 1M, and the fable alias + * resolver passes the env value through untouched in that case. The [1m] + * suffix is therefore the only thing that turns the long window on for CCS. + */ + /** Check if model is a native Gemini model (auto-enabled behavior). */ export function isNativeGeminiModel(modelId: string): boolean { return modelId.toLowerCase().startsWith('gemini-'); @@ -44,6 +79,11 @@ export function isAnthropicModelEnvKey(key: string): key is AnthropicModelEnvKey return ANTHROPIC_MODEL_ENV_KEY_SET.has(key); } +/** True when key holds a model id the [1m] preference is applied to. */ +export function isExtendedContextModelEnvKey(key: string): key is ExtendedContextModelEnvKey { + return EXTENDED_CONTEXT_MODEL_ENV_KEY_SET.has(key); +} + /** Strip transient config suffixes so model IDs can be checked against catalogs. */ export function stripModelConfigurationSuffixes(modelId: string): string { return stripExtendedContextSuffix(modelId.trim()).replace(/\([^)]+\)$/, ''); @@ -53,7 +93,7 @@ export function stripModelConfigurationSuffixes(modelId: string): string { export function hasAnthropicExtendedContextEnabled( env: Partial> ): boolean { - return ANTHROPIC_MODEL_ENV_KEYS.some((key) => { + return EXTENDED_CONTEXT_MODEL_ENV_KEYS.some((key) => { const value = env[key]; return typeof value === 'string' && hasExtendedContextSuffix(value); }); @@ -66,12 +106,12 @@ export function applyExtendedContextPreferenceToAnthropicModels< env: T, enabled: boolean, options: { - supportsExtendedContext?: (modelId: string, key: AnthropicModelEnvKey) => boolean; + supportsExtendedContext?: (modelId: string, key: ExtendedContextModelEnvKey) => boolean; } = {} ): T { const nextEnv: Record = { ...env }; - for (const key of ANTHROPIC_MODEL_ENV_KEYS) { + for (const key of EXTENDED_CONTEXT_MODEL_ENV_KEYS) { const value = nextEnv[key]; if (typeof value !== 'string' || value.trim().length === 0) { continue; diff --git a/src/shared/provider-preset-catalog.ts b/src/shared/provider-preset-catalog.ts index 5880588a..64728ebc 100644 --- a/src/shared/provider-preset-catalog.ts +++ b/src/shared/provider-preset-catalog.ts @@ -10,6 +10,7 @@ export type ProviderPresetTarget = 'claude' | 'droid'; export const PROVIDER_PRESET_IDS = [ 'openrouter', + 'orcarouter', 'alibaba-coding-plan', 'huggingface', 'tuningengines', @@ -52,7 +53,8 @@ export interface ProviderPresetDefinition { icon?: string; } -export const OPENROUTER_BASE_URL = 'https://openrouter.ai/api/v1'; +export const OPENROUTER_BASE_URL = 'https://openrouter.ai/api'; +export const ORCAROUTER_BASE_URL = 'https://api.orcarouter.ai/v1'; /** * Legacy aliases mapped to canonical preset IDs. @@ -84,6 +86,21 @@ const RAW_PROVIDER_PRESET_DEFINITIONS: readonly ProviderPresetDefinition[] = [ featured: true, icon: '/icons/openrouter.svg', }, + { + id: 'orcarouter', + name: 'OrcaRouter', + description: '188+ models from OpenAI, Anthropic, Google, Meta via auto-routing gateway', + baseUrl: ORCAROUTER_BASE_URL, + defaultProfileName: 'orcarouter', + defaultModel: 'openai/gpt-5.5', + apiKeyPlaceholder: 'sk-orca-...', + apiKeyHint: 'Get your API key at orcarouter.ai/console', + category: 'recommended', + requiresApiKey: true, + badge: '188+ models', + featured: true, + icon: '/icons/orcarouter.svg', + }, { id: 'alibaba-coding-plan', name: 'Alibaba Coding Plan', diff --git a/src/utils/hooks/image-analysis-backend-resolver.ts b/src/utils/hooks/image-analysis-backend-resolver.ts index 229d6e11..8bef4ad6 100644 --- a/src/utils/hooks/image-analysis-backend-resolver.ts +++ b/src/utils/hooks/image-analysis-backend-resolver.ts @@ -8,6 +8,7 @@ import { mapExternalProviderName, } from '../../cliproxy/provider-capabilities'; import { getProviderCatalog, supportsNativeImageInput } from '../../cliproxy/model-catalog'; +import { buildCliproxyProviderPath } from '../../cliproxy/config/provider-route'; import { extractProviderFromPathname } from '../../cliproxy/ai-providers/model-id-normalizer'; import type { CliproxyBridgeMetadata } from '../../api/services/profile-types'; import type { Settings } from '../../types/config'; @@ -251,6 +252,12 @@ function getRuntimePath(backendId: string | null): string | null { return null; } + if (isCLIProxyProvider(backendId)) { + // Claude-compatible traffic and the original backend route at the + // CLIProxy root; /api/provider/ scoped routes are Plus-only. + return buildCliproxyProviderPath(backendId) || null; + } + return `/api/provider/${backendId}`; } diff --git a/src/utils/hooks/image-analysis-runtime-status.ts b/src/utils/hooks/image-analysis-runtime-status.ts index c68e429a..b875559e 100644 --- a/src/utils/hooks/image-analysis-runtime-status.ts +++ b/src/utils/hooks/image-analysis-runtime-status.ts @@ -16,10 +16,8 @@ import { getProxyTarget, type ProxyTarget } from '../../cliproxy/proxy/proxy-tar import { getProviderDisplayName, isCLIProxyProvider } from '../../cliproxy/provider-capabilities'; import { isCliproxyRunning } from '../../cliproxy/services/stats-fetcher'; import type { CLIProxyProvider } from '../../cliproxy/types'; -import { - DEFAULT_IMAGE_ANALYSIS_CONFIG, - type ImageAnalysisConfig, -} from '../../config/unified-config-types'; +import { getImageAnalysisConfig } from '../../config/config-loader-facade'; +import { type ImageAnalysisConfig } from '../../config/unified-config-types'; import { resolveImageAnalysisStatus, type ImageAnalysisResolutionContext, @@ -191,9 +189,14 @@ export async function hydrateImageAnalysisRuntimeStatus( export async function resolveImageAnalysisRuntimeStatus( context: ImageAnalysisResolutionContext, - config: ImageAnalysisConfig = DEFAULT_IMAGE_ANALYSIS_CONFIG, + config?: ImageAnalysisConfig, deps: Partial = {} ): Promise { - const baseStatus = resolveImageAnalysisStatus(context, config); + // Fall back to the user's saved image_analysis config, not the built-in + // constant. Launch paths call this without an explicit config, and the + // constant carries empty profile_backends plus a gemini fallback_backend, + // so user mappings were dropped and every profile resolved to gemini. + const resolvedConfig = config ?? getImageAnalysisConfig(); + const baseStatus = resolveImageAnalysisStatus(context, resolvedConfig); return hydrateImageAnalysisRuntimeStatus(baseStatus, deps); } diff --git a/src/utils/package-manager-detector.ts b/src/utils/package-manager-detector.ts index d5f4a051..347f5d7c 100644 --- a/src/utils/package-manager-detector.ts +++ b/src/utils/package-manager-detector.ts @@ -125,9 +125,15 @@ function inferInstallFromPath( normalizedPath.includes('/global/') && normalizedPath.includes('/node_modules/@kaitranntt/ccs') ) { - const pnpmFlatMatch = normalizedPath.match(/\/global\/([^/]+)\/node_modules\/@kaitranntt\/ccs/); + // pnpm v9+ nests a version segment plus a store-hash segment under + // global/ (e.g. global/v11/139e7-1a00df73ffb.../node_modules), while + // older pnpm uses a single segment (global/5/node_modules). Accept one + // or more segments but keep excluding yarn's global/lib/node_modules. + const pnpmFlatMatch = normalizedPath.match( + /\/global\/([^/]+(?:\/[^/]+)*)\/node_modules\/@kaitranntt\/ccs/ + ); - if (!pnpmFlatMatch || pnpmFlatMatch[1] === 'lib') { + if (!pnpmFlatMatch || pnpmFlatMatch[1].split('/')[0] === 'lib') { return null; } diff --git a/src/utils/websearch/agy.ts b/src/utils/websearch/agy.ts index f5528fdf..0a987728 100644 --- a/src/utils/websearch/agy.ts +++ b/src/utils/websearch/agy.ts @@ -22,35 +22,47 @@ let agyCliCache: AgyCliStatus | null = null; * * @returns Antigravity CLI status with path and version */ -export function getAgyCliStatus(): AgyCliStatus { - // Return cached result if available - if (agyCliCache) { +export function getAgyCliStatus(options?: { fetchVersion?: boolean }): AgyCliStatus { + const fetchVersion = options?.fetchVersion !== false; + + if ( + agyCliCache && + (!fetchVersion || agyCliCache.version !== undefined || !agyCliCache.installed) + ) { + if (!fetchVersion) { + return { ...agyCliCache, version: undefined }; + } return agyCliCache; } - const result: AgyCliStatus = { - installed: false, - path: undefined, - version: undefined, - }; + const result: AgyCliStatus = agyCliCache + ? { ...agyCliCache } + : { + installed: false, + path: undefined, + version: undefined, + }; try { - const isWindows = process.platform === 'win32'; - const whichCmd = isWindows ? 'where agy' : 'which agy'; + if (!agyCliCache) { + const isWindows = process.platform === 'win32'; + const whichCmd = isWindows ? 'where agy' : 'which agy'; - const pathResult = execSync(whichCmd, { - encoding: 'utf8', - timeout: 5000, - stdio: ['pipe', 'pipe', 'pipe'], - }); + const pathResult = execSync(whichCmd, { + encoding: 'utf8', + timeout: 5000, + stdio: ['pipe', 'pipe', 'pipe'], + }); - const agyPath = pathResult.trim().split('\n')[0]; // First result on Windows + const agyPath = pathResult.trim().split('\n')[0]; - if (agyPath) { - result.installed = true; - result.path = agyPath; + if (agyPath) { + result.installed = true; + result.path = agyPath; + } + } - // Try to get version + if (result.installed && fetchVersion && result.version === undefined) { try { const versionResult = execSync('agy --version', { encoding: 'utf8', @@ -59,17 +71,17 @@ export function getAgyCliStatus(): AgyCliStatus { }); result.version = versionResult.trim(); } catch { - // Version check failed, but CLI is installed result.version = 'unknown'; } + } else if (!fetchVersion) { + result.version = undefined; } } catch { // Command not found - Antigravity CLI not installed } - // Cache result agyCliCache = result; - return result; + return fetchVersion ? result : { ...result, version: undefined }; } /** diff --git a/src/utils/websearch/gemini-cli.ts b/src/utils/websearch/gemini-cli.ts index 1069355e..6878b855 100644 --- a/src/utils/websearch/gemini-cli.ts +++ b/src/utils/websearch/gemini-cli.ts @@ -23,35 +23,47 @@ let geminiCliCache: GeminiCliStatus | null = null; * * @returns Gemini CLI status with path and version */ -export function getGeminiCliStatus(): GeminiCliStatus { - // Return cached result if available - if (geminiCliCache) { +export function getGeminiCliStatus(options?: { fetchVersion?: boolean }): GeminiCliStatus { + const fetchVersion = options?.fetchVersion !== false; + + if ( + geminiCliCache && + (!fetchVersion || geminiCliCache.version !== undefined || !geminiCliCache.installed) + ) { + if (!fetchVersion) { + return { ...geminiCliCache, version: undefined }; + } return geminiCliCache; } - const result: GeminiCliStatus = { - installed: false, - path: undefined, - version: undefined, - }; + const result: GeminiCliStatus = geminiCliCache + ? { ...geminiCliCache } + : { + installed: false, + path: undefined, + version: undefined, + }; try { - const isWindows = process.platform === 'win32'; - const whichCmd = isWindows ? 'where gemini' : 'which gemini'; + if (!geminiCliCache) { + const isWindows = process.platform === 'win32'; + const whichCmd = isWindows ? 'where gemini' : 'which gemini'; - const pathResult = execSync(whichCmd, { - encoding: 'utf8', - timeout: 5000, - stdio: ['pipe', 'pipe', 'pipe'], - }); + const pathResult = execSync(whichCmd, { + encoding: 'utf8', + timeout: 5000, + stdio: ['pipe', 'pipe', 'pipe'], + }); - const geminiPath = pathResult.trim().split('\n')[0]; // First result on Windows + const geminiPath = pathResult.trim().split('\n')[0]; - if (geminiPath) { - result.installed = true; - result.path = geminiPath; + if (geminiPath) { + result.installed = true; + result.path = geminiPath; + } + } - // Try to get version + if (result.installed && fetchVersion && result.version === undefined) { try { const versionResult = execSync('gemini --version', { encoding: 'utf8', @@ -60,17 +72,17 @@ export function getGeminiCliStatus(): GeminiCliStatus { }); result.version = versionResult.trim(); } catch { - // Version check failed, but CLI is installed result.version = 'unknown'; } + } else if (!fetchVersion) { + result.version = undefined; } } catch { // Command not found - Gemini CLI not installed } - // Cache result geminiCliCache = result; - return result; + return fetchVersion ? result : { ...result, version: undefined }; } /** diff --git a/src/utils/websearch/grok-cli.ts b/src/utils/websearch/grok-cli.ts index 3115e6bb..d84a62e1 100644 --- a/src/utils/websearch/grok-cli.ts +++ b/src/utils/websearch/grok-cli.ts @@ -20,35 +20,47 @@ let grokCliCache: GrokCliStatus | null = null; * * @returns Grok CLI status with path and version */ -export function getGrokCliStatus(): GrokCliStatus { - // Return cached result if available - if (grokCliCache) { +export function getGrokCliStatus(options?: { fetchVersion?: boolean }): GrokCliStatus { + const fetchVersion = options?.fetchVersion !== false; + + if ( + grokCliCache && + (!fetchVersion || grokCliCache.version !== undefined || !grokCliCache.installed) + ) { + if (!fetchVersion) { + return { ...grokCliCache, version: undefined }; + } return grokCliCache; } - const result: GrokCliStatus = { - installed: false, - path: undefined, - version: undefined, - }; + const result: GrokCliStatus = grokCliCache + ? { ...grokCliCache } + : { + installed: false, + path: undefined, + version: undefined, + }; try { - const isWindows = process.platform === 'win32'; - const whichCmd = isWindows ? 'where grok' : 'which grok'; + if (!grokCliCache) { + const isWindows = process.platform === 'win32'; + const whichCmd = isWindows ? 'where grok' : 'which grok'; - const pathResult = execSync(whichCmd, { - encoding: 'utf8', - timeout: 5000, - stdio: ['pipe', 'pipe', 'pipe'], - }); + const pathResult = execSync(whichCmd, { + encoding: 'utf8', + timeout: 5000, + stdio: ['pipe', 'pipe', 'pipe'], + }); - const grokPath = pathResult.trim().split('\n')[0]; // First result on Windows + const grokPath = pathResult.trim().split('\n')[0]; - if (grokPath) { - result.installed = true; - result.path = grokPath; + if (grokPath) { + result.installed = true; + result.path = grokPath; + } + } - // Try to get version + if (result.installed && fetchVersion && result.version === undefined) { try { const versionResult = execSync('grok --version', { encoding: 'utf8', @@ -57,17 +69,17 @@ export function getGrokCliStatus(): GrokCliStatus { }); result.version = versionResult.trim(); } catch { - // Version check failed, but CLI is installed result.version = 'unknown'; } + } else if (!fetchVersion) { + result.version = undefined; } } catch { // Command not found - Grok CLI not installed } - // Cache result grokCliCache = result; - return result; + return fetchVersion ? result : { ...result, version: undefined }; } /** diff --git a/src/utils/websearch/opencode-cli.ts b/src/utils/websearch/opencode-cli.ts index 36a04cfc..4061d2a3 100644 --- a/src/utils/websearch/opencode-cli.ts +++ b/src/utils/websearch/opencode-cli.ts @@ -20,35 +20,47 @@ let opencodeCliCache: OpenCodeCliStatus | null = null; * * @returns OpenCode CLI status with path and version */ -export function getOpenCodeCliStatus(): OpenCodeCliStatus { - // Return cached result if available - if (opencodeCliCache) { +export function getOpenCodeCliStatus(options?: { fetchVersion?: boolean }): OpenCodeCliStatus { + const fetchVersion = options?.fetchVersion !== false; + + if ( + opencodeCliCache && + (!fetchVersion || opencodeCliCache.version !== undefined || !opencodeCliCache.installed) + ) { + if (!fetchVersion) { + return { ...opencodeCliCache, version: undefined }; + } return opencodeCliCache; } - const result: OpenCodeCliStatus = { - installed: false, - path: undefined, - version: undefined, - }; + const result: OpenCodeCliStatus = opencodeCliCache + ? { ...opencodeCliCache } + : { + installed: false, + path: undefined, + version: undefined, + }; try { - const isWindows = process.platform === 'win32'; - const whichCmd = isWindows ? 'where opencode' : 'which opencode'; + if (!opencodeCliCache) { + const isWindows = process.platform === 'win32'; + const whichCmd = isWindows ? 'where opencode' : 'which opencode'; - const pathResult = execSync(whichCmd, { - encoding: 'utf8', - timeout: 5000, - stdio: ['pipe', 'pipe', 'pipe'], - }); + const pathResult = execSync(whichCmd, { + encoding: 'utf8', + timeout: 5000, + stdio: ['pipe', 'pipe', 'pipe'], + }); - const opencodePath = pathResult.trim().split('\n')[0]; // First result on Windows + const opencodePath = pathResult.trim().split('\n')[0]; - if (opencodePath) { - result.installed = true; - result.path = opencodePath; + if (opencodePath) { + result.installed = true; + result.path = opencodePath; + } + } - // Try to get version + if (result.installed && fetchVersion && result.version === undefined) { try { const versionResult = execSync('opencode --version', { encoding: 'utf8', @@ -57,17 +69,17 @@ export function getOpenCodeCliStatus(): OpenCodeCliStatus { }); result.version = versionResult.trim(); } catch { - // Version check failed, but CLI is installed result.version = 'unknown'; } + } else if (!fetchVersion) { + result.version = undefined; } } catch { // Command not found - OpenCode CLI not installed } - // Cache result opencodeCliCache = result; - return result; + return fetchVersion ? result : { ...result, version: undefined }; } /** diff --git a/src/utils/websearch/status.ts b/src/utils/websearch/status.ts index 946fd28f..24839bf8 100644 --- a/src/utils/websearch/status.ts +++ b/src/utils/websearch/status.ts @@ -113,13 +113,25 @@ function applyCooldownStatus( }; } -function getLegacyProviderStatuses(wsConfig: WebSearchConfigSnapshot): WebSearchCliInfo[] { - const agyStatus = getAgyCliStatus(); - const geminiStatus = getGeminiCliStatus(); - const grokStatus = getGrokCliStatus(); - const opencodeStatus = getOpenCodeCliStatus(); - const geminiAuthed = geminiStatus.installed && isGeminiAuthenticated(); +function getLegacyProviderStatuses( + wsConfig: WebSearchConfigSnapshot, + options?: { includeVersions?: boolean; probeDisabled?: boolean } +): WebSearchCliInfo[] { + const probeAll = options?.probeDisabled ?? true; + const fetchVersion = options?.includeVersions ?? true; + const agyEnabled = probeAll || (wsConfig.providers?.agy?.enabled ?? false); + const geminiEnabled = probeAll || (wsConfig.providers?.gemini?.enabled ?? false); + const grokEnabled = probeAll || (wsConfig.providers?.grok?.enabled ?? false); + const opencodeEnabled = probeAll || (wsConfig.providers?.opencode?.enabled ?? false); + // agy detail renders its version on launch and in dashboard, so always fetch version when probed + const agyStatus = agyEnabled ? getAgyCliStatus({ fetchVersion: true }) : { installed: false }; + const geminiStatus = geminiEnabled ? getGeminiCliStatus({ fetchVersion }) : { installed: false }; + const grokStatus = grokEnabled ? getGrokCliStatus({ fetchVersion }) : { installed: false }; + const opencodeStatus = opencodeEnabled + ? getOpenCodeCliStatus({ fetchVersion }) + : { installed: false }; + const geminiAuthed = geminiEnabled && geminiStatus.installed && isGeminiAuthenticated(); return [ { id: 'agy', @@ -133,11 +145,13 @@ function getLegacyProviderStatuses(wsConfig: WebSearchConfigSnapshot): WebSearch docsUrl: 'https://antigravity.google/cli', requiresApiKey: false, description: 'Recommended LLM CLI fallback with Google web search (Gemini CLI successor).', - detail: agyStatus.installed - ? agyStatus.version - ? `Installed (${agyStatus.version})` - : 'Installed' - : 'Not installed', + detail: !agyEnabled + ? 'Disabled' + : agyStatus.installed + ? agyStatus.version + ? `Installed (${agyStatus.version})` + : 'Installed' + : 'Not installed', }, { id: 'gemini', @@ -152,11 +166,13 @@ function getLegacyProviderStatuses(wsConfig: WebSearchConfigSnapshot): WebSearch requiresApiKey: false, description: 'Deprecated legacy fallback (Google retired the gemini CLI). Prefer Antigravity.', - detail: geminiStatus.installed - ? geminiAuthed - ? 'Authenticated' - : "Run 'gemini' to login" - : 'Not installed (retired - use Antigravity)', + detail: !geminiEnabled + ? 'Disabled' + : geminiStatus.installed + ? geminiAuthed + ? 'Authenticated' + : "Run 'gemini' to login" + : 'Not installed (retired - use Antigravity)', }, { id: 'opencode', @@ -170,7 +186,11 @@ function getLegacyProviderStatuses(wsConfig: WebSearchConfigSnapshot): WebSearch docsUrl: 'https://github.com/sst/opencode', requiresApiKey: false, description: 'Optional legacy LLM fallback via OpenCode.', - detail: opencodeStatus.installed ? 'Installed' : 'Not installed', + detail: !opencodeEnabled + ? 'Disabled' + : opencodeStatus.installed + ? 'Installed' + : 'Not installed', }, { id: 'grok', @@ -185,11 +205,13 @@ function getLegacyProviderStatuses(wsConfig: WebSearchConfigSnapshot): WebSearch requiresApiKey: true, apiKeyEnvVar: 'GROK_API_KEY', description: 'Optional legacy LLM fallback with xAI Grok.', - detail: grokStatus.installed - ? hasEnvValue('GROK_API_KEY') - ? 'Ready' - : 'Set GROK_API_KEY' - : 'Not installed', + detail: !grokEnabled + ? 'Disabled' + : grokStatus.installed + ? hasEnvValue('GROK_API_KEY') + ? 'Ready' + : 'Set GROK_API_KEY' + : 'Not installed', }, ]; } @@ -198,7 +220,8 @@ function getLegacyProviderStatuses(wsConfig: WebSearchConfigSnapshot): WebSearch * Get all WebSearch providers with their current status. */ export function getWebSearchCliProviders( - wsConfig: WebSearchConfigSnapshot = getWebSearchConfig() + wsConfig: WebSearchConfigSnapshot = getWebSearchConfig(), + options?: { includeVersions?: boolean; probeDisabled?: boolean } ): WebSearchCliInfo[] { const apiKeyStates = getWebSearchApiKeyStates(); const cooldowns = readProviderCooldowns(); @@ -284,7 +307,7 @@ export function getWebSearchCliProviders( }, ]; - return [...providers, ...getLegacyProviderStatuses(wsConfig)].map((provider) => + return [...providers, ...getLegacyProviderStatuses(wsConfig, options)].map((provider) => applyCooldownStatus(provider, cooldowns) ); } @@ -359,7 +382,10 @@ export function buildWebSearchReadiness( export function getWebSearchReadiness( wsConfig: WebSearchConfigSnapshot = getWebSearchConfig() ): WebSearchStatus { - const providers = getWebSearchCliProviders(wsConfig); + const providers = getWebSearchCliProviders(wsConfig, { + includeVersions: false, + probeDisabled: false, + }); return buildWebSearchReadiness(wsConfig.enabled, providers); } diff --git a/src/web-server/model-pricing.ts b/src/web-server/model-pricing.ts index b4eec3e5..d1cdfacf 100644 --- a/src/web-server/model-pricing.ts +++ b/src/web-server/model-pricing.ts @@ -216,20 +216,21 @@ const PRICING_REGISTRY: Record = { cacheCreationPerMillion: 3.75, cacheReadPerMillion: 0.3, }, - // Claude Sonnet 5 ($3/$15) — latest Sonnet; shares the standard Sonnet rates. + // Claude Sonnet 5 ($2/$10) — the launch introductory rate is now the standard + // price; Anthropic cancelled the scheduled 2026-09-01 increase to $3/$15. // Registered explicitly so it is a known model rather than relying on the - // unknown-model fallback that coincidentally matches these rates. + // unknown-model fallback. 'claude-sonnet-5': { - inputPerMillion: 3.0, - outputPerMillion: 15.0, - cacheCreationPerMillion: 3.75, - cacheReadPerMillion: 0.3, + inputPerMillion: 2.0, + outputPerMillion: 10.0, + cacheCreationPerMillion: 2.5, + cacheReadPerMillion: 0.2, }, 'claude-sonnet-5-thinking': { - inputPerMillion: 3.0, - outputPerMillion: 15.0, - cacheCreationPerMillion: 3.75, - cacheReadPerMillion: 0.3, + inputPerMillion: 2.0, + outputPerMillion: 10.0, + cacheCreationPerMillion: 2.5, + cacheReadPerMillion: 0.2, }, // Claude 4 Opus ($15/$75) 'claude-4-opus-20250514': { @@ -360,6 +361,16 @@ const PRICING_REGISTRY: Record = { cacheCreationPerMillion: 12.5, cacheReadPerMillion: 1.0, }, + // Claude Fable 5.1 ($10/$50) — same tier and base rates as Fable 5, but cache + // hits are billed at 0.025x base input ($0.25/MTok) instead of the usual 0.1x. + // Anthropic applies that reduced multiplier only to Fable 5.1 and Mythos 5.1, + // so this entry cannot use CACHE_READ_MULTIPLIER-derived rates. + 'claude-fable-5-1': { + inputPerMillion: 10.0, + outputPerMillion: 50.0, + cacheCreationPerMillion: 12.5, + cacheReadPerMillion: 0.25, + }, // --------------------------------------------------------------------------- // OpenAI Models - Source: better-ccusage diff --git a/src/web-server/services/cliproxy-dashboard-install-service.ts b/src/web-server/services/cliproxy-dashboard-install-service.ts index 029bd472..1a567a0f 100644 --- a/src/web-server/services/cliproxy-dashboard-install-service.ts +++ b/src/web-server/services/cliproxy-dashboard-install-service.ts @@ -1,9 +1,14 @@ -import { installCliproxyVersion, resolveLocalBackend } from '../../cliproxy/binary-manager'; +import { + installCliproxyVersion, + resolveLocalBackend, + withCliproxyInstallLifecycleLock, +} from '../../cliproxy/binary-manager'; import { resolveLifecyclePort } from '../../cliproxy/config/port-manager'; import { ensureCliproxyService, type ServiceStartResult } from '../../cliproxy/service-manager'; import { getProxyStatus as getProxyProcessStatus } from '../../cliproxy/session-tracker'; import { isCliproxyRunning } from '../../cliproxy/services/stats-fetcher'; import type { CLIProxyBackend } from '../../cliproxy/types'; +import { ProxyError } from '../../errors/error-types'; import { isRunningUnderSupervisord, restartCliproxyViaSupervisord, @@ -22,6 +27,9 @@ interface InstallDashboardCliproxyVersionDeps { backend?: CLIProxyBackend ) => Promise; ensureCliproxyService: () => Promise; + isRunningUnderSupervisord?: () => boolean; + restartCliproxyViaSupervisord?: typeof restartCliproxyViaSupervisord; + withInstallLifecycleLock?: typeof withCliproxyInstallLifecycleLock; } const defaultDeps: InstallDashboardCliproxyVersionDeps = { @@ -48,6 +56,23 @@ async function wasProxyRunning(deps: InstallDashboardCliproxyVersionDeps): Promi return deps.isCliproxyRunning(); } +async function restoreProxyService( + deps: InstallDashboardCliproxyVersionDeps +): Promise { + const underSupervisord = deps.isRunningUnderSupervisord?.() ?? isRunningUnderSupervisord(); + if (underSupervisord) { + const restart = deps.restartCliproxyViaSupervisord?.() ?? restartCliproxyViaSupervisord(); + return { + started: restart.success, + alreadyRunning: false, + port: restart.port ?? resolveLifecyclePort(), + error: restart.error, + }; + } + + return deps.ensureCliproxyService(); +} + export async function installDashboardCliproxyVersion( version: string, backend: CLIProxyBackend, @@ -55,48 +80,53 @@ export async function installDashboardCliproxyVersion( ): Promise { const effectiveBackend = resolveLocalBackend(backend, { notifyOnPlus: true }); const backendLabel = effectiveBackend === 'plus' ? 'CLIProxy Plus' : 'CLIProxy'; - const shouldRestoreService = await wasProxyRunning(deps); + const withLifecycleLock = deps.withInstallLifecycleLock ?? withCliproxyInstallLifecycleLock; - // The installer owns the stop-and-replace lifecycle, including best-effort - // shutdown for tracked and untracked proxies before swapping the binary. - await deps.installCliproxyVersion(version, true, effectiveBackend); + return withLifecycleLock(effectiveBackend, async () => { + const shouldRestoreService = await wasProxyRunning(deps); + + // The installer owns the stop-and-replace lifecycle, including best-effort + // shutdown for tracked and untracked proxies before swapping the binary. + try { + await deps.installCliproxyVersion(version, true, effectiveBackend); + } catch (error) { + if (shouldRestoreService) { + const restoreResult = await restoreProxyService(deps); + if (!restoreResult.started && !restoreResult.alreadyRunning) { + const installMessage = error instanceof Error ? error.message : String(error); + throw new ProxyError( + `${installMessage}; previous ${backendLabel} service also failed to restart: ${restoreResult.error ?? 'unknown restart error'}`, + restoreResult.port + ); + } + } + throw error; + } + + if (!shouldRestoreService) { + return { + success: true, + restarted: false, + message: `Successfully installed ${backendLabel} v${version}`, + }; + } + + // In Docker, supervisord owns process lifecycle — delegate restart to it + const startResult = await restoreProxyService(deps); + if (!startResult.started && !startResult.alreadyRunning) { + return { + success: false, + restarted: false, + error: startResult.error || `Installed ${backendLabel} v${version}, but restart failed`, + message: `Installed ${backendLabel} v${version}, but failed to restart it`, + }; + } - if (!shouldRestoreService) { return { success: true, - restarted: false, - message: `Successfully installed ${backendLabel} v${version}`, + restarted: true, + port: startResult.port, + message: `Successfully installed ${backendLabel} v${version} and restarted it on port ${startResult.port}`, }; - } - - // In Docker, supervisord owns process lifecycle — delegate restart to it - if (isRunningUnderSupervisord()) { - const result = restartCliproxyViaSupervisord(); - return { - success: result.success, - restarted: result.success, - port: result.port, - error: result.error, - message: result.success - ? `Successfully installed ${backendLabel} v${version} and restarted it on port ${result.port}` - : `Installed ${backendLabel} v${version}, but restart failed`, - }; - } - - const startResult = await deps.ensureCliproxyService(); - if (!startResult.started && !startResult.alreadyRunning) { - return { - success: false, - restarted: false, - error: startResult.error || `Installed ${backendLabel} v${version}, but restart failed`, - message: `Installed ${backendLabel} v${version}, but failed to restart it`, - }; - } - - return { - success: true, - restarted: true, - port: startResult.port, - message: `Successfully installed ${backendLabel} v${version} and restarted it on port ${startResult.port}`, - }; + }); } diff --git a/tests/unit/api/profile-writer-anthropic.test.ts b/tests/unit/api/profile-writer-anthropic.test.ts index 5a497d98..f25c016b 100644 --- a/tests/unit/api/profile-writer-anthropic.test.ts +++ b/tests/unit/api/profile-writer-anthropic.test.ts @@ -3,6 +3,7 @@ import * as fs from 'fs'; import * as os from 'os'; import * as path from 'path'; import { createApiProfile } from '../../../src/api/services/profile-writer'; +import { getPresetById } from '../../../src/api/services/provider-presets'; describe('profile-writer Anthropic direct', () => { let tempHome = ''; @@ -131,10 +132,14 @@ describe('profile-writer Anthropic direct', () => { expect(config.profile_targets?.['hf-target-claude']).toBeUndefined(); }); - it('preserves OpenRouter ANTHROPIC_API_KEY blank behavior', () => { + it('preserves OpenRouter ANTHROPIC_API_KEY blank behavior and resolves preset baseUrl without duplicating /v1 (#1728)', () => { + const preset = getPresetById('openrouter'); + expect(preset).toBeDefined(); + expect(preset?.baseUrl).toBe('https://openrouter.ai/api'); + const result = createApiProfile( 'openrouter-test', - 'https://openrouter.ai/api/v1', + preset!.baseUrl, 'sk-or-testkey', { default: 'anthropic/claude-opus-4.5', opus: 'anthropic/claude-opus-4.5', sonnet: 'anthropic/claude-opus-4.5', haiku: 'anthropic/claude-opus-4.5' } ); @@ -144,8 +149,9 @@ describe('profile-writer Anthropic direct', () => { const settingsPath = path.join(tempHome, '.ccs', 'openrouter-test.settings.json'); const settings = JSON.parse(fs.readFileSync(settingsPath, 'utf8')); - // OpenRouter: proxy mode with ANTHROPIC_API_KEY explicitly blank - expect(settings.env.ANTHROPIC_BASE_URL).toBe('https://openrouter.ai/api/v1'); + // OpenRouter: proxy mode with ANTHROPIC_API_KEY explicitly blank and baseUrl without /v1 + expect(settings.env.ANTHROPIC_BASE_URL).toBe('https://openrouter.ai/api'); + expect(`${settings.env.ANTHROPIC_BASE_URL}/v1/messages`).toBe('https://openrouter.ai/api/v1/messages'); expect(settings.env.ANTHROPIC_AUTH_TOKEN).toBe('sk-or-testkey'); expect(settings.env.ANTHROPIC_API_KEY).toBe(''); }); diff --git a/tests/unit/api/provider-presets-orcarouter.test.ts b/tests/unit/api/provider-presets-orcarouter.test.ts new file mode 100644 index 00000000..b4cac1a3 --- /dev/null +++ b/tests/unit/api/provider-presets-orcarouter.test.ts @@ -0,0 +1,58 @@ +import { describe, expect, it } from 'bun:test'; +import { getPresetById, isValidPresetId } from '../../../src/api/services/provider-presets'; +import { PROVIDER_PRESET_IDS } from '../../../src/shared/provider-preset-catalog'; + +describe('provider-presets-orcarouter', () => { + it('resolves orcarouter preset id', () => { + const preset = getPresetById('orcarouter'); + expect(preset?.id).toBe('orcarouter'); + expect(preset?.baseUrl).toBe('https://api.orcarouter.ai/v1'); + expect(preset?.defaultProfileName).toBe('orcarouter'); + }); + + it('registers orcarouter in PROVIDER_PRESET_IDS', () => { + expect(PROVIDER_PRESET_IDS).toContain('orcarouter'); + }); + + it('uses the OpenAI-compatible base URL with a /v1 suffix', () => { + const preset = getPresetById('orcarouter'); + expect(preset?.baseUrl).toBe('https://api.orcarouter.ai/v1'); + expect(preset?.baseUrl.endsWith('/v1')).toBe(true); + }); + + it('pins a provider/model default (openai/gpt-5.5)', () => { + const preset = getPresetById('orcarouter'); + expect(preset?.defaultModel).toBe('openai/gpt-5.5'); + }); + + it('validates orcarouter preset requires an API key with the sk-orca- placeholder', () => { + const preset = getPresetById('orcarouter'); + expect(preset?.requiresApiKey).toBe(true); + expect(preset?.apiKeyPlaceholder).toBe('sk-orca-...'); + }); + + it('is a recommended, featured provider', () => { + const preset = getPresetById('orcarouter'); + expect(preset?.category).toBe('recommended'); + expect(preset?.featured).toBe(true); + }); + + it('treats orcarouter as a valid preset id', () => { + expect(isValidPresetId('orcarouter')).toBe(true); + }); + + it('handles whitespace in orcarouter preset id', () => { + const preset = getPresetById(' orcarouter '); + expect(preset?.id).toBe('orcarouter'); + }); + + it('handles uppercase orcarouter preset id', () => { + const preset = getPresetById('ORCAROUTER'); + expect(preset?.id).toBe('orcarouter'); + }); + + it('does not resolve partial or invalid orcarouter ids', () => { + expect(getPresetById('orcarouter-invalid')).toBeUndefined(); + expect(isValidPresetId('orcarouter-invalid')).toBe(false); + }); +}); diff --git a/tests/unit/api/provider-presets.test.ts b/tests/unit/api/provider-presets.test.ts index 69386dbe..69bb4501 100644 --- a/tests/unit/api/provider-presets.test.ts +++ b/tests/unit/api/provider-presets.test.ts @@ -113,9 +113,12 @@ describe('provider-presets', () => { expect(isValidPresetId('te')).toBe(true); }); - it('uses OpenRouter v1 as the OpenAI-compatible API root', () => { + it('uses OpenRouter /api (without /v1) as the Anthropic-compatible API root to avoid double /v1 404 in Claude Code (#1728)', () => { const preset = getPresetById('openrouter'); - expect(preset?.baseUrl).toBe('https://openrouter.ai/api/v1'); + expect(preset?.baseUrl).toBe('https://openrouter.ai/api'); + const finalEndpoint = `${preset?.baseUrl}/v1/messages`; + expect(finalEndpoint).toBe('https://openrouter.ai/api/v1/messages'); + expect(finalEndpoint).not.toContain('/v1/v1/'); }); it('keeps Anthropic direct last in the recommended preset order and reuses the Claude logo', () => { diff --git a/tests/unit/cliproxy/extended-context-config.test.ts b/tests/unit/cliproxy/extended-context-config.test.ts new file mode 100644 index 00000000..b8422b9f --- /dev/null +++ b/tests/unit/cliproxy/extended-context-config.test.ts @@ -0,0 +1,117 @@ +import { describe, expect, it } from 'bun:test'; + +import { applyExtendedContextConfig } from '../../../src/cliproxy/config/extended-context-config'; + +describe('applyExtendedContextConfig', () => { + it('keeps a saved [1m] on the fable tier key in auto mode', () => { + const env: NodeJS.ProcessEnv = { + ANTHROPIC_MODEL: 'claude-opus-5[1m]', + ANTHROPIC_DEFAULT_FABLE_MODEL: 'claude-fable-5-1[1m]', + }; + + applyExtendedContextConfig(env, 'claude'); + + expect(env.ANTHROPIC_DEFAULT_FABLE_MODEL).toBe('claude-fable-5-1[1m]'); + expect(env.ANTHROPIC_MODEL).toBe('claude-opus-5[1m]'); + }); + + it('suffixes the fable tier key like every other tier when forced on', () => { + const env: NodeJS.ProcessEnv = { + ANTHROPIC_MODEL: 'claude-fable-5-1', + ANTHROPIC_DEFAULT_FABLE_MODEL: 'claude-fable-5-1', + CLAUDE_CODE_SUBAGENT_MODEL: 'claude-fable-5-1', + }; + + applyExtendedContextConfig(env, 'claude', true); + + expect(env.ANTHROPIC_DEFAULT_FABLE_MODEL).toBe('claude-fable-5-1[1m]'); + expect(env.ANTHROPIC_MODEL).toBe('claude-fable-5-1[1m]'); + expect(env.CLAUDE_CODE_SUBAGENT_MODEL).toBe('claude-fable-5-1[1m]'); + }); + + it('strips the fable tier key with the others when forced off', () => { + const env: NodeJS.ProcessEnv = { + ANTHROPIC_MODEL: 'claude-opus-5[1m]', + ANTHROPIC_DEFAULT_FABLE_MODEL: 'claude-fable-5-1[1m]', + }; + + applyExtendedContextConfig(env, 'claude', false); + + expect(env.ANTHROPIC_DEFAULT_FABLE_MODEL).toBe('claude-fable-5-1'); + expect(env.ANTHROPIC_MODEL).toBe('claude-opus-5'); + }); + + describe('fable tier default on the claude provider', () => { + it('fills a missing fable tier with the catalog Fable model plus [1m] when a saved tier carries [1m]', () => { + const env: NodeJS.ProcessEnv = { + ANTHROPIC_MODEL: 'claude-opus-5[1m]', + }; + + applyExtendedContextConfig(env, 'claude'); + + expect(env.ANTHROPIC_DEFAULT_FABLE_MODEL).toBe('claude-fable-5-1[1m]'); + }); + + it('fills a missing fable tier when --1m is passed', () => { + const env: NodeJS.ProcessEnv = { + ANTHROPIC_MODEL: 'claude-opus-5', + }; + + applyExtendedContextConfig(env, 'claude', true); + + expect(env.ANTHROPIC_MODEL).toBe('claude-opus-5[1m]'); + expect(env.ANTHROPIC_DEFAULT_FABLE_MODEL).toBe('claude-fable-5-1[1m]'); + }); + + it('fills a missing fable tier on a model-neutral claude launch when --1m is passed', () => { + const env: NodeJS.ProcessEnv = {}; + + applyExtendedContextConfig(env, 'claude', true); + + expect(env.ANTHROPIC_DEFAULT_FABLE_MODEL).toBe('claude-fable-5-1[1m]'); + expect(Object.keys(env)).toEqual(['ANTHROPIC_DEFAULT_FABLE_MODEL']); + }); + + it('leaves the fable tier alone when no long-context intent exists', () => { + const env: NodeJS.ProcessEnv = { + ANTHROPIC_MODEL: 'claude-opus-5', + }; + + applyExtendedContextConfig(env, 'claude'); + + expect(env.ANTHROPIC_DEFAULT_FABLE_MODEL).toBeUndefined(); + }); + + it('leaves the fable tier alone when --no-1m is passed', () => { + const env: NodeJS.ProcessEnv = { + ANTHROPIC_MODEL: 'claude-opus-5[1m]', + }; + + applyExtendedContextConfig(env, 'claude', false); + + expect(env.ANTHROPIC_MODEL).toBe('claude-opus-5'); + expect(env.ANTHROPIC_DEFAULT_FABLE_MODEL).toBeUndefined(); + }); + + it('never overrides an explicit fable tier mapping', () => { + const env: NodeJS.ProcessEnv = { + ANTHROPIC_MODEL: 'claude-opus-5[1m]', + ANTHROPIC_DEFAULT_FABLE_MODEL: 'claude-fable-5[1m]', + }; + + applyExtendedContextConfig(env, 'claude'); + + expect(env.ANTHROPIC_DEFAULT_FABLE_MODEL).toBe('claude-fable-5[1m]'); + }); + + it('does not invent a fable tier for providers without Fable models', () => { + const env: NodeJS.ProcessEnv = { + ANTHROPIC_MODEL: 'gpt-5.4[1m]', + }; + + applyExtendedContextConfig(env, 'codex'); + + expect(env.ANTHROPIC_DEFAULT_FABLE_MODEL).toBeUndefined(); + }); + }); +}); diff --git a/tests/unit/commands/cliproxy-quota-subcommand.test.ts b/tests/unit/commands/cliproxy-quota-subcommand.test.ts index dc1fa4b9..e8b31f93 100644 --- a/tests/unit/commands/cliproxy-quota-subcommand.test.ts +++ b/tests/unit/commands/cliproxy-quota-subcommand.test.ts @@ -1,6 +1,16 @@ -import { describe, expect, it } from 'bun:test'; +import { describe, expect, it, spyOn } from 'bun:test'; +import { mkdtempSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; import { displayClaudeQuotaSection } from '../../../src/commands/cliproxy/quota-subcommand/sections/claude'; - +import { displayCodexQuotaSection } from '../../../src/commands/cliproxy/quota-subcommand/sections/codex'; +import { displayGeminiCliQuotaSection } from '../../../src/commands/cliproxy/quota-subcommand/sections/gemini-cli'; +import { formatQuotaBar } from '../../../src/commands/cliproxy/quota-subcommand/format-helpers'; +import { handleDoctor } from '../../../src/commands/cliproxy/quota-subcommand/handlers'; +import type { AccountInfo } from '../../../src/cliproxy/accounts/types'; +import type { AllAccountsQuotaResult } from '../../../src/cliproxy/quota/quota-fetcher'; +import * as quotaFetcher from '../../../src/cliproxy/quota/quota-fetcher'; +import * as accountManager from '../../../src/cliproxy/accounts/account-manager'; async function loadQuotaCommandTestExports() { const moduleId = Date.now() + Math.random(); const mod = await import( @@ -167,3 +177,196 @@ describe('cliproxy quota subcommand Codex label formatting', () => { expect(label).not.toContain('\u0007'); }); }); + +function captureConsoleLog(fn: () => void): string { + const output: string[] = []; + const originalLog = console.log; + console.log = (...args: unknown[]) => output.push(args.map(String).join(' ')); + try { + fn(); + } finally { + console.log = originalLog; + } + return output.join('\n'); +} + +describe('cliproxy quota remaining percent labels and ASCII compliance', () => { + it('renders formatQuotaBar with pure ASCII characters', () => { + const bar100 = formatQuotaBar(100); + const bar75 = formatQuotaBar(75); + const bar50 = formatQuotaBar(50); + const bar25 = formatQuotaBar(25); + const bar5 = formatQuotaBar(5); + const bar0 = formatQuotaBar(0); + + expect(bar100).toBe(`[${'#'.repeat(20)}]`); + expect(bar75).toBe(`[${'#'.repeat(15)}${' '.repeat(5)}]`); + expect(bar50).toBe(`[${'+'.repeat(10)}${' '.repeat(10)}]`); + expect(bar25).toBe(`[${'+'.repeat(5)}${' '.repeat(15)}]`); + expect(bar5).toBe(`[${'-'.repeat(1)}${' '.repeat(19)}]`); + expect(bar0).toBe(`[${' '.repeat(20)}]`); + + // Strict ASCII verification + expect(bar100).toMatch(/^[\x00-\x7F]+$/); + expect(bar75).toMatch(/^[\x00-\x7F]+$/); + expect(bar50).toMatch(/^[\x00-\x7F]+$/); + expect(bar25).toMatch(/^[\x00-\x7F]+$/); + expect(bar5).toMatch(/^[\x00-\x7F]+$/); + expect(bar0).toMatch(/^[\x00-\x7F]+$/); + }); + + it('renders Claude remaining percent with a remaining suffix and pure ASCII', () => { + const text = captureConsoleLog(() => { + displayClaudeQuotaSection([ + { + account: 'claude@example.com', + quota: { + success: true, + windows: [ + { + rateLimitType: 'five_hour', + label: '5h usage limit', + status: 'allowed', + utilization: 0.28, + usedPercent: 28, + remainingPercent: 72, + resetAt: null, + }, + ], + coreUsage: { fiveHour: null, weekly: null }, + lastUpdated: 1, + accountId: 'claude@example.com', + }, + }, + ]); + }); + + expect(text).toContain('72% remaining'); + expect(text).toMatch(/^[\x00-\x7F\n\r]+$/); + }); + + it('renders Codex remaining percent with a remaining suffix and pure ASCII', () => { + const text = captureConsoleLog(() => { + displayCodexQuotaSection([ + { + account: 'codex@example.com', + quota: { + success: true, + windows: [ + { + label: 'Primary', + usedPercent: 28, + remainingPercent: 72, + resetAfterSeconds: null, + resetAt: null, + category: 'usage', + cadence: '5h', + }, + ], + planType: 'pro', + lastUpdated: 1, + accountId: 'codex@example.com', + }, + }, + ]); + }); + + expect(text).toContain('72% remaining'); + expect(text).toMatch(/^[\x00-\x7F\n\r]+$/); + }); + + it('renders Gemini CLI remaining percent with a remaining suffix and pure ASCII', () => { + const text = captureConsoleLog(() => { + displayGeminiCliQuotaSection([ + { + account: 'gemini@example.com', + quota: { + success: true, + buckets: [ + { + id: 'gemini-flash-series', + label: 'Gemini Flash Series', + tokenType: null, + remainingFraction: 0.72, + remainingPercent: 72, + resetTime: null, + modelIds: ['gemini-flash'], + }, + ], + projectId: null, + lastUpdated: 1, + accountId: 'gemini@example.com', + }, + }, + ]); + }); + + expect(text).toContain('72% remaining'); + expect(text).toMatch(/^[\x00-\x7F\n\r]+$/); + }); + it('renders Doctor Antigravity quota with remaining suffix and pure ASCII', async () => { + const tempHome = mkdtempSync(join(tmpdir(), 'ccs-doctor-test-')); + const originalCcsHome = process.env.CCS_HOME; + process.env.CCS_HOME = tempHome; + + const mockAccount: AccountInfo = { + id: 'agy@example.com', + email: 'agy@example.com', + provider: 'agy', + isDefault: true, + tokenFile: 'agy-token.json', + createdAt: new Date().toISOString(), + }; + + const mockQuotaResult: AllAccountsQuotaResult = { + accounts: [ + { + account: mockAccount, + quota: { + success: true, + models: [ + { + name: 'gemini-2.5-flash', + percentage: 85, + resetTime: null, + }, + ], + projectId: 'proj-123', + lastUpdated: 1, + accountId: 'agy@example.com', + }, + }, + ], + projectGroups: {}, + }; + + const accountsSpy = spyOn(accountManager, 'getProviderAccounts').mockReturnValue([mockAccount]); + const fetchSpy = spyOn(quotaFetcher, 'fetchAllProviderQuotas').mockResolvedValue( + mockQuotaResult + ); + + try { + const output: string[] = []; + const originalLog = console.log; + console.log = (...args: unknown[]) => output.push(args.map(String).join(' ')); + try { + await handleDoctor(false); + } finally { + console.log = originalLog; + } + const text = output.join('\n'); + expect(text).toContain('gemini-2.5-flash'); + expect(text).toContain('85% remaining'); + expect(text).toMatch(/^[\x00-\x7F\n\r]+$/); + } finally { + accountsSpy.mockRestore(); + fetchSpy.mockRestore(); + if (originalCcsHome !== undefined) { + process.env.CCS_HOME = originalCcsHome; + } else { + delete process.env.CCS_HOME; + } + rmSync(tempHome, { recursive: true, force: true }); + } + }); +}); diff --git a/tests/unit/docker/cliproxy-host-continuity-assets.test.ts b/tests/unit/docker/cliproxy-host-continuity-assets.test.ts new file mode 100644 index 00000000..5d5f6fcc --- /dev/null +++ b/tests/unit/docker/cliproxy-host-continuity-assets.test.ts @@ -0,0 +1,143 @@ +import { mkdtempSync, readFileSync, rmSync } from 'fs'; +import { spawnSync } from 'child_process'; +import { describe, expect, it } from 'bun:test'; +import { tmpdir } from 'os'; +import { join } from 'path'; + +const updateScript = readFileSync('docker/host/ccs-cliproxy-safe-update.sh', 'utf8'); +const reconcileScript = readFileSync('docker/host/ccs-cliproxy-reconcile.sh', 'utf8'); +const updateService = readFileSync('docker/host/systemd/ccs-cliproxy-update.service', 'utf8'); +const updateTimer = readFileSync('docker/host/systemd/ccs-cliproxy-update.timer', 'utf8'); +const reconcileService = readFileSync('docker/host/systemd/ccs-cliproxy-reconcile.service', 'utf8'); +const reconcileTimer = readFileSync('docker/host/systemd/ccs-cliproxy-reconcile.timer', 'utf8'); + +describe('CLIProxy Docker host continuity assets', () => { + it('stages and validates the replacement before stopping the live proxy', () => { + const stageIndex = updateScript.indexOf('CCS_DIR="$stage_ccs_dir" ccs cliproxy --latest'); + const validateIndex = updateScript.indexOf('"$stage_binary" --version'); + const stopIndex = updateScript.indexOf('supervisorctl_cmd stop cliproxy', validateIndex); + + expect(stageIndex).toBeGreaterThan(-1); + expect(validateIndex).toBeGreaterThan(stageIndex); + expect(stopIndex).toBeGreaterThan(validateIndex); + }); + + it('serializes updates and reconciliation with the same lock', () => { + expect(updateScript).toContain('/run/lock/ccs-cliproxy-maintenance.lock'); + expect(reconcileScript).toContain('/run/lock/ccs-cliproxy-maintenance.lock'); + expect(updateScript).toContain('flock -n 9'); + expect(reconcileScript).toContain('flock -n 9'); + }); + + it('shares the in-container install lifecycle lock with CLI and dashboard installs', () => { + expect(updateScript).toContain( + "install_lock_target='/root/.ccs/cliproxy/bin/.install-lifecycle-plus'" + ); + expect(updateScript).toContain('install_lock_dir="$install_lock_target.lock"'); + expect(updateScript).toContain('while ! mkdir "$install_lock_dir"'); + expect(updateScript).toContain('touch "$install_lock_dir"'); + expect(updateScript).toContain('install_lock_stale_seconds=600'); + expect(updateScript).toContain('remove_stale_install_lock'); + expect(updateScript).toContain('rmdir "$install_lock_dir"'); + expect(updateScript.indexOf('acquire_install_lock')).toBeLessThan( + updateScript.indexOf('supervisorctl_cmd stop cliproxy') + ); + }); + + it('reclaims an orphaned stale lifecycle lock', () => { + const testRoot = mkdtempSync(join(tmpdir(), 'ccs-cliproxy-host-lock-')); + const helpersStart = updateScript.indexOf('remove_stale_install_lock() {'); + const helpersEnd = updateScript.indexOf('\nwait_for_proxy() {'); + const helpers = updateScript.slice(helpersStart, helpersEnd); + try { + const result = spawnSync( + 'bash', + [ + '-c', + `set -Eeuo pipefail +install_lock_target="$1/target" +install_lock_dir="$install_lock_target.lock" +install_lock_stale_seconds=600 +install_lock_owned=0 +mkdir -p "$install_lock_target" "$install_lock_dir" +touch -t 200001010000 "$install_lock_dir" +${helpers} +acquire_install_lock +test "$install_lock_owned" -eq 1 +release_install_lock`, + 'test-shell', + testRoot, + ], + { encoding: 'utf8' } + ); + + expect(result.status).toBe(0); + } finally { + rmSync(testRoot, { recursive: true, force: true }); + } + }); + + it('rolls back failed swaps and health-checks both services', () => { + expect(updateScript).toContain('rollback()'); + expect(updateScript).toContain('previous-binary'); + expect(updateScript).toContain('wait_for_proxy'); + expect(reconcileScript).toContain('http://127.0.0.1:3000/'); + expect(reconcileScript).toContain('http://127.0.0.1:8317/'); + }); + + it('recreates missing containers and escalates unhealthy recovery', () => { + expect(reconcileScript).toContain('CCS_CLIPROXY_COMPOSE_DIR:-/root/.ccs/docker'); + expect(reconcileScript).toContain('$compose_dir/docker-compose.integrated.yml'); + expect(reconcileScript).toContain('CCS_CLIPROXY_COMPOSE_PROJECT:-docker'); + expect(reconcileScript).toContain('--project-name "$compose_project"'); + expect(reconcileScript).toContain('-f "$compose_file" up -d --no-build'); + expect(reconcileScript).toContain("2>/dev/null || true"); + expect(reconcileScript).toContain('if [ -z "$running_state" ]'); + expect(reconcileScript).toContain('restart ccs-dashboard cliproxy'); + expect(reconcileScript).toContain('docker restart "$container_name"'); + expect(reconcileScript).toContain('up -d --force-recreate --no-build'); + expect(reconcileScript.indexOf('docker restart "$container_name"')).toBeGreaterThan( + reconcileScript.indexOf('restart ccs-dashboard cliproxy') + ); + }); + + it('installs executable services on bounded timers', () => { + expect(updateService).toContain('ExecStart=/opt/cliproxy/ccs-cliproxy-safe-update.sh'); + expect(updateService).toContain('TimeoutStartSec=10min'); + expect(updateTimer).toContain('OnUnitActiveSec=15min'); + expect(reconcileService).toContain('ExecStart=/opt/cliproxy/ccs-cliproxy-reconcile.sh'); + expect(reconcileTimer).toContain('OnUnitActiveSec=30s'); + }); + + it('forces nonzero signal exits so the EXIT trap rolls back maintenance', () => { + expect(updateScript).toContain("trap 'exit 130' INT"); + expect(updateScript).toContain("trap 'exit 143' TERM"); + expect(updateScript).toContain("trap 'exit 129' HUP"); + + const handlerStart = updateScript.indexOf('on_exit() {'); + const handlerEnd = updateScript.indexOf('\ntrap on_exit EXIT'); + const handlers = updateScript.slice(handlerStart, handlerEnd); + const result = spawnSync( + 'bash', + [ + '-c', + `set -Eeuo pipefail +maintenance_started=1 +stage_root=/tmp/unused +rollback() { printf 'rollback\\n'; } +cleanup() { printf 'cleanup\\n'; } +release_install_lock() { :; } +${handlers} +trap on_exit EXIT +trap 'exit 143' TERM +true +kill -TERM $$`, + ], + { encoding: 'utf8' } + ); + + expect(result.status).toBe(143); + expect(result.stdout).toContain('rollback'); + expect(result.stdout).toContain('cleanup'); + }); +}); diff --git a/tests/unit/model-pricing.test.ts b/tests/unit/model-pricing.test.ts index 2df898fc..8202db38 100644 --- a/tests/unit/model-pricing.test.ts +++ b/tests/unit/model-pricing.test.ts @@ -277,12 +277,24 @@ describe('model-pricing', () => { expect(fable5.cacheReadPerMillion).toBe(1.0); }); + it('should return correct pricing for Claude Fable 5.1', () => { + // Same base rates as Fable 5, but cache hits bill at 0.025x base input + // ($0.25/MTok) rather than the standard 0.1x multiplier. + const fable51 = getModelPricing('claude-fable-5-1'); + expect(fable51.inputPerMillion).toBe(10.0); + expect(fable51.outputPerMillion).toBe(50.0); + expect(fable51.cacheCreationPerMillion).toBe(12.5); + expect(fable51.cacheReadPerMillion).toBe(0.25); + }); + it('should return correct pricing for Claude Sonnet 5', () => { + // The $2/$10 launch introductory rate became the standard price; the + // scheduled 2026-09-01 increase to $3/$15 was cancelled. const sonnet5 = getModelPricing('claude-sonnet-5'); - expect(sonnet5.inputPerMillion).toBe(3.0); - expect(sonnet5.outputPerMillion).toBe(15.0); - expect(sonnet5.cacheCreationPerMillion).toBe(3.75); - expect(sonnet5.cacheReadPerMillion).toBe(0.3); + expect(sonnet5.inputPerMillion).toBe(2.0); + expect(sonnet5.outputPerMillion).toBe(10.0); + expect(sonnet5.cacheCreationPerMillion).toBe(2.5); + expect(sonnet5.cacheReadPerMillion).toBe(0.2); }); it('should return Opus-tier pricing for Claude Opus 5', () => { @@ -430,7 +442,7 @@ describe('model-pricing', () => { cacheReadTokens: 1_000_000, }; const cost = calculateCost(usage, 'claude-sonnet-5'); - expect(cost).toBe(22.05); // 3 + 15 + 3.75 + 0.3 + expect(cost).toBe(14.7); // 2 + 10 + 2.5 + 0.2 }); it('should calculate fast-tier Claude Opus 4.8 cost (2x premium)', () => { diff --git a/tests/unit/scripts/github/dev-release-workflow.test.ts b/tests/unit/scripts/github/dev-release-workflow.test.ts index b61d79ff..5a2d1d40 100644 --- a/tests/unit/scripts/github/dev-release-workflow.test.ts +++ b/tests/unit/scripts/github/dev-release-workflow.test.ts @@ -60,4 +60,16 @@ describe('dev release workflow', () => { expect(script).toContain('git commit -m "chore(release): ${VERSION}"'); expect(script).not.toContain('git commit -m "chore(release): ${VERSION} [skip ci]"'); }); + + test('publishes dev releases with explicit public access for scoped package', () => { + const packageJsonPath = resolvePath('../../../../package.json'); + const scriptPath = resolvePath('../../../../scripts/dev-release.sh'); + + const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, 'utf8')); + const script = fs.readFileSync(scriptPath, 'utf8'); + + expect(packageJson.name).toBe('@kaitranntt/ccs'); + expect(packageJson.publishConfig).toEqual({ access: 'public' }); + expect(script).toContain('npm publish --tag dev --access public'); + }); }); diff --git a/tests/unit/shared-manager.test.ts b/tests/unit/shared-manager.test.ts index 0053a7da..7b1e70ea 100644 --- a/tests/unit/shared-manager.test.ts +++ b/tests/unit/shared-manager.test.ts @@ -544,7 +544,18 @@ describe('SharedManager', () => { }); }); - it('preserves a canonical write that lands during no-replace publication', () => { + /** + * Replaces 'preserves a canonical write that lands during no-replace + * publication', which locked in the outcome of the CCS-4 incident. + * + * The intent it encoded - never clobber a writer that got to the canonical + * file first - is kept, but it is now enforced by the compare-and-swap + * guard instead of by an EEXIST from a no-replace link. The difference + * that matters: the canonical path is no longer emptied first, so only a + * genuinely concurrent write can land here, and it is preserved without + * costing the user the settings that were already there. + */ + it('refuses to publish when the canonical file changes before publication', () => { const manager = new SharedManager(); const instancePath = instanceDir('canonical-race'); const canonicalPath = path.join(claudeDir(), 'settings.json'); @@ -555,24 +566,136 @@ describe('SharedManager', () => { writeJson(divergedPath, { generation: 1 }); setMtime(divergedPath, fs.statSync(canonicalPath).mtimeMs + 2_000); - const originalLinkSync = fs.linkSync; - const linkSpy = spyOn(fs, 'linkSync').mockImplementation((( - existingPath: fs.PathLike, - newPath: fs.PathLike + // Land the competing write while the publication temp file is being + // prepared, i.e. after the canonical bytes were read but before the + // compare-and-swap guard re-checks the inode. + const originalOpenSync = fs.openSync; + const openSpy = spyOn(fs, 'openSync').mockImplementation((( + openPath: fs.PathLike, + flags: number | string, + mode?: fs.Mode ) => { - if (String(newPath) === canonicalPath && String(existingPath).includes('.ccs-write-')) { + if (String(openPath).startsWith(`${canonicalPath}.ccs-write-`)) { writeJson(canonicalPath, { generation: 99 }); } - return originalLinkSync(existingPath, newPath); - }) as typeof fs.linkSync); + return originalOpenSync(openPath, flags, mode); + }) as typeof fs.openSync); - expect(() => manager.linkSharedDirectories(instancePath)).toThrow(); - linkSpy.mockRestore(); + expect(() => manager.linkSharedDirectories(instancePath)).toThrow( + 'Canonical file changed during adoption' + ); + openSpy.mockRestore(); expect(readJson(canonicalPath)).toEqual({ generation: 99 }); expect(readJson(divergedPath)).toEqual({ generation: 1 }); expect(readJson(`${canonicalPath}.bak-ccs-adopt`)).toEqual({ generation: 0 }); + expect(readJson(`${divergedPath}.ccs-adopted-recovery`)).toEqual({ generation: 1 }); }); + /** + * Model a foreign writer that shares ownership of the canonical + * settings.json: the instant the path is left without a file, it lands an + * empty-settings placeholder there. Claude Code does exactly this on + * startup, and a second concurrent `ccs` does the same through + * shared-dir-linker.ts:127. + * + * The writer reacts to the path becoming empty rather than to one specific + * call site, so it keeps modelling the race no matter which fs primitive + * the adopter uses to move the canonical file out of the way. + */ + function installForeignCanonicalWriter( + canonicalPath: string, + placeholder: string + ): { placeholderWrites: () => number; restore: () => void } { + let placeholderWrites = 0; + const claimEmptyCanonicalPath = (): void => { + if (fs.existsSync(canonicalPath)) return; + fs.writeFileSync(canonicalPath, placeholder, 'utf8'); + placeholderWrites++; + }; + + const originalRenameSync = fs.renameSync; + const renameSpy = spyOn(fs, 'renameSync').mockImplementation((( + oldPath: fs.PathLike, + newPath: fs.PathLike + ) => { + originalRenameSync(oldPath, newPath); + claimEmptyCanonicalPath(); + }) as typeof fs.renameSync); + + const originalUnlinkSync = fs.unlinkSync; + const unlinkSpy = spyOn(fs, 'unlinkSync').mockImplementation(((targetPath: fs.PathLike) => { + originalUnlinkSync(targetPath); + claimEmptyCanonicalPath(); + }) as typeof fs.unlinkSync); + + return { + placeholderWrites: () => placeholderWrites, + restore: () => { + renameSpy.mockRestore(); + unlinkSpy.mockRestore(); + }, + }; + } + + /** + * Reproduce the 2026-08-20 incident: adoption moves the canonical + * settings.json aside, a foreign writer fills the empty path, and the user + * ends up with neither the adopted nor the previous settings on the live + * path. + * + * The opposite outcome used to be pinned by 'preserves a canonical write + * that lands during no-replace publication'; that test was rewritten as + * 'refuses to publish when the canonical file changes before publication' + * once publication stopped emptying the canonical path. + */ + const foreignWriterCases: ReadonlyArray<{ writer: string; placeholder: string }> = [ + // Claude Code starts, finds no settings file and writes empty settings. + { writer: 'Claude Code', placeholder: '{}\n' }, + // A second concurrent `ccs` provisions the same placeholder without the + // trailing newline (shared-dir-linker.ts:127). + { writer: 'a concurrent ccs run', placeholder: JSON.stringify({}, null, 2) }, + ]; + + for (const { writer, placeholder } of foreignWriterCases) { + it(`keeps live settings when ${writer} fills the canonical path during adoption`, () => { + const manager = new SharedManager(); + const canonicalPath = path.join(claudeDir(), 'settings.json'); + const sharedSettingsPath = path.join(ccsDir(), 'shared', 'settings.json'); + const previousSettings = { + model: 'opus', + permissions: { allow: ['Bash(git status:*)'] }, + }; + const divergedSettings = { + model: 'opus', + permissions: { allow: ['Bash(git status:*)', 'Bash(git diff:*)'] }, + }; + + fs.mkdirSync(claudeDir(), { recursive: true }); + fs.mkdirSync(path.join(ccsDir(), 'shared'), { recursive: true }); + writeJson(canonicalPath, previousSettings); + writeJson(sharedSettingsPath, divergedSettings); + setMtime(sharedSettingsPath, fs.statSync(canonicalPath).mtimeMs + 2_000); + + const foreignWriter = installForeignCanonicalWriter(canonicalPath, placeholder); + try { + manager.ensureSharedDirectories(); + } catch { + // Losing the race may abort reconciliation; the user's live settings + // must survive either way. + } finally { + foreignWriter.restore(); + } + + // The writer only fires when the canonical path is observed empty, so + // zero writes is the invariant itself: adoption never left the path + // without a regular file. + expect(foreignWriter.placeholderWrites()).toBe(0); + expect(fs.existsSync(canonicalPath)).toBe(true); + expect(fs.readFileSync(canonicalPath, 'utf8')).not.toBe(placeholder); + expect([divergedSettings, previousSettings]).toContainEqual(readJson(canonicalPath)); + }); + } + it('keeps adopted bytes recoverable when canonical changes after verification', () => { const manager = new SharedManager(); const instancePath = instanceDir('late-canonical-writer'); @@ -584,10 +707,12 @@ describe('SharedManager', () => { writeJson(divergedPath, { generation: 1 }); setMtime(divergedPath, fs.statSync(canonicalPath).mtimeMs + 2_000); + // The diverged claim is dropped only after publication was verified, so + // a write injected there lands strictly after adoption completed. const originalUnlinkSync = fs.unlinkSync; let injected = false; const unlinkSpy = spyOn(fs, 'unlinkSync').mockImplementation(((targetPath: fs.PathLike) => { - if (!injected && String(targetPath).includes('.ccs-canonical-claim-')) { + if (!injected && String(targetPath).includes('.ccs-adopt-claim-')) { injected = true; writeJson(canonicalPath, { generation: 99 }); } @@ -596,6 +721,7 @@ describe('SharedManager', () => { manager.linkSharedDirectories(instancePath); unlinkSpy.mockRestore(); + expect(injected).toBe(true); expect(readJson(canonicalPath)).toEqual({ generation: 99 }); expect(readJson(`${divergedPath}.ccs-adopted-recovery`)).toEqual({ generation: 1 }); }); @@ -611,20 +737,30 @@ describe('SharedManager', () => { writeJson(divergedPath, { generation: 1 }); setMtime(divergedPath, fs.statSync(canonicalPath).mtimeMs + 2_000); - const originalUnlinkSync = fs.unlinkSync; + // Recreate the managed source right after the canonical file was + // replaced, while the adopter is still cleaning up. + const originalRenameSync = fs.renameSync; let injected = false; - const unlinkSpy = spyOn(fs, 'unlinkSync').mockImplementation(((targetPath: fs.PathLike) => { - if (!injected && String(targetPath).includes('.ccs-canonical-claim-')) { + const renameSpy = spyOn(fs, 'renameSync').mockImplementation((( + oldPath: fs.PathLike, + newPath: fs.PathLike + ) => { + originalRenameSync(oldPath, newPath); + if ( + !injected && + String(newPath) === canonicalPath && + String(oldPath).startsWith(`${canonicalPath}.ccs-write-`) + ) { injected = true; writeJson(divergedPath, { generation: 2 }); } - return originalUnlinkSync(targetPath); - }) as typeof fs.unlinkSync); + }) as typeof fs.renameSync); expect(() => manager.linkSharedDirectories(instancePath)).toThrow( 'Concurrent replacement detected' ); - unlinkSpy.mockRestore(); + renameSpy.mockRestore(); + expect(injected).toBe(true); expect(readJson(divergedPath)).toEqual({ generation: 2 }); }); @@ -694,7 +830,7 @@ describe('SharedManager', () => { } }); - it('uses the mode of the canonical inode actually claimed for publication', () => { + it('publishes with the mode the canonical inode carries at publication time', () => { const manager = new SharedManager(); const instancePath = instanceDir('concurrent-mode'); const canonicalPath = path.join(claudeDir(), 'settings.json'); @@ -706,23 +842,24 @@ describe('SharedManager', () => { writeJson(divergedPath, { generation: 1 }); setMtime(divergedPath, fs.statSync(canonicalPath).mtimeMs + 2_000); - const originalRenameSync = fs.renameSync; - const renameSpy = spyOn(fs, 'renameSync').mockImplementation((( - oldPath: fs.PathLike, - newPath: fs.PathLike + // A chmod between reading the canonical file and publishing it leaves + // the content untouched, so publication proceeds with the newer mode. + const originalOpenSync = fs.openSync; + const openSpy = spyOn(fs, 'openSync').mockImplementation((( + openPath: fs.PathLike, + flags: number | string, + mode?: fs.Mode ) => { - if ( - String(oldPath) === canonicalPath && - String(newPath).includes('.ccs-canonical-claim-') - ) { + if (String(openPath).startsWith(`${canonicalPath}.ccs-write-`)) { fs.chmodSync(canonicalPath, 0o664); } - return originalRenameSync(oldPath, newPath); - }) as typeof fs.renameSync); + return originalOpenSync(openPath, flags, mode); + }) as typeof fs.openSync); manager.linkSharedDirectories(instancePath); - renameSpy.mockRestore(); + openSpy.mockRestore(); expect(fs.statSync(canonicalPath).mode & 0o777).toBe(0o664); + expect(readJson(canonicalPath)).toEqual({ generation: 1 }); }); it('recovers an interrupted canonical claim before provisioning defaults', () => { diff --git a/tests/unit/shared/extended-context-utils.test.ts b/tests/unit/shared/extended-context-utils.test.ts index 3694f74c..c1ce474a 100644 --- a/tests/unit/shared/extended-context-utils.test.ts +++ b/tests/unit/shared/extended-context-utils.test.ts @@ -2,7 +2,11 @@ import { describe, expect, it } from 'bun:test'; import { ANTHROPIC_MODEL_ENV_KEYS, + EXTENDED_CONTEXT_MODEL_ENV_KEYS, + applyExtendedContextPreferenceToAnthropicModels, + hasAnthropicExtendedContextEnabled, isAnthropicModelEnvKey, + isExtendedContextModelEnvKey, } from '../../../src/shared/extended-context-utils'; describe('Anthropic model env keys', () => { @@ -22,3 +26,96 @@ describe('Anthropic model env keys', () => { expect(isAnthropicModelEnvKey('ANTHROPIC_BASE_URL')).toBe(false); }); }); + +describe('extended-context model env keys', () => { + it('covers the Anthropic tiers plus the subagent and startup-default model keys', () => { + expect([...EXTENDED_CONTEXT_MODEL_ENV_KEYS]).toEqual([ + ...ANTHROPIC_MODEL_ENV_KEYS, + 'CLAUDE_CODE_SUBAGENT_MODEL', + 'ANTHROPIC_DEFAULT_MODEL', + ]); + expect(isExtendedContextModelEnvKey('CLAUDE_CODE_SUBAGENT_MODEL')).toBe(true); + expect(isExtendedContextModelEnvKey('ANTHROPIC_DEFAULT_MODEL')).toBe(true); + expect(isAnthropicModelEnvKey('CLAUDE_CODE_SUBAGENT_MODEL')).toBe(false); + expect(isAnthropicModelEnvKey('ANTHROPIC_DEFAULT_MODEL')).toBe(false); + }); +}); + +describe('applyExtendedContextPreferenceToAnthropicModels', () => { + it('writes [1m] into every managed key, the fable tier included', () => { + // Behind a proxy base URL Claude Code clamps a bare Fable id to 200k, so the + // fable tier needs the suffix exactly like the opus/sonnet tiers do. + const env = applyExtendedContextPreferenceToAnthropicModels( + { + ANTHROPIC_MODEL: 'claude-fable-5-1', + ANTHROPIC_DEFAULT_OPUS_MODEL: 'claude-opus-5', + ANTHROPIC_DEFAULT_FABLE_MODEL: 'claude-fable-5-1', + CLAUDE_CODE_SUBAGENT_MODEL: 'claude-fable-5-1', + }, + true + ); + + expect(env).toEqual({ + ANTHROPIC_MODEL: 'claude-fable-5-1[1m]', + ANTHROPIC_DEFAULT_OPUS_MODEL: 'claude-opus-5[1m]', + ANTHROPIC_DEFAULT_FABLE_MODEL: 'claude-fable-5-1[1m]', + CLAUDE_CODE_SUBAGENT_MODEL: 'claude-fable-5-1[1m]', + }); + }); + + it('strips every managed key when the preference is off', () => { + const env = applyExtendedContextPreferenceToAnthropicModels( + { + ANTHROPIC_MODEL: 'claude-opus-5[1m]', + ANTHROPIC_DEFAULT_MODEL: 'claude-opus-5[1m]', + ANTHROPIC_DEFAULT_FABLE_MODEL: 'claude-fable-5-1[1m]', + CLAUDE_CODE_SUBAGENT_MODEL: 'claude-fable-5-1[1m]', + }, + false + ); + + expect(env).toEqual({ + ANTHROPIC_MODEL: 'claude-opus-5', + ANTHROPIC_DEFAULT_MODEL: 'claude-opus-5', + ANTHROPIC_DEFAULT_FABLE_MODEL: 'claude-fable-5-1', + CLAUDE_CODE_SUBAGENT_MODEL: 'claude-fable-5-1', + }); + }); + + it('suffixes the startup-default model key when the preference is on', () => { + const env = applyExtendedContextPreferenceToAnthropicModels( + { ANTHROPIC_DEFAULT_MODEL: 'claude-opus-5' }, + true + ); + + expect(env).toEqual({ ANTHROPIC_DEFAULT_MODEL: 'claude-opus-5[1m]' }); + }); + + it('honors a caller compatibility predicate', () => { + const env = applyExtendedContextPreferenceToAnthropicModels( + { + ANTHROPIC_MODEL: 'claude-opus-5', + ANTHROPIC_DEFAULT_HAIKU_MODEL: 'claude-haiku-4-5-20251001', + }, + true, + { supportsExtendedContext: (modelId) => !modelId.startsWith('claude-haiku-') } + ); + + expect(env).toEqual({ + ANTHROPIC_MODEL: 'claude-opus-5[1m]', + ANTHROPIC_DEFAULT_HAIKU_MODEL: 'claude-haiku-4-5-20251001', + }); + }); + + it('reads saved intent from any managed key', () => { + expect( + hasAnthropicExtendedContextEnabled({ CLAUDE_CODE_SUBAGENT_MODEL: 'claude-fable-5-1[1m]' }) + ).toBe(true); + expect( + hasAnthropicExtendedContextEnabled({ ANTHROPIC_DEFAULT_FABLE_MODEL: 'claude-fable-5-1[1m]' }) + ).toBe(true); + expect( + hasAnthropicExtendedContextEnabled({ ANTHROPIC_DEFAULT_FABLE_MODEL: 'claude-fable-5-1' }) + ).toBe(false); + }); +}); diff --git a/tests/unit/utils/hooks/image-analysis-runtime-status-circular-dependency.test.ts b/tests/unit/utils/hooks/image-analysis-runtime-status-circular-dependency.test.ts index d6060612..e4fb1a75 100644 --- a/tests/unit/utils/hooks/image-analysis-runtime-status-circular-dependency.test.ts +++ b/tests/unit/utils/hooks/image-analysis-runtime-status-circular-dependency.test.ts @@ -89,8 +89,15 @@ describe('image-analysis-runtime-status circular dependency regression', () => { '../../cliproxy/services/stats-fetcher': { isCliproxyRunning: async () => true, }, - '../../config/unified-config-types': { - DEFAULT_IMAGE_ANALYSIS_CONFIG: {}, + '../../config/unified-config-types': {}, + '../../config/config-loader-facade': { + getImageAnalysisConfig: () => ({ + enabled: true, + timeout: 60, + provider_models: { ghcp: 'claude-haiku-4.5' }, + fallback_backend: 'ghcp', + profile_backends: {}, + }), }, './image-analysis-backend-resolver': { resolveImageAnalysisStatus: () => createStatus(), diff --git a/tests/unit/utils/hooks/image-analysis-runtime-status-config-default.test.ts b/tests/unit/utils/hooks/image-analysis-runtime-status-config-default.test.ts new file mode 100644 index 00000000..e3909c8f --- /dev/null +++ b/tests/unit/utils/hooks/image-analysis-runtime-status-config-default.test.ts @@ -0,0 +1,114 @@ +import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from 'fs'; +import { tmpdir } from 'os'; +import { join } from 'path'; +import { afterEach, beforeEach, describe, expect, it } from 'bun:test'; + +/** + * Regression: resolveImageAnalysisRuntimeStatus used to default to the built-in + * DEFAULT_IMAGE_ANALYSIS_CONFIG constant, which carries empty profile_backends + * and a gemini fallback_backend. Launch paths call it without an explicit + * config, so user-configured profile_backends were dropped and every settings + * profile resolved to gemini, then bailed to native Read on missing Gemini auth. + */ +describe('resolveImageAnalysisRuntimeStatus config default', () => { + let tmpHome = ''; + let previousCcsHome: string | undefined; + + beforeEach(() => { + tmpHome = mkdtempSync(join(tmpdir(), 'ccs-image-analysis-config-default-')); + const ccsDir = join(tmpHome, '.ccs'); + mkdirSync(ccsDir, { recursive: true }); + + const settingsPath = join(ccsDir, 'deepseek.settings.json'); + writeFileSync( + settingsPath, + JSON.stringify( + { + env: { + ANTHROPIC_BASE_URL: 'https://api.deepseek.com/anthropic', + ANTHROPIC_MODEL: 'deepseek-v4-pro', + }, + }, + null, + 2 + ) + '\n' + ); + + writeFileSync( + join(ccsDir, 'config.yaml'), + [ + 'version: 14', + 'profiles:', + ' deepseek:', + ' type: api', + ` settings: ${settingsPath}`, + 'image_analysis:', + ' enabled: true', + ' timeout: 60', + ' provider_models:', + ' claude: claude-haiku-4-5-20251001', + ' fallback_backend: claude', + ' profile_backends:', + ' deepseek: claude', + '', + ].join('\n') + ); + + previousCcsHome = process.env.CCS_HOME; + process.env.CCS_HOME = tmpHome; + }); + + afterEach(() => { + if (previousCcsHome === undefined) { + delete process.env.CCS_HOME; + } else { + process.env.CCS_HOME = previousCcsHome; + } + + if (tmpHome) { + rmSync(tmpHome, { recursive: true, force: true }); + } + }); + + it('honors user profile_backends when no explicit config is passed', async () => { + const { resolveImageAnalysisRuntimeStatus } = await import( + '../../../../src/utils/hooks/image-analysis-runtime-status' + ); + + const status = await resolveImageAnalysisRuntimeStatus( + { + profileName: 'deepseek', + profileType: 'settings', + }, + undefined, + { + checkRemoteProxy: async () => ({ reachable: true }), + fetchRemoteAuthStatus: async () => [{ provider: 'claude', authenticated: true }], + getProxyTarget: () => ({ + host: '100.64.0.1', + port: 8317, + protocol: 'http', + isRemote: true, + }), + initializeAccounts: () => {}, + getAuthStatus: () => ({ + provider: 'claude', + authenticated: true, + tokenDir: join(tmpHome, 'auth'), + tokenFiles: [], + accounts: [], + defaultAccount: undefined, + }), + isCliproxyRunning: async () => true, + } + ); + + expect(status.backendId).toBe('claude'); + expect(status.resolutionSource).toBe('profile-backend'); + expect(status.model).toBe('claude-haiku-4-5-20251001'); + // Claude-compatible traffic routes at the CLIProxy root; the scoped + // /api/provider/claude path is not part of the canonical route helper. + expect(status.runtimePath).toBeNull(); + expect(status.effectiveRuntimeMode).toBe('cliproxy-image-analysis'); + }); +}); diff --git a/tests/unit/utils/hooks/image-analysis-runtime-status-original-backend-route.test.ts b/tests/unit/utils/hooks/image-analysis-runtime-status-original-backend-route.test.ts new file mode 100644 index 00000000..4e9a63bc --- /dev/null +++ b/tests/unit/utils/hooks/image-analysis-runtime-status-original-backend-route.test.ts @@ -0,0 +1,111 @@ +import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from 'fs'; +import { tmpdir } from 'os'; +import { join } from 'path'; +import { afterEach, beforeEach, describe, expect, it } from 'bun:test'; + +/** + * Regression (#1703): the original CLIProxy backend only serves the root + * /v1/messages route. Image analysis used to build + * /api/provider//v1/messages unconditionally, which 404s on original. + * The default backend is original, so the default-config path must resolve + * a null runtimePath (root route) for CLIProxy providers. + */ +describe('resolveImageAnalysisRuntimeStatus original backend route', () => { + let tmpHome = ''; + let previousCcsHome: string | undefined; + + beforeEach(() => { + tmpHome = mkdtempSync(join(tmpdir(), 'ccs-image-analysis-original-route-')); + const ccsDir = join(tmpHome, '.ccs'); + mkdirSync(ccsDir, { recursive: true }); + + const settingsPath = join(ccsDir, 'deepseek.settings.json'); + writeFileSync( + settingsPath, + JSON.stringify( + { + env: { + ANTHROPIC_BASE_URL: 'https://api.deepseek.com/anthropic', + ANTHROPIC_MODEL: 'deepseek-v4-pro', + }, + }, + null, + 2 + ) + '\n' + ); + + writeFileSync( + join(ccsDir, 'config.yaml'), + [ + 'version: 14', + 'profiles:', + ' deepseek:', + ' type: api', + ` settings: ${settingsPath}`, + 'image_analysis:', + ' enabled: true', + ' timeout: 60', + ' provider_models:', + ' gemini: gemini-2.5-flash', + ' fallback_backend: gemini', + ' profile_backends:', + ' deepseek: gemini', + '', + ].join('\n') + ); + + previousCcsHome = process.env.CCS_HOME; + process.env.CCS_HOME = tmpHome; + }); + + afterEach(() => { + if (previousCcsHome === undefined) { + delete process.env.CCS_HOME; + } else { + process.env.CCS_HOME = previousCcsHome; + } + + if (tmpHome) { + rmSync(tmpHome, { recursive: true, force: true }); + } + }); + + it('resolves the root runtime path for the original backend', async () => { + const { resolveImageAnalysisRuntimeStatus } = await import( + '../../../../src/utils/hooks/image-analysis-runtime-status' + ); + + const status = await resolveImageAnalysisRuntimeStatus( + { + profileName: 'deepseek', + profileType: 'settings', + }, + undefined, + { + checkRemoteProxy: async () => ({ reachable: true }), + fetchRemoteAuthStatus: async () => [{ provider: 'gemini', authenticated: true }], + getProxyTarget: () => ({ + host: '100.64.0.1', + port: 8317, + protocol: 'http', + isRemote: true, + }), + initializeAccounts: () => {}, + getAuthStatus: () => ({ + provider: 'gemini', + authenticated: true, + tokenDir: join(tmpHome, 'auth'), + tokenFiles: [], + accounts: [], + defaultAccount: undefined, + }), + isCliproxyRunning: async () => true, + } + ); + + expect(status.backendId).toBe('gemini'); + expect(status.resolutionSource).toBe('profile-backend'); + // Root route: the hook appends /v1/messages directly to the proxy base. + expect(status.runtimePath).toBeNull(); + }); +}); diff --git a/tests/unit/utils/hooks/image-analysis-runtime-status-plus-backend-route.test.ts b/tests/unit/utils/hooks/image-analysis-runtime-status-plus-backend-route.test.ts new file mode 100644 index 00000000..6d23d83b --- /dev/null +++ b/tests/unit/utils/hooks/image-analysis-runtime-status-plus-backend-route.test.ts @@ -0,0 +1,110 @@ +import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from 'fs'; +import { tmpdir } from 'os'; +import { join } from 'path'; +import { afterEach, beforeEach, describe, expect, it } from 'bun:test'; + +/** + * Counterpart to the original-backend route regression (#1703): with + * cliproxy.backend: plus, non-Claude providers keep their scoped + * /api/provider/ routes for image analysis. + */ +describe('resolveImageAnalysisRuntimeStatus plus backend route', () => { + let tmpHome = ''; + let previousCcsHome: string | undefined; + + beforeEach(() => { + tmpHome = mkdtempSync(join(tmpdir(), 'ccs-image-analysis-plus-route-')); + const ccsDir = join(tmpHome, '.ccs'); + mkdirSync(ccsDir, { recursive: true }); + + const settingsPath = join(ccsDir, 'deepseek.settings.json'); + writeFileSync( + settingsPath, + JSON.stringify( + { + env: { + ANTHROPIC_BASE_URL: 'https://api.deepseek.com/anthropic', + ANTHROPIC_MODEL: 'deepseek-v4-pro', + }, + }, + null, + 2 + ) + '\n' + ); + + writeFileSync( + join(ccsDir, 'config.yaml'), + [ + 'version: 14', + 'profiles:', + ' deepseek:', + ' type: api', + ` settings: ${settingsPath}`, + 'cliproxy:', + ' backend: plus', + 'image_analysis:', + ' enabled: true', + ' timeout: 60', + ' provider_models:', + ' gemini: gemini-2.5-flash', + ' fallback_backend: gemini', + ' profile_backends:', + ' deepseek: gemini', + '', + ].join('\n') + ); + + previousCcsHome = process.env.CCS_HOME; + process.env.CCS_HOME = tmpHome; + }); + + afterEach(() => { + if (previousCcsHome === undefined) { + delete process.env.CCS_HOME; + } else { + process.env.CCS_HOME = previousCcsHome; + } + + if (tmpHome) { + rmSync(tmpHome, { recursive: true, force: true }); + } + }); + + it('keeps the scoped provider route for the plus backend', async () => { + const { resolveImageAnalysisRuntimeStatus } = await import( + '../../../../src/utils/hooks/image-analysis-runtime-status' + ); + + const status = await resolveImageAnalysisRuntimeStatus( + { + profileName: 'deepseek', + profileType: 'settings', + }, + undefined, + { + checkRemoteProxy: async () => ({ reachable: true }), + fetchRemoteAuthStatus: async () => [{ provider: 'gemini', authenticated: true }], + getProxyTarget: () => ({ + host: '100.64.0.1', + port: 8317, + protocol: 'http', + isRemote: true, + }), + initializeAccounts: () => {}, + getAuthStatus: () => ({ + provider: 'gemini', + authenticated: true, + tokenDir: join(tmpHome, 'auth'), + tokenFiles: [], + accounts: [], + defaultAccount: undefined, + }), + isCliproxyRunning: async () => true, + } + ); + + expect(status.backendId).toBe('gemini'); + expect(status.resolutionSource).toBe('profile-backend'); + expect(status.runtimePath).toBe('/api/provider/gemini'); + }); +}); diff --git a/tests/unit/utils/package-manager-detector.test.ts b/tests/unit/utils/package-manager-detector.test.ts index 5bb1164a..d297c015 100644 --- a/tests/unit/utils/package-manager-detector.test.ts +++ b/tests/unit/utils/package-manager-detector.test.ts @@ -186,6 +186,45 @@ describe('package-manager-detector', () => { expect(install.prefix).toBe(join(tempRoot, 'custom-pnpm-root')); }); + it('detects pnpm v9+ global store layouts with version and hash segments', () => { + const tempRoot = makeTempDir('ccs-install-detector-pnpm-global-v11-'); + const packageRoot = join( + tempRoot, + 'Library', + 'pnpm', + 'global', + 'v11', + '139e7-1a00df73ffb-064d180929716eff', + 'node_modules', + '@kaitranntt', + 'ccs' + ); + + writePackage(packageRoot, '7.67.0-dev.9'); + + const install = detectCurrentInstall(join(packageRoot, 'dist', 'ccs.js')); + + expect(install.manager).toBe('pnpm'); + expect(install.prefix).toBe(join(tempRoot, 'Library', 'pnpm')); + }); + + it('detects Windows pnpm v10 global store layouts with nested segments', () => { + const install = detectCurrentInstall( + 'C:/Users/dev/AppData/Local/pnpm/global/v10/b3b1c2de-9f88-4a7e/node_modules/@kaitranntt/ccs/dist/ccs.js' + ); + + expect(install.manager).toBe('pnpm'); + expect(install.prefix).toBe('C:/Users/dev/AppData/Local/pnpm'); + }); + + it('still excludes yarn global/lib/node_modules from pnpm detection', () => { + const install = detectCurrentInstall( + '/Users/dev/.yarn/global/lib/node_modules/@kaitranntt/ccs/dist/ccs.js' + ); + + expect(install.manager).not.toBe('pnpm'); + }); + it('detects Windows npm globals without a lib directory', () => { const install = detectCurrentInstall( 'C:/Program Files/node-prefix/node_modules/@kaitranntt/ccs/dist/ccs.js' diff --git a/tests/unit/utils/websearch/status.test.ts b/tests/unit/utils/websearch/status.test.ts index f625c2a2..2651ebec 100644 --- a/tests/unit/utils/websearch/status.test.ts +++ b/tests/unit/utils/websearch/status.test.ts @@ -301,6 +301,113 @@ describe('websearch readiness', () => { } }); + it('does not invoke legacy CLI status probes when those providers are disabled', () => { + const getConfigSpy = spyOn(unifiedConfigLoader, 'getWebSearchConfig').mockReturnValue({ + enabled: true, + providers: { + exa: { enabled: false, max_results: 5 }, + tavily: { enabled: false, max_results: 5 }, + brave: { enabled: false, max_results: 5 }, + searxng: { enabled: false, url: '', max_results: 5 }, + duckduckgo: { enabled: true, max_results: 5 }, + agy: { enabled: false }, + gemini: { enabled: false }, + grok: { enabled: false }, + opencode: { enabled: false }, + }, + } as any); + const apiKeySpy = spyOn(providerSecrets, 'getWebSearchApiKeyStates').mockReturnValue({ + exa: { envVar: 'EXA_API_KEY', configured: false, available: false, source: 'none' }, + tavily: { envVar: 'TAVILY_API_KEY', configured: false, available: false, source: 'none' }, + brave: { envVar: 'BRAVE_API_KEY', configured: false, available: false, source: 'none' }, + }); + const agyStatusSpy = spyOn(agyCli, 'getAgyCliStatus').mockReturnValue({ + installed: false, + } as any); + const geminiStatusSpy = spyOn(geminiCli, 'getGeminiCliStatus').mockReturnValue({ + installed: false, + } as any); + const geminiAuthSpy = spyOn(geminiCli, 'isGeminiAuthenticated').mockReturnValue(false); + const grokStatusSpy = spyOn(grokCli, 'getGrokCliStatus').mockReturnValue({ + installed: false, + } as any); + const opencodeStatusSpy = spyOn(opencodeCli, 'getOpenCodeCliStatus').mockReturnValue({ + installed: false, + } as any); + + try { + getWebSearchCliProviders(undefined, { probeDisabled: false }); + expect(agyStatusSpy).not.toHaveBeenCalled(); + expect(geminiStatusSpy).not.toHaveBeenCalled(); + expect(geminiAuthSpy).not.toHaveBeenCalled(); + expect(grokStatusSpy).not.toHaveBeenCalled(); + expect(opencodeStatusSpy).not.toHaveBeenCalled(); + } finally { + getConfigSpy.mockRestore(); + apiKeySpy.mockRestore(); + agyStatusSpy.mockRestore(); + geminiStatusSpy.mockRestore(); + geminiAuthSpy.mockRestore(); + grokStatusSpy.mockRestore(); + opencodeStatusSpy.mockRestore(); + } + }); + it('does not spawn --version when includeVersions is false', () => { + const wsConfig = { + enabled: true, + providers: { + exa: { enabled: false, max_results: 5 }, + tavily: { enabled: false, max_results: 5 }, + brave: { enabled: false, max_results: 5 }, + searxng: { enabled: false, url: '', max_results: 5 }, + duckduckgo: { enabled: false, max_results: 5 }, + agy: { enabled: true, model: 'gemini-2.5-flash', timeout: 90 }, + gemini: { enabled: true }, + grok: { enabled: true }, + opencode: { enabled: true }, + }, + }; + const apiKeySpy = spyOn(providerSecrets, 'getWebSearchApiKeyStates').mockReturnValue({ + exa: { envVar: 'EXA_API_KEY', configured: false, available: false, source: 'none' }, + tavily: { envVar: 'TAVILY_API_KEY', configured: false, available: false, source: 'none' }, + brave: { envVar: 'BRAVE_API_KEY', configured: false, available: false, source: 'none' }, + }); + const agyStatusSpy = spyOn(agyCli, 'getAgyCliStatus').mockReturnValue({ + installed: true, + version: undefined, + }); + const geminiStatusSpy = spyOn(geminiCli, 'getGeminiCliStatus').mockReturnValue({ + installed: false, + version: undefined, + }); + const geminiAuthSpy = spyOn(geminiCli, 'isGeminiAuthenticated').mockReturnValue(false); + const grokStatusSpy = spyOn(grokCli, 'getGrokCliStatus').mockReturnValue({ + installed: false, + version: undefined, + }); + const opencodeStatusSpy = spyOn(opencodeCli, 'getOpenCodeCliStatus').mockReturnValue({ + installed: false, + version: undefined, + }); + + try { + getWebSearchCliProviders(wsConfig as Parameters[0], { + includeVersions: false, + }); + expect(agyStatusSpy).toHaveBeenCalledWith({ fetchVersion: true }); + expect(geminiStatusSpy).toHaveBeenCalledWith({ fetchVersion: false }); + expect(grokStatusSpy).toHaveBeenCalledWith({ fetchVersion: false }); + expect(opencodeStatusSpy).toHaveBeenCalledWith({ fetchVersion: false }); + } finally { + apiKeySpy.mockRestore(); + agyStatusSpy.mockRestore(); + geminiStatusSpy.mockRestore(); + geminiAuthSpy.mockRestore(); + grokStatusSpy.mockRestore(); + opencodeStatusSpy.mockRestore(); + } + }); + it('treats cooled-down providers as temporarily unavailable in readiness status', () => { const tempHome = mkdtempSync(join(tmpdir(), 'websearch-status-cooldown-')); const statePath = join(tempHome, '.ccs', 'cache', 'websearch-provider-state.json'); diff --git a/tests/unit/web-server/cliproxy-dashboard-install-service.test.ts b/tests/unit/web-server/cliproxy-dashboard-install-service.test.ts index 5805ed54..c634d6f1 100644 --- a/tests/unit/web-server/cliproxy-dashboard-install-service.test.ts +++ b/tests/unit/web-server/cliproxy-dashboard-install-service.test.ts @@ -10,6 +10,7 @@ function createDeps( sessionRunning?: boolean; remoteRunning?: boolean; startResult?: { started: boolean; alreadyRunning: boolean; port: number; error?: string }; + installError?: Error; } = {} ) { const calls = { @@ -30,6 +31,7 @@ function createDeps( _backend?: CLIProxyBackend ) => { calls.installCliproxyVersion += 1; + if (overrides.installError) throw overrides.installError; }, ensureCliproxyService: async () => { calls.ensureCliproxyService += 1; @@ -41,6 +43,10 @@ function createDeps( } ); }, + withInstallLifecycleLock: async ( + _backend: CLIProxyBackend, + operation: () => Promise + ) => operation(), }; return { deps, calls }; @@ -122,4 +128,67 @@ describe('installDashboardCliproxyVersion', () => { message: 'Installed CLIProxy Plus v6.7.1, but failed to restart it', }); }); + + it('restores a previously running proxy when installation fails', async () => { + const { deps, calls } = createDeps({ + sessionRunning: true, + installError: new Error('checksum mismatch'), + }); + + await expect(installDashboardCliproxyVersion('6.7.1', 'plus', deps)).rejects.toThrow( + 'checksum mismatch' + ); + expect(calls.ensureCliproxyService).toBe(1); + }); + + it('serializes concurrent dashboard stop-install-restore transactions', async () => { + let running = true; + let queue = Promise.resolve(); + let activeTransactions = 0; + let maxActiveTransactions = 0; + let restores = 0; + + const deps = { + getProxyStatus: () => ({ running }), + isCliproxyRunning: async () => running, + installCliproxyVersion: async () => { + running = false; + await new Promise((resolve) => setTimeout(resolve, 5)); + }, + ensureCliproxyService: async () => { + running = true; + restores += 1; + return { started: true, alreadyRunning: false, port: 8317 }; + }, + withInstallLifecycleLock: ( + _backend: CLIProxyBackend, + operation: () => Promise + ): Promise => { + const result = queue.then(async () => { + activeTransactions += 1; + maxActiveTransactions = Math.max(maxActiveTransactions, activeTransactions); + try { + return await operation(); + } finally { + activeTransactions -= 1; + } + }); + queue = result.then( + () => undefined, + () => undefined + ); + return result; + }, + }; + + const results = await Promise.all([ + installDashboardCliproxyVersion('6.7.1', 'plus', deps), + installDashboardCliproxyVersion('6.7.2', 'plus', deps), + ]); + + expect(maxActiveTransactions).toBe(1); + expect(restores).toBe(2); + expect(results.every((result) => result.restarted)).toBe(true); + expect(running).toBe(true); + }); }); diff --git a/ui/public/icons/orcarouter.svg b/ui/public/icons/orcarouter.svg new file mode 100644 index 00000000..09c5ee0e --- /dev/null +++ b/ui/public/icons/orcarouter.svg @@ -0,0 +1 @@ +OrcaRouter \ No newline at end of file diff --git a/ui/src/components/cliproxy/provider-editor/use-provider-editor.ts b/ui/src/components/cliproxy/provider-editor/use-provider-editor.ts index 35ac28b1..6bec47a4 100644 --- a/ui/src/components/cliproxy/provider-editor/use-provider-editor.ts +++ b/ui/src/components/cliproxy/provider-editor/use-provider-editor.ts @@ -12,7 +12,7 @@ import type { ProviderCatalog } from '../provider-model-selector'; import { applyExtendedContextPreferenceToAnthropicModels, hasAnthropicExtendedContextEnabled, - isAnthropicModelEnvKey, + isExtendedContextModelEnvKey, } from '@/lib/extended-context-utils'; import { supportsExtendedContext } from '@/lib/model-catalogs'; import { isValidProvider } from '@/lib/provider-config'; @@ -104,7 +104,7 @@ export function useProviderEditor( const updateEnvValue = useCallback( (key: string, value: string) => { const newEnv = { ...(currentSettings?.env || {}), [key]: value }; - const envWithIntent = isAnthropicModelEnvKey(key) + const envWithIntent = isExtendedContextModelEnvKey(key) ? applySavedLongContextIntent(newEnv, extendedContextEnabled) : newEnv; delete envWithIntent['CCS_EXTENDED_CONTEXT']; @@ -132,7 +132,7 @@ export function useProviderEditor( const updateEnvValues = useCallback( (updates: Record) => { const newEnv = { ...(currentSettings?.env || {}), ...updates }; - const touchesAnthropicModel = Object.keys(updates).some(isAnthropicModelEnvKey); + const touchesAnthropicModel = Object.keys(updates).some(isExtendedContextModelEnvKey); const envWithIntent = touchesAnthropicModel ? applySavedLongContextIntent(newEnv, extendedContextEnabled) : newEnv; diff --git a/ui/src/components/monitoring/proxy-status-widget.tsx b/ui/src/components/monitoring/proxy-status-widget.tsx index cfa026df..c61ca5ee 100644 --- a/ui/src/components/monitoring/proxy-status-widget.tsx +++ b/ui/src/components/monitoring/proxy-status-widget.tsx @@ -27,6 +27,8 @@ import { FileDown, Check, AlertCircle, + ChevronDown, + ChevronUp, } from 'lucide-react'; import { Button } from '@/components/ui/button'; import { Badge } from '@/components/ui/badge'; @@ -75,6 +77,26 @@ import { RoutingGuidanceCard } from '@/components/cliproxy/routing-guidance-card type PendingInstallRisk = 'faulty' | 'experimental'; +const WIDGET_COLLAPSED_STORAGE_KEY = 'cliproxy-status-widget-collapsed'; + +function readCollapsedPreference(): boolean { + if (typeof window === 'undefined') return false; + try { + return window.localStorage.getItem(WIDGET_COLLAPSED_STORAGE_KEY) === 'true'; + } catch { + return false; + } +} + +function persistCollapsedPreference(collapsed: boolean): void { + if (typeof window === 'undefined') return; + try { + window.localStorage.setItem(WIDGET_COLLAPSED_STORAGE_KEY, String(collapsed)); + } catch { + // Ignore storage errors (private mode, quota, etc.) + } +} + function formatUptime(startedAt?: string): string { if (!startedAt) return ''; const start = new Date(startedAt).getTime(); @@ -135,6 +157,7 @@ function IconButton({ )} onClick={onClick} disabled={disabled} + aria-label={tooltip} > {isPending ? ( @@ -182,6 +205,31 @@ export function ProxyStatusWidget() { const [isExpanded, setIsExpanded] = useState(false); const [selectedVersion, setSelectedVersion] = useState(''); + // Whole-widget collapse so the sidebar provider list keeps the vertical space + const [isCollapsed, setIsCollapsed] = useState(readCollapsedPreference); + + const handleToggleCollapsed = () => { + const next = !isCollapsed; + setIsCollapsed(next); + // Collapsing also closes version management so it cannot stay open but hidden + if (next) setIsExpanded(false); + persistCollapsedPreference(next); + }; + + // Opening version settings from a collapsed widget must reveal the section + const handleToggleVersionSettings = () => { + const next = !isExpanded; + setIsExpanded(next); + if (next && isCollapsed) { + setIsCollapsed(false); + persistCollapsedPreference(false); + } + }; + + const collapseTooltip = isCollapsed + ? t('proxyStatusWidget.tooltipExpandWidget') + : t('proxyStatusWidget.tooltipCollapseWidget'); + // Confirmation dialog state for risky versions const [showUnstableConfirm, setShowUnstableConfirm] = useState(false); const [pendingInstallVersion, setPendingInstallVersion] = useState(null); @@ -325,30 +373,50 @@ export function ProxyStatusWidget() { {t('proxyStatusWidget.active')} - - - -
-
- {remoteDisplayHost} +
+ +
-

- {t('proxyStatusWidget.trafficAutoRouted')} -

- updateRouting.mutate(strategy)} - onApplyAffinity={(data) => updateSessionAffinity.mutate(data)} - /> + {!isCollapsed && ( + <> +
+
+ {remoteDisplayHost} +
+

+ {t('proxyStatusWidget.trafficAutoRouted')} +

+
+ + updateRouting.mutate(strategy)} + onApplyAffinity={(data) => updateSessionAffinity.mutate(data)} + /> + + )}
); } @@ -413,9 +481,14 @@ export function ProxyStatusWidget() { ? t('proxyStatusWidget.tooltipClose') : t('proxyStatusWidget.tooltipVersionSettings') } - onClick={() => setIsExpanded(!isExpanded)} + onClick={handleToggleVersionSettings} className={isExpanded ? 'bg-muted' : undefined} /> + @@ -458,7 +531,7 @@ export function ProxyStatusWidget() { )} {/* Stats row when running */} - {isRunning && status && ( + {!isCollapsed && isRunning && status && (
{t('proxyStatusWidget.port', { port: status.port })} @@ -478,35 +551,39 @@ export function ProxyStatusWidget() {
)} - {/* Sync status row */} -
- {isSyncConfigured ? ( - - ) : ( - - )} - - {syncStatusText} - -
+ {!isCollapsed && ( + <> + {/* Sync status row */} +
+ {isSyncConfigured ? ( + + ) : ( + + )} + + {syncStatusText} + +
- updateRouting.mutate(strategy)} - onApplyAffinity={(data) => updateSessionAffinity.mutate(data)} - /> + updateRouting.mutate(strategy)} + onApplyAffinity={(data) => updateSessionAffinity.mutate(data)} + /> + + )} {/* Expanded section: Version Management (available even when not running) */} diff --git a/ui/src/lib/extended-context-utils.ts b/ui/src/lib/extended-context-utils.ts index 2ce76d69..1dbb35b8 100644 --- a/ui/src/lib/extended-context-utils.ts +++ b/ui/src/lib/extended-context-utils.ts @@ -4,10 +4,13 @@ export { ANTHROPIC_MODEL_ENV_KEYS, + EXTENDED_CONTEXT_MODEL_ENV_KEYS, EXTENDED_CONTEXT_SUFFIX, + EXTRA_EXTENDED_CONTEXT_MODEL_ENV_KEYS, applyExtendedContextPreferenceToAnthropicModels, isNativeGeminiModel, isAnthropicModelEnvKey, + isExtendedContextModelEnvKey, hasAnthropicExtendedContextEnabled, hasExtendedContextSuffix, applyExtendedContextSuffix, diff --git a/ui/src/lib/i18n.ts b/ui/src/lib/i18n.ts index 6ac2da8a..d8bdc792 100644 --- a/ui/src/lib/i18n.ts +++ b/ui/src/lib/i18n.ts @@ -428,6 +428,8 @@ const resources = { tooltipStop: 'Stop', tooltipClose: 'Close', tooltipVersionSettings: 'Version settings', + tooltipCollapseWidget: 'Collapse', + tooltipExpandWidget: 'Expand', versionManagement: 'Version Management', selectVersionPlaceholder: 'Select version to install...', stable: '(stable)', @@ -3180,6 +3182,8 @@ const resources = { tooltipStop: 'Parar', tooltipClose: 'Fechar', tooltipVersionSettings: 'Configurações de versão', + tooltipCollapseWidget: 'Recolher', + tooltipExpandWidget: 'Expandir', versionManagement: 'Gerenciamento de Versão', selectVersionPlaceholder: 'Selecione a versão a instalar...', stable: '(estável)', @@ -5962,6 +5966,8 @@ const resources = { tooltipStop: '停止', tooltipClose: '关闭', tooltipVersionSettings: '版本设置', + tooltipCollapseWidget: '收起', + tooltipExpandWidget: '展开', versionManagement: '版本管理', selectVersionPlaceholder: '选择要安装的版本...', stable: '(稳定版)', @@ -8595,6 +8601,8 @@ const resources = { tooltipStop: 'Dừng lại', tooltipClose: 'Đóng', tooltipVersionSettings: 'Cài đặt phiên bản', + tooltipCollapseWidget: 'Thu gọn', + tooltipExpandWidget: 'Mở rộng', versionManagement: 'Quản lý phiên bản', selectVersionPlaceholder: 'Chọn phiên bản để cài đặt...', stable: '(ổn định)', @@ -11329,6 +11337,8 @@ const resources = { tooltipStop: '停止', tooltipClose: '閉じる', tooltipVersionSettings: 'バージョン設定', + tooltipCollapseWidget: '折りたたむ', + tooltipExpandWidget: '展開', versionManagement: 'バージョン管理', selectVersionPlaceholder: 'インストールするバージョンを選択...', stable: '(安定版)', @@ -14087,6 +14097,8 @@ const resources = { tooltipStop: '중지', tooltipClose: '닫기', tooltipVersionSettings: '버전 설정', + tooltipCollapseWidget: '접기', + tooltipExpandWidget: '펼치기', versionManagement: '버전 관리', selectVersionPlaceholder: '설치할 버전 선택...', stable: '(안정)', diff --git a/ui/src/lib/model-catalogs.ts b/ui/src/lib/model-catalogs.ts index b209a653..265e49e9 100644 --- a/ui/src/lib/model-catalogs.ts +++ b/ui/src/lib/model-catalogs.ts @@ -398,6 +398,13 @@ export const MODEL_CATALOGS: Record = { haiku: 'grok-composer-2.5-fast', }, }, + { + id: 'grok-4.6', + name: 'Grok 4.6', + description: + "SpaceXAI's smartest model built for long-running agents, interactive and visual work.", + reasoningLevels: ['low', 'medium', 'high', 'xhigh'], + }, { id: 'grok-4.5', name: 'Grok 4.5', @@ -970,10 +977,22 @@ export const MODEL_CATALOGS: Record = { haiku: 'claude-haiku-4-5-20251001', }, }, + { + id: 'claude-fable-5-1', + name: 'Claude Fable 5.1', + description: 'Most powerful model', + extendedContext: true, + presetMapping: { + default: 'claude-fable-5-1', + opus: 'claude-fable-5-1', + sonnet: 'claude-sonnet-5', + haiku: 'claude-haiku-4-5-20251001', + }, + }, { id: 'claude-fable-5', name: 'Claude Fable 5', - description: 'Most powerful model', + description: 'Previous most powerful model', extendedContext: true, presetMapping: { default: 'claude-fable-5', diff --git a/ui/tests/unit/components/monitoring/proxy-status-widget.test.tsx b/ui/tests/unit/components/monitoring/proxy-status-widget.test.tsx new file mode 100644 index 00000000..55893a05 --- /dev/null +++ b/ui/tests/unit/components/monitoring/proxy-status-widget.test.tsx @@ -0,0 +1,97 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { fireEvent, render, screen } from '../../../setup/test-utils'; + +const COLLAPSED_STORAGE_KEY = 'cliproxy-status-widget-collapsed'; + +const mocks = vi.hoisted(() => ({ + noopMutation: () => ({ mutate: vi.fn(), isPending: false }), +})); + +vi.mock('@/hooks/use-cliproxy', () => ({ + useProxyStatus: () => ({ + data: { running: true, port: 8317, sessionCount: 1 }, + isLoading: false, + }), + useCliproxyUpdateCheck: () => ({ data: { backendLabel: 'CLIProxy', currentVersion: '7.2.137' } }), + useCliproxyVersions: () => ({ data: undefined, isLoading: false }), + useCliproxyRoutingStrategy: () => ({ data: undefined, isLoading: false, error: null }), + useCliproxySessionAffinity: () => ({ data: undefined, isLoading: false, error: null }), + useUpdateCliproxyRoutingStrategy: mocks.noopMutation, + useUpdateCliproxySessionAffinity: mocks.noopMutation, + useStartProxy: mocks.noopMutation, + useStopProxy: mocks.noopMutation, + useRestartProxy: mocks.noopMutation, + useInstallVersion: mocks.noopMutation, +})); + +vi.mock('@/hooks/use-cliproxy-sync', () => ({ + useSyncStatus: () => ({ data: { configured: true } }), + useExecuteSync: () => ({ mutate: vi.fn(), isPending: false }), +})); + +vi.mock('@/lib/api-client', () => ({ + api: { + cliproxyServer: { + get: () => Promise.resolve({ remote: { enabled: false, host: '' } }), + }, + }, +})); + +vi.mock('@/components/cliproxy/routing-guidance-card', () => ({ + RoutingGuidanceCard: () =>
, +})); + +import { ProxyStatusWidget } from '@/components/monitoring/proxy-status-widget'; + +describe('ProxyStatusWidget collapse', () => { + beforeEach(() => { + vi.mocked(window.localStorage.getItem).mockReturnValue(null); + vi.mocked(window.localStorage.setItem).mockClear(); + }); + + it('shows the routing and status detail by default', () => { + render(); + + expect(screen.getByTestId('routing-guidance-card')).toBeInTheDocument(); + expect(screen.getByText('v7.2.137')).toBeInTheDocument(); + }); + + it('hides the detail and persists the preference when collapsed', () => { + render(); + + fireEvent.click(screen.getByRole('button', { name: 'Collapse' })); + + expect(screen.queryByTestId('routing-guidance-card')).toBeNull(); + expect(window.localStorage.setItem).toHaveBeenCalledWith(COLLAPSED_STORAGE_KEY, 'true'); + }); + + it('keeps the status row and version visible while collapsed', () => { + render(); + + fireEvent.click(screen.getByRole('button', { name: 'Collapse' })); + + expect(screen.getByText('CLIProxy')).toBeInTheDocument(); + expect(screen.getByText('v7.2.137')).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Expand' })).toBeInTheDocument(); + }); + + it('starts collapsed when the stored preference says so', () => { + vi.mocked(window.localStorage.getItem).mockReturnValue('true'); + + render(); + + expect(screen.queryByTestId('routing-guidance-card')).toBeNull(); + expect(screen.getByRole('button', { name: 'Expand' })).toBeInTheDocument(); + }); + + it('reveals the detail again when version settings are opened while collapsed', () => { + vi.mocked(window.localStorage.getItem).mockReturnValue('true'); + + render(); + + fireEvent.click(screen.getByRole('button', { name: 'Version settings' })); + + expect(screen.getByTestId('routing-guidance-card')).toBeInTheDocument(); + expect(window.localStorage.setItem).toHaveBeenCalledWith(COLLAPSED_STORAGE_KEY, 'false'); + }); +}); diff --git a/ui/tests/unit/ui/lib/model-catalogs-xai.test.ts b/ui/tests/unit/ui/lib/model-catalogs-xai.test.ts index bb88a8c5..6568d504 100644 --- a/ui/tests/unit/ui/lib/model-catalogs-xai.test.ts +++ b/ui/tests/unit/ui/lib/model-catalogs-xai.test.ts @@ -11,6 +11,7 @@ describe('xAI model catalog defaults', () => { expect(catalog.defaultModel).toBe('grok-build-0.1'); expect(ids).toEqual([ 'grok-build-0.1', + 'grok-4.6', 'grok-4.5', 'grok-4.3', 'grok-4.20-0309-reasoning', @@ -20,6 +21,12 @@ describe('xAI model catalog defaults', () => { 'grok-3-mini-fast', 'grok-composer-2.5-fast', ]); + expect(catalog.models.find((model) => model.id === 'grok-4.6')?.reasoningLevels).toEqual([ + 'low', + 'medium', + 'high', + 'xhigh', + ]); expect(defaultModel?.presetMapping).toEqual({ default: 'grok-build-0.1', opus: 'grok-4.5', diff --git a/ui/tests/unit/ui/lib/preset-utils.test.ts b/ui/tests/unit/ui/lib/preset-utils.test.ts index 06615ca0..d8ce4632 100644 --- a/ui/tests/unit/ui/lib/preset-utils.test.ts +++ b/ui/tests/unit/ui/lib/preset-utils.test.ts @@ -22,6 +22,7 @@ describe('claude preset utils', () => { expect(claudeCatalog.defaultModel).toBe('claude-sonnet-5'); expect(ids).toContain('claude-sonnet-5'); + expect(ids).toContain('claude-fable-5-1'); expect(ids).toContain('claude-fable-5'); expect(ids).toContain('claude-opus-5'); expect(ids).toContain('claude-opus-4-8'); diff --git a/ui/tests/unit/ui/lib/reasoning-control.test.ts b/ui/tests/unit/ui/lib/reasoning-control.test.ts index a31f815f..a301070b 100644 --- a/ui/tests/unit/ui/lib/reasoning-control.test.ts +++ b/ui/tests/unit/ui/lib/reasoning-control.test.ts @@ -13,6 +13,11 @@ const xaiCatalog: ProviderCatalog = { defaultModel: 'grok-build-0.1', models: [ { id: 'grok-build-0.1', name: 'Grok Build 0.1' }, + { + id: 'grok-4.6', + name: 'Grok 4.6', + reasoningLevels: ['low', 'medium', 'high', 'xhigh'], + }, { id: 'grok-4.5', name: 'Grok 4.5', reasoningLevels: ['low', 'medium', 'high'] }, { id: 'grok-4.3', name: 'Grok 4.3', reasoningLevels: ['none', 'low', 'medium', 'high'] }, ], @@ -96,6 +101,7 @@ describe('xai adapter', () => { const adapter = requireAdapter('xai'); it('returns per-model levels from catalog metadata', () => { + expect(adapter.getOptions('grok-4.6', xaiCatalog)).toEqual(['low', 'medium', 'high', 'xhigh']); expect(adapter.getOptions('grok-4.5', xaiCatalog)).toEqual(['low', 'medium', 'high']); expect(adapter.getOptions('grok-4.3', xaiCatalog)).toEqual(['none', 'low', 'medium', 'high']); }); @@ -107,6 +113,7 @@ describe('xai adapter', () => { }); it('writes and clears suffixed values through apply', () => { + expect(adapter.apply('grok-4.6', 'xhigh')).toBe('grok-4.6(xhigh)'); expect(adapter.apply('grok-4.5', 'high')).toBe('grok-4.5(high)'); expect(adapter.apply('grok-4.5(high)', undefined)).toBe('grok-4.5'); expect(adapter.apply('grok-4.3', 'none')).toBe('grok-4.3(none)');