mirror of
https://github.com/tiennm99/ccs.git
synced 2026-10-14 16:17:02 +00:00
86 files changed
+3951
-587
No files matched your search
@@ -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
@@ -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
|
||||
|
||||
|
||||
Executable
+102
@@ -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"
|
||||
Executable
+187
@@ -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
|
||||
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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
@@ -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"
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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';
|
||||
}
|
||||
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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' },
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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('');
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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
@@ -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 };
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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'
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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}>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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: '(안정)',
|
||||
|
||||
@@ -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)');
|
||||
|
||||
Reference in new issue
Block a user