Merge pull request #1732 from kaitranntt/dev

feat: promote dev to main
This commit is contained in:
Kai (Tam Nhu) Tran authored and GitHub committed 2026-09-11 08:06:47 -06:00
commit 6f88efb881
86 files changed
+3951 -587

No files matched your search

+2 -2
View File
@@ -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.
+30 -2
View File
@@ -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
+102
View File
@@ -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"
+187
View File
@@ -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)"
@@ -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
@@ -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
@@ -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
@@ -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
+45 -47
View File
@@ -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
}
]
}
+11 -11
View File
@@ -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 |
+19 -19
View File
@@ -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",
+4 -1
View File
@@ -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"
+1 -1
View File
@@ -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
@@ -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', () => {
@@ -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;
@@ -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.
@@ -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);
});
});
+19 -1
View File
@@ -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';
+38 -28
View File
@@ -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<T>(
backend: CLIProxyBackend,
operation: () => Promise<T>
): Promise<T> {
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 */
@@ -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([]);
});
});
@@ -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,
@@ -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);
});
});
@@ -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<Set<string>>();
export async function withInstallLifecycleLock<T>(
lockTarget: string,
operation: () => Promise<T>
): Promise<T> {
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;
}
}
}
+90 -46
View File
@@ -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<void> {
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;
}
+23 -7
View File
@@ -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<string> {
interface EnsureBinaryDeps {
downloadAndInstallFn?: typeof downloadAndInstall;
}
export async function ensureBinary(
config: BinaryManagerConfig,
deps: EnsureBinaryDeps = {}
): Promise<string> {
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<string>
// 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<string>
log(`Force version mode: using specified version ${config.version}`, verbose);
}
await downloadAndInstall(config, verbose);
await downloadAndInstallFn(config, verbose);
return binaryPath;
}
+5 -6
View File
@@ -84,7 +84,10 @@ const RELEASE_ARCH_MAP: Record<SupportedArch, SupportedArch> = {
};
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<Record<CLIProxyBackend, string>> = {
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';
}
+10
View File
@@ -21,6 +21,8 @@ import {
} from '../binary/platform-detector';
import type { CLIProxyBackend } from '../types';
export type CLIProxyBackendMinVersions = Readonly<Record<CLIProxyBackend, string>>;
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
@@ -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', () => {
+43 -2
View File
@@ -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);
}
}
@@ -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<string, string>;
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' },
+9 -2
View File
@@ -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<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value);
+41 -1
View File
@@ -323,6 +323,18 @@ export const MODEL_CATALOG: Partial<Record<CLIProxyProvider, ProviderCatalog>> =
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<Record<CLIProxyProvider, ProviderCatalog>> =
},
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.
*/
@@ -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<string, unknown> }> =
[];
let responseFactory: (() => Promise<Response>) | 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<string, unknown>) => {
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);
});
});
});
});
+24 -14
View File
@@ -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,
}
);
}
+27 -22
View File
@@ -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,
+2
View File
@@ -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;
@@ -56,6 +56,7 @@ describe('types.ts backward compatibility', () => {
maxRetries: 3,
verbose: false,
forceVersion: false,
replaceExisting: false,
skipAutoUpdate: false,
allowInstall: true,
};
+1
View File
@@ -14,6 +14,7 @@ export interface BinaryManagerConfig {
maxRetries: number;
verbose: boolean;
forceVersion: boolean;
replaceExisting?: boolean;
skipAutoUpdate: boolean;
allowInstall: boolean;
backend?: CLIProxyBackend;
@@ -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<typeof spyOn>;
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<string, unknown> = {}): 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<void> {
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');
});
});
}
});
});
+31
View File
@@ -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<void> {
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('');
@@ -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)}]`;
}
@@ -138,7 +138,7 @@ export async function handleDoctor(verbose = false): Promise<void> {
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('');
}
@@ -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('');
@@ -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('');
@@ -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('');
+13 -2
View File
@@ -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<void> {
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);
}
@@ -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;
}
+43 -3
View File
@@ -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<string>(ANTHROPIC_MODEL_ENV_KEYS);
const EXTENDED_CONTEXT_MODEL_ENV_KEY_SET = new Set<string>(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<Record<string, string | undefined>>
): 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<string, string | undefined> = { ...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;
+18 -1
View File
@@ -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',
@@ -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/<id> scoped routes are Plus-only.
return buildCliproxyProviderPath(backendId) || null;
}
return `/api/provider/${backendId}`;
}
@@ -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<ImageAnalysisRuntimeStatusDeps> = {}
): Promise<ImageAnalysisStatus> {
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);
}
+8 -2
View File
@@ -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;
}
+35 -23
View File
@@ -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 };
}
/**
+35 -23
View File
@@ -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 };
}
/**
+35 -23
View File
@@ -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 };
}
/**
+35 -23
View File
@@ -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 };
}
/**
+51 -25
View File
@@ -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);
}
+21 -10
View File
@@ -216,20 +216,21 @@ const PRICING_REGISTRY: Record<string, ModelPricing> = {
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<string, ModelPricing> = {
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
@@ -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<void>;
ensureCliproxyService: () => Promise<ServiceStartResult>;
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<ServiceStartResult> {
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<DashboardCliproxyInstallResult> {
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}`,
};
});
}
@@ -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('');
});
@@ -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);
});
});
+5 -2
View File
@@ -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', () => {
@@ -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();
});
});
});
@@ -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 });
}
});
});
@@ -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');
});
});
+17 -5
View File
@@ -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)', () => {
@@ -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');
});
});
+166 -29
View File
@@ -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', () => {
@@ -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);
});
});
@@ -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(),
@@ -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');
});
});
@@ -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/<id>/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();
});
});
@@ -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/<id> 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');
});
});
@@ -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'
+107
View File
@@ -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<typeof getWebSearchCliProviders>[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');
@@ -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<unknown>
) => 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: <T>(
_backend: CLIProxyBackend,
operation: () => Promise<T>
): Promise<T> => {
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);
});
});
+1
View File
@@ -0,0 +1 @@
<svg fill="#0160E6" fill-rule="evenodd" height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>OrcaRouter</title><path d="M 0.00 0.00 C 6.37 0.00 12.74 0.00 19.30 0.00 C 19.23 0.05 19.17 0.10 19.10 0.15 C 18.03 0.98 17.24 1.93 17.18 3.33 C 17.15 3.75 17.15 3.75 16.95 3.80 C 16.77 3.72 16.59 3.64 16.40 3.55 C 14.76 3.14 13.22 3.54 12.10 4.85 C 12.00 5.05 12.00 5.05 12.05 5.25 C 12.30 5.30 12.30 5.30 12.75 5.25 C 13.99 5.24 13.99 5.24 14.49 5.28 C 14.88 5.30 15.27 5.31 15.66 5.31 C 15.72 5.31 15.72 5.31 16.07 5.31 C 16.39 5.30 16.72 5.28 17.05 5.25 C 17.50 5.70 19.12 7.71 18.85 8.25 C 18.62 8.25 18.39 8.25 18.15 8.25 C 17.88 8.52 18.27 8.97 18.40 9.20 C 18.80 9.65 18.80 9.65 19.30 10.05 C 19.30 11.09 18.29 11.72 17.45 12.10 C 16.78 12.27 15.92 12.39 15.45 11.80 C 15.50 11.50 15.50 11.50 15.65 11.10 C 15.58 10.77 15.58 10.77 15.25 10.70 C 14.52 10.76 13.94 10.92 13.25 11.15 C 12.15 11.37 11.26 10.61 10.38 10.05 C 8.22 8.69 5.87 7.41 3.27 7.21 C 2.75 7.15 2.75 7.15 2.24 6.97 C 1.46 6.70 0.81 6.63 0.25 7.30 C 0.15 7.55 0.15 7.55 0.15 8.10 C 0.18 8.08 0.18 8.08 0.35 8.00 C 1.62 7.36 3.26 9.18 4.05 10.00 C 4.49 10.51 4.87 10.99 5.21 11.58 C 5.55 12.10 5.84 12.36 6.35 12.70 C 6.79 13.14 6.95 13.54 7.20 14.10 C 7.55 14.45 7.89 14.68 8.30 14.95 C 9.41 15.75 11.11 17.81 10.60 19.35 C 10.10 19.68 9.39 19.40 8.90 19.20 C 8.39 19.00 7.88 18.81 7.35 18.65 C 6.87 18.36 6.41 18.07 5.95 17.75 C 5.85 17.68 5.75 17.62 5.65 17.55 C 5.50 17.40 5.50 17.40 5.50 17.30 C 5.45 17.30 5.40 17.30 5.35 17.30 C 5.35 17.27 5.35 17.23 5.35 17.20 C 5.28 17.17 5.22 17.13 5.15 17.10 C 5.02 17.00 4.89 16.90 4.75 16.80 C 4.65 16.73 4.55 16.67 4.45 16.60 C 4.33 16.47 4.22 16.34 4.10 16.20 C 4.05 16.18 4.05 16.18 3.80 16.05 C 3.68 15.93 3.57 15.82 3.45 15.70 C 3.40 15.68 3.35 15.67 3.30 15.65 C 3.30 15.62 3.30 15.58 3.30 15.55 C 3.23 15.53 3.17 15.52 3.10 15.50 C 3.08 15.42 3.07 15.34 3.05 15.25 C 3.00 15.25 2.95 15.25 2.90 15.25 C 3.03 15.51 3.18 15.77 3.45 15.90 C 3.50 15.98 3.55 16.07 3.60 16.15 C 3.86 16.42 4.12 16.66 4.41 16.89 C 4.65 17.10 4.65 17.10 4.60 17.30 C 4.30 17.57 4.00 17.84 3.70 18.10 C 2.78 19.02 0.96 21.07 1.65 22.45 C 3.23 23.50 5.64 22.04 6.85 21.00 C 7.36 20.54 7.80 20.11 8.20 19.55 C 8.40 19.50 8.40 19.50 8.69 19.64 C 9.52 20.02 10.39 20.29 11.28 20.47 C 11.79 20.57 11.88 20.73 12.23 21.11 C 13.76 22.76 15.64 23.74 17.90 23.95 C 17.90 23.97 17.90 23.98 17.90 24.00 C 11.99 24.00 6.09 24.00 0.00 24.00 C 0.00 16.08 0.00 8.16 0.00 0.00 Z"/></svg>

After

Width:  |  Height:  |  Size: 2.5 KiB

@@ -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<string, string>) => {
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;
@@ -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 ? (
<RefreshCw className="w-3.5 h-3.5 animate-spin" />
@@ -182,6 +205,31 @@ export function ProxyStatusWidget() {
const [isExpanded, setIsExpanded] = useState(false);
const [selectedVersion, setSelectedVersion] = useState<string>('');
// Whole-widget collapse so the sidebar provider list keeps the vertical space
const [isCollapsed, setIsCollapsed] = useState<boolean>(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<string | null>(null);
@@ -325,30 +373,50 @@ export function ProxyStatusWidget() {
{t('proxyStatusWidget.active')}
</Badge>
</div>
<Activity className="w-3 h-3 text-blue-600" />
</div>
<div className="mt-2 text-xs text-muted-foreground">
<div className="flex items-center gap-1 mb-1">
<span className="font-mono">{remoteDisplayHost}</span>
<div className="flex items-center gap-1">
<Activity className="w-3 h-3 text-blue-600" />
<Button
variant="ghost"
size="sm"
className="h-7 w-7 p-0"
onClick={handleToggleCollapsed}
title={collapseTooltip}
aria-label={collapseTooltip}
>
{isCollapsed ? (
<ChevronDown className="w-3.5 h-3.5" />
) : (
<ChevronUp className="w-3.5 h-3.5" />
)}
</Button>
</div>
<p className="text-[10px] text-muted-foreground/70 leading-tight">
{t('proxyStatusWidget.trafficAutoRouted')}
</p>
</div>
<RoutingGuidanceCard
key={`remote:${routingState?.strategy ?? 'round-robin'}:${effectiveSessionAffinityState?.enabled ?? 'na'}:${effectiveSessionAffinityState?.ttl ?? 'na'}:${effectiveSessionAffinityState?.manageable ?? 'na'}`}
compact
className="mt-3"
state={routingState}
sessionAffinityState={effectiveSessionAffinityState}
isLoading={routingLoading || sessionAffinityLoading}
isSaving={isSavingRoutingConfig}
error={routingConfigError}
onApply={(strategy) => updateRouting.mutate(strategy)}
onApplyAffinity={(data) => updateSessionAffinity.mutate(data)}
/>
{!isCollapsed && (
<>
<div className="mt-2 text-xs text-muted-foreground">
<div className="flex items-center gap-1 mb-1">
<span className="font-mono">{remoteDisplayHost}</span>
</div>
<p className="text-[10px] text-muted-foreground/70 leading-tight">
{t('proxyStatusWidget.trafficAutoRouted')}
</p>
</div>
<RoutingGuidanceCard
key={`remote:${routingState?.strategy ?? 'round-robin'}:${effectiveSessionAffinityState?.enabled ?? 'na'}:${effectiveSessionAffinityState?.ttl ?? 'na'}:${effectiveSessionAffinityState?.manageable ?? 'na'}`}
compact
className="mt-3"
state={routingState}
sessionAffinityState={effectiveSessionAffinityState}
isLoading={routingLoading || sessionAffinityLoading}
isSaving={isSavingRoutingConfig}
error={routingConfigError}
onApply={(strategy) => updateRouting.mutate(strategy)}
onApplyAffinity={(data) => updateSessionAffinity.mutate(data)}
/>
</>
)}
</div>
);
}
@@ -413,9 +481,14 @@ export function ProxyStatusWidget() {
? t('proxyStatusWidget.tooltipClose')
: t('proxyStatusWidget.tooltipVersionSettings')
}
onClick={() => setIsExpanded(!isExpanded)}
onClick={handleToggleVersionSettings}
className={isExpanded ? 'bg-muted' : undefined}
/>
<IconButton
icon={isCollapsed ? ChevronDown : ChevronUp}
tooltip={collapseTooltip}
onClick={handleToggleCollapsed}
/>
</div>
</div>
@@ -458,7 +531,7 @@ export function ProxyStatusWidget() {
)}
{/* Stats row when running */}
{isRunning && status && (
{!isCollapsed && isRunning && status && (
<div className="mt-2 flex items-center gap-4 text-xs text-muted-foreground">
<span className="flex items-center gap-1">
{t('proxyStatusWidget.port', { port: status.port })}
@@ -478,35 +551,39 @@ export function ProxyStatusWidget() {
</div>
)}
{/* Sync status row */}
<div className="mt-2 flex items-center gap-1.5 text-xs">
{isSyncConfigured ? (
<Check className="w-3 h-3 text-green-600 dark:text-green-400" />
) : (
<AlertCircle className="w-3 h-3 text-muted-foreground" />
)}
<span
className={cn(
'text-xs',
isSyncConfigured ? 'text-green-600 dark:text-green-400' : 'text-muted-foreground'
)}
>
{syncStatusText}
</span>
</div>
{!isCollapsed && (
<>
{/* Sync status row */}
<div className="mt-2 flex items-center gap-1.5 text-xs">
{isSyncConfigured ? (
<Check className="w-3 h-3 text-green-600 dark:text-green-400" />
) : (
<AlertCircle className="w-3 h-3 text-muted-foreground" />
)}
<span
className={cn(
'text-xs',
isSyncConfigured ? 'text-green-600 dark:text-green-400' : 'text-muted-foreground'
)}
>
{syncStatusText}
</span>
</div>
<RoutingGuidanceCard
key={`local:${routingState?.strategy ?? 'round-robin'}:${effectiveSessionAffinityState?.enabled ?? 'na'}:${effectiveSessionAffinityState?.ttl ?? 'na'}:${effectiveSessionAffinityState?.manageable ?? 'na'}`}
compact
className="mt-3"
state={routingState}
sessionAffinityState={effectiveSessionAffinityState}
isLoading={routingLoading || sessionAffinityLoading}
isSaving={isSavingRoutingConfig}
error={routingConfigError}
onApply={(strategy) => updateRouting.mutate(strategy)}
onApplyAffinity={(data) => updateSessionAffinity.mutate(data)}
/>
<RoutingGuidanceCard
key={`local:${routingState?.strategy ?? 'round-robin'}:${effectiveSessionAffinityState?.enabled ?? 'na'}:${effectiveSessionAffinityState?.ttl ?? 'na'}:${effectiveSessionAffinityState?.manageable ?? 'na'}`}
compact
className="mt-3"
state={routingState}
sessionAffinityState={effectiveSessionAffinityState}
isLoading={routingLoading || sessionAffinityLoading}
isSaving={isSavingRoutingConfig}
error={routingConfigError}
onApply={(strategy) => updateRouting.mutate(strategy)}
onApplyAffinity={(data) => updateSessionAffinity.mutate(data)}
/>
</>
)}
{/* Expanded section: Version Management (available even when not running) */}
<Collapsible open={isExpanded} onOpenChange={setIsExpanded}>
+3
View File
@@ -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,
+12
View File
@@ -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: '(안정)',
+20 -1
View File
@@ -398,6 +398,13 @@ export const MODEL_CATALOGS: Record<string, ProviderCatalog> = {
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<string, ProviderCatalog> = {
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',
@@ -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: () => <div data-testid="routing-guidance-card" />,
}));
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(<ProxyStatusWidget />);
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(<ProxyStatusWidget />);
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(<ProxyStatusWidget />);
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(<ProxyStatusWidget />);
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(<ProxyStatusWidget />);
fireEvent.click(screen.getByRole('button', { name: 'Version settings' }));
expect(screen.getByTestId('routing-guidance-card')).toBeInTheDocument();
expect(window.localStorage.setItem).toHaveBeenCalledWith(COLLAPSED_STORAGE_KEY, 'false');
});
});
@@ -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',
@@ -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');
@@ -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)');