chore: merge origin/dev into pr-1061 fix branch

This commit is contained in:
Tam Nhu Tran
2026-04-22 22:38:20 -04:00
96 changed files with 3128 additions and 624 deletions
Binary file not shown.

After

Width:  |  Height:  |  Size: 210 KiB

+138
View File
@@ -0,0 +1,138 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Issue 1058 UI Evidence</title>
<style>
:root {
color-scheme: light dark;
--bg: #111111;
--fg: #f5f1ea;
--muted: #b8aea0;
--card: #1a1714;
--border: #3a332b;
--accent: #ef4444;
}
body {
margin: 0;
padding: 32px;
background: var(--bg);
color: var(--fg);
font:
15px/1.5 "SF Mono",
"IBM Plex Sans",
system-ui,
sans-serif;
}
main {
max-width: 1200px;
margin: 0 auto;
}
h1,
h2,
p {
margin: 0;
}
.header,
.shot {
border: 1px solid var(--border);
background: var(--card);
border-radius: 18px;
}
.header {
padding: 24px;
display: grid;
gap: 12px;
}
.summary {
display: grid;
gap: 16px;
margin-top: 20px;
}
.summary-card {
border: 1px solid var(--border);
border-radius: 14px;
padding: 16px;
}
.eyebrow {
color: var(--accent);
font-size: 12px;
text-transform: uppercase;
letter-spacing: 0.14em;
}
.muted {
color: var(--muted);
}
.shot {
margin-top: 24px;
overflow: hidden;
}
.shot-header {
padding: 16px 20px;
border-bottom: 1px solid var(--border);
display: grid;
gap: 6px;
}
img {
display: block;
width: 100%;
height: auto;
}
</style>
</head>
<body>
<main>
<section class="header">
<p class="eyebrow">Issue 1058</p>
<h1>Codex selector now exposes pinned effort variants</h1>
<p class="muted">
Capture environment: local dashboard at <code>http://127.0.0.1:3001/cliproxy</code>,
light theme, Codex provider editor, Model Config tab.
</p>
<div class="summary">
<div class="summary-card">
<p class="eyebrow">What changed</p>
<p>The selector now offers real codex model variants like <code>-high</code> and <code>-xhigh</code>.</p>
</div>
<div class="summary-card">
<p class="eyebrow">Why it matters</p>
<p>The dashboard can now emit the exact suffixed model IDs that runtime code already understands.</p>
</div>
<div class="summary-card">
<p class="eyebrow">Review cue</p>
<p>Look at the opened model picker: suffixed entries appear as first-class options with pinned-effort badges.</p>
</div>
</div>
</section>
<section class="shot">
<div class="shot-header">
<p class="eyebrow">Targeted evidence</p>
<h2>Codex provider editor model picker</h2>
<p class="muted">
The open selector shows canonical rows plus generated pinned variants such as
<code>gpt-5.3-codex-high</code>, <code>gpt-5.3-codex-xhigh</code>, and
<code>gpt-5.4-xhigh</code>.
</p>
</div>
<img
src="./codex-selector-variants.png"
alt="Codex provider editor with the model selector opened and suffixed effort variants visible."
/>
</section>
</main>
</body>
</html>
+4 -2
View File
@@ -6,8 +6,9 @@
Use what applies. If you skipped something, add a short note instead of forcing it.
- [ ] `bun run validate`
- [ ] `bun run validate:ci-parity`
- [ ] `bun run format && bun run lint:fix && bun run validate`
- [ ] `bun run validate:ci-parity` before requesting review
- [ ] `bun run test:e2e` if this PR touches command routing, proxy flows, or workflow/release logic
- [ ] `cd ui && bun run validate` if UI changed
- [ ] Not run
@@ -20,6 +21,7 @@ Check what applies. Not every item is relevant for every PR.
- [ ] Relevant `--help` output updated if CLI behavior changed
- [ ] Tests added or updated if behavior changed
- [ ] README or local docs updated if user-facing behavior changed
- [ ] If a check failed, the PR body explains what failed and what changed to fix it
- [ ] No secrets, tokens, or private config data are included
## Docs Impact
+13 -11
View File
@@ -17,13 +17,17 @@ concurrency:
jobs:
validate:
runs-on: [self-hosted, linux, x64]
env:
# Keep Bun cache isolated per job workspace so parallel self-hosted runs
# do not race on ~/.bun/install/cache and corrupt restore/install state.
BUN_INSTALL_CACHE_DIR: ${{ github.workspace }}/.bun/install/cache
strategy:
fail-fast: false
matrix:
check:
- { name: typecheck, cmd: 'bun run typecheck' }
- { name: lint, cmd: 'bun run lint' }
- { name: format, cmd: 'bun run format:check' }
- { name: lint, cmd: 'bun run lint' }
- { name: format, cmd: 'bun run format:check' }
name: ${{ matrix.check.name }}
steps:
@@ -44,10 +48,8 @@ jobs:
uses: actions/cache@v4
with:
path: |
~/.bun/install/cache
${{ env.BUN_INSTALL_CACHE_DIR }}
key: ${{ runner.os }}-bun-cache-v2-${{ hashFiles('bun.lock', 'ui/bun.lock') }}
restore-keys: |
${{ runner.os }}-bun-cache-v2-
- name: Ensure dependencies
run: bash scripts/ensure-deps.sh
@@ -58,6 +60,8 @@ jobs:
build:
runs-on: [self-hosted, linux, x64]
name: build
env:
BUN_INSTALL_CACHE_DIR: ${{ github.workspace }}/.bun/install/cache
steps:
- name: Checkout code
uses: actions/checkout@v4
@@ -76,10 +80,8 @@ jobs:
uses: actions/cache@v4
with:
path: |
~/.bun/install/cache
${{ env.BUN_INSTALL_CACHE_DIR }}
key: ${{ runner.os }}-bun-cache-v2-${{ hashFiles('bun.lock', 'ui/bun.lock') }}
restore-keys: |
${{ runner.os }}-bun-cache-v2-
- name: Ensure dependencies
run: bash scripts/ensure-deps.sh
@@ -99,6 +101,8 @@ jobs:
runs-on: [self-hosted, linux, x64]
name: test
needs: [build]
env:
BUN_INSTALL_CACHE_DIR: ${{ github.workspace }}/.bun/install/cache
steps:
- name: Checkout code
uses: actions/checkout@v4
@@ -117,10 +121,8 @@ jobs:
uses: actions/cache@v4
with:
path: |
~/.bun/install/cache
${{ env.BUN_INSTALL_CACHE_DIR }}
key: ${{ runner.os }}-bun-cache-v2-${{ hashFiles('bun.lock', 'ui/bun.lock') }}
restore-keys: |
${{ runner.os }}-bun-cache-v2-
- name: Ensure dependencies
run: bash scripts/ensure-deps.sh
+4 -1
View File
@@ -47,9 +47,12 @@ jobs:
- name: Build
run: bun run build:all
- name: Validate (typecheck + lint + format + tests)
- name: Validate fast gate
run: bun run validate
- name: Test slow bucket
run: bun run test:slow
- name: Test CLI e2e
env:
CCS_E2E_SKIP_BUILD: '1'
+134
View File
@@ -0,0 +1,134 @@
name: Push CI
on:
push:
branches: [dev]
concurrency:
group: push-ci-${{ github.ref }}
cancel-in-progress: true
jobs:
validate:
runs-on: [self-hosted, linux, x64]
env:
BUN_INSTALL_CACHE_DIR: ${{ github.workspace }}/.bun/install/cache
strategy:
fail-fast: false
matrix:
check:
- { name: typecheck, cmd: 'bun run typecheck' }
- { name: lint, cmd: 'bun run lint' }
- { name: format, cmd: 'bun run format:check' }
name: ${{ matrix.check.name }}
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Setup Bun
uses: oven-sh/setup-bun@v2
with:
bun-version: '1.3.9'
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '22'
- name: Restore bun package cache
uses: actions/cache@v4
with:
path: |
${{ env.BUN_INSTALL_CACHE_DIR }}
key: ${{ runner.os }}-bun-cache-v2-${{ hashFiles('bun.lock', 'ui/bun.lock') }}
- name: Ensure dependencies
run: bash scripts/ensure-deps.sh
- name: Run ${{ matrix.check.name }}
run: ${{ matrix.check.cmd }}
build:
runs-on: [self-hosted, linux, x64]
name: build
env:
BUN_INSTALL_CACHE_DIR: ${{ github.workspace }}/.bun/install/cache
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Setup Bun
uses: oven-sh/setup-bun@v2
with:
bun-version: '1.3.9'
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '22'
- name: Restore bun package cache
uses: actions/cache@v4
with:
path: |
${{ env.BUN_INSTALL_CACHE_DIR }}
key: ${{ runner.os }}-bun-cache-v2-${{ hashFiles('bun.lock', 'ui/bun.lock') }}
- name: Ensure dependencies
run: bash scripts/ensure-deps.sh
- name: Build
run: bun run build:all
- name: Upload dist artifact
uses: actions/upload-artifact@v4
with:
name: dist
path: dist/
retention-days: 1
if-no-files-found: error
test:
runs-on: [self-hosted, linux, x64]
name: test
needs: [build]
env:
BUN_INSTALL_CACHE_DIR: ${{ github.workspace }}/.bun/install/cache
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Setup Bun
uses: oven-sh/setup-bun@v2
with:
bun-version: '1.3.9'
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '22'
- name: Restore bun package cache
uses: actions/cache@v4
with:
path: |
${{ env.BUN_INSTALL_CACHE_DIR }}
key: ${{ runner.os }}-bun-cache-v2-${{ hashFiles('bun.lock', 'ui/bun.lock') }}
- name: Ensure dependencies
run: bash scripts/ensure-deps.sh
- name: Download dist artifact
uses: actions/download-artifact@v4
with:
name: dist
path: dist/
- name: Test
run: bun run test:all
- name: Test CLI e2e
env:
CCS_E2E_SKIP_BUILD: '1'
run: bun run test:e2e
+4 -1
View File
@@ -42,9 +42,12 @@ jobs:
- name: Build package
run: bun run build:all
- name: Validate (typecheck + lint + format + tests)
- name: Validate fast gate
run: bun run validate
- name: Test slow bucket
run: bun run test:slow
- name: Test CLI e2e
env:
CCS_E2E_SKIP_BUILD: '1'
+2 -2
View File
@@ -45,9 +45,9 @@ echo " branch: $CURRENT_BRANCH"
echo " base: $BASE_BRANCH"
bun run typecheck
bun run lint:fix
bun run lint
bun run format:check
bun run build:all
bun run test:fast
git fetch origin "$BASE_BRANCH" --quiet || true
DIFF_RANGE="HEAD"
+29 -35
View File
@@ -41,6 +41,15 @@ gh pr checks <n>
### Absolute rule
AI MUST NOT declare a task done, close a session, or move to the next task while CI is red or still running. Leaving a PR red and moving on is the primary failure mode this protocol prevents.
### Dev Release vs Push CI
- `CI` is the pull-request quality gate for contributor branches.
- `Push CI` is the post-merge quality signal for `dev`.
- `Dev Release` publishes the `@dev` package after `dev` changes land.
- A red `Dev Release` does **not** automatically mean contributor code failed. Check `Push CI` first.
- Verified on `2026-04-22` via `gh api repos/kaitranntt/ccs/branches/dev/protection`: `dev` currently requires `typecheck`, `lint`, `format`, `build`, and `test`, has no branch restrictions, and has no required PR-review gate.
- `dev-release.yml` currently pushes with `PAT_TOKEN` because `dev` is protected by those required status checks. Do not switch it back to `github.token` unless branch protection changes with it.
## Core Function
Multi-provider profile and runtime manager for Claude Code, Factory Droid,
@@ -87,7 +96,7 @@ broader topic.
| Mistake | Consequence | Correct Action |
|---------|-------------|----------------|
| Running `validate` without `format` first | format:check fails | Run `bun run format` BEFORE validate |
| Assuming maintainability check is always strict | PR/feature branches run warning mode by default | Use `bun run maintainability:check:strict` before merge when touching debt-sensitive code |
| Treating `Dev Release` as the contributor quality signal | Publish failures on `dev` look like broken code | Check PR `CI` on the branch and `Push CI` on `dev` first |
| Using `chore:` for dev→main PR | No npm release triggered | Use `feat:` or `fix:` prefix |
| Committing directly to `main` or `dev` | Bypasses CI/review | Always use PRs |
| Manual version bump or git tag | Conflicts with semantic-release | Let CI handle versioning |
@@ -208,8 +217,8 @@ Quality gates MUST pass before pushing. **Both projects have identical workflow.
# Main project (from repo root)
bun run format # Step 1: Fix formatting
bun run lint:fix # Step 2: Fix lint issues
bun run validate # Step 3: Full gate (typecheck + lint + format + maintainability + tests)
bun run validate:ci-parity # Step 4: full CI parity gate (build + validate + base branch check)
bun run validate # Step 3: Fast gate (typecheck + lint + format + test:fast)
bun run validate:ci-parity # Step 4: PR-CI parity gate (branch check + build + full tests + e2e)
# UI project (if UI changed)
cd ui
@@ -221,17 +230,17 @@ bun run validate # Step 3: Final check (must pass)
**WHY THIS ORDER:**
- `validate` runs `format:check` which only VERIFIES—won't fix
- If format:check fails, you skipped step 1
- CI runs `validate` only (no auto-fix)—local must be clean
- `validate` now uses read-only `lint`, so autofix still belongs in step 2
- PR CI and `validate:ci-parity` both run non-mutating checks only
### What Validate Runs
### What Each Gate Runs
| Project | Command | Runs |
|---------|---------|------|
| Main | `bun run validate` | typecheck + lint:fix + format:check + test:all |
| Main | `bun run validate` | typecheck + lint + format:check + test:fast |
| Main | `bun run validate:ci-parity` | base branch check + typecheck + lint + format:check + build:all + test:all + test:e2e |
| UI | `bun run validate` | typecheck + lint:fix + format:check |
**Note:** `maintainability:check` is a SEPARATE gate — not part of `validate`. Run it explicitly via `bun run maintainability:check[:strict|:warn]` when touching debt-sensitive code or before merging to protected branches.
### ESLint Rules (ALL errors)
| Rule | Level | Notes |
@@ -255,33 +264,19 @@ bun run validate # Step 3: Final check (must pass)
### Automatic Enforcement
- `prepublishOnly` / `prepack` runs `build:all` + `validate` + `sync-version.js`
- CI/CD runs `bun run validate` on every PR (maintainability is warning mode on PR events)
- `prepack` runs `build:all`
- PR `CI` runs `typecheck`, `lint`, `format`, `build`, `test:all`, and `test:e2e`
- `Push CI` runs the same quality suite on `dev` after merge, separate from release publishing
- `Dev Release` still runs build + fast validation + slow tests + e2e before publishing and still requires `PAT_TOKEN` to push back to protected `dev`
- husky `pre-commit` runs quick lint/type/format checks
- husky `pre-push` runs the full `bun run validate:ci-parity` gate on `main`/`dev`/hotfix branches
- husky `pre-push` runs a faster feature-branch gate (`typecheck` + `lint:fix` + `format:check` + targeted checks based on changed files) before GitHub CI handles the full matrix
- husky `pre-push` runs a faster feature-branch gate (`typecheck` + `lint` + `format:check` + `test:fast`) plus targeted checks based on changed files
### Maintainability Baseline Gate
### Maintainability Gate Status
- Baseline file: `docs/metrics/maintainability-baseline.json`
- Metric collector/check script: `scripts/maintainability-baseline.js`
- Branch-aware gate wrapper: `scripts/maintainability-check.js`
- Enforcement path: `bun run maintainability:check` (run separately — NOT part of `bun run validate`; invoked by `validate:ci-parity` on protected branches)
- Gate modes:
- `strict`: protected branches (`main`, `dev`, `hotfix/*`, `kai/hotfix-*`) and equivalent CI refs
- `warn`: pull request CI and non-protected local branches (non-blocking for parallel PR workflow)
- override commands:
- `bun run maintainability:check:strict`
- `bun run maintainability:check:warn`
- Gated metrics (must not increase vs baseline):
- `processExitReferenceCount`
- `synchronousFsApiReferenceCount`
- Informational metrics (collected but not gated):
- `largeFileCountOver350Loc`
- Baseline update policy:
1. Prefer reducing the metric and keeping the baseline unchanged.
2. On protected-branch integration (strict mode), if increase is intentional and accepted, run `bun run maintainability:baseline`.
3. Commit both the code change and `docs/metrics/maintainability-baseline.json`, and state reason in PR description.
- The historical maintainability baseline gate is retired from the active CCS workflow.
- `validate`, `validate:ci-parity`, PR `CI`, `Push CI`, and release workflows do **not** invoke `maintainability:check`.
- Older roadmap references to `maintainability:baseline` / `maintainability:check` are historical context, not current repo commands.
## Critical Constraints (NEVER VIOLATE)
@@ -532,17 +527,16 @@ rm -rf ~/.ccs # Clean environment
Optimized for iterative push-then-review workflow. Do NOT run the full gate on every push — CI is the safety net. Run the full gate once before asking for review / merge.
### Tier 1 — Iterative push (feature branch)
Husky `pre-push` auto-runs: `typecheck + lint:fix + format:check + build:all` plus targeted tests based on changed files. AI does **nothing extra** at push time.
Husky `pre-push` auto-runs: `typecheck + lint + format:check + test:fast` plus targeted checks based on changed files. AI does **nothing extra** at push time.
**After push (MANDATORY):** follow the [CI-First Protocol](#ci-first-protocol-mandatory) — watch CI until green. Do not move on while CI is red.
### Tier 2 — Before requesting review / merge
Run ONCE, not per push:
- [ ] `bun run validate:ci-parity`full build + validate matches CI
- [ ] `bun run validate:ci-parity`branch freshness + build + full non-e2e tests + e2e
- [ ] `gh pr checks <n>` — all checks green
- [ ] If touching debt-sensitive code: `bun run maintainability:check:strict`
- [ ] If strict mode fails and increase is intentional: `bun run maintainability:baseline` and commit `docs/metrics/maintainability-baseline.json`
- [ ] If UI changed: `cd ui && bun run format && bun run validate`
- [ ] If touching command routing, proxy flows, workflows, or release logic: `bun run test:e2e`
### Code / Docs / Standards (verify before merge)
- [ ] Conventional commit format (`feat:`, `fix:`, etc.)
+49 -5
View File
@@ -76,14 +76,29 @@ Rules:
- Treat `hotfix/*` as maintainer-only emergency flow from `main`.
- Delete your branch after merge.
## CI and Release Flow
CCS now uses three separate automation lanes:
- `CI` runs on pull requests to `dev` and `main`. This is the review gate for contributor branches.
- `Push CI` runs after a merge lands on `dev`. This is the code-quality signal for the shared `dev` branch.
- `Dev Release` publishes the `@dev` package after `dev` changes land. It is release automation, not the primary contributor quality signal.
If `Dev Release` is red but your PR checks were green, check `Push CI` before assuming the merged code is broken.
If `CI` or `Push CI` stays queued for a long time, it is a maintainer infrastructure issue, not a contributor mistake. Leave a comment on your PR and a maintainer will address it.
## AI Agent Rules
`CONTRIBUTING.md` is the human entry point. For AI agents working in this repo, the authoritative automation and workflow rules live in [CLAUDE.md](./CLAUDE.md).
## AI Review Lane
CCS PR review no longer depends on `anthropics/claude-code-action`. The repository review lane is self-hosted PR-Agent:
- The retained `.github/workflows/ai-review.yml` runs PR-Agent in GitHub Actions.
- PR-Agent reviews run on the existing self-hosted `cliproxy` runner.
- Use `/review` on the PR when you need a fresh pass after follow-up commits.
- Only the trusted `/review` comment path is enabled on the privileged self-hosted runner.
- Only the trusted `/review` comment path is enabled.
- Keep repository-level reviewer instructions in the root `.pr_agent.toml`.
- Keep runtime wiring and defaults in `ai-review.yml`, which still maps the existing `AI_REVIEW_BASE_URL`, `AI_REVIEW_MODEL`, and `AI_REVIEW_API_KEY` integrations onto PR-Agent's `OPENAI.*` and `config.*` settings.
- If you change review defaults, update the workflow or `.pr_agent.toml` alongside the contributor or architecture docs in the same PR.
@@ -129,16 +144,35 @@ Use `bun run dev` from the repo root when working on the local dashboard experie
## Validation
If you can, run these before you open or update a PR:
Run this fast local gate before you open or update a PR:
```bash
bun run format
bun run lint:fix
bun run validate
```
`bun run validate` is the day-to-day contributor gate. It runs:
- `typecheck`
- `lint`
- `format:check`
- `test:fast`
Before you ask for review, or whenever you want the closest local equivalent to PR CI, run:
```bash
bun run validate:ci-parity
```
If you changed the dashboard:
`bun run validate:ci-parity` adds:
- branch freshness check against `origin/dev` or `origin/main`
- `build:all`
- full non-e2e test suite via `test:all`
- `test:e2e` with `CCS_E2E_SKIP_BUILD=1`
If you changed the dashboard, run the UI gate too:
```bash
cd ui
@@ -155,7 +189,17 @@ bun run test:native
bun run test:e2e
```
`bun run validate` is the main gate. It covers typechecking, linting, format checks, maintainability checks, and automated tests for the main project.
Use `bun run test:e2e` locally before review if you touch command routing, proxy flows, release automation, or workflow wiring and want to reproduce the same CLI e2e lane that PR CI runs.
### Why Did CI Fail?
| Symptom | Likely cause | Fix |
| --- | --- | --- |
| `format` fails in PR CI | `bun run format` was skipped locally | Run `bun run format`, recommit, push again |
| `lint` fails in PR CI | `validate` now uses read-only `lint` | Run `bun run lint:fix`, then rerun `bun run validate` |
| `test` fails in PR CI but `validate` passed | The failure is in `test:slow` or `test:e2e` | Run `bun run validate:ci-parity` locally |
| Checks stay queued for >10 min | Self-hosted runner is offline | Wait for maintainer intervention; rerunning usually does not help |
| `Dev Release` is red on `dev` after merge | Release-only failure or publish problem | Check `Push CI` first to confirm code quality |
If you cannot run the full suite, that is still fine for early or docs-only PRs. Just say what you did run, or what blocked you, in the PR.
+3
View File
@@ -177,6 +177,9 @@ ccs ollama "summarize these logs"
## Contribute And Report Safely
- Contributing guide: [CONTRIBUTING.md](./CONTRIBUTING.md)
- Daily local gate: `bun run format && bun run lint:fix && bun run validate` (`validate` is the fast path only)
- Before review or merge confidence: `bun run validate:ci-parity`
- If PR checks stay queued for more than 10 minutes, assume the self-hosted runner is offline and notify a maintainer instead of retrying blindly
- Starter work:
[good first issue](https://github.com/kaitranntt/ccs/labels/good%20first%20issue),
[help wanted](https://github.com/kaitranntt/ccs/labels/help%20wanted)
+2 -1
View File
@@ -599,6 +599,7 @@ return (
bun run format
bun run lint:fix
bun run validate
bun run validate:ci-parity
# UI project (if changed)
cd ui
@@ -611,7 +612,7 @@ bun run validate
| Project | Command | Checks |
|---------|---------|--------|
| Main | `bun run validate` | typecheck + lint + format:check + test |
| Main | `bun run validate` | typecheck + lint + format:check + test:fast |
| UI | `bun run validate` | typecheck + lint + format:check |
---
+7 -21
View File
@@ -1,6 +1,6 @@
# CCS Project Roadmap
Last Updated: 2026-04-19
Last Updated: 2026-04-21
Forward-looking roadmap documenting current priorities, GitHub issues, and future feature plans.
@@ -41,6 +41,7 @@ All major modularization work is complete. The codebase evolved from monolithic
### Recent Fixes
- **2026-04-21**: CLIProxy quota failover now quarantines exhausted Claude and Antigravity accounts out of live rotation when a healthy fallback exists. CCS persists those quota-triggered pauses across launches, automatically resumes them after the configured cooldown window, and deliberately avoids auto-pausing the last available account so single-account setups still degrade gracefully instead of hard-locking themselves.
- **2026-04-20**: **#1051** Browser automation now defaults safe-off for new installs and upgrades that do not already carry explicit browser settings. CCS changes both Claude Browser Attach and Codex Browser Tools to start with `enabled: false` and `policy: manual`, normalizes missing browser policies on upgrade back to `manual`, preserves explicit existing enablement, and updates status/help/docs so browser tooling is never implied to auto-expose unless users opt in.
- **2026-04-19**: **#1051** Browser tooling now has an explicit exposure policy instead of only coarse enablement toggles. CCS adds `browser.<lane>.policy` (`auto` or `manual`) for both Claude Browser Attach and Codex Browser Tools, exposes CLI-first policy controls through `ccs browser policy`, `ccs browser enable`, and `ccs browser disable`, and adds one-run launch overrides `--browser` and `--no-browser` so users can force browser tooling on or off without editing saved config.
- **2026-04-19**: **#1049** Browser setup now has a real remediation path instead of status/doctor-only guidance. CCS adds `ccs browser setup` as the primary one-command flow for Claude Browser Attach, shortens managed browser-path output to home-relative display paths where appropriate, and updates browser readiness guidance to point users at setup first while keeping browser doctor read-only by default.
@@ -241,28 +242,13 @@ All criteria achieved:
- [x] Clear domain boundaries
- [x] Consistent naming conventions
## Maintainability Gate (Issue #539 Foundation)
## Historical Maintainability Gate (Retired)
- Baseline metrics artifact: `docs/metrics/maintainability-baseline.json`
- Branch-aware gate wrapper: `scripts/maintainability-check.js`
- Generate or refresh baseline:
- `bun run maintainability:baseline`
- `npm run maintainability:baseline`
- Run regression check gate:
- `bun run maintainability:check`
- `npm run maintainability:check`
- `bun run maintainability:check:strict` (force strict locally)
This section is preserved as historical context from the original Issue `#539` work.
The baseline/check scripts enumerate git-tracked files under `src` for deterministic results and fail fast if git file listing is unavailable.
Default gate behavior:
- strict mode on protected branches (`main`, `dev`, `hotfix/*`, `kai/hotfix-*`)
- warning mode on PR CI and non-protected branches (parallel PR friendly)
The check mode supports a maintainability regression gate that blocks increases in:
- `process.exit` references
- synchronous fs API references
- TypeScript files over 350 LOC
- The maintainability baseline gate is no longer part of the active CCS workflow.
- Current contributor and CI gates are documented in `CLAUDE.md`, `CONTRIBUTING.md`, and the GitHub workflow files.
- Do not assume `maintainability:baseline` or `maintainability:check` exist unless they are reintroduced in a future follow-up.
---
@@ -257,6 +257,7 @@ async function checkRemoteProxyHealth(config: ResolvedProxyConfig): Promise<bool
### Overview
Hybrid quota management enables automatic detection of exhausted accounts and failover to next available account.
When CCS detects exhaustion and a healthy fallback exists, it also temporarily pauses the exhausted account out of CLIProxy rotation and automatically resumes that pause after the configured cooldown expires.
```
+===========================================================================+
@@ -293,6 +294,11 @@ Hybrid quota management enables automatic detection of exhausted accounts and fa
+---> Select best account (not paused, not exhausted)
|
+---> Auto-failover to next account if current exhausted
|
+---> Temporarily pause exhausted account when fallback exists
| - move token out of live auth discovery
| - persist cooldown expiry across launches
| - auto-resume only CCS-created quota pauses
CLI Commands:
ccs cliproxy pause <account> --> Set isPaused=true in account-manager
+5 -3
View File
@@ -1,6 +1,6 @@
{
"name": "@kaitranntt/ccs",
"version": "7.73.0-dev.2",
"version": "7.73.1-dev.6",
"description": "Claude Code Switch - Instant profile switching between Claude, GLM, Kimi, and more",
"keywords": [
"cli",
@@ -70,12 +70,14 @@
"lint:fix": "eslint src/ --fix",
"format": "prettier --write src/",
"format:check": "prettier --check src/",
"validate": "bun run typecheck && bun run lint:fix && bun run format:check && bun run test:all",
"validate": "bun run typecheck && bun run lint && bun run format:check && bun run test:fast",
"validate:ci-parity": "bash scripts/ci-parity-gate.sh",
"verify:bundle": "node scripts/verify-bundle.js",
"test": "bun run build && bun run test:all",
"test:ci": "bun run test:all",
"test:all": "bun test tests/unit tests/integration tests/npm",
"test:fast": "node scripts/run-test-bucket.js fast",
"test:slow": "node scripts/run-test-bucket.js slow",
"test:all": "node scripts/run-test-bucket.js all",
"test:unit": "bun test tests/unit",
"test:npm": "bun test tests/npm/",
"test:native": "bash tests/native/unix/edge-cases.sh",
+8 -2
View File
@@ -57,8 +57,14 @@ if git show-ref --verify --quiet "refs/remotes/origin/$BASE_BRANCH"; then
fi
fi
echo "[i] Running CI-equivalent local checks..."
echo "[i] Running CI-parity local checks..."
# `set -euo pipefail` above makes every step fail fast. Keep these commands
# explicit so parity drift is visible when CI changes.
bun run typecheck
bun run lint
bun run format:check
bun run build:all
bun run validate
bun run test:all
CCS_E2E_SKIP_BUILD=1 bun run test:e2e
echo "[OK] CI parity gate passed."
+181
View File
@@ -0,0 +1,181 @@
#!/usr/bin/env node
const fs = require('node:fs');
const path = require('node:path');
const { spawnSync } = require('node:child_process');
const rootDir = path.resolve(__dirname, '..');
const candidateRoots = ['tests/unit', 'tests/integration', 'tests/npm'];
// Add a `.ts` test to `slowTests` when ANY of these apply:
// 1. It spawns a child process (CLI, bun test, node, gh, etc.).
// 2. It binds a port, starts a server, or talks to localhost.
// 3. It reads a real file from `dist/` or the repo root at runtime.
// 4. It waits on a timer > 500ms or a filesystem watcher.
// 5. A single run consistently takes > 1500ms on reference hardware.
// Tests that literally reference `dist/` in source are auto-forced slow by
// `readsBuiltDist`. This list is the manual catch-all for `.ts` tests that
// meet the criteria above without the literal `dist/` string.
// `tests/unit/scripts/run-test-bucket.test.js` verifies every path here exists
// (catches deletion drift) but CANNOT detect new undeclared slow tests.
// Automated perf-budget enforcement tracked in issue #1071.
const slowTests = [
'tests/integration/cursor-daemon-lifecycle.test.ts',
'tests/integration/proxy/daemon-lifecycle.test.ts',
'tests/unit/commands/persist-command-handler.test.ts',
'tests/unit/hooks/ccs-browser-mcp-server.test.ts',
'tests/unit/targets/codex-runtime-integration.test.ts',
'tests/unit/targets/codex-settings-bridge-launch.test.ts',
'tests/unit/targets/droid-command-routing-integration.test.ts',
'tests/unit/targets/droid-config-manager.test.ts',
'tests/unit/targets/settings-profile-browser-launch.test.ts',
'tests/unit/targets/settings-profile-image-analysis-launch.test.ts',
'tests/unit/targets/settings-profile-websearch-launch.test.ts',
'tests/unit/web-server/cursor-routes.test.ts',
'tests/unit/web-server/websearch-routes.test.ts',
];
// CommonJS-heavy JS suites stay slow by default because many of them mutate
// module cache or process state. Opt them into `test:fast` only after they are
// proven stable in the mixed fast bucket.
const fastJsTests = new Set([
'tests/unit/flag-parsing-simple.test.js',
]);
const filePattern = /(\.test\.(c|m)?[jt]s|\.spec\.(c|m)?[jt]s|-test\.(c|m)?[jt]s)$/;
function collectFiles(dir, files = []) {
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
const fullPath = path.join(dir, entry.name);
if (entry.isDirectory()) {
collectFiles(fullPath, files);
continue;
}
if (filePattern.test(entry.name)) {
files.push(path.relative(rootDir, fullPath).split(path.sep).join('/'));
}
}
return files;
}
function readsBuiltDist(relativePath) {
const source = fs.readFileSync(path.join(rootDir, relativePath), 'utf8');
return source.includes('dist/');
}
function getDiscoveredTests() {
return candidateRoots
.flatMap((relativeDir) => collectFiles(path.join(rootDir, relativeDir)))
.sort();
}
function shouldForceSlow(file) {
if (file.startsWith('tests/npm/')) {
return true;
}
if (/\.(c|m)?js$/.test(file) && !fastJsTests.has(file)) {
return true;
}
return readsBuiltDist(file);
}
function getSlowSet() {
const discovered = getDiscoveredTests();
const forceSlow = discovered.filter((file) => shouldForceSlow(file));
return new Set([...slowTests, ...forceSlow]);
}
function selectBucket(name) {
const discovered = getDiscoveredTests();
const slowSet = getSlowSet();
return name === 'slow'
? [...slowSet].sort()
: discovered.filter((file) => !slowSet.has(file));
}
function ensureBuildForSlowBucket() {
if (fs.existsSync(path.join(rootDir, 'dist', 'ccs.js'))) {
return 0;
}
const build = spawnSync('bun', ['run', 'build'], {
cwd: rootDir,
stdio: 'inherit',
shell: process.platform === 'win32',
});
return build.status ?? 1;
}
function runBucket(name) {
const selected = selectBucket(name);
if (selected.length === 0) {
console.error(`[X] No tests matched the '${name}' bucket.`);
return 1;
}
if (name === 'slow') {
const buildStatus = ensureBuildForSlowBucket();
if (buildStatus !== 0) {
return buildStatus;
}
}
// Slow bucket forces sequential execution because it spawns subprocesses,
// binds ports, and touches shared state — parallelism causes flakes.
// Fast bucket keeps bun's default parallelism for speed.
const bunArgs = name === 'slow'
? ['test', '--max-concurrency=1', ...selected]
: ['test', ...selected];
const result = spawnSync('bun', bunArgs, {
cwd: rootDir,
stdio: 'inherit',
shell: process.platform === 'win32',
});
return result.status ?? 1;
}
function main(args = process.argv.slice(2)) {
const bucket = args[0];
if (!['fast', 'slow', 'all'].includes(bucket)) {
console.error('[X] Usage: node scripts/run-test-bucket.js <fast|slow|all>');
return 1;
}
if (bucket === 'all') {
let exitCode = 0;
for (const name of ['fast', 'slow']) {
const status = runBucket(name);
if (status !== 0) {
exitCode = status;
}
}
return exitCode;
}
return runBucket(bucket);
}
if (require.main === module) {
process.exit(main());
}
module.exports = {
slowTests,
fastJsTests,
readsBuiltDist,
shouldForceSlow,
getDiscoveredTests,
getSlowSet,
selectBucket,
main,
};
+17 -4
View File
@@ -80,7 +80,7 @@ import { handleError, runCleanup } from './errors';
import { tryHandleRootCommand } from './commands/root-command-router';
// Import extracted utility functions
import { execClaude } from './utils/shell-executor';
import { execClaude, stripAnthropicRoutingEnv } from './utils/shell-executor';
import { isDeprecatedGlmtProfileName, normalizeDeprecatedGlmtEnv } from './utils/glmt-deprecation';
import { maybeWarnAboutResumeLaneMismatch } from './auth/resume-lane-warning';
import { createLogger } from './services/logging';
@@ -1363,8 +1363,21 @@ async function main(): Promise<void> {
console.error(info(`Global env: ${envNames}`));
}
// Explicitly inject effective settings env vars so stale ANTHROPIC_*
// values from prior sessions cannot leak into the active profile.
// For Claude target launches that already pass `--settings`, keep runtime
// env free of ANTHROPIC routing/auth while preserving non-routing profile
// env so nested Team/subagent sessions can still inherit model intent and
// other profile-scoped runtime flags.
const claudeRuntimeEnvVars: NodeJS.ProcessEnv = {
...stripAnthropicRoutingEnv({ ...globalEnv, ...settingsEnv }),
...(inheritedClaudeConfigDir ? { CLAUDE_CONFIG_DIR: inheritedClaudeConfigDir } : {}),
...webSearchEnv,
...imageAnalysisEnv,
...(browserRuntimeEnv || {}),
CCS_PROFILE_TYPE: 'settings',
CCS_STRIP_INHERITED_ANTHROPIC_ENV: '1',
};
// Non-Claude targets still need effective credentials injected directly.
const envVars: NodeJS.ProcessEnv = {
...globalEnv,
...settingsEnv,
@@ -1476,7 +1489,7 @@ async function main(): Promise<void> {
settingsPath: expandedSettingsPath,
});
execClaude(claudeCli, launchArgs, { ...envVars, ...traceEnv });
execClaude(claudeCli, launchArgs, { ...claudeRuntimeEnvVars, ...traceEnv });
} else if (profileInfo.type === 'account') {
// NEW FLOW: Account-based profile (work, personal)
// All platforms: Use instance isolation with CLAUDE_CONFIG_DIR
+146
View File
@@ -37,10 +37,26 @@ interface AutoPausedFile {
sessions: AutoPausedSession[];
}
interface QuotaPausedEntry {
provider: CLIProxyProvider;
accountId: string;
pausedAt: string;
until: number;
reason: 'quota_exhausted';
}
interface QuotaPausedFile {
entries: QuotaPausedEntry[];
}
function getAutoPausedPath(): string {
return path.join(getCcsDir(), 'cliproxy', 'auto-paused.json');
}
function getQuotaPausedPath(): string {
return path.join(getCcsDir(), 'cliproxy', 'quota-paused.json');
}
function loadAutoPaused(): AutoPausedFile {
try {
const filePath = getAutoPausedPath();
@@ -69,6 +85,48 @@ function saveAutoPaused(data: AutoPausedFile): void {
fs.writeFileSync(filePath, JSON.stringify(data, null, 2) + '\n', { mode: 0o600 });
}
function loadQuotaPaused(): QuotaPausedFile {
try {
const filePath = getQuotaPausedPath();
if (fs.existsSync(filePath)) {
const data = JSON.parse(fs.readFileSync(filePath, 'utf-8')) as {
entries?: unknown;
};
if (Array.isArray(data.entries)) {
return {
entries: data.entries.filter(
(entry): entry is QuotaPausedEntry =>
typeof entry === 'object' &&
entry !== null &&
typeof (entry as QuotaPausedEntry).provider === 'string' &&
typeof (entry as QuotaPausedEntry).accountId === 'string' &&
typeof (entry as QuotaPausedEntry).pausedAt === 'string' &&
Number.isFinite((entry as QuotaPausedEntry).until)
),
};
}
}
} catch {
// Corrupted or malformed file — start fresh
}
return { entries: [] };
}
function saveQuotaPaused(data: QuotaPausedFile): void {
const filePath = getQuotaPausedPath();
if (data.entries.length === 0) {
try {
fs.unlinkSync(filePath);
} catch {
/* already gone */
}
return;
}
const dir = path.dirname(filePath);
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
fs.writeFileSync(filePath, JSON.stringify(data, null, 2) + '\n', { mode: 0o600 });
}
/**
* Check if a process is alive. NOTE: PIDs can be recycled by the OS.
* If a stale PID is reused by an unrelated process, cleanup is deferred until that process exits.
@@ -325,6 +383,93 @@ export function cleanupStaleAutoPauses(): void {
}
}
/**
* Resume quota-paused accounts whose cooldown windows have expired.
* Auto-resume only applies to pauses created by CCS quota handling.
*/
export function restoreExpiredQuotaPauses(now = Date.now()): number {
const data = loadQuotaPaused();
if (data.entries.length === 0) return 0;
const keep: QuotaPausedEntry[] = [];
let resumed = 0;
const registry = loadAccountsRegistry();
for (const entry of data.entries) {
if (!Number.isFinite(entry.until) || entry.until > now) {
keep.push(entry);
continue;
}
const account = registry.providers[entry.provider]?.accounts[entry.accountId];
if (!account?.paused) {
continue;
}
// Only auto-resume the exact pause CCS created for quota cooldown.
// Missing or changed pausedAt metadata is treated as a mismatch so we do
// not accidentally resume a manually paused account.
if (account.pausedAt !== entry.pausedAt) {
continue;
}
if (resumeAccount(entry.provider, entry.accountId)) {
resumed += 1;
continue;
}
// Resume failures are treated as transient I/O/state issues. Keep the
// quota-pause record so the next restore pass can retry instead of leaving
// the account paused forever without any cooldown metadata.
keep.push(entry);
}
saveQuotaPaused({ entries: keep });
return resumed;
}
/**
* Temporarily remove an exhausted account from CLIProxy rotation for the
* configured cooldown window. Returns false when the account was already paused
* or could not be paused, so callers can fall back to in-memory cooldown only.
*/
export function pauseAccountForQuotaCooldown(
provider: CLIProxyProvider,
accountId: string,
cooldownMinutes: number,
now = Date.now()
): boolean {
const registryBefore = loadAccountsRegistry();
const accountBefore = registryBefore.providers[provider]?.accounts[accountId];
if (!accountBefore || accountBefore.paused) {
return false;
}
if (!pauseAccount(provider, accountId)) {
return false;
}
const registryAfter = loadAccountsRegistry();
const pausedAt = registryAfter.providers[provider]?.accounts[accountId]?.pausedAt;
if (!pausedAt) {
return false;
}
const data = loadQuotaPaused();
data.entries = data.entries.filter(
(entry) => !(entry.provider === provider && entry.accountId === accountId)
);
data.entries.push({
provider,
accountId,
pausedAt,
until: now + cooldownMinutes * 60 * 1000,
reason: 'quota_exhausted',
});
saveQuotaPaused(data);
return true;
}
/**
* Enforce provider isolation by auto-pausing conflicting accounts in other providers.
* Records paused accounts for crash recovery and session exit restore.
@@ -553,6 +698,7 @@ export async function handleQuotaExhaustion(
const alternative = await findHealthyAccount(provider, [accountId]);
if (alternative) {
pauseAccountForQuotaCooldown(provider, accountId, cooldownMinutes);
setDefaultAccount(provider, alternative.id);
touchAccount(provider, alternative.id);
writeQuotaExhausted(accountId, alternative.id, cooldownMinutes);
+115 -12
View File
@@ -5,10 +5,17 @@
* Pattern: Mirrors npm install behavior (fast check, download only when needed)
*/
import * as fs from 'fs';
import * as path from 'path';
import { info, warn } from '../utils/ui';
import { getBinDir, CLIPROXY_DEFAULT_PORT } from './config-generator';
import { BinaryInfo, BinaryManagerConfig } from './types';
import { BACKEND_CONFIG, DEFAULT_BACKEND, CLIPROXY_MAX_STABLE_VERSION } from './platform-detector';
import {
BACKEND_CONFIG,
DEFAULT_BACKEND,
CLIPROXY_MAX_STABLE_VERSION,
getExecutableName,
} from './platform-detector';
import { stopProxy } from './services/proxy-lifecycle-service';
import { waitForPortFree } from '../utils/port-utils';
import { loadOrCreateUnifiedConfig } from '../config/unified-config-loader';
@@ -16,6 +23,7 @@ import {
UpdateCheckResult,
checkForUpdates,
deleteBinary,
getVersionCachePath,
getBinaryPath,
isBinaryInstalled,
getBinaryInfo,
@@ -30,11 +38,72 @@ import {
} from './binary';
import type { CLIProxyBackend } from './types';
import { getVersionListCachePath } from './binary/version-cache';
export const CLIPROXY_PLUS_TRACKING_URL = 'https://github.com/kaitranntt/ccs/issues/1062';
/**
* Get backend from config or default to 'plus'
* Track whether we've already warned the user about the Plus fallback this
* process lifetime. Prevents spamming the warning on every command.
*/
function getConfiguredBackend(): CLIProxyBackend {
let plusFallbackWarned = false;
function emitPlusFallbackWarning(): void {
if (plusFallbackWarned) return;
plusFallbackWarned = true;
process.stderr.write(
`${warn(
'CLIProxyAPIPlus upstream repo is currently unavailable; local CLIProxy is falling back to ' +
'`backend: original`. Run `ccs config` to update your saved config. ' +
`Tracking: ${CLIPROXY_PLUS_TRACKING_URL}`
)}\n`
);
}
export function getPlusBackendUnavailableMessage(provider?: string): string {
const prefix = provider
? `${provider} requires CLIProxyAPIPlus,`
: 'CLIProxyAPIPlus upstream repo is currently unavailable,';
return (
`${prefix} but local CLIProxy currently supports only \`backend: original\`. ` +
`Tracking: ${CLIPROXY_PLUS_TRACKING_URL}`
);
}
export function resolveLocalBackend(
backend: CLIProxyBackend = DEFAULT_BACKEND,
options: { warnOnFallback?: boolean } = {}
): CLIProxyBackend {
if (backend !== 'plus') return backend;
if (options.warnOnFallback) {
emitPlusFallbackWarning();
}
return 'original';
}
function copyFallbackStateIfMissing(sourcePath: string, targetPath: string): void {
if (!fs.existsSync(sourcePath) || fs.existsSync(targetPath)) return;
fs.mkdirSync(path.dirname(targetPath), { recursive: true });
fs.copyFileSync(sourcePath, targetPath);
}
export function syncPlusFallbackStateIfNeeded(configuredBackend: CLIProxyBackend): void {
if (configuredBackend !== 'plus') return;
const plusDir = getBackendBinDir('plus');
const originalDir = getBackendBinDir('original');
copyFallbackStateIfMissing(
path.join(plusDir, getExecutableName('plus')),
path.join(originalDir, getExecutableName('original'))
);
copyFallbackStateIfMissing(path.join(plusDir, '.version'), path.join(originalDir, '.version'));
copyFallbackStateIfMissing(getVersionPinPath('plus'), getVersionPinPath('original'));
copyFallbackStateIfMissing(getVersionCachePath('plus'), getVersionCachePath('original'));
copyFallbackStateIfMissing(getVersionListCachePath('plus'), getVersionListCachePath('original'));
}
function getConfiguredOrDefaultBackend(): CLIProxyBackend {
try {
const config = loadOrCreateUnifiedConfig();
return config.cliproxy?.backend || DEFAULT_BACKEND;
@@ -43,6 +112,24 @@ function getConfiguredBackend(): CLIProxyBackend {
}
}
export function getStoredConfiguredBackend(): CLIProxyBackend {
return getConfiguredOrDefaultBackend();
}
/**
* Get backend from config, with runtime fallback to 'original' when the user
* still has `backend: plus` saved.
*
* Context (issue #1062): the upstream `router-for-me/CLIProxyAPIPlus` repo was
* deleted, so any `backend: plus` install/update path hits a 404. Rather than
* forcing users to manually edit config.yaml, we degrade to `original` at
* runtime and warn once. This keeps existing installations working without a
* reconfig step, while CCS self-maintains its own Plus fork (future work).
*/
export function getConfiguredBackend(options: { warnOnFallback?: boolean } = {}): CLIProxyBackend {
return resolveLocalBackend(getConfiguredOrDefaultBackend(), options);
}
/**
* Get backend-specific binary directory.
* Stores binaries in separate dirs: bin/original/ and bin/plus/
@@ -52,7 +139,7 @@ function getBackendBinDir(backend: CLIProxyBackend = DEFAULT_BACKEND): string {
return `${baseDir}/${backend}`;
}
/** Default configuration (uses backend from config.yaml or defaults to 'plus') */
/** Default configuration (uses backend from config.yaml or defaults to `DEFAULT_BACKEND`) */
function createDefaultConfig(backend: CLIProxyBackend = DEFAULT_BACKEND): BinaryManagerConfig {
const backendConfig = BACKEND_CONFIG[backend];
return {
@@ -76,7 +163,9 @@ export class BinaryManager {
private backend: CLIProxyBackend;
constructor(config: Partial<BinaryManagerConfig> = {}, backend?: CLIProxyBackend) {
this.backend = backend ?? getConfiguredBackend();
const configuredBackend = backend ?? getConfiguredOrDefaultBackend();
syncPlusFallbackStateIfNeeded(configuredBackend);
this.backend = resolveLocalBackend(configuredBackend, { warnOnFallback: true });
const defaultConfig = createDefaultConfig(this.backend);
this.config = { ...defaultConfig, ...config };
}
@@ -127,7 +216,9 @@ export async function ensureCLIProxyBinary(
verbose = false,
options: EnsureCLIProxyBinaryOptions = {}
): Promise<string> {
const backend = getConfiguredBackend();
const configuredBackend = getConfiguredOrDefaultBackend();
syncPlusFallbackStateIfNeeded(configuredBackend);
const backend = resolveLocalBackend(configuredBackend, { warnOnFallback: true });
// Migrate old shared pin to backend-specific location (one-time migration)
migrateVersionPin(backend);
@@ -158,19 +249,25 @@ export async function ensureCLIProxyBinary(
/** Check if CLIProxyAPI binary is installed */
export function isCLIProxyInstalled(backend?: CLIProxyBackend): boolean {
const effectiveBackend = backend ?? getConfiguredBackend();
const configuredBackend = backend ?? getConfiguredOrDefaultBackend();
syncPlusFallbackStateIfNeeded(configuredBackend);
const effectiveBackend = resolveLocalBackend(configuredBackend, { warnOnFallback: true });
return new BinaryManager({}, effectiveBackend).isBinaryInstalled();
}
/** Get CLIProxyAPI binary path (may not exist) */
export function getCLIProxyPath(backend?: CLIProxyBackend): string {
const effectiveBackend = backend ?? getConfiguredBackend();
const configuredBackend = backend ?? getConfiguredOrDefaultBackend();
syncPlusFallbackStateIfNeeded(configuredBackend);
const effectiveBackend = resolveLocalBackend(configuredBackend, { warnOnFallback: true });
return new BinaryManager({}, effectiveBackend).getBinaryPath();
}
/** Get installed CLIProxyAPI version from .version file */
export function getInstalledCliproxyVersion(backend?: CLIProxyBackend): string {
const effectiveBackend = backend ?? getConfiguredBackend();
const configuredBackend = backend ?? getConfiguredOrDefaultBackend();
syncPlusFallbackStateIfNeeded(configuredBackend);
const effectiveBackend = resolveLocalBackend(configuredBackend, { warnOnFallback: true });
return readInstalledVersion(
getBackendBinDir(effectiveBackend),
BACKEND_CONFIG[effectiveBackend].fallbackVersion
@@ -196,7 +293,9 @@ export async function installCliproxyVersion(
backend?: CLIProxyBackend,
deps: InstallCliproxyVersionDeps = {}
): Promise<void> {
const effectiveBackend = backend ?? getConfiguredBackend();
const configuredBackend = backend ?? getConfiguredOrDefaultBackend();
syncPlusFallbackStateIfNeeded(configuredBackend);
const effectiveBackend = resolveLocalBackend(configuredBackend, { warnOnFallback: true });
const manager =
deps.createManager?.({ version, verbose, forceVersion: true }, effectiveBackend) ??
new BinaryManager({ version, verbose, forceVersion: true }, effectiveBackend);
@@ -237,7 +336,9 @@ export async function installCliproxyVersion(
/** Fetch the latest CLIProxyAPI version from GitHub API */
export async function fetchLatestCliproxyVersion(backend?: CLIProxyBackend): Promise<string> {
const effectiveBackend = backend ?? getConfiguredBackend();
const configuredBackend = backend ?? getConfiguredOrDefaultBackend();
syncPlusFallbackStateIfNeeded(configuredBackend);
const effectiveBackend = resolveLocalBackend(configuredBackend, { warnOnFallback: true });
const result = await new BinaryManager({}, effectiveBackend).checkForUpdates();
return result.latestVersion;
}
@@ -262,7 +363,9 @@ export interface CliproxyUpdateCheckResult {
export async function checkCliproxyUpdate(
backend?: CLIProxyBackend
): Promise<CliproxyUpdateCheckResult> {
const effectiveBackend = backend ?? getConfiguredBackend();
const configuredBackend = backend ?? getConfiguredOrDefaultBackend();
syncPlusFallbackStateIfNeeded(configuredBackend);
const effectiveBackend = resolveLocalBackend(configuredBackend, { warnOnFallback: true });
const result = await new BinaryManager({}, effectiveBackend).checkForUpdates();
// Import isNewerVersion for stability check
+8
View File
@@ -264,9 +264,17 @@ export function mergeCatalog(
mergedIds.add(remote.id.toLowerCase());
if (staticEntry) {
const mergedThinking = remoteEntry.thinking
? {
...remoteEntry.thinking,
maxLevel: remoteEntry.thinking.maxLevel ?? staticEntry.thinking?.maxLevel,
}
: staticEntry.thinking;
// Merge: remote overrides, static fills gaps
mergedModels.push({
...remoteEntry,
thinking: mergedThinking,
// Preserve static-only fields
tier: staticEntry.tier,
broken: staticEntry.broken,
-63
View File
@@ -42,7 +42,6 @@ interface ProviderSettings {
const DEPRECATED_MODEL_PREFIX = 'gemini-claude-';
/** Replacement prefix matching actual upstream model names */
const UPSTREAM_MODEL_PREFIX = 'claude-';
const CODEX_EFFORT_SUFFIX_REGEX = /-(xhigh|high|medium)$/i;
const PRESET_MODEL_KEYS = ['default', 'opus', 'sonnet', 'haiku'] as const;
const REQUIRED_PROVIDER_ENV_KEYS = [
'ANTHROPIC_BASE_URL',
@@ -62,10 +61,6 @@ function isObjectRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value);
}
function stripCodexEffortSuffix(modelId: string): string {
return modelId.replace(CODEX_EFFORT_SUFFIX_REGEX, '');
}
/**
* Migrate deprecated gemini-claude-* model names to upstream claude-* names in a settings file.
* CLIProxyAPI registry no longer recognizes the gemini-claude-* prefix convention.
@@ -130,58 +125,6 @@ function migrateDeprecatedModelNames(
return migrated;
}
/**
* Migrate codex effort-suffixed model IDs in settings to canonical IDs.
* Example: gpt-5.3-codex-xhigh -> gpt-5.3-codex
*/
function migrateCodexEffortSuffixes(
settingsPath: string,
provider: CLIProxyProvider,
settings: ProviderSettings
): boolean {
if (provider !== 'codex') return false;
if (!settings.env || typeof settings.env !== 'object') return false;
let migrated = false;
for (const key of MODEL_ENV_VAR_KEYS) {
const value = settings.env[key];
if (typeof value !== 'string') continue;
const canonical = stripCodexEffortSuffix(value);
if (canonical !== value) {
settings.env[key] = canonical;
migrated = true;
}
}
if (Array.isArray(settings.presets)) {
for (const preset of settings.presets) {
if (!preset || typeof preset !== 'object') continue;
const presetRecord = preset as Record<string, unknown>;
for (const key of PRESET_MODEL_KEYS) {
const value = presetRecord[key];
if (typeof value !== 'string') continue;
const canonical = stripCodexEffortSuffix(value);
if (canonical !== value) {
presetRecord[key] = canonical;
migrated = true;
}
}
}
}
if (migrated) {
try {
fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + '\n', { mode: 0o600 });
} catch {
// Best-effort migration — don't block startup if write fails
}
}
return migrated;
}
/**
* Migrate legacy iFlow model IDs to current upstream model IDs.
* Example: iflow-default -> qwen3-coder-plus, kimi-k2.5 -> kimi-k2
@@ -510,8 +453,6 @@ export function getEffectiveEnvVars(
if (settings.env && typeof settings.env === 'object') {
// Migrate deprecated gemini-claude-* model names if present
migrateDeprecatedModelNames(expandedPath, provider, settings);
// Migrate codex effort suffixes to canonical IDs if present
migrateCodexEffortSuffixes(expandedPath, provider, settings);
// Migrate legacy iFlow placeholders to supported model IDs
migrateIFlowPlaceholderModel(expandedPath, provider, settings);
// Custom variant settings found - merge with global env
@@ -545,8 +486,6 @@ export function getEffectiveEnvVars(
if (settings.env && typeof settings.env === 'object') {
// Migrate deprecated gemini-claude-* model names if present
migrateDeprecatedModelNames(settingsPath, provider, settings);
// Migrate codex effort suffixes to canonical IDs if present
migrateCodexEffortSuffixes(settingsPath, provider, settings);
// Migrate legacy iFlow placeholders to supported model IDs
migrateIFlowPlaceholderModel(settingsPath, provider, settings);
// User override found - merge with global env
@@ -719,7 +658,6 @@ export function getRemoteEnvVars(
const settings: ProviderSettings = JSON.parse(content);
if (settings.env && typeof settings.env === 'object') {
migrateDeprecatedModelNames(expandedPath, provider, settings);
migrateCodexEffortSuffixes(expandedPath, provider, settings);
migrateIFlowPlaceholderModel(expandedPath, provider, settings);
userEnvVars = settings.env as Record<string, string>;
}
@@ -739,7 +677,6 @@ export function getRemoteEnvVars(
const settings: ProviderSettings = JSON.parse(content);
if (settings.env && typeof settings.env === 'object') {
migrateDeprecatedModelNames(settingsPath, provider, settings);
migrateCodexEffortSuffixes(settingsPath, provider, settings);
migrateIFlowPlaceholderModel(settingsPath, provider, settings);
userEnvVars = settings.env as Record<string, string>;
}
+12 -2
View File
@@ -7,7 +7,7 @@ import type { CLIProxyProvider } from '../types';
import { DEFAULT_THINKING_TIER_DEFAULTS } from '../../config/unified-config-types';
import type { ThinkingConfig } from '../../config/unified-config-types';
import { getThinkingConfig } from '../../config/unified-config-loader';
import { supportsThinking } from '../model-catalog';
import { getModelThinkingSupport, supportsThinking } from '../model-catalog';
import { isThinkingOffValue, validateThinking } from '../thinking-validator';
import { normalizeModelIdForProvider } from '../model-id-normalizer';
import { warn } from '../../utils/ui';
@@ -86,7 +86,8 @@ function applyThinkingSuffixForProvider(
const parenthesizedSuffixMatch = model.match(/\(([^)]+)\)$/);
// Existing parenthesized suffix:
// - keep as-is for non-codex providers
// - keep as-is for non-codex providers unless the target model now expects
// named levels and we need to rewrite an old numeric suffix
// - for codex effort levels, normalize to codex model suffix style
if (parenthesizedSuffixMatch) {
if (provider === 'codex') {
@@ -95,6 +96,15 @@ function applyThinkingSuffixForProvider(
return model.replace(/\([^)]+\)$/, `-${normalizedParensValue}`);
}
}
if (provider) {
const normalizedBaseModel = normalizeModelForThinkingLookup(model, provider);
const thinking = getModelThinkingSupport(provider, normalizedBaseModel);
if (thinking?.type === 'levels') {
return model.replace(/\([^)]+\)$/, `(${thinkingValue})`);
}
}
return model;
}
+22 -20
View File
@@ -18,7 +18,11 @@ import { ProgressIndicator } from '../../utils/progress-indicator';
import { ok, fail, info, warn } from '../../utils/ui';
import { getCcsDir } from '../../utils/config-manager';
import { escapeShellArg, getWindowsEscapedCommandShell } from '../../utils/shell-executor';
import { ensureCLIProxyBinary } from '../binary-manager';
import {
ensureCLIProxyBinary,
getConfiguredBackend,
getPlusBackendUnavailableMessage,
} from '../binary-manager';
import {
generateConfig,
getProviderConfig,
@@ -30,7 +34,6 @@ import {
import { checkRemoteProxy } from '../remote-proxy-client';
import { isAuthenticated } from '../auth-handler';
import { CLIProxyProvider, CLIProxyBackend, PLUS_ONLY_PROVIDERS, ExecutorConfig } from '../types';
import { DEFAULT_BACKEND } from '../platform-detector';
import { configureProviderModel, getCurrentModel } from '../model-config';
import { reconcileCodexModelForActivePlan } from '../codex-plan-compatibility';
import { resolveProxyConfig, PROXY_CLI_FLAGS } from '../proxy-config-resolver';
@@ -211,23 +214,8 @@ export async function execClaudeWithCLIProxy(
// 0. Resolve proxy configuration (CLI > ENV > config.yaml > defaults)
const unifiedConfig = loadOrCreateUnifiedConfig();
// 0a. Runtime backend/provider validation
const backend: CLIProxyBackend = unifiedConfig.cliproxy?.backend ?? DEFAULT_BACKEND;
// Collect all providers to validate (default + composite tiers)
const allProviders = [provider, ...compositeProviders];
for (const p of allProviders) {
if (backend === 'original' && PLUS_ONLY_PROVIDERS.includes(p as CLIProxyProvider)) {
console.error('');
console.error(fail(`${p} requires CLIProxyAPIPlus backend`));
console.error('');
console.error('To use this provider, either:');
console.error(' 1. Set `cliproxy.backend: plus` in ~/.ccs/config.yaml');
console.error(' 2. Use --backend=plus flag: ccs ' + p + ' --backend=plus');
console.error('');
throw new Error(`Provider ${p} requires Plus backend`);
}
}
const cliproxyServerConfig = unifiedConfig.cliproxy_server;
const { config: proxyConfig, remainingArgs: argsWithoutProxy } = resolveProxyConfig(args, {
@@ -314,6 +302,7 @@ export async function execClaudeWithCLIProxy(
// Check remote proxy if configured
let useRemoteProxy = false;
let localBackend: CLIProxyBackend = 'original';
if (proxyConfig.mode === 'remote' && proxyConfig.host) {
const status = await checkRemoteProxy({
host: proxyConfig.host,
@@ -361,6 +350,19 @@ export async function execClaudeWithCLIProxy(
}
}
if (!useRemoteProxy) {
localBackend = getConfiguredBackend({ warnOnFallback: true });
for (const p of allProviders) {
if (localBackend === 'original' && PLUS_ONLY_PROVIDERS.includes(p as CLIProxyProvider)) {
console.error('');
console.error(fail(getPlusBackendUnavailableMessage(p)));
console.error('');
throw new Error(`Provider ${p} is temporarily unavailable on local CLIProxy`);
}
}
}
// Variables for local proxy mode
let binaryPath: string | undefined;
let sessionId: string | undefined;
@@ -572,7 +574,7 @@ export async function execClaudeWithCLIProxy(
console.error(' Alias: --thinking xhigh (same behavior)');
} else {
console.error(' Examples: --thinking low, --thinking 8192, --thinking off');
console.error(' Levels: minimal, low, medium, high, xhigh, auto');
console.error(' Levels: minimal, low, medium, high, xhigh, max, auto');
}
process.exit(1);
@@ -1000,13 +1002,13 @@ export async function execClaudeWithCLIProxy(
cfg.port,
cfg.timeout,
cfg.pollInterval,
backend,
localBackend,
configPath
);
// Register session
if (proxy.pid) {
sessionId = registerProxySession(cfg.port, proxy.pid, backend, verbose);
sessionId = registerProxySession(cfg.port, proxy.pid, localBackend, verbose);
}
}
}
+8 -5
View File
@@ -33,7 +33,7 @@ export interface ThinkingSupport {
/** Valid level names (for levels type) */
levels?: string[];
/** Maximum reasoning effort level (caps effort at this level for levels type) */
maxLevel?: 'minimal' | 'low' | 'medium' | 'high' | 'xhigh';
maxLevel?: 'minimal' | 'low' | 'medium' | 'high' | 'xhigh' | 'max';
/** Whether zero/disabled thinking is allowed */
zeroAllowed?: boolean;
/** Whether dynamic/auto thinking is allowed */
@@ -295,11 +295,14 @@ export const MODEL_CATALOG: Partial<Record<CLIProxyProvider, ProviderCatalog>> =
name: 'Claude Opus 4.7',
description: 'Latest flagship model',
nativeImageInput: true,
// Opus 4.7 only supports adaptive thinking on the Anthropic API; manual
// thinking.type: "enabled" with budget_tokens is rejected with 400.
// Expose effort levels; the proxy translates these into adaptive effort.
// `max` is a distinct adaptive effort above `xhigh` exposed by Anthropic.
thinking: {
type: 'budget',
min: 1024,
max: 128000,
zeroAllowed: false,
type: 'levels',
levels: ['low', 'medium', 'high', 'xhigh', 'max'],
maxLevel: 'max',
dynamicAllowed: true,
},
extendedContext: true,
+1 -9
View File
@@ -15,16 +15,8 @@ import { initUI, color, bold, dim, ok, info, header } from '../utils/ui';
import { getCcsDir } from '../utils/config-manager';
import { normalizeModelIdForProvider } from './model-id-normalizer';
const CODEX_EFFORT_SUFFIX_REGEX = /-(xhigh|high|medium)$/i;
function stripCodexEffortSuffix(model: string, provider: CLIProxyProvider): string {
if (provider !== 'codex') return model;
return model.replace(CODEX_EFFORT_SUFFIX_REGEX, '');
}
function canonicalizeModelForProvider(provider: CLIProxyProvider, model: string): string {
const withoutCodexSuffix = stripCodexEffortSuffix(model, provider);
return normalizeModelIdForProvider(withoutCodexSuffix, provider);
return normalizeModelIdForProvider(model, provider);
}
/**
+22 -9
View File
@@ -65,6 +65,18 @@ function splitBaseModelAndSuffix(model: string): { baseModel: string; suffix: st
};
}
function splitCodexEffortSuffix(model: string): { baseModel: string; suffix: string } {
const match = model.match(CODEX_EFFORT_SUFFIX_REGEX);
if (!match?.[0]) {
return { baseModel: model, suffix: '' };
}
return {
baseModel: model.slice(0, -match[0].length),
suffix: match[0],
};
}
/**
* Extract provider segment from `/api/provider/{provider}` request paths.
*
@@ -97,20 +109,24 @@ export function isIFlowProvider(provider: ProviderLike): boolean {
return provider.trim().toLowerCase() === 'iflow';
}
/** Normalize Codex effort-suffixed IDs to canonical IDs. */
/** Strip Codex effort suffixes while preserving trailing config suffixes. */
export function stripCodexEffortSuffix(model: string): string {
return model.replace(CODEX_EFFORT_SUFFIX_REGEX, '');
const trimmed = trimModelId(model);
const { baseModel, suffix } = splitBaseModelAndSuffix(trimmed);
const { baseModel: withoutEffort } = splitCodexEffortSuffix(baseModel);
return `${withoutEffort}${suffix}`;
}
/** Normalize legacy Codex aliases to the current public Codex model IDs. */
export function normalizeCodexLegacyModelAliases(model: string): string {
const trimmed = trimModelId(model);
const { baseModel, suffix } = splitBaseModelAndSuffix(trimmed);
const replacement = CODEX_LEGACY_MODEL_ALIASES[baseModel.trim().toLowerCase()];
const { baseModel: baseWithoutEffort, suffix: effortSuffix } = splitCodexEffortSuffix(baseModel);
const replacement = CODEX_LEGACY_MODEL_ALIASES[baseWithoutEffort.trim().toLowerCase()];
if (!replacement) {
return trimmed;
}
return `${replacement}${suffix}`;
return `${replacement}${effortSuffix}${suffix}`;
}
/**
@@ -217,15 +233,12 @@ export function normalizeModelIdForProvider(model: string, provider: ProviderLik
/**
* Canonicalize model ID for provider-specific compatibility.
* - Codex: strip effort suffixes.
* - Codex: preserve valid effort suffixes while normalizing legacy aliases.
* - Antigravity: normalize dotted/historical aliases.
*/
export function canonicalizeModelIdForProvider(model: string, provider: ProviderLike): string {
const trimmedModel = trimModelId(model);
const withoutCodexSuffix = isCodexProvider(provider)
? stripCodexEffortSuffix(trimmedModel)
: trimmedModel;
return normalizeModelIdForProvider(withoutCodexSuffix, provider);
return normalizeModelIdForProvider(trimmedModel, provider);
}
/**
+9 -2
View File
@@ -29,8 +29,15 @@ export const BACKEND_CONFIG = {
},
} as const;
/** Default backend */
export const DEFAULT_BACKEND: CLIProxyBackend = 'plus';
/**
* Default backend
*
* Set to 'original' because upstream `router-for-me/CLIProxyAPIPlus` was
* deleted (issue #1062). The original `router-for-me/CLIProxyAPI` repo is
* still maintained. Users with existing `backend: plus` configs are migrated
* at runtime via a 404 fallback in the installer (see binary/installer.ts).
*/
export const DEFAULT_BACKEND: CLIProxyBackend = 'original';
/**
* CLIProxyAPIPlus fallback version (used when GitHub API unavailable)
+33 -3
View File
@@ -286,6 +286,9 @@ export async function findHealthyAccount(
return null;
}
const { restoreExpiredQuotaPauses } = await import('./account-safety');
restoreExpiredQuotaPauses();
const config = loadOrCreateUnifiedConfig();
const tierPriority = config.quota_management?.auto?.tier_priority ?? ['ultra', 'pro', 'free'];
const threshold = config.quota_management?.auto?.exhaustion_threshold ?? 5;
@@ -388,6 +391,11 @@ export async function preflightCheck(provider: CLIProxyProvider): Promise<Prefli
return { proceed: true, accountId: defaultAccount?.id || '' };
}
const { pauseAccountForQuotaCooldown, restoreExpiredQuotaPauses } = await import(
'./account-safety'
);
restoreExpiredQuotaPauses();
const config = loadOrCreateUnifiedConfig();
const quotaConfig = config.quota_management;
@@ -442,13 +450,32 @@ export async function preflightCheck(provider: CLIProxyProvider): Promise<Prefli
const threshold = quotaConfig.auto?.exhaustion_threshold ?? 5;
if (avgQuota < threshold) {
// Apply cooldown to exhausted account
applyCooldown(provider, defaultAccount.id, quotaConfig.auto?.cooldown_minutes ?? 5);
return await findAndSwitch(
const alternative = await findHealthyAccount(provider, [defaultAccount.id]);
if (!alternative) {
return {
proceed: true,
accountId: defaultAccount.id,
reason: `Quota exhausted (${avgQuota.toFixed(1)}%), no alternatives available`,
quotaPercent,
};
}
pauseAccountForQuotaCooldown(
provider,
defaultAccount.id,
`Quota exhausted (${avgQuota.toFixed(1)}%)`
quotaConfig.auto?.cooldown_minutes ?? 5
);
setDefaultAccount(provider, alternative.id);
touchAccount(provider, alternative.id);
return {
proceed: true,
accountId: alternative.id,
switchedFrom: defaultAccount.id,
reason: `Quota exhausted (${avgQuota.toFixed(1)}%)`,
quotaPercent: alternative.lastQuota,
};
}
return {
@@ -471,6 +498,9 @@ export async function getQuotaStatus(provider: CLIProxyProvider): Promise<{
isDefault: boolean;
}>;
}> {
const { restoreExpiredQuotaPauses } = await import('./account-safety');
restoreExpiredQuotaPauses();
const accounts = getProviderAccounts(provider);
const defaultAccount = getDefaultAccount(provider);
+23 -7
View File
@@ -18,6 +18,8 @@ import {
savePinnedVersion,
clearPinnedVersion,
isVersionPinned,
resolveLocalBackend,
syncPlusFallbackStateIfNeeded,
} from '../binary-manager';
import { BACKEND_CONFIG, DEFAULT_BACKEND } from '../platform-detector';
import { CLIProxyBackend } from '../types';
@@ -54,8 +56,10 @@ export interface LatestVersionResult {
* Get current binary status for a specific backend
*/
export function getBinaryStatus(backend?: CLIProxyBackend): BinaryStatusResult {
const effectiveBackend =
const configuredBackend =
backend ?? loadOrCreateUnifiedConfig().cliproxy?.backend ?? DEFAULT_BACKEND;
syncPlusFallbackStateIfNeeded(configuredBackend);
const effectiveBackend = resolveLocalBackend(configuredBackend, { warnOnFallback: true });
const backendConfig = BACKEND_CONFIG[effectiveBackend];
return {
installed: isCLIProxyInstalled(effectiveBackend),
@@ -71,8 +75,10 @@ export function getBinaryStatus(backend?: CLIProxyBackend): BinaryStatusResult {
* Check for latest version
*/
export async function checkLatestVersion(backend?: CLIProxyBackend): Promise<LatestVersionResult> {
const effectiveBackend =
const configuredBackend =
backend ?? loadOrCreateUnifiedConfig().cliproxy?.backend ?? DEFAULT_BACKEND;
syncPlusFallbackStateIfNeeded(configuredBackend);
const effectiveBackend = resolveLocalBackend(configuredBackend, { warnOnFallback: true });
try {
// Use checkCliproxyUpdate which is backend-aware (uses correct GitHub repo)
@@ -119,8 +125,10 @@ export async function installVersion(
};
}
const effectiveBackend =
const configuredBackend =
backend ?? loadOrCreateUnifiedConfig().cliproxy?.backend ?? DEFAULT_BACKEND;
syncPlusFallbackStateIfNeeded(configuredBackend);
const effectiveBackend = resolveLocalBackend(configuredBackend, { warnOnFallback: true });
try {
await installCliproxyVersion(version, verbose, effectiveBackend);
@@ -147,8 +155,10 @@ export async function installLatest(
verbose = false,
backend?: CLIProxyBackend
): Promise<InstallResult> {
const effectiveBackend =
const configuredBackend =
backend ?? loadOrCreateUnifiedConfig().cliproxy?.backend ?? DEFAULT_BACKEND;
syncPlusFallbackStateIfNeeded(configuredBackend);
const effectiveBackend = resolveLocalBackend(configuredBackend, { warnOnFallback: true });
try {
const latestVersion = await fetchLatestCliproxyVersion(effectiveBackend);
@@ -184,8 +194,10 @@ export async function installLatest(
* Check if a version is pinned
*/
export function isPinned(backend?: CLIProxyBackend): boolean {
const effectiveBackend =
const configuredBackend =
backend ?? loadOrCreateUnifiedConfig().cliproxy?.backend ?? DEFAULT_BACKEND;
syncPlusFallbackStateIfNeeded(configuredBackend);
const effectiveBackend = resolveLocalBackend(configuredBackend, { warnOnFallback: true });
return isVersionPinned(effectiveBackend);
}
@@ -193,8 +205,10 @@ export function isPinned(backend?: CLIProxyBackend): boolean {
* Get pinned version if any
*/
export function getPinned(backend?: CLIProxyBackend): string | null {
const effectiveBackend =
const configuredBackend =
backend ?? loadOrCreateUnifiedConfig().cliproxy?.backend ?? DEFAULT_BACKEND;
syncPlusFallbackStateIfNeeded(configuredBackend);
const effectiveBackend = resolveLocalBackend(configuredBackend, { warnOnFallback: true });
return getPinnedVersion(effectiveBackend);
}
@@ -202,7 +216,9 @@ export function getPinned(backend?: CLIProxyBackend): string | null {
* Clear version pin
*/
export function clearPin(backend?: CLIProxyBackend): void {
const effectiveBackend =
const configuredBackend =
backend ?? loadOrCreateUnifiedConfig().cliproxy?.backend ?? DEFAULT_BACKEND;
syncPlusFallbackStateIfNeeded(configuredBackend);
const effectiveBackend = resolveLocalBackend(configuredBackend, { warnOnFallback: true });
clearPinnedVersion(effectiveBackend);
}
+13 -16
View File
@@ -8,12 +8,10 @@
import * as os from 'os';
import * as path from 'path';
import { CLIProxyProfileName } from '../../auth/profile-detector';
import { CLIProxyProvider, CLIProxyBackend, PLUS_ONLY_PROVIDERS } from '../types';
import { CLIProxyProvider, PLUS_ONLY_PROVIDERS } from '../types';
import { CompositeTierConfig, CompositeVariantConfig } from '../../config/unified-config-types';
import type { TargetType } from '../../targets/target-adapter';
import { isReservedName, isWindowsReservedName } from '../../config/reserved-names';
import { loadOrCreateUnifiedConfig } from '../../config/unified-config-loader';
import { DEFAULT_BACKEND } from '../platform-detector';
import { isUnifiedMode } from '../../config/unified-config-loader';
import { deleteConfigForPort } from '../config-generator';
import { hasActiveSessions, deleteSessionLockForPort } from '../session-tracker';
@@ -44,6 +42,7 @@ import {
removeVariantFromLegacyConfig,
getNextAvailablePort,
} from './variant-config-adapter';
import { getConfiguredBackend, getPlusBackendUnavailableMessage } from '../binary-manager';
// Re-export VariantConfig from adapter
export type { VariantConfig } from './variant-config-adapter';
@@ -80,16 +79,15 @@ export function validateProfileName(name: string): string | null {
/**
* Validate provider/backend compatibility
* Returns error message if provider requires Plus backend but original is configured
* Returns error message if a provider requires Plus while local CLIProxy is
* running with the fallbacked original backend.
*/
export function validateProviderBackend(provider: CLIProxyProfileName): string | null {
const config = loadOrCreateUnifiedConfig();
const backend: CLIProxyBackend = config.cliproxy?.backend ?? DEFAULT_BACKEND;
// Normalize provider to lowercase for case-insensitive comparison
const normalizedProvider = provider.toLowerCase() as CLIProxyProvider;
const backend = getConfiguredBackend();
if (backend === 'original' && PLUS_ONLY_PROVIDERS.includes(normalizedProvider)) {
return `${provider} requires CLIProxyAPIPlus. Set \`cliproxy.backend: plus\` in config.yaml or use --backend=plus`;
return getPlusBackendUnavailableMessage(provider);
}
return null;
}
@@ -280,6 +278,13 @@ export function updateVariant(name: string, updates: UpdateVariantOptions): Vari
}
}
if (updates.provider !== undefined) {
const backendError = validateProviderBackend(updates.provider);
if (backendError) {
return { success: false, error: backendError };
}
}
// Update settings file
if (existing.settings) {
const settingsPath = existing.settings.replace(/^~/, os.homedir());
@@ -302,14 +307,6 @@ export function updateVariant(name: string, updates: UpdateVariantOptions): Vari
// Update config entry if provider/account/target changed
if (updates.provider !== undefined || updates.account !== undefined || targetChanged) {
const newProvider = updates.provider ?? existing.provider;
// Validate provider/backend compatibility on provider change
if (updates.provider !== undefined) {
const backendError = validateProviderBackend(updates.provider);
if (backendError) {
return { success: false, error: backendError };
}
}
const newAccount = updates.account !== undefined ? updates.account : existing.account;
const newTarget = updates.target ?? existingTarget;
+2 -6
View File
@@ -41,16 +41,12 @@ interface SettingsFile {
[key: string]: unknown;
}
const CODEX_EFFORT_SUFFIX_REGEX = /-(xhigh|high|medium)$/i;
function canonicalizeModelForProvider(
provider: CLIProxyProfileName | undefined,
model: string
): string {
const withoutCodexSuffix =
provider === 'codex' ? model.replace(CODEX_EFFORT_SUFFIX_REGEX, '') : model;
if (!provider) return withoutCodexSuffix;
return normalizeModelIdForProvider(withoutCodexSuffix, provider);
if (!provider) return model;
return normalizeModelIdForProvider(model, provider);
}
/**
+22 -2
View File
@@ -32,6 +32,11 @@ export interface ThinkingValidationResult {
/**
* Named thinking level mappings to budget values (when converting levelbudget)
*
* `max` sits above `xhigh` to represent unconstrained thinking (Claude Opus 4.7,
* Mythos). The numeric value is a CCS-internal mapping, not an Anthropic wire
* value: Opus 4.7 uses adaptive thinking with an effort string, and other
* max-capable models already treat the level as a qualitative cap.
*/
export const THINKING_LEVEL_BUDGETS: Record<string, number> = {
minimal: 512,
@@ -39,6 +44,7 @@ export const THINKING_LEVEL_BUDGETS: Record<string, number> = {
medium: 8192,
high: 24576,
xhigh: 32768,
max: 65536,
};
/**
@@ -50,12 +56,21 @@ export const THINKING_LEVEL_RANK: Record<string, number> = {
medium: 3,
high: 4,
xhigh: 5,
max: 6,
};
/**
* Valid thinking level names
*/
export const VALID_THINKING_LEVELS = ['minimal', 'low', 'medium', 'high', 'xhigh', 'auto'] as const;
export const VALID_THINKING_LEVELS = [
'minimal',
'low',
'medium',
'high',
'xhigh',
'max',
'auto',
] as const;
export type ThinkingLevel = (typeof VALID_THINKING_LEVELS)[number];
/**
@@ -121,7 +136,12 @@ function findClosestLevel(input: string, validLevels: string[]): string | undefi
}
}
// Common aliases
// Common aliases.
//
// `max: 'xhigh'` is a graceful fallback for models whose levels list does not
// include `max` (e.g. Codex `gpt-5.4`). Exact match above takes priority, so
// Opus 4.7 (which has `max` in its validLevels) returns `max` directly while
// Codex still maps `max` → `xhigh`.
const aliases: Record<string, string> = {
min: 'minimal',
lo: 'low',
+4 -2
View File
@@ -148,8 +148,10 @@ export type CLIProxyProvider =
/**
* CLIProxy backend selection
* - original: CLIProxyAPI (legacy provider subset)
* - plus: CLIProxyAPIPlus (expanded provider support, default)
* - original: CLIProxyAPI (current default; upstream still maintained)
* - plus: CLIProxyAPIPlus (expanded provider support; upstream deleted as of
* issue #1062 runtime falls back to `original`. Retained for forward
* compatibility once CCS self-maintains its own Plus build.)
*/
export type CLIProxyBackend = 'original' | 'plus';
+9 -1
View File
@@ -84,7 +84,10 @@ export async function showHelp(): Promise<void> {
[
'Options:',
[
['--backend <type>', 'Use specific backend: original | plus (default: from config)'],
[
'--backend <type>',
'Use specific backend: original | plus (local default: original; plus currently falls back locally)',
],
['--target <cli>', 'Default target for created/edited variants: claude | droid'],
['--verbose, -v', 'Show detailed diagnostics including routing hints and quota fetches'],
],
@@ -102,6 +105,11 @@ export async function showHelp(): Promise<void> {
console.log(dim(' Note: CLIProxy now persists by default. Use "stop" to terminate.'));
console.log(dim(' Routing: use gcli/<model> or agy/<model> to keep overlapping models pinned.'));
console.log(
dim(
' Backend: local CLIProxy currently uses original by default; saved plus configs fall back locally.'
)
);
console.log('');
console.log(subheader('Notes:'));
console.log(` Default fallback version: ${color(getFallbackVersion(), 'info')}`);
+3 -6
View File
@@ -6,14 +6,13 @@
*/
import { CLIProxyBackend } from '../../cliproxy/types';
import { DEFAULT_BACKEND } from '../../cliproxy/platform-detector';
import { getStoredConfiguredBackend } from '../../cliproxy/binary-manager';
import {
type QuotaSupportedProvider,
QUOTA_PROVIDER_HELP_TEXT,
mapExternalProviderName,
isQuotaSupportedProvider,
} from '../../cliproxy/provider-capabilities';
import { loadOrCreateUnifiedConfig } from '../../config/unified-config-loader';
import { handleSync } from '../cliproxy-sync-handler';
import { extractOption, hasAnyFlag } from '../arg-extractor';
@@ -71,12 +70,10 @@ function parseBackendArg(args: string[]): {
}
/**
* Get effective backend (CLI flag > config.yaml > default)
* Get selected backend input (CLI flag > config.yaml > default)
*/
function getEffectiveBackend(cliBackend?: CLIProxyBackend): CLIProxyBackend {
if (cliBackend) return cliBackend;
const config = loadOrCreateUnifiedConfig();
return config.cliproxy?.backend ?? DEFAULT_BACKEND;
return cliBackend ?? getStoredConfiguredBackend();
}
/**
+1 -1
View File
@@ -47,7 +47,7 @@ export async function showStatus(verbose: boolean, backend: CLIProxyBackend): Pr
console.log(` ${dim('Run "ccs gemini" or any provider to auto-install')}`);
}
const latestCheck = await checkLatestVersion();
const latestCheck = await checkLatestVersion(backend);
if (latestCheck.success && latestCheck.latestVersion) {
console.log('');
if (latestCheck.updateAvailable) {
+1 -1
View File
@@ -60,7 +60,7 @@ function showHelp(): void {
console.log(subheader('Levels:'));
console.log(
` ${dim('minimal (512), low (1K), medium (8K), high (24K), xhigh (32K), auto, off')}`
` ${dim('minimal (512), low (1K), medium (8K), high (24K), xhigh (32K), max (adaptive ceiling), auto, off')}`
);
console.log('');
+4 -2
View File
@@ -431,7 +431,7 @@ function mergeWithDefaults(partial: Partial<UnifiedConfig>): UnifiedConfig {
backend:
partial.cliproxy?.backend === 'original' || partial.cliproxy?.backend === 'plus'
? partial.cliproxy.backend
: undefined, // Invalid values become undefined (defaults to 'plus' at runtime)
: undefined, // Invalid values become undefined (defaults to 'original' at runtime)
// Auto-sync - default to true
auto_sync: partial.cliproxy?.auto_sync ?? defaults.cliproxy.auto_sync ?? true,
routing: {
@@ -925,7 +925,9 @@ function generateYamlWithComments(config: UnifiedConfig): string {
lines.push(
'# Modes: auto (use tier_defaults), off (disable), manual (--thinking/--effort flags)'
);
lines.push('# Levels: minimal (512), low (1K), medium (8K), high (24K), xhigh (32K), auto');
lines.push(
'# Levels: minimal (512), low (1K), medium (8K), high (24K), xhigh (32K), max (adaptive ceiling), auto'
);
lines.push('# Override: Set global override value (number or level name)');
lines.push('# Provider overrides: Per-provider tier defaults');
lines.push('# ----------------------------------------------------------------------------');
+2 -2
View File
@@ -211,7 +211,7 @@ export interface CLIProxyRoutingConfig {
* CLIProxy configuration section.
*/
export interface CLIProxyConfig {
/** Backend selection: 'original' or 'plus' (default: 'plus') */
/** Backend selection: 'original' or 'plus' (default: 'original') */
backend?: 'original' | 'plus';
/** Nickname to email mapping for OAuth accounts */
oauth_accounts: OAuthAccounts;
@@ -1023,7 +1023,7 @@ export function createEmptyUnifiedConfig(): UnifiedConfig {
accounts: {},
profiles: {},
cliproxy: {
backend: 'plus',
backend: 'original',
oauth_accounts: {},
providers: [...CLIPROXY_SUPPORTED_PROVIDERS],
variants: {},
+18 -5
View File
@@ -68,17 +68,30 @@ function toToolResultContent(content: unknown, label: string): string {
return safeJsonStringify(content, TOOL_RESULT_SERIALIZATION_FALLBACK);
}
function mapThinkingToReasoningEffort(
thinking: CursorAnthropicRequest['thinking']
): string | undefined {
function mapAdaptiveEffortToCursorReasoningEffort(effort: string | undefined): string {
const normalized = effort?.trim().toLowerCase();
if (!normalized || normalized === 'auto') {
return 'high';
}
if (normalized === 'minimal' || normalized === 'low' || normalized === 'medium') {
return 'medium';
}
return 'high';
}
function mapThinkingToReasoningEffort(request: CursorAnthropicRequest): string | undefined {
const thinking = request.thinking;
if (!thinking) {
return undefined;
}
if (thinking.type === 'disabled') {
return undefined;
}
if (thinking.type === 'adaptive') {
return mapAdaptiveEffortToCursorReasoningEffort(request.output_config?.effort);
}
if (thinking.type !== 'enabled') {
throw new Error('thinking.type must be "enabled" or "disabled"');
throw new Error('thinking.type must be "enabled", "adaptive", or "disabled"');
}
return typeof thinking.budget_tokens === 'number' && thinking.budget_tokens >= 8192
? 'high'
@@ -209,7 +222,7 @@ export function translateAnthropicRequest(raw: unknown): TranslatedAnthropicRequ
? request.model
: undefined,
stream: request.stream === true,
reasoning_effort: mapThinkingToReasoningEffort(request.thinking),
reasoning_effort: mapThinkingToReasoningEffort(request),
tools: Array.isArray(request.tools) ? request.tools : undefined,
messages: translatedMessages,
};
+3
View File
@@ -41,6 +41,9 @@ export interface CursorAnthropicRequest {
system?: string | AnthropicTextBlock[];
stream?: boolean;
tools?: CursorTool[];
output_config?: {
effort?: string;
};
thinking?: {
type?: string;
budget_tokens?: number;
+51 -2
View File
@@ -16,8 +16,13 @@ import { type ExecutionOptions, type ExecutionResult, type StreamMessage } from
import { StreamBuffer, formatToolVerbose } from './executor/stream-parser';
import { buildExecutionResult } from './executor/result-aggregator';
import { getCcsDir, getModelDisplayName, loadSettings } from '../utils/config-manager';
import { getGlobalEnvConfig } from '../config/unified-config-loader';
import { getProfileLookupCandidates } from '../utils/profile-compat';
import { getClaudeLaunchEnvOverrides, stripClaudeCodeEnv } from '../utils/shell-executor';
import {
getClaudeLaunchEnvOverrides,
stripAnthropicRoutingEnv,
stripClaudeCodeEnv,
} from '../utils/shell-executor';
import { resolveProfileContinuityInheritance } from '../auth/profile-continuity-inheritance';
import {
appendThirdPartyImageAnalysisToolArgs,
@@ -38,6 +43,11 @@ import {
import { resolveCliproxyBridgeMetadata } from '../api/services';
import { ensureCliproxyService } from '../cliproxy';
import { CLIPROXY_DEFAULT_PORT } from '../cliproxy/config/port-manager';
import {
buildOpenAICompatProxyEnv,
resolveOpenAICompatProfileConfig,
startOpenAICompatProxy,
} from '../proxy';
import {
appendThirdPartyWebSearchToolArgs,
appendWebSearchTrace,
@@ -129,6 +139,14 @@ export class HeadlessExecutor {
syncImageAnalysisMcpToConfigDir(inheritedClaudeConfigDir);
const settings = loadSettings(settingsPath);
const globalEnvConfig = getGlobalEnvConfig();
const globalEnv = globalEnvConfig.enabled ? globalEnvConfig.env : {};
const settingsEnv = settings.env || {};
const openAICompatProfile = resolveOpenAICompatProfileConfig(
profile,
settingsPath,
settingsEnv
);
const cliproxyBridge = resolveCliproxyBridgeMetadata(settings);
let imageAnalysisFallbackHookReady: boolean | undefined;
if (imageAnalysisMcpReady) {
@@ -207,6 +225,33 @@ export class HeadlessExecutor {
}
}
let runtimeEnvVars: NodeJS.ProcessEnv = {
...stripAnthropicRoutingEnv({ ...globalEnv, ...settingsEnv }),
...(inheritedClaudeConfigDir ? { CLAUDE_CONFIG_DIR: inheritedClaudeConfigDir } : {}),
CCS_PROFILE_TYPE: 'settings',
CCS_STRIP_INHERITED_ANTHROPIC_ENV: '1',
};
if (openAICompatProfile) {
const proxyStart = await startOpenAICompatProxy(openAICompatProfile, {
insecure: openAICompatProfile.insecure,
});
if (!proxyStart.success) {
throw new Error(proxyStart.error || 'Failed to start local OpenAI-compatible proxy');
}
runtimeEnvVars = {
...runtimeEnvVars,
...buildOpenAICompatProxyEnv(
openAICompatProfile,
proxyStart.port,
proxyStart.authToken || '',
inheritedClaudeConfigDir
),
};
delete runtimeEnvVars.ANTHROPIC_API_KEY;
}
// Smart slash command detection and preservation
const processedPrompt = this._processSlashCommand(enhancedPrompt);
@@ -321,6 +366,7 @@ export class HeadlessExecutor {
sessionMgr,
claudeConfigDir: inheritedClaudeConfigDir,
imageAnalysisEnv,
runtimeEnvVars,
traceEnv,
});
}
@@ -340,6 +386,7 @@ export class HeadlessExecutor {
sessionMgr: SessionManager;
claudeConfigDir?: string;
imageAnalysisEnv?: Record<string, string>;
runtimeEnvVars?: NodeJS.ProcessEnv;
traceEnv?: Record<string, string>;
}
): Promise<ExecutionResult> {
@@ -352,6 +399,7 @@ export class HeadlessExecutor {
sessionMgr,
claudeConfigDir,
imageAnalysisEnv = {},
runtimeEnvVars = {},
traceEnv = {},
} = ctx;
@@ -368,9 +416,10 @@ export class HeadlessExecutor {
// Strip Claude Code nested session guard env var to allow CCS delegation
// (Claude Code v2.1.39+ sets CLAUDECODE to detect nested sessions)
const cleanEnv = stripClaudeCodeEnv({
...process.env,
...stripAnthropicRoutingEnv(process.env),
...getClaudeLaunchEnvOverrides(),
...getWebSearchHookEnv(),
...runtimeEnvVars,
...imageAnalysisEnv,
...traceEnv,
...(claudeConfigDir ? { CLAUDE_CONFIG_DIR: claudeConfigDir } : {}),
@@ -396,7 +396,7 @@ function mapThinkingToReasoning(
};
}
const VALID_EFFORT_LEVELS = new Set(['low', 'medium', 'high', 'max']);
const VALID_EFFORT_LEVELS = new Set(['low', 'medium', 'high', 'xhigh', 'max']);
function resolveOutputConfigEffort(
outputConfig: AnthropicOutputConfig | undefined
@@ -416,7 +416,7 @@ function resolveOutputConfigEffort(
* for Codex; for generic OpenAI-compat providers we clamp to high.
*/
function toOpenAIEffort(effort: string): string {
return effort === 'max' ? 'high' : effort;
return effort === 'max' || effort === 'xhigh' ? 'high' : effort;
}
function transformMessages(messagesValue: unknown): OpenAIMessage[] {
+44
View File
@@ -9,6 +9,9 @@ import * as fs from 'fs';
import * as path from 'path';
import * as os from 'os';
import * as lockfile from 'proper-lockfile';
import { getModelThinkingSupport } from '../cliproxy/model-catalog';
import { validateThinking } from '../cliproxy/thinking-validator';
import { stripModelConfigurationSuffixes } from '../shared/extended-context-utils';
const CCS_MODEL_PREFIX = 'ccs-';
const CCS_DISPLAY_PREFIX = 'CCS ';
@@ -144,6 +147,24 @@ function toAnthropicBudget(value: string | number): number {
return DROID_ANTHROPIC_BUDGET_BY_EFFORT[normalized] ?? DROID_ANTHROPIC_BUDGET_BY_EFFORT.high;
}
function resolveAnthropicModelId(model: string): string {
return stripModelConfigurationSuffixes(model);
}
function usesAnthropicAdaptiveThinking(model: string): boolean {
return getModelThinkingSupport('claude', resolveAnthropicModelId(model))?.type === 'levels';
}
function toAnthropicAdaptiveEffort(model: string, value: string | number): string | undefined {
const validation = validateThinking('claude', resolveAnthropicModelId(model), value);
if (isReasoningOffValue(validation.value)) {
return undefined;
}
const normalized = String(validation.value).trim().toLowerCase();
return normalized === 'auto' ? undefined : normalized;
}
function toReasoningEffort(value: string | number): string {
if (typeof value === 'number') {
if (value <= 4000) return 'low';
@@ -181,12 +202,35 @@ function applyReasoningOverride(
if (isReasoningOffValue(reasoningOverride)) {
delete extraArgs.thinking;
delete extraArgs.output_config;
} else if (usesAnthropicAdaptiveThinking(entry.model)) {
const thinking = isObject(extraArgs.thinking) ? { ...extraArgs.thinking } : {};
const outputConfig = isObject(extraArgs.output_config) ? { ...extraArgs.output_config } : {};
const effort = toAnthropicAdaptiveEffort(entry.model, reasoningOverride);
thinking.type = 'adaptive';
delete thinking.budget_tokens;
delete thinking.budgetTokens;
if (effort) {
outputConfig.effort = effort;
} else {
delete outputConfig.effort;
}
extraArgs.thinking = thinking;
if (Object.keys(outputConfig).length > 0) {
extraArgs.output_config = outputConfig;
} else {
delete extraArgs.output_config;
}
} else {
const thinking = isObject(extraArgs.thinking) ? { ...extraArgs.thinking } : {};
thinking.type = 'enabled';
thinking.budget_tokens = toAnthropicBudget(reasoningOverride);
delete thinking.budgetTokens;
extraArgs.thinking = thinking;
delete extraArgs.output_config;
}
} else if (provider === 'openai') {
delete extraArgs.reasoning_effort;
+83 -22
View File
@@ -26,6 +26,71 @@ export function stripAnthropicEnv(env: NodeJS.ProcessEnv): NodeJS.ProcessEnv {
return result;
}
const ANTHROPIC_ROUTING_ENV_KEYS = [
'ANTHROPIC_BASE_URL',
'ANTHROPIC_AUTH_TOKEN',
'ANTHROPIC_API_KEY',
];
const ANTHROPIC_ROUTING_ENV_KEY_SET = new Set(ANTHROPIC_ROUTING_ENV_KEYS);
const ANTHROPIC_MODEL_ENV_KEYS = [
'ANTHROPIC_MODEL',
'ANTHROPIC_DEFAULT_OPUS_MODEL',
'ANTHROPIC_DEFAULT_SONNET_MODEL',
'ANTHROPIC_DEFAULT_HAIKU_MODEL',
'ANTHROPIC_SMALL_FAST_MODEL',
];
const TMUX_SYNC_ENV_KEYS = [
'CLAUDE_CONFIG_DIR',
'CCS_PROFILE_TYPE',
'CCS_WEBSEARCH_SKIP',
'CCS_STRIP_INHERITED_ANTHROPIC_ENV',
'CLAUDE_CODE_MAX_OUTPUT_TOKENS',
...ANTHROPIC_MODEL_ENV_KEYS,
...ANTHROPIC_ROUTING_ENV_KEYS,
];
/**
* Strip inherited Anthropic routing/auth env while preserving model intent.
* Used for nested settings-profile Claude launches where `--settings` already
* defines the provider transport and the parent process should only lend model
* defaults or effort hints.
*/
export function stripAnthropicRoutingEnv(env: NodeJS.ProcessEnv): NodeJS.ProcessEnv {
const result: NodeJS.ProcessEnv = {};
for (const key of Object.keys(env)) {
if (!ANTHROPIC_ROUTING_ENV_KEY_SET.has(key.toUpperCase())) {
result[key] = env[key];
}
}
return result;
}
function syncTmuxNestedSessionEnv(env: NodeJS.ProcessEnv, profileType: string | undefined): void {
if (!process.env.TMUX) {
return;
}
const nestedSessionEnv =
profileType === 'account' || profileType === 'default'
? stripAnthropicEnv(env)
: profileType === 'settings'
? stripAnthropicRoutingEnv(env)
: env;
for (const key of TMUX_SYNC_ENV_KEYS) {
try {
const value = nestedSessionEnv[key];
if (value !== undefined) {
spawnSync('tmux', ['setenv', key, value], { stdio: 'ignore' });
} else {
spawnSync('tmux', ['setenv', '-u', key], { stdio: 'ignore' });
}
} catch {
// tmux setenv can fail if not in a tmux session; safe to ignore
}
}
}
/**
* Strip inherited browser attach/runtime env vars from a process environment.
*
@@ -153,14 +218,18 @@ export function execClaude(
const webSearchEnv = getWebSearchHookEnv();
const claudeLaunchEnv = getClaudeLaunchEnvOverrides();
// For account/default profiles, strip ANTHROPIC_* from parent env to prevent
// stale proxy config (e.g., from prior CLIProxy sessions) from interfering
// with native Claude API routing. Settings-based profiles explicitly inject
// their own ANTHROPIC_* values, so they don't need this protection.
// Strip inherited ANTHROPIC_* when the launch should not reuse parent routing.
// Account/default profiles need full isolation from prior proxy sessions.
// Settings profiles can selectively strip only routing/auth when `--settings`
// already carries the provider source of truth but the parent model intent
// should still flow into nested Team/subagent launches.
const profileType = envVars?.CCS_PROFILE_TYPE;
const inheritedEnv =
profileType === 'account' || profileType === 'default'
? stripAnthropicEnv(process.env)
const stripInheritedAnthropicEnv = profileType === 'account' || profileType === 'default';
const stripInheritedAnthropicRoutingEnv = envVars?.CCS_STRIP_INHERITED_ANTHROPIC_ENV === '1';
const inheritedEnv = stripInheritedAnthropicEnv
? stripAnthropicEnv(process.env)
: stripInheritedAnthropicRoutingEnv
? stripAnthropicRoutingEnv(process.env)
: process.env;
const baseEnv = stripBrowserEnv(inheritedEnv);
@@ -168,10 +237,13 @@ export function execClaude(
const mergedEnv = envVars
? { ...baseEnv, ...claudeLaunchEnv, ...envVars, ...webSearchEnv }
: { ...baseEnv, ...claudeLaunchEnv, ...webSearchEnv };
const effectiveMergedEnv = stripInheritedAnthropicRoutingEnv
? stripAnthropicRoutingEnv(mergedEnv)
: mergedEnv;
// Strip Claude Code nested session guard env var to allow CCS delegation
// (Claude Code v2.1.39+ sets CLAUDECODE to detect nested sessions)
const env = stripClaudeCodeEnv(mergedEnv);
const env = stripClaudeCodeEnv(effectiveMergedEnv);
if (profileType !== 'account') {
try {
@@ -181,20 +253,9 @@ export function execClaude(
}
}
// propagate key env vars to tmux session so agent team teammates
// (spawned via tmux split-window) inherit the correct config dir
if (process.env.TMUX && envVars) {
const tmuxPropagateVars = ['CLAUDE_CONFIG_DIR', 'CCS_PROFILE_TYPE', 'CCS_WEBSEARCH_SKIP'];
for (const key of tmuxPropagateVars) {
if (envVars[key]) {
try {
spawnSync('tmux', ['setenv', key, envVars[key] ?? ''], { stdio: 'ignore' });
} catch {
// tmux setenv can fail if not in a tmux session; safe to ignore
}
}
}
}
// Keep tmux teammate panes aligned with the nested-safe Claude runtime env
// rather than the tmux server's original shell environment.
syncTmuxNestedSessionEnv(env, profileType);
let child: ChildProcess;
if (isPowerShellScript) {
+6 -2
View File
@@ -3,7 +3,7 @@
* Session-based auth with httpOnly cookies for CCS dashboard.
*/
import type { NextFunction, Request, RequestHandler, Response } from 'express';
import type { NextFunction, Request, Response } from 'express';
import session from 'express-session';
import rateLimit from 'express-rate-limit';
import { getDashboardAuthConfig, isDashboardAuthEnabled } from '../../config/unified-config-loader';
@@ -84,7 +84,11 @@ export const loginRateLimiter = rateLimit({
/**
* Create session middleware configured for CCS dashboard.
*/
export function createSessionMiddleware(): RequestHandler {
export function createSessionMiddleware(): (
req: Request,
res: Response,
next: NextFunction
) => void {
const authConfig = getDashboardAuthConfig();
const maxAge = (authConfig.session_timeout_hours ?? 24) * 60 * 60 * 1000;
+12 -20
View File
@@ -38,7 +38,11 @@ import {
} from '../../cliproxy/config-generator';
import { getProxyStatus as getProxyProcessStatus, stopProxy } from '../../cliproxy/session-tracker';
import { ensureCliproxyService } from '../../cliproxy/service-manager';
import { checkCliproxyUpdate, getInstalledCliproxyVersion } from '../../cliproxy/binary-manager';
import {
checkCliproxyUpdate,
getInstalledCliproxyVersion,
getStoredConfiguredBackend,
} from '../../cliproxy/binary-manager';
import {
fetchAllVersions,
isNewerVersion,
@@ -47,9 +51,7 @@ import {
import {
CLIPROXY_MAX_STABLE_VERSION,
CLIPROXY_FAULTY_RANGE,
DEFAULT_BACKEND,
} from '../../cliproxy/platform-detector';
import { loadOrCreateUnifiedConfig } from '../../config/unified-config-loader';
import { CLIPROXY_DEFAULT_PORT } from '../../cliproxy/config/port-manager';
import {
MODEL_ENV_VAR_KEYS,
@@ -143,18 +145,8 @@ export function shouldCacheQuotaResult(result: {
return !transientPatterns.some((p) => msg.includes(p));
}
/** Get configured backend from config */
function getConfiguredBackend() {
try {
const config = loadOrCreateUnifiedConfig();
return config.cliproxy?.backend || DEFAULT_BACKEND;
} catch {
return DEFAULT_BACKEND;
}
}
function buildUpdateCheckFallback(
backend: ReturnType<typeof getConfiguredBackend>,
backend: ReturnType<typeof getStoredConfiguredBackend>,
getInstalledVersionFn: typeof getInstalledCliproxyVersion = getInstalledCliproxyVersion
) {
const currentVersion = getInstalledVersionFn(backend);
@@ -178,7 +170,7 @@ function buildUpdateCheckFallback(
}
function buildVersionsFallback(
backend: ReturnType<typeof getConfiguredBackend>,
backend: ReturnType<typeof getStoredConfiguredBackend>,
getInstalledVersionFn: typeof getInstalledCliproxyVersion = getInstalledCliproxyVersion
) {
const currentVersion = getInstalledVersionFn(backend);
@@ -206,7 +198,7 @@ interface ResolveVersionsDeps {
}
export async function resolveCliproxyUpdateCheckPayload(
backend: ReturnType<typeof getConfiguredBackend>,
backend: ReturnType<typeof getStoredConfiguredBackend>,
deps: ResolveUpdateCheckDeps = {}
) {
const checkCliproxyUpdateFn = deps.checkCliproxyUpdateFn ?? checkCliproxyUpdate;
@@ -218,7 +210,7 @@ export async function resolveCliproxyUpdateCheckPayload(
}
export async function resolveCliproxyVersionsPayload(
backend: ReturnType<typeof getConfiguredBackend>,
backend: ReturnType<typeof getStoredConfiguredBackend>,
deps: ResolveVersionsDeps = {}
) {
const fetchAllVersionsFn = deps.fetchAllVersionsFn ?? fetchAllVersions;
@@ -409,7 +401,7 @@ router.post('/proxy-stop', async (_req: Request, res: Response): Promise<void> =
*/
router.get('/update-check', async (_req: Request, res: Response): Promise<void> => {
try {
const backend = getConfiguredBackend();
const backend = getStoredConfiguredBackend();
const result = await resolveCliproxyUpdateCheckPayload(backend);
res.json(result);
@@ -1019,7 +1011,7 @@ router.get('/quota/:provider/:accountId', async (req: Request, res: Response): P
*/
router.get('/versions', async (_req: Request, res: Response): Promise<void> => {
try {
const backend = getConfiguredBackend();
const backend = getStoredConfiguredBackend();
res.json(await resolveCliproxyVersionsPayload(backend));
} catch (error) {
console.error(`[cliproxy-stats] ${(error as Error).message}`);
@@ -1073,7 +1065,7 @@ router.post('/install', async (req: Request, res: Response): Promise<void> => {
return;
}
const backend = getConfiguredBackend();
const backend = getStoredConfiguredBackend();
const installResult = await installDashboardCliproxyVersion(version, backend);
res.json({
@@ -1,4 +1,8 @@
import { installCliproxyVersion } from '../../cliproxy/binary-manager';
import {
installCliproxyVersion,
resolveLocalBackend,
syncPlusFallbackStateIfNeeded,
} from '../../cliproxy/binary-manager';
import { ensureCliproxyService, type ServiceStartResult } from '../../cliproxy/service-manager';
import { getProxyStatus as getProxyProcessStatus } from '../../cliproxy/session-tracker';
import { isCliproxyRunning } from '../../cliproxy/stats-fetcher';
@@ -52,12 +56,14 @@ export async function installDashboardCliproxyVersion(
backend: CLIProxyBackend,
deps: InstallDashboardCliproxyVersionDeps = defaultDeps
): Promise<DashboardCliproxyInstallResult> {
const backendLabel = backend === 'plus' ? 'CLIProxy Plus' : 'CLIProxy';
syncPlusFallbackStateIfNeeded(backend);
const effectiveBackend = resolveLocalBackend(backend, { warnOnFallback: true });
const backendLabel = effectiveBackend === 'plus' ? 'CLIProxy Plus' : 'CLIProxy';
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.
await deps.installCliproxyVersion(version, true, backend);
await deps.installCliproxyVersion(version, true, effectiveBackend);
if (!shouldRestoreService) {
return {
@@ -1,5 +1,4 @@
import { afterEach, beforeEach, describe, expect, it } from 'bun:test';
import getPort from 'get-port';
import * as fs from 'fs';
import * as http from 'http';
import * as os from 'os';
@@ -16,6 +15,29 @@ let tempDir: string;
let originalTimeoutEnv: string | undefined;
let originalCcsHome: string | undefined;
function resolveListeningPort(server: http.Server): number {
const address = server.address();
if (!address || typeof address === 'string') {
throw new Error('Failed to resolve server port');
}
return address.port;
}
async function waitForServerListening(server: http.Server): Promise<number> {
if (server.listening) {
return resolveListeningPort(server);
}
return new Promise<number>((resolve, reject) => {
const onError = (error: Error) => reject(error);
server.once('error', onError);
server.once('listening', () => {
server.off('error', onError);
resolve(resolveListeningPort(server));
});
});
}
async function startUpstream(
handler: (req: http.IncomingMessage, res: http.ServerResponse) => Promise<void> | void
): Promise<void> {
@@ -28,9 +50,8 @@ async function startUpstream(
upstreamSockets.delete(socket);
});
});
await new Promise<void>((resolve) =>
upstreamServer.listen(upstreamPort, '127.0.0.1', () => resolve())
);
upstreamServer.listen(0, '127.0.0.1');
upstreamPort = await waitForServerListening(upstreamServer);
}
async function requestProxy(payload: unknown, signal?: AbortSignal): Promise<Response> {
@@ -46,8 +67,6 @@ async function requestProxy(payload: unknown, signal?: AbortSignal): Promise<Res
}
beforeEach(async () => {
upstreamPort = await getPort();
proxyPort = await getPort();
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-proxy-edge-'));
originalTimeoutEnv = process.env.CCS_OPENAI_PROXY_REQUEST_TIMEOUT_MS;
originalCcsHome = process.env.CCS_HOME;
@@ -94,9 +113,10 @@ describe('openai proxy message edge cases', () => {
};
proxyServer = startOpenAICompatProxyServer({
profile,
port: proxyPort,
port: 0,
authToken: 'test-proxy-token',
});
proxyPort = await waitForServerListening(proxyServer);
}
it('preserves rate-limit errors from the upstream provider', async () => {
@@ -1,5 +1,4 @@
import { afterEach, beforeEach, describe, expect, it } from 'bun:test';
import getPort from 'get-port';
import * as http from 'http';
import { startOpenAICompatProxyServer } from '../../../src/proxy/server/proxy-server';
import type { OpenAICompatProfileConfig } from '../../../src/proxy/profile-router';
@@ -10,52 +9,64 @@ let upstreamBody: unknown;
let upstreamPort: number;
let proxyPort: number;
function startMockUpstream(): Promise<void> {
return new Promise((resolve) => {
upstreamServer = http.createServer(async (req, res) => {
if (req.method !== 'POST' || req.url !== '/v1/chat/completions') {
res.writeHead(404).end();
return;
}
function resolveListeningPort(server: http.Server): number {
const address = server.address();
if (!address || typeof address === 'string') {
throw new Error('Failed to resolve server port');
}
return address.port;
}
let body = '';
for await (const chunk of req) {
body += chunk.toString();
}
upstreamBody = JSON.parse(body);
const parsed = upstreamBody as {
stream?: boolean;
messages?: Array<{ role?: string; content?: string | Array<{ type?: string; text?: string }> }>;
};
async function waitForServerListening(server: http.Server): Promise<number> {
if (server.listening) {
return resolveListeningPort(server);
}
if (parsed.stream) {
res.writeHead(200, { 'Content-Type': 'text/event-stream' });
return new Promise<number>((resolve, reject) => {
const onError = (error: Error) => reject(error);
server.once('error', onError);
server.once('listening', () => {
server.off('error', onError);
resolve(resolveListeningPort(server));
});
});
}
if (parsed.messages?.[0]?.content === 'interleaved tool fragments') {
res.write(
'data: {"id":"chatcmpl_1","model":"hf-model","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"id":"call_1","type":"function","function":{"name":"search"}}]}}]}\n\n'
);
res.write(
'data: {"id":"chatcmpl_1","model":"hf-model","choices":[{"index":0,"delta":{"tool_calls":[{"index":1,"id":"call_2","type":"function","function":{"name":"open"}}]}}]}\n\n'
);
res.write(
'data: {"id":"chatcmpl_1","model":"hf-model","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"function":{"arguments":"{\\"path\\":\\"a.ts\\"}"}}]}}]}\n\n'
);
res.write(
'data: {"id":"chatcmpl_1","model":"hf-model","choices":[{"index":0,"delta":{"tool_calls":[{"index":1,"function":{"arguments":"{\\"path\\":\\"b.ts\\"}"}}]}}]}\n\n'
);
res.write(
'data: {"id":"chatcmpl_1","model":"hf-model","choices":[{"index":0,"delta":{},"finish_reason":"tool_calls"}],"usage":{"prompt_tokens":9,"completion_tokens":4}}\n\n'
);
res.end('data: [DONE]\n\n');
return;
}
async function startMockUpstream(): Promise<void> {
upstreamServer = http.createServer(async (req, res) => {
if (req.method !== 'POST' || req.url !== '/v1/chat/completions') {
res.writeHead(404).end();
return;
}
let body = '';
for await (const chunk of req) {
body += chunk.toString();
}
upstreamBody = JSON.parse(body);
const parsed = upstreamBody as {
stream?: boolean;
messages?: Array<{
role?: string;
content?: string | Array<{ type?: string; text?: string }>;
}>;
};
if (parsed.stream) {
res.writeHead(200, { 'Content-Type': 'text/event-stream' });
if (parsed.messages?.[0]?.content === 'interleaved tool fragments') {
res.write(
'data: {"id":"chatcmpl_1","model":"hf-model","choices":[{"index":0,"delta":{"role":"assistant","content":"Hello"}}]}\n\n'
'data: {"id":"chatcmpl_1","model":"hf-model","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"id":"call_1","type":"function","function":{"name":"search"}}]}}]}\n\n'
);
res.write(
'data: {"id":"chatcmpl_1","model":"hf-model","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"id":"call_1","type":"function","function":{"name":"search","arguments":"{\\"q\\":\\"docs\\"}"}}]}}]}\n\n'
'data: {"id":"chatcmpl_1","model":"hf-model","choices":[{"index":0,"delta":{"tool_calls":[{"index":1,"id":"call_2","type":"function","function":{"name":"open"}}]}}]}\n\n'
);
res.write(
'data: {"id":"chatcmpl_1","model":"hf-model","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"function":{"arguments":"{\\"path\\":\\"a.ts\\"}"}}]}}]}\n\n'
);
res.write(
'data: {"id":"chatcmpl_1","model":"hf-model","choices":[{"index":0,"delta":{"tool_calls":[{"index":1,"function":{"arguments":"{\\"path\\":\\"b.ts\\"}"}}]}}]}\n\n'
);
res.write(
'data: {"id":"chatcmpl_1","model":"hf-model","choices":[{"index":0,"delta":{},"finish_reason":"tool_calls"}],"usage":{"prompt_tokens":9,"completion_tokens":4}}\n\n'
@@ -64,25 +75,38 @@ function startMockUpstream(): Promise<void> {
return;
}
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(
JSON.stringify({
id: 'chatcmpl_1',
model: 'hf-model',
choices: [
{
index: 0,
message: { role: 'assistant', content: 'Plain answer' },
finish_reason: 'stop',
},
],
usage: { prompt_tokens: 2, completion_tokens: 3 },
})
res.write(
'data: {"id":"chatcmpl_1","model":"hf-model","choices":[{"index":0,"delta":{"role":"assistant","content":"Hello"}}]}\n\n'
);
});
res.write(
'data: {"id":"chatcmpl_1","model":"hf-model","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"id":"call_1","type":"function","function":{"name":"search","arguments":"{\\"q\\":\\"docs\\"}"}}]}}]}\n\n'
);
res.write(
'data: {"id":"chatcmpl_1","model":"hf-model","choices":[{"index":0,"delta":{},"finish_reason":"tool_calls"}],"usage":{"prompt_tokens":9,"completion_tokens":4}}\n\n'
);
res.end('data: [DONE]\n\n');
return;
}
upstreamServer.listen(upstreamPort, '127.0.0.1', () => resolve());
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(
JSON.stringify({
id: 'chatcmpl_1',
model: 'hf-model',
choices: [
{
index: 0,
message: { role: 'assistant', content: 'Plain answer' },
finish_reason: 'stop',
},
],
usage: { prompt_tokens: 2, completion_tokens: 3 },
})
);
});
upstreamServer.listen(0, '127.0.0.1');
upstreamPort = await waitForServerListening(upstreamServer);
}
async function requestProxy(payload: unknown): Promise<Response> {
@@ -98,8 +122,6 @@ async function requestProxy(payload: unknown): Promise<Response> {
}
beforeEach(async () => {
upstreamPort = await getPort();
proxyPort = await getPort();
upstreamBody = undefined;
await startMockUpstream();
const profile: OpenAICompatProfileConfig = {
@@ -112,9 +134,10 @@ beforeEach(async () => {
};
proxyServer = startOpenAICompatProxyServer({
profile,
port: proxyPort,
port: 0,
authToken: 'test-proxy-token',
});
proxyPort = await waitForServerListening(proxyServer);
});
afterEach(async () => {
+63 -46
View File
@@ -1,5 +1,4 @@
import { afterEach, beforeEach, describe, expect, it } from 'bun:test';
import getPort from 'get-port';
import * as fs from 'fs';
import * as http from 'http';
import * as os from 'os';
@@ -13,40 +12,61 @@ let proxyServer: http.Server;
let upstreamServers: http.Server[] = [];
let proxyPort: number;
function startMockUpstream(
port: number,
function resolveListeningPort(server: http.Server): number {
const address = server.address();
if (!address || typeof address === 'string') {
throw new Error('Failed to resolve server port');
}
return address.port;
}
async function waitForServerListening(server: http.Server): Promise<number> {
if (server.listening) {
return resolveListeningPort(server);
}
return new Promise<number>((resolve, reject) => {
const onError = (error: Error) => reject(error);
server.once('error', onError);
server.once('listening', () => {
server.off('error', onError);
resolve(resolveListeningPort(server));
});
});
}
async function startMockUpstream(
hitLabel: string,
hits: string[],
bodies: Array<{ label: string; body: unknown }>
): Promise<void> {
return new Promise((resolve) => {
const server = http.createServer(async (req, res) => {
let body = '';
for await (const chunk of req) {
body += chunk.toString();
}
hits.push(hitLabel);
bodies.push({ label: hitLabel, body: JSON.parse(body) });
): Promise<number> {
const server = http.createServer(async (req, res) => {
let body = '';
for await (const chunk of req) {
body += chunk.toString();
}
hits.push(hitLabel);
bodies.push({ label: hitLabel, body: JSON.parse(body) });
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(
JSON.stringify({
id: `chatcmpl_${hitLabel}`,
model: hitLabel,
choices: [
{
index: 0,
message: { role: 'assistant', content: `Reply from ${hitLabel}` },
finish_reason: 'stop',
},
],
usage: { prompt_tokens: 2, completion_tokens: 3 },
})
);
});
upstreamServers.push(server);
server.listen(port, '127.0.0.1', () => resolve());
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(
JSON.stringify({
id: `chatcmpl_${hitLabel}`,
model: hitLabel,
choices: [
{
index: 0,
message: { role: 'assistant', content: `Reply from ${hitLabel}` },
finish_reason: 'stop',
},
],
usage: { prompt_tokens: 2, completion_tokens: 3 },
})
);
});
upstreamServers.push(server);
server.listen(0, '127.0.0.1');
return waitForServerListening(server);
}
function writeSettings(profileName: string, env: Record<string, string>): string {
@@ -71,7 +91,7 @@ beforeEach(async () => {
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-proxy-routing-'));
fs.mkdirSync(path.join(tempDir, '.ccs'), { recursive: true });
process.env.CCS_HOME = tempDir;
proxyPort = await getPort();
proxyPort = 0;
});
afterEach(async () => {
@@ -97,12 +117,10 @@ afterEach(async () => {
describe('openai proxy request routing', () => {
it('routes explicit profile:model selectors to the matching upstream profile', async () => {
const primaryPort = await getPort();
const secondaryPort = await getPort();
const hits: string[] = [];
const bodies: Array<{ label: string; body: unknown }> = [];
await startMockUpstream(primaryPort, 'primary', hits, bodies);
await startMockUpstream(secondaryPort, 'secondary', hits, bodies);
const primaryPort = await startMockUpstream('primary', hits, bodies);
const secondaryPort = await startMockUpstream('secondary', hits, bodies);
const primarySettings = writeSettings('hf', {
ANTHROPIC_BASE_URL: `http://127.0.0.1:${primaryPort}`,
@@ -133,9 +151,10 @@ describe('openai proxy request routing', () => {
};
proxyServer = startOpenAICompatProxyServer({
profile,
port: proxyPort,
port: 0,
authToken: 'test-proxy-token',
});
proxyPort = await waitForServerListening(proxyServer);
const response = await requestProxy({
model: 'deepseek:deepseek-reasoner',
@@ -150,12 +169,10 @@ describe('openai proxy request routing', () => {
});
it('routes thinking requests through the configured think scenario', async () => {
const primaryPort = await getPort();
const thinkPort = await getPort();
const hits: string[] = [];
const bodies: Array<{ label: string; body: unknown }> = [];
await startMockUpstream(primaryPort, 'primary', hits, bodies);
await startMockUpstream(thinkPort, 'thinker', hits, bodies);
const primaryPort = await startMockUpstream('primary', hits, bodies);
const thinkPort = await startMockUpstream('thinker', hits, bodies);
const primarySettings = writeSettings('hf', {
ANTHROPIC_BASE_URL: `http://127.0.0.1:${primaryPort}`,
@@ -197,9 +214,10 @@ describe('openai proxy request routing', () => {
};
proxyServer = startOpenAICompatProxyServer({
profile,
port: proxyPort,
port: 0,
authToken: 'test-proxy-token',
});
proxyPort = await waitForServerListening(proxyServer);
const response = await requestProxy({
model: 'hf-default',
@@ -216,12 +234,10 @@ describe('openai proxy request routing', () => {
});
it('routes adaptive thinking requests through the configured think scenario', async () => {
const primaryPort = await getPort();
const thinkPort = await getPort();
const hits: string[] = [];
const bodies: Array<{ label: string; body: unknown }> = [];
await startMockUpstream(primaryPort, 'primary', hits, bodies);
await startMockUpstream(thinkPort, 'thinker', hits, bodies);
const primaryPort = await startMockUpstream('primary', hits, bodies);
const thinkPort = await startMockUpstream('thinker', hits, bodies);
const primarySettings = writeSettings('hf', {
ANTHROPIC_BASE_URL: `http://127.0.0.1:${primaryPort}`,
@@ -263,9 +279,10 @@ describe('openai proxy request routing', () => {
};
proxyServer = startOpenAICompatProxyServer({
profile,
port: proxyPort,
port: 0,
authToken: 'test-proxy-token',
});
proxyPort = await waitForServerListening(proxyServer);
const response = await requestProxy({
model: 'hf-default',
@@ -17,6 +17,8 @@ import {
handleQuotaExhaustion,
writeQuotaWarning,
maskEmail,
pauseAccountForQuotaCooldown,
restoreExpiredQuotaPauses,
} from '../../../src/cliproxy/account-safety';
import { sanitizeEmail } from '../../../src/cliproxy/auth-utils';
@@ -84,6 +86,12 @@ function writeClaudeAuth(accountId: string, accessToken: string): void {
);
}
function writeAuthToken(tokenFile: string, payload: Record<string, unknown>): void {
const authDir = path.join(tmpDir, '.ccs', 'cliproxy', 'auth');
fs.mkdirSync(authDir, { recursive: true });
fs.writeFileSync(path.join(authDir, tokenFile), JSON.stringify(payload, null, 2));
}
describe('Quota Exhaustion Handlers', () => {
describe('writeQuotaWarning', () => {
it('should write to stderr with box format', async () => {
@@ -247,10 +255,13 @@ describe('Quota Exhaustion Handlers', () => {
});
const result = await handleQuotaExhaustion('agy', 'only@gmail.com', 10);
const { getAccount } = await import('../../../src/cliproxy/account-manager');
// Should return gracefully with null switched
expect(result.switchedTo).toBeNull();
expect(result.reason).toContain('no alternatives');
expect(getAccount('agy', 'only@gmail.com')?.paused).not.toBe(true);
expect(fs.existsSync(path.join(tmpDir, '.ccs', 'cliproxy', 'quota-paused.json'))).toBe(false);
});
it('should switch Claude accounts when fallback quota endpoint returns 404', async () => {
@@ -305,6 +316,18 @@ describe('Quota Exhaustion Handlers', () => {
expect(result.switchedTo).toBe('fallback@example.com');
expect(getDefaultAccount('claude')?.id).toBe('fallback@example.com');
expect(fs.existsSync(path.join(tmpDir, '.ccs', 'cliproxy', 'quota-paused.json'))).toBe(true);
expect(
fs.existsSync(
path.join(
tmpDir,
'.ccs',
'cliproxy',
'auth-paused',
`claude-${sanitizeEmail('exhausted@example.com')}.json`
)
)
).toBe(true);
});
it('should write warning to stderr', async () => {
@@ -389,5 +412,121 @@ describe('Quota Exhaustion Handlers', () => {
expect(result).toBeDefined();
expect(result.switchedTo).toBeNull();
});
it('auto-resumes quota-paused accounts after cooldown expiry', async () => {
writeRegistry({
agy: {
default: 'cooldown@gmail.com',
accounts: {
'cooldown@gmail.com': {
email: 'cooldown@gmail.com',
tokenFile: 'agy-cooldown.json',
},
},
},
});
writeAuthToken('agy-cooldown.json', {
type: 'agy',
email: 'cooldown@gmail.com',
access_token: 'token',
});
const now = Date.now();
expect(pauseAccountForQuotaCooldown('agy', 'cooldown@gmail.com', 5, now)).toBe(true);
const { getAccount } = await import('../../../src/cliproxy/account-manager');
expect(getAccount('agy', 'cooldown@gmail.com')?.paused).toBe(true);
const resumed = restoreExpiredQuotaPauses(now + 6 * 60 * 1000);
expect(resumed).toBe(1);
expect(getAccount('agy', 'cooldown@gmail.com')?.paused).not.toBe(true);
expect(fs.existsSync(path.join(tmpDir, '.ccs', 'cliproxy', 'quota-paused.json'))).toBe(false);
});
it('does not auto-resume quota-paused accounts when pausedAt metadata is missing', async () => {
writeRegistry({
agy: {
default: 'cooldown@gmail.com',
accounts: {
'cooldown@gmail.com': {
email: 'cooldown@gmail.com',
tokenFile: 'agy-cooldown.json',
},
},
},
});
writeAuthToken('agy-cooldown.json', {
type: 'agy',
email: 'cooldown@gmail.com',
access_token: 'token',
});
const now = Date.now();
expect(pauseAccountForQuotaCooldown('agy', 'cooldown@gmail.com', 5, now)).toBe(true);
const registryPath = path.join(tmpDir, '.ccs', 'cliproxy', 'accounts.json');
const registry = JSON.parse(fs.readFileSync(registryPath, 'utf8')) as {
providers: {
agy: {
default: string;
accounts: Record<string, { paused?: boolean; pausedAt?: string }>;
};
};
};
delete registry.providers.agy.accounts['cooldown@gmail.com']?.pausedAt;
fs.writeFileSync(registryPath, JSON.stringify(registry, null, 2));
const resumed = restoreExpiredQuotaPauses(now + 6 * 60 * 1000);
const { getAccount } = await import('../../../src/cliproxy/account-manager');
expect(resumed).toBe(0);
expect(getAccount('agy', 'cooldown@gmail.com')?.paused).toBe(true);
expect(
fs.existsSync(path.join(tmpDir, '.ccs', 'cliproxy', 'auth-paused', 'agy-cooldown.json'))
).toBe(true);
expect(fs.existsSync(path.join(tmpDir, '.ccs', 'cliproxy', 'quota-paused.json'))).toBe(false);
});
it('keeps quota-paused entries when auto-resume fails and retries later', async () => {
writeRegistry({
agy: {
default: 'cooldown@gmail.com',
accounts: {
'cooldown@gmail.com': {
email: 'cooldown@gmail.com',
tokenFile: 'agy-cooldown.json',
},
},
},
});
writeAuthToken('agy-cooldown.json', {
type: 'agy',
email: 'cooldown@gmail.com',
access_token: 'token',
});
const now = Date.now();
expect(pauseAccountForQuotaCooldown('agy', 'cooldown@gmail.com', 5, now)).toBe(true);
fs.rmSync(path.join(tmpDir, '.ccs', 'cliproxy', 'auth'), {
recursive: true,
force: true,
});
const resumed = restoreExpiredQuotaPauses(now + 6 * 60 * 1000);
const { getAccount } = await import('../../../src/cliproxy/account-manager');
const quotaPausedPath = path.join(tmpDir, '.ccs', 'cliproxy', 'quota-paused.json');
const quotaPaused = JSON.parse(fs.readFileSync(quotaPausedPath, 'utf8')) as {
entries?: Array<{ accountId?: string }>;
};
expect(resumed).toBe(0);
expect(getAccount('agy', 'cooldown@gmail.com')?.paused).toBe(true);
expect(
fs.existsSync(path.join(tmpDir, '.ccs', 'cliproxy', 'auth-paused', 'agy-cooldown.json'))
).toBe(true);
expect(quotaPaused.entries?.map((entry) => entry.accountId)).toContain('cooldown@gmail.com');
});
});
});
+11 -8
View File
@@ -28,8 +28,8 @@ describe('Backend Selection', () => {
});
describe('DEFAULT_BACKEND', () => {
it('defaults to plus backend for backward compatibility', () => {
assert.strictEqual(platformDetector.DEFAULT_BACKEND, 'plus');
it('defaults to original backend (Plus upstream deleted, issue #1062)', () => {
assert.strictEqual(platformDetector.DEFAULT_BACKEND, 'original');
});
});
@@ -45,9 +45,10 @@ describe('Backend Selection', () => {
assert(info.binaryName.startsWith('CLIProxyAPIPlus_6.6.51-0_'));
});
it('uses plus backend by default', () => {
it('uses original backend by default (Plus upstream deleted, issue #1062)', () => {
const info = platformDetector.detectPlatform();
assert(info.binaryName.includes('CLIProxyAPIPlus'));
assert(info.binaryName.startsWith('CLIProxyAPI_'));
assert(!info.binaryName.includes('CLIProxyAPIPlus'));
});
it('uses fallback version when version not specified', () => {
@@ -76,9 +77,10 @@ describe('Backend Selection', () => {
assert.strictEqual(name, expected);
});
it('defaults to plus backend', () => {
it('defaults to original backend (Plus upstream deleted, issue #1062)', () => {
const name = platformDetector.getExecutableName();
assert(name.includes('cli-proxy-api-plus'));
const expected = isWindows ? 'cli-proxy-api.exe' : 'cli-proxy-api';
assert.strictEqual(name, expected);
});
});
@@ -94,9 +96,10 @@ describe('Backend Selection', () => {
assert(url.includes('router-for-me/CLIProxyAPIPlus/releases'));
});
it('defaults to plus backend', () => {
it('defaults to original backend (Plus upstream deleted, issue #1062)', () => {
const url = platformDetector.getDownloadUrl();
assert(url.includes('CLIProxyAPIPlus'));
assert(url.includes('router-for-me/CLIProxyAPI/releases'));
assert(!url.includes('CLIProxyAPIPlus'));
});
});
@@ -19,9 +19,12 @@ import {
} from '../../../ui/src/lib/default-ports';
import {
CLIPROXY_PROVIDERS as UI_CLIPROXY_PROVIDERS,
CORE_CLIPROXY_PROVIDERS as UI_CORE_CLIPROXY_PROVIDERS,
DEVICE_CODE_PROVIDERS as UI_DEVICE_CODE_PROVIDERS,
PLUS_EXTRA_CLIPROXY_PROVIDERS as UI_PLUS_EXTRA_CLIPROXY_PROVIDERS,
PROVIDER_METADATA as UI_PROVIDER_METADATA,
} from '../../../ui/src/lib/provider-config';
import { PLUS_ONLY_PROVIDERS as BACKEND_PLUS_ONLY_PROVIDERS } from '../../../src/cliproxy/types';
function sorted(values: readonly string[]): string[] {
return [...values].sort((a, b) => a.localeCompare(b));
@@ -44,6 +47,17 @@ describe('Default Port Sync', () => {
expect(sorted(UI_DEVICE_CODE_PROVIDERS)).toEqual(sorted(getProvidersByOAuthFlow('device_code')));
});
test('plus-extra providers are synced between backend and UI', () => {
expect(sorted(UI_PLUS_EXTRA_CLIPROXY_PROVIDERS)).toEqual(sorted(BACKEND_PLUS_ONLY_PROVIDERS));
expect(sorted(UI_CORE_CLIPROXY_PROVIDERS)).toEqual(
sorted(
BACKEND_CLIPROXY_PROVIDER_IDS.filter(
(provider) => !BACKEND_PLUS_ONLY_PROVIDERS.includes(provider)
)
)
);
});
test('Provider display names are synced between backend and UI', () => {
for (const provider of BACKEND_CLIPROXY_PROVIDER_IDS) {
expect(UI_PROVIDER_METADATA[provider].displayName).toBe(getBackendProviderDisplayName(provider));
@@ -25,6 +25,80 @@ afterEach(() => {
});
describe('installCliproxyVersion', () => {
it('degrades explicit plus backend requests to original before install flows run', async () => {
let seenBackend: string | undefined;
const binaryManager = await import(
`../../../src/cliproxy/binary-manager?binary-manager-explicit-plus=${Date.now()}`
);
await binaryManager.installCliproxyVersion('6.7.1', false, 'plus', {
createManager: (_config: unknown, backend: string) => {
seenBackend = backend;
return {
isBinaryInstalled: () => false,
deleteBinary: () => undefined,
ensureBinary: async () => '/tmp/ccs-bin/original/cliproxy',
};
},
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(seenBackend).toBe('original');
});
it('returns original and emits a real warning when plus backend is resolved locally', async () => {
const binaryManager = await import(
`../../../src/cliproxy/binary-manager?binary-manager-warning=${Date.now()}`
);
const writes: string[] = [];
const originalWrite = process.stderr.write.bind(process.stderr);
process.stderr.write = ((chunk: string | Uint8Array) => {
writes.push(typeof chunk === 'string' ? chunk : Buffer.from(chunk).toString('utf8'));
return true;
}) as typeof process.stderr.write;
try {
expect(binaryManager.resolveLocalBackend('plus', { warnOnFallback: true })).toBe('original');
} finally {
process.stderr.write = originalWrite;
}
expect(writes.join('')).toContain('CLIProxyAPIPlus upstream repo is currently unavailable');
expect(writes.join('')).toContain('backend: original');
});
it('reuses plus binary and pin state when local runtime falls back to original', async () => {
const { createEmptyUnifiedConfig } = await import('../../../src/config/unified-config-types');
const { saveUnifiedConfig } = await import('../../../src/config/unified-config-loader');
const { savePinnedVersion } = await import('../../../src/cliproxy/binary/version-cache');
const { getExecutableName } = await import('../../../src/cliproxy/platform-detector');
const binaryService = await import(
`../../../src/cliproxy/services/binary-service?binary-service-plus-migration=${Date.now()}`
);
const config = createEmptyUnifiedConfig();
config.cliproxy = { ...config.cliproxy, backend: 'plus' };
saveUnifiedConfig(config);
const plusBinDir = path.join(tempHome, '.ccs', 'cliproxy', 'bin', 'plus');
fs.mkdirSync(plusBinDir, { recursive: true });
fs.writeFileSync(path.join(plusBinDir, getExecutableName('plus')), 'fake-binary');
fs.writeFileSync(path.join(plusBinDir, '.version'), '6.6.80-0');
savePinnedVersion('6.6.80-0', 'plus');
const status = binaryService.getBinaryStatus();
expect(status.installed).toBe(true);
expect(status.pinnedVersion).toBe('6.6.80-0');
expect(status.binaryPath).toContain('/original/');
});
it('attempts to stop the proxy even when there is no tracked running session', async () => {
const calls = {
stopProxy: 0,
@@ -78,7 +152,7 @@ describe('installCliproxyVersion', () => {
skipAutoUpdate: true,
})
).rejects.toThrow(
'CLIProxy Plus binary is not installed locally. Run "ccs cliproxy install" when you have network access.'
'CLIProxy binary is not installed locally. Run "ccs cliproxy install" when you have network access.'
);
});
});
@@ -538,6 +538,20 @@ describe('applyThinkingConfig - composite variant integration', () => {
expect(result.ANTHROPIC_DEFAULT_OPUS_MODEL).toBe('claude-opus-4-6-thinking(32768)');
});
it('rewrites legacy budget suffixes for claude level-based models', () => {
const envVars: NodeJS.ProcessEnv = {
ANTHROPIC_MODEL: 'claude-opus-4-7(32768)',
ANTHROPIC_DEFAULT_OPUS_MODEL: 'claude-opus-4-7(32768)',
ANTHROPIC_DEFAULT_SONNET_MODEL: 'claude-sonnet-4-6',
ANTHROPIC_DEFAULT_HAIKU_MODEL: 'claude-haiku-4-5-20251001',
};
const result = applyThinkingConfig(envVars, 'claude' as CLIProxyProvider, 'max');
expect(result.ANTHROPIC_MODEL).toBe('claude-opus-4-7(max)');
expect(result.ANTHROPIC_DEFAULT_OPUS_MODEL).toBe('claude-opus-4-7(max)');
});
it('should use codex effort suffix style when provider is codex', () => {
const envVars: NodeJS.ProcessEnv = {
ANTHROPIC_MODEL: 'gpt-5.3-codex',
@@ -40,7 +40,7 @@ describe('getEffectiveEnvVars local provider URL normalization', () => {
fs.rmSync(tempHome, { recursive: true, force: true });
});
it('rewrites local root URL to provider endpoint', () => {
it('rewrites local root URL to provider endpoint without stripping codex effort suffixes', () => {
writeSettings(settingsPath, {
ANTHROPIC_BASE_URL: 'http://127.0.0.1:8317',
ANTHROPIC_AUTH_TOKEN: 'ccs-internal-managed',
@@ -52,18 +52,18 @@ describe('getEffectiveEnvVars local provider URL normalization', () => {
const env = getEffectiveEnvVars('codex', 8317, settingsPath);
expect(env.ANTHROPIC_BASE_URL).toBe('http://127.0.0.1:8317/api/provider/codex');
expect(env.ANTHROPIC_MODEL).toBe('gpt-5.3-codex');
expect(env.ANTHROPIC_DEFAULT_OPUS_MODEL).toBe('gpt-5.3-codex');
expect(env.ANTHROPIC_DEFAULT_SONNET_MODEL).toBe('gpt-5.3-codex');
expect(env.ANTHROPIC_DEFAULT_HAIKU_MODEL).toBe('gpt-5.4-mini');
expect(env.ANTHROPIC_MODEL).toBe('gpt-5.3-codex-xhigh');
expect(env.ANTHROPIC_DEFAULT_OPUS_MODEL).toBe('gpt-5.3-codex-xhigh');
expect(env.ANTHROPIC_DEFAULT_SONNET_MODEL).toBe('gpt-5.3-codex-high');
expect(env.ANTHROPIC_DEFAULT_HAIKU_MODEL).toBe('gpt-5.4-mini-medium');
const persisted = JSON.parse(fs.readFileSync(settingsPath, 'utf-8')) as {
env: Record<string, string>;
};
expect(persisted.env.ANTHROPIC_MODEL).toBe('gpt-5.3-codex');
expect(persisted.env.ANTHROPIC_DEFAULT_OPUS_MODEL).toBe('gpt-5.3-codex');
expect(persisted.env.ANTHROPIC_DEFAULT_SONNET_MODEL).toBe('gpt-5.3-codex');
expect(persisted.env.ANTHROPIC_DEFAULT_HAIKU_MODEL).toBe('gpt-5.4-mini');
expect(persisted.env.ANTHROPIC_MODEL).toBe('gpt-5.3-codex-xhigh');
expect(persisted.env.ANTHROPIC_DEFAULT_OPUS_MODEL).toBe('gpt-5.3-codex-xhigh');
expect(persisted.env.ANTHROPIC_DEFAULT_SONNET_MODEL).toBe('gpt-5.3-codex-high');
expect(persisted.env.ANTHROPIC_DEFAULT_HAIKU_MODEL).toBe('gpt-5.4-mini-medium');
});
it('rewrites wrong local provider path to the requested provider', () => {
@@ -162,7 +162,7 @@ describe('getEffectiveEnvVars local provider URL normalization', () => {
expect(persisted.presets?.[0]?.haiku).toBe('claude-sonnet-4-6');
});
it('migrates codex preset model mappings to canonical IDs', () => {
it('preserves codex preset effort suffixes while loading provider env vars', () => {
writeSettings(
settingsPath,
{
@@ -191,10 +191,10 @@ describe('getEffectiveEnvVars local provider URL normalization', () => {
const persisted = JSON.parse(fs.readFileSync(settingsPath, 'utf-8')) as {
presets: Array<Record<string, string>>;
};
expect(persisted.presets[0]?.default).toBe('gpt-5.3-codex');
expect(persisted.presets[0]?.opus).toBe('gpt-5.3-codex');
expect(persisted.presets[0]?.sonnet).toBe('gpt-5.3-codex');
expect(persisted.presets[0]?.haiku).toBe('gpt-5-mini');
expect(persisted.presets[0]?.default).toBe('gpt-5.3-codex-xhigh');
expect(persisted.presets[0]?.opus).toBe('gpt-5.3-codex-xhigh');
expect(persisted.presets[0]?.sonnet).toBe('gpt-5.3-codex-high');
expect(persisted.presets[0]?.haiku).toBe('gpt-5-mini-medium');
});
it('migrates iflow placeholder model IDs to a supported default', () => {
@@ -424,7 +424,7 @@ describe('getEffectiveEnvVars local provider URL normalization', () => {
expect(repaired.presets?.[0]?.haiku).toBe('claude-sonnet-4-6');
});
it('migrates codex effort-suffixed preset IDs during ensureProviderSettings', () => {
it('preserves codex effort-suffixed IDs during ensureProviderSettings', () => {
process.env.CCS_HOME = tempHome;
const codexSettingsPath = path.join(tempHome, '.ccs', 'codex.settings.json');
fs.mkdirSync(path.dirname(codexSettingsPath), { recursive: true });
@@ -461,14 +461,14 @@ describe('getEffectiveEnvVars local provider URL normalization', () => {
env?: Record<string, string>;
presets?: Array<Record<string, string>>;
};
expect(repaired.env?.ANTHROPIC_MODEL).toBe('gpt-5.3-codex');
expect(repaired.env?.ANTHROPIC_DEFAULT_OPUS_MODEL).toBe('gpt-5.3-codex');
expect(repaired.env?.ANTHROPIC_DEFAULT_SONNET_MODEL).toBe('gpt-5.3-codex');
expect(repaired.env?.ANTHROPIC_DEFAULT_HAIKU_MODEL).toBe('gpt-5.4-mini');
expect(repaired.presets?.[0]?.default).toBe('gpt-5.3-codex');
expect(repaired.presets?.[0]?.opus).toBe('gpt-5.3-codex');
expect(repaired.presets?.[0]?.sonnet).toBe('gpt-5.3-codex');
expect(repaired.presets?.[0]?.haiku).toBe('gpt-5.4-mini');
expect(repaired.env?.ANTHROPIC_MODEL).toBe('gpt-5.3-codex-xhigh');
expect(repaired.env?.ANTHROPIC_DEFAULT_OPUS_MODEL).toBe('gpt-5.3-codex-xhigh');
expect(repaired.env?.ANTHROPIC_DEFAULT_SONNET_MODEL).toBe('gpt-5.3-codex-high');
expect(repaired.env?.ANTHROPIC_DEFAULT_HAIKU_MODEL).toBe('gpt-5.4-mini-medium');
expect(repaired.presets?.[0]?.default).toBe('gpt-5.3-codex-xhigh');
expect(repaired.presets?.[0]?.opus).toBe('gpt-5.3-codex-xhigh');
expect(repaired.presets?.[0]?.sonnet).toBe('gpt-5.3-codex-high');
expect(repaired.presets?.[0]?.haiku).toBe('gpt-5.4-mini-medium');
});
it('recovers malformed provider settings files by writing defaults and backup copy', () => {
@@ -58,4 +58,24 @@ describe('model-catalog compatibility lookups', () => {
expect(catalog?.models.map((model) => model.id)).toEqual(['gemini-2.5-pro']);
});
it('preserves static maxLevel when live thinking metadata omits it', () => {
const catalog = mergeCatalog('claude', [
{
id: 'claude-opus-4-7',
display_name: 'Claude Opus 4.7',
thinking: {
levels: ['low', 'medium', 'high', 'xhigh', 'max'],
dynamic_allowed: true,
},
},
]);
expect(catalog?.models[0]?.thinking).toMatchObject({
type: 'levels',
levels: ['low', 'medium', 'high', 'xhigh', 'max'],
maxLevel: 'max',
dynamicAllowed: true,
});
});
});
+5 -3
View File
@@ -147,9 +147,11 @@ describe('Model Catalog', () => {
const opus47 = MODEL_CATALOG.claude.models.find((m) => m.id === 'claude-opus-4-7');
assert(opus47, 'Should include Claude Opus 4.7');
assert.strictEqual(opus47.name, 'Claude Opus 4.7');
// Claude provider differs from AGY: zero thinking budget is disallowed,
// and extended (1M) context is available on the Anthropic API.
assert.strictEqual(opus47.thinking.zeroAllowed, false);
// Opus 4.7 requires adaptive thinking (type: 'levels'); manual budget_tokens
// is rejected by the Anthropic API with 400. Extended (1M) context is available.
assert.strictEqual(opus47.thinking.type, 'levels');
assert.deepStrictEqual(opus47.thinking.levels, ['low', 'medium', 'high', 'xhigh', 'max']);
assert.strictEqual(opus47.thinking.maxLevel, 'max');
assert.strictEqual(opus47.extendedContext, true);
});
@@ -83,7 +83,9 @@ describe('model-id-normalizer', () => {
});
it('applies provider canonicalization for codex and antigravity', () => {
expect(canonicalizeModelIdForProvider('gpt-5.3-codex-xhigh', 'codex')).toBe('gpt-5.3-codex');
expect(canonicalizeModelIdForProvider('gpt-5.3-codex-xhigh', 'codex')).toBe(
'gpt-5.3-codex-xhigh'
);
expect(canonicalizeModelIdForProvider('claude-sonnet-4.6-thinking', 'agy')).toBe(
'claude-sonnet-4-6'
);
@@ -94,7 +96,7 @@ describe('model-id-normalizer', () => {
it('trims and canonicalizes provider model IDs with surrounding whitespace', () => {
expect(canonicalizeModelIdForProvider(' gpt-5.3-codex-high ', 'codex')).toBe(
'gpt-5.3-codex'
'gpt-5.3-codex-high'
);
expect(canonicalizeModelIdForProvider(' claude-sonnet-4.6-thinking ', 'agy')).toBe(
'claude-sonnet-4-6'
@@ -120,9 +122,11 @@ describe('model-id-normalizer', () => {
it('normalizes legacy codex aliases to the current supported model IDs', () => {
expect(normalizeCodexLegacyModelAliases('gpt-5-codex')).toBe('gpt-5.4');
expect(normalizeCodexLegacyModelAliases('gpt-5-codex-mini[1m]')).toBe('gpt-5.4-mini[1m]');
expect(normalizeCodexLegacyModelAliases('gpt-5-codex-high')).toBe('gpt-5.4-high');
expect(normalizeCodexLegacyModelAliases('gpt-5-codex-high[1m]')).toBe('gpt-5.4-high[1m]');
expect(normalizeModelIdForProvider('gpt-5.2-codex', 'codex')).toBe('gpt-5.2');
expect(normalizeModelIdForProvider('gpt-5.1-codex-mini', 'codex')).toBe('gpt-5.4-mini');
expect(canonicalizeModelIdForProvider('gpt-5-codex-high', 'codex')).toBe('gpt-5.4');
expect(canonicalizeModelIdForProvider('gpt-5-codex-high', 'codex')).toBe('gpt-5.4-high');
});
});
@@ -29,6 +29,7 @@ describe('Thinking Validator', () => {
expect(VALID_THINKING_LEVELS).toContain('medium');
expect(VALID_THINKING_LEVELS).toContain('high');
expect(VALID_THINKING_LEVELS).toContain('xhigh');
expect(VALID_THINKING_LEVELS).toContain('max');
expect(VALID_THINKING_LEVELS).toContain('auto');
});
@@ -38,6 +39,24 @@ describe('Thinking Validator', () => {
expect(THINKING_LEVEL_BUDGETS.medium).toBe(8192);
expect(THINKING_LEVEL_BUDGETS.high).toBe(24576);
expect(THINKING_LEVEL_BUDGETS.xhigh).toBe(32768);
// `max` sits above xhigh — unconstrained thinking on Opus 4.7 / Mythos
expect(THINKING_LEVEL_BUDGETS.max).toBeGreaterThan(THINKING_LEVEL_BUDGETS.xhigh);
});
it('should treat max as a distinct top tier on models that list it (Opus 4.7)', () => {
// Opus 4.7 exposes both xhigh and max; max must not collapse into xhigh.
const result = validateThinking('claude', 'claude-opus-4-7', 'max');
expect(result.valid).toBe(true);
expect(result.value).toBe('max');
expect(result.warning).toBeUndefined();
});
it('should still alias max -> xhigh for models without a max level (backcompat)', () => {
// Codex catalog uses ['low','medium','high','xhigh'] with maxLevel 'xhigh'.
// User input "max" should map down to xhigh rather than be rejected.
const result = validateThinking('codex', 'gpt-5.4', 'max');
expect(result.valid).toBe(true);
expect(result.value).toBe('xhigh');
});
it('should export off values', () => {
@@ -6,7 +6,10 @@ import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import { afterEach, beforeEach, describe, expect, it } from 'bun:test';
import { updateVariant } from '../../../src/cliproxy/services/variant-service';
import {
updateVariant,
validateProviderBackend,
} from '../../../src/cliproxy/services/variant-service';
import { loadOrCreateUnifiedConfig } from '../../../src/config/unified-config-loader';
describe('updateVariant - provider/model consistency', () => {
@@ -50,6 +53,7 @@ preferences:
telemetry: false
auto_update: true
cliproxy:
backend: plus
oauth_accounts: {}
providers:
- gemini
@@ -92,6 +96,25 @@ cliproxy:
expect(result.error).toContain('denylist');
});
it('reports plus-only providers as temporarily unavailable on local CLIProxy', () => {
const error = validateProviderBackend('ghcp');
expect(error).toContain('currently supports only `backend: original`');
expect(error).toContain('issues/1062');
});
it('leaves the settings file unchanged when a plus-only provider update is rejected', () => {
const settingsPath = path.join(tmpDir, 'gemini-demo.settings.json');
const before = fs.readFileSync(settingsPath, 'utf-8');
const result = updateVariant('demo', {
provider: 'ghcp',
model: 'gpt-5.4-mini',
});
expect(result.success).toBe(false);
expect(fs.readFileSync(settingsPath, 'utf-8')).toBe(before);
});
it('updates provider and regenerates provider-specific core env in same settings file', () => {
const result = updateVariant('demo', {
provider: 'codex',
@@ -35,6 +35,7 @@ describe('config thinking override normalization', () => {
it('accepts valid levels', () => {
expect(parseThinkingOverrideInput('High')).toEqual({ value: 'high' });
expect(parseThinkingOverrideInput('Max')).toEqual({ value: 'max' });
});
it('validates numeric bounds', () => {
@@ -90,6 +90,16 @@ describe('translateAnthropicRequest', () => {
]);
});
it('maps adaptive anthropic thinking into Cursor reasoning effort', () => {
const translated = translateAnthropicRequest({
thinking: { type: 'adaptive' },
output_config: { effort: 'xhigh' },
messages: [{ role: 'user', content: 'hello' }],
});
expect(translated.reasoning_effort).toBe('high');
});
it('preserves mixed user text around tool_result blocks in order', () => {
const translated = translateAnthropicRequest({
messages: [
@@ -30,6 +30,17 @@ describe('ProxyRequestTransformer regressions', () => {
expect(result.reasoning).toEqual({ enabled: true, effort: 'high' });
});
it('explicitly normalizes anthropic xhigh adaptive effort for OpenAI-compatible upstreams', () => {
const result = new ProxyRequestTransformer().transform({
messages: [{ role: 'user', content: 'hello' }],
thinking: { type: 'adaptive' },
output_config: { effort: 'xhigh' },
});
expect(result.reasoning_effort).toBe('high');
expect(result.reasoning).toEqual({ enabled: true, effort: 'high' });
});
it('rejects unsupported thinking types instead of silently dropping them', () => {
expect(() =>
new ProxyRequestTransformer().transform({
@@ -0,0 +1,36 @@
import { describe, expect, test } from 'bun:test';
import * as fs from 'node:fs';
import * as path from 'node:path';
function resolvePath(relativePath: string) {
return path.resolve(import.meta.dir, relativePath);
}
describe('pr ci workflow', () => {
test('keeps full coverage on pull requests', () => {
const workflowPath = resolvePath('../../../../.github/workflows/ci.yml');
expect(fs.existsSync(workflowPath)).toBe(true);
const workflow = fs.readFileSync(workflowPath, 'utf8');
expect(workflow).toContain('name: CI');
expect(workflow).toContain('pull_request:');
expect(workflow).toContain('branches: [main, dev]');
expect(workflow).toContain('group: ci-${{ github.ref }}');
expect(workflow).toContain('cancel-in-progress: true');
expect(workflow).toContain('fail-fast: false');
expect(workflow).toContain('runs-on: [self-hosted, linux, x64]');
expect(workflow).toContain("cmd: 'bun run typecheck'");
expect(workflow).toContain("cmd: 'bun run lint'");
expect(workflow).toContain("cmd: 'bun run format:check'");
expect(workflow).toContain("key: ${{ runner.os }}-bun-cache-v2-${{ hashFiles('bun.lock', 'ui/bun.lock') }}");
expect(workflow).not.toContain('restore-keys:');
expect(workflow).toContain('name: dist');
expect(workflow).toContain('path: dist/');
expect(workflow).toContain('needs: [build]');
expect(workflow).toContain('run: bun run test:all');
expect(workflow).toContain("CCS_E2E_SKIP_BUILD: '1'");
expect(workflow).toContain('run: bun run test:e2e');
});
});
@@ -0,0 +1,34 @@
import { describe, expect, test } from 'bun:test';
import * as fs from 'node:fs';
import * as path from 'node:path';
function resolvePath(relativePath: string) {
return path.resolve(import.meta.dir, relativePath);
}
describe('push ci workflow', () => {
test('keeps dev push quality checks separate from release automation', () => {
const workflowPath = resolvePath('../../../../.github/workflows/push-ci.yml');
expect(fs.existsSync(workflowPath)).toBe(true);
const workflow = fs.readFileSync(workflowPath, 'utf8');
expect(workflow).toContain('name: Push CI');
expect(workflow).toContain('push:');
expect(workflow).toContain('branches: [dev]');
expect(workflow).toContain('group: push-ci-${{ github.ref }}');
expect(workflow).toContain('cancel-in-progress: true');
expect(workflow).toContain('runs-on: [self-hosted, linux, x64]');
expect(workflow).toContain("key: ${{ runner.os }}-bun-cache-v2-${{ hashFiles('bun.lock', 'ui/bun.lock') }}");
expect(workflow).not.toContain('restore-keys:');
expect(workflow).toContain("name: ${{ matrix.check.name }}");
expect(workflow).toContain("cmd: 'bun run typecheck'");
expect(workflow).toContain("cmd: 'bun run lint'");
expect(workflow).toContain("cmd: 'bun run format:check'");
expect(workflow).toContain('run: bun run build:all');
expect(workflow).toContain('run: bun run test:all');
expect(workflow).toContain("CCS_E2E_SKIP_BUILD: '1'");
expect(workflow).toContain('run: bun run test:e2e');
});
});
@@ -0,0 +1,28 @@
const { describe, expect, test } = require('bun:test');
const path = require('node:path');
const bucket = require('../../../scripts/run-test-bucket.js');
describe('run-test-bucket', () => {
test('all declared slow tests still exist on disk', () => {
for (const relativePath of bucket.slowTests) {
const absolutePath = path.resolve(__dirname, '../../../', relativePath);
expect(Bun.file(absolutePath).exists()).resolves.toBe(true);
}
});
test('forces npm tests into the slow bucket', () => {
expect(bucket.shouldForceSlow('tests/npm/cli.test.js')).toBe(true);
});
test('keeps dist-independent javascript tests in the fast bucket', () => {
expect(bucket.shouldForceSlow('tests/unit/flag-parsing-simple.test.js')).toBe(false);
});
test('keeps non-allowlisted javascript tests in the slow bucket', () => {
expect(bucket.shouldForceSlow('tests/unit/commands/persist-command.test.js')).toBe(true);
});
test('still forces dist-dependent tests into the slow bucket', () => {
expect(bucket.shouldForceSlow('tests/unit/config-dir-override.test.js')).toBe(true);
});
});
@@ -143,6 +143,23 @@ describe('droid-config-manager', () => {
expect(settings.customModels[0].extraArgs?.thinking?.budget_tokens).toBe(40960);
});
it('writes adaptive anthropic thinking for claude-opus-4-7 overrides', async () => {
await upsertCcsModel('claude', {
model: 'claude-opus-4-7',
displayName: 'CCS claude',
baseUrl: 'https://api.anthropic.com',
apiKey: 'anthropic-key',
provider: 'anthropic',
reasoningOverride: 'max',
});
const settingsPath = path.join(tmpDir, '.factory', 'settings.json');
const settings = JSON.parse(fs.readFileSync(settingsPath, 'utf8'));
expect(settings.customModels[0].extraArgs?.thinking?.type).toBe('adaptive');
expect(settings.customModels[0].extraArgs?.thinking?.budget_tokens).toBeUndefined();
expect(settings.customModels[0].extraArgs?.output_config?.effort).toBe('max');
});
it('should clear prior reasoning config when override disables thinking', async () => {
await upsertCcsModel('glm', {
model: 'glm-4.7',
@@ -136,6 +136,13 @@ printf "%s\n" "$@" > "${claudeArgsLogPath}"
printf "port=%s\n" "$CCS_BROWSER_DEVTOOLS_PORT"
printf "httpUrl=%s\n" "$CCS_BROWSER_DEVTOOLS_HTTP_URL"
printf "wsUrl=%s\n" "$CCS_BROWSER_DEVTOOLS_WS_URL"
printf "stripAnthropic=%s\n" "$CCS_STRIP_INHERITED_ANTHROPIC_ENV"
printf "anthropicBaseUrl=%s\n" "$ANTHROPIC_BASE_URL"
printf "anthropicAuthToken=%s\n" "$ANTHROPIC_AUTH_TOKEN"
printf "anthropicApiKey=%s\n" "$ANTHROPIC_API_KEY"
printf "anthropicModel=%s\n" "$ANTHROPIC_MODEL"
printf "anthropicSonnet=%s\n" "$ANTHROPIC_DEFAULT_SONNET_MODEL"
printf "maxOutputTokens=%s\n" "$CLAUDE_CODE_MAX_OUTPUT_TOKENS"
} > "${claudeEnvLogPath}"
exit 0
`,
@@ -187,6 +194,69 @@ exit 0
expect(fs.existsSync(claudeArgsLogPath)).toBe(true);
});
it('passes selective Anthropic env stripping to settings-profile Claude launches while preserving model defaults', () => {
if (process.platform === 'win32') return;
fs.writeFileSync(
settingsPath,
JSON.stringify(
{
env: {
ANTHROPIC_BASE_URL: 'https://api.z.ai/api/anthropic',
ANTHROPIC_AUTH_TOKEN: 'profile-token',
ANTHROPIC_MODEL: 'gpt-5.4',
ANTHROPIC_DEFAULT_SONNET_MODEL: 'gpt-5.4',
CLAUDE_CODE_MAX_OUTPUT_TOKENS: '12345',
},
},
null,
2
) + '\n'
);
const originalCcsHome = process.env.CCS_HOME;
process.env.CCS_HOME = tmpHome;
try {
mutateUnifiedConfig((config) => {
config.global_env = {
enabled: true,
env: {
ANTHROPIC_BASE_URL: 'http://127.0.0.1:9999/api/provider/global',
ANTHROPIC_AUTH_TOKEN: 'global-routing-token',
ANTHROPIC_API_KEY: 'global-api-key',
CLAUDE_CODE_MAX_OUTPUT_TOKENS: '54321',
},
};
});
const result = runCcs(['glm', 'smoke'], {
...baseEnv,
ANTHROPIC_BASE_URL: 'http://127.0.0.1:8317/api/provider/codex',
ANTHROPIC_AUTH_TOKEN: 'parent-routing-token',
ANTHROPIC_API_KEY: 'parent-api-key',
ANTHROPIC_MODEL: 'gpt-5.4',
ANTHROPIC_DEFAULT_SONNET_MODEL: 'gpt-5.4',
});
expect(result.status).toBe(0);
const launchedEnv = fs.readFileSync(claudeEnvLogPath, 'utf8');
expect(launchedEnv).toContain('stripAnthropic=1');
expect(launchedEnv).toContain('anthropicBaseUrl=');
expect(launchedEnv).toContain('anthropicAuthToken=');
expect(launchedEnv).toContain('anthropicApiKey=');
expect(launchedEnv).toContain('anthropicModel=gpt-5.4');
expect(launchedEnv).toContain('anthropicSonnet=gpt-5.4');
expect(launchedEnv).toContain('maxOutputTokens=12345');
} finally {
if (originalCcsHome !== undefined) {
process.env.CCS_HOME = originalCcsHome;
} else {
delete process.env.CCS_HOME;
}
}
});
it('does not auto-enable browser reuse for settings-profile launches from env overrides alone', async () => {
if (process.platform === 'win32') return;
@@ -21,8 +21,16 @@ type SpawnCall = {
options: Record<string, unknown> | undefined;
};
const STEERING_PROMPT_SNIPPET = 'prefer the CCS MCP tool WebSearch instead of Bash/curl/http fetches';
type SpawnSyncCall = {
command: string;
args: string[];
options: Record<string, unknown> | undefined;
};
const STEERING_PROMPT_SNIPPET =
'prefer the CCS MCP tool WebSearch instead of Bash/curl/http fetches';
const spawnCalls: SpawnCall[] = [];
const spawnSyncCalls: SpawnSyncCall[] = [];
const originalPlatform = process.platform;
let baselineSigintListeners: Array<(...args: unknown[]) => void> = [];
let baselineSigtermListeners: Array<(...args: unknown[]) => void> = [];
@@ -31,6 +39,7 @@ let originalCcsHome: string | undefined;
let originalCcsClaudePath: string | undefined;
let originalDisableAutoUpdater: string | undefined;
let originalClaudeConfigDir: string | undefined;
let originalTmux: string | undefined;
const realSpawn = childProcess.spawn.bind(childProcess);
const realSpawnSync = childProcess.spawnSync.bind(childProcess);
const realExecSync = childProcess.execSync.bind(childProcess);
@@ -103,6 +112,18 @@ function registerChildProcessMock(): void {
| Record<string, unknown>
| undefined;
if (command === 'tmux') {
spawnSyncCalls.push({ command, args, options });
return {
pid: process.pid,
output: ['', '', ''],
stdout: '',
stderr: '',
status: 0,
signal: null,
};
}
return realSpawnSync(command, args, options as Parameters<typeof childProcess.spawnSync>[2]);
},
execSync: (...execArgs: unknown[]) =>
@@ -140,6 +161,7 @@ ${yamlBody}
}
let execClaude: typeof import('../../../src/utils/shell-executor').execClaude;
let stripAnthropicRoutingEnv: typeof import('../../../src/utils/shell-executor').stripAnthropicRoutingEnv;
let stripClaudeCodeEnv: typeof import('../../../src/utils/shell-executor').stripClaudeCodeEnv;
let HeadlessExecutor: typeof import('../../../src/delegation/headless-executor').HeadlessExecutor;
let SharedManager: typeof import('../../../src/management/shared-manager').default;
@@ -149,6 +171,7 @@ beforeAll(async () => {
const shellExecutor = await import('../../../src/utils/shell-executor');
execClaude = shellExecutor.execClaude;
stripAnthropicRoutingEnv = shellExecutor.stripAnthropicRoutingEnv;
stripClaudeCodeEnv = shellExecutor.stripClaudeCodeEnv;
const sharedManagerModule = await import('../../../src/management/shared-manager');
@@ -165,6 +188,7 @@ afterAll(() => {
describe('CLAUDECODE environment stripping', () => {
beforeEach(() => {
spawnCalls.length = 0;
spawnSyncCalls.length = 0;
process.env.CCS_QUIET = '1';
// Save original env values for restoration in afterEach
@@ -172,10 +196,12 @@ describe('CLAUDECODE environment stripping', () => {
originalCcsClaudePath = process.env.CCS_CLAUDE_PATH;
originalDisableAutoUpdater = process.env.DISABLE_AUTOUPDATER;
originalClaudeConfigDir = process.env.CLAUDE_CONFIG_DIR;
originalTmux = process.env.TMUX;
// Clear CCS-managed env vars that leak from host sessions
delete process.env.DISABLE_AUTOUPDATER;
delete process.env.CLAUDE_CONFIG_DIR;
delete process.env.TMUX;
baselineSigintListeners = process.listeners('SIGINT');
baselineSigtermListeners = process.listeners('SIGTERM');
@@ -197,8 +223,20 @@ describe('CLAUDECODE environment stripping', () => {
} else {
delete process.env.DISABLE_AUTOUPDATER;
}
if (originalClaudeConfigDir !== undefined) process.env.CLAUDE_CONFIG_DIR = originalClaudeConfigDir;
if (originalClaudeConfigDir !== undefined)
process.env.CLAUDE_CONFIG_DIR = originalClaudeConfigDir;
else delete process.env.CLAUDE_CONFIG_DIR;
if (originalTmux !== undefined) process.env.TMUX = originalTmux;
else delete process.env.TMUX;
delete process.env.ANTHROPIC_BASE_URL;
delete process.env.ANTHROPIC_AUTH_TOKEN;
delete process.env.ANTHROPIC_API_KEY;
delete process.env.ANTHROPIC_MODEL;
delete process.env.ANTHROPIC_DEFAULT_OPUS_MODEL;
delete process.env.ANTHROPIC_DEFAULT_SONNET_MODEL;
delete process.env.ANTHROPIC_DEFAULT_HAIKU_MODEL;
delete process.env.ANTHROPIC_SMALL_FAST_MODEL;
delete process.env.CLAUDE_CODE_MAX_OUTPUT_TOKENS;
for (const listener of process.listeners('SIGINT')) {
if (!baselineSigintListeners.includes(listener)) {
@@ -230,6 +268,25 @@ describe('CLAUDECODE environment stripping', () => {
expect(result.PATH).toBe('/usr/bin');
});
it('stripAnthropicRoutingEnv removes routing/auth env case-insensitively while preserving model vars', () => {
const input: NodeJS.ProcessEnv = {
anthropic_base_url: 'http://127.0.0.1:8317/api/provider/codex',
Anthropic_Auth_Token: 'parent-routing-token',
ANTHROPIC_API_KEY: 'parent-api-key',
ANTHROPIC_MODEL: 'gpt-5.4',
ANTHROPIC_DEFAULT_SONNET_MODEL: 'gpt-5.4',
PATH: '/usr/bin',
};
const result = stripAnthropicRoutingEnv(input);
expect(result.anthropic_base_url).toBeUndefined();
expect(result.Anthropic_Auth_Token).toBeUndefined();
expect(result.ANTHROPIC_API_KEY).toBeUndefined();
expect(result.ANTHROPIC_MODEL).toBe('gpt-5.4');
expect(result.ANTHROPIC_DEFAULT_SONNET_MODEL).toBe('gpt-5.4');
expect(result.PATH).toBe('/usr/bin');
});
it('execClaude strips CLAUDECODE from merged env (including overrides)', () => {
process.env.CLAUDECODE = 'from-parent';
process.env.claudecode = 'from-parent-lower';
@@ -336,6 +393,97 @@ describe('CLAUDECODE environment stripping', () => {
expect(normalizeSpy).toHaveBeenCalledWith(instancePath);
});
it('execClaude strips inherited ANTHROPIC routing env but keeps model intent for settings-profile Claude launches', () => {
process.env.ANTHROPIC_BASE_URL = 'http://127.0.0.1:8317/api/provider/codex';
process.env.ANTHROPIC_AUTH_TOKEN = 'ccs-internal-managed';
process.env.ANTHROPIC_API_KEY = 'stale-api-key';
process.env.ANTHROPIC_MODEL = 'gpt-5.4';
process.env.ANTHROPIC_DEFAULT_OPUS_MODEL = 'gpt-5.4';
process.env.ANTHROPIC_DEFAULT_SONNET_MODEL = 'gpt-5.4';
process.env.ANTHROPIC_DEFAULT_HAIKU_MODEL = 'gpt-5.4-mini';
process.env.ANTHROPIC_SMALL_FAST_MODEL = 'gpt-5-codex-mini';
execClaude('claude', ['--help'], {
CCS_PROFILE_TYPE: 'settings',
CCS_STRIP_INHERITED_ANTHROPIC_ENV: '1',
CLAUDE_CONFIG_DIR: path.join(os.tmpdir(), 'ccs-settings-profile-instance'),
CCS_WEBSEARCH_SKIP: '1',
});
expect(spawnCalls.length).toBeGreaterThan(0);
const env = spawnCalls[0].options?.env as NodeJS.ProcessEnv;
expect(env.CCS_PROFILE_TYPE).toBe('settings');
expect(env.CLAUDE_CONFIG_DIR).toContain('ccs-settings-profile-instance');
expect(env.ANTHROPIC_BASE_URL).toBeUndefined();
expect(env.ANTHROPIC_AUTH_TOKEN).toBeUndefined();
expect(env.ANTHROPIC_API_KEY).toBeUndefined();
expect(env.ANTHROPIC_MODEL).toBe('gpt-5.4');
expect(env.ANTHROPIC_DEFAULT_OPUS_MODEL).toBe('gpt-5.4');
expect(env.ANTHROPIC_DEFAULT_SONNET_MODEL).toBe('gpt-5.4');
expect(env.ANTHROPIC_DEFAULT_HAIKU_MODEL).toBe('gpt-5.4-mini');
expect(env.ANTHROPIC_SMALL_FAST_MODEL).toBe('gpt-5-codex-mini');
});
it('execClaude strips routing env reintroduced by explicit settings-profile overrides', () => {
execClaude('claude', ['--help'], {
CCS_PROFILE_TYPE: 'settings',
CCS_STRIP_INHERITED_ANTHROPIC_ENV: '1',
ANTHROPIC_BASE_URL: 'http://127.0.0.1:8317/api/provider/codex',
ANTHROPIC_AUTH_TOKEN: 'reintroduced-routing-token',
ANTHROPIC_API_KEY: 'reintroduced-api-key',
ANTHROPIC_MODEL: 'gpt-5.4',
ANTHROPIC_DEFAULT_SONNET_MODEL: 'gpt-5.4',
});
expect(spawnCalls.length).toBeGreaterThan(0);
const env = spawnCalls[0].options?.env as NodeJS.ProcessEnv;
expect(env.ANTHROPIC_BASE_URL).toBeUndefined();
expect(env.ANTHROPIC_AUTH_TOKEN).toBeUndefined();
expect(env.ANTHROPIC_API_KEY).toBeUndefined();
expect(env.ANTHROPIC_MODEL).toBe('gpt-5.4');
expect(env.ANTHROPIC_DEFAULT_SONNET_MODEL).toBe('gpt-5.4');
});
it('execClaude sanitizes tmux teammate env for bridge-backed settings launches while keeping the launched child on the runtime proxy', () => {
process.env.TMUX = 'session-1';
process.env.ANTHROPIC_BASE_URL = 'http://127.0.0.1:8317/api/provider/codex';
process.env.ANTHROPIC_AUTH_TOKEN = 'parent-routing-token';
process.env.ANTHROPIC_API_KEY = 'parent-api-key';
process.env.ANTHROPIC_MODEL = 'gpt-5.4';
process.env.ANTHROPIC_DEFAULT_SONNET_MODEL = 'gpt-5.4';
execClaude('claude', ['--help'], {
CCS_PROFILE_TYPE: 'settings',
CLAUDE_CONFIG_DIR: path.join(os.tmpdir(), 'ccs-settings-profile-instance'),
ANTHROPIC_BASE_URL: 'http://127.0.0.1:3456',
ANTHROPIC_AUTH_TOKEN: 'fresh-runtime-token',
ANTHROPIC_MODEL: 'gpt-5.4',
});
expect(spawnCalls.length).toBeGreaterThan(0);
const childEnv = spawnCalls[0].options?.env as NodeJS.ProcessEnv;
expect(childEnv.ANTHROPIC_BASE_URL).toBe('http://127.0.0.1:3456');
expect(childEnv.ANTHROPIC_AUTH_TOKEN).toBe('fresh-runtime-token');
const unsetBaseUrlCall = spawnSyncCalls.find(
(call) => call.command === 'tmux' && call.args.join(' ') === 'setenv -u ANTHROPIC_BASE_URL'
);
const unsetAuthTokenCall = spawnSyncCalls.find(
(call) => call.command === 'tmux' && call.args.join(' ') === 'setenv -u ANTHROPIC_AUTH_TOKEN'
);
const modelCall = spawnSyncCalls.find(
(call) =>
call.command === 'tmux' &&
call.args[0] === 'setenv' &&
call.args[1] === 'ANTHROPIC_MODEL' &&
call.args[2] === 'gpt-5.4'
);
expect(unsetBaseUrlCall).toBeDefined();
expect(unsetAuthTokenCall).toBeDefined();
expect(modelCall).toBeDefined();
});
it('headless executor spawn path strips CLAUDECODE before spawn', async () => {
writeConfigWithAutoUpdatePreference(false);
process.env.CLAUDECODE = 'nested';
@@ -432,6 +580,92 @@ describe('CLAUDECODE environment stripping', () => {
});
});
it('headless executor strips inherited routing env for settings-profile delegation while preserving model intent', async () => {
writeConfigWithAutoUpdatePreference(false);
const ccsDir = path.join(process.env.CCS_HOME as string, '.ccs');
fs.writeFileSync(
path.join(ccsDir, 'glm.settings.json'),
JSON.stringify(
{
env: {
ANTHROPIC_MODEL: 'gpt-5.4',
ANTHROPIC_DEFAULT_SONNET_MODEL: 'gpt-5.4',
CLAUDE_CODE_MAX_OUTPUT_TOKENS: '12345',
},
},
null,
2
) + '\n',
'utf8'
);
const projectDir = path.join(ccsDir, 'project-headless-settings');
fs.mkdirSync(projectDir, { recursive: true });
process.env.CCS_CLAUDE_PATH = 'claude';
process.env.ANTHROPIC_BASE_URL = 'http://127.0.0.1:8317/api/provider/codex';
process.env.ANTHROPIC_AUTH_TOKEN = 'parent-routing-token';
process.env.ANTHROPIC_API_KEY = 'parent-api-key';
process.env.ANTHROPIC_MODEL = 'gpt-5.4';
const result = await HeadlessExecutor.execute('glm', 'latest AI chip news', {
cwd: projectDir,
permissionMode: 'default',
timeout: 1000,
});
expect(result.success).toBe(true);
expect(spawnCalls.length).toBeGreaterThan(0);
const env = spawnCalls[0].options?.env as NodeJS.ProcessEnv;
expect(env.ANTHROPIC_BASE_URL).toBeUndefined();
expect(env.ANTHROPIC_AUTH_TOKEN).toBeUndefined();
expect(env.ANTHROPIC_API_KEY).toBeUndefined();
expect(env.ANTHROPIC_MODEL).toBe('gpt-5.4');
expect(env.ANTHROPIC_DEFAULT_SONNET_MODEL).toBe('gpt-5.4');
expect(env.CLAUDE_CODE_MAX_OUTPUT_TOKENS).toBe('12345');
});
it('headless executor rebuilds OpenAI-compatible bridge env from settings instead of inheriting stale parent routing', async () => {
writeConfigWithAutoUpdatePreference(false);
const ccsDir = path.join(process.env.CCS_HOME as string, '.ccs');
fs.writeFileSync(
path.join(ccsDir, 'bridge.settings.json'),
JSON.stringify(
{
env: {
ANTHROPIC_BASE_URL: 'https://api.openai.com/v1',
ANTHROPIC_AUTH_TOKEN: 'settings-bridge-token',
ANTHROPIC_MODEL: 'gpt-5.4',
CLAUDE_CODE_MAX_OUTPUT_TOKENS: '12345',
},
},
null,
2
) + '\n',
'utf8'
);
const projectDir = path.join(ccsDir, 'project-headless-bridge');
fs.mkdirSync(projectDir, { recursive: true });
process.env.CCS_CLAUDE_PATH = 'claude';
process.env.ANTHROPIC_BASE_URL = 'http://127.0.0.1:8317/api/provider/codex';
process.env.ANTHROPIC_AUTH_TOKEN = 'parent-routing-token';
process.env.ANTHROPIC_API_KEY = 'parent-api-key';
const result = await HeadlessExecutor.execute('bridge', 'latest AI chip news', {
cwd: projectDir,
permissionMode: 'default',
timeout: 1000,
});
expect(result.success).toBe(true);
expect(spawnCalls.length).toBeGreaterThan(0);
const env = spawnCalls[0].options?.env as NodeJS.ProcessEnv;
expect(env.ANTHROPIC_BASE_URL).toMatch(/^http:\/\/127\.0\.0\.1:\d+$/);
expect(env.ANTHROPIC_BASE_URL).not.toBe('http://127.0.0.1:8317/api/provider/codex');
expect(env.ANTHROPIC_AUTH_TOKEN).toBeDefined();
expect(env.ANTHROPIC_AUTH_TOKEN).not.toBe('parent-routing-token');
expect(env.ANTHROPIC_MODEL).toBe('gpt-5.4');
expect(env.CLAUDE_CODE_MAX_OUTPUT_TOKENS).toBe('12345');
});
it('headless executor prepares image-analysis MCP and suppresses the legacy hook on healthy launches', async () => {
writeConfigWithAutoUpdatePreference(false);
const ccsDir = path.join(process.env.CCS_HOME as string, '.ccs');
@@ -468,9 +702,7 @@ describe('CLAUDECODE environment stripping', () => {
args: [path.join(ccsDir, 'mcp', 'ccs-image-analysis-server.cjs')],
env: {},
});
expect(fs.existsSync(path.join(ccsDir, 'hooks', 'image-analyzer-transformer.cjs'))).toBe(
false
);
expect(fs.existsSync(path.join(ccsDir, 'hooks', 'image-analyzer-transformer.cjs'))).toBe(false);
expect(fs.existsSync(path.join(ccsDir, 'hooks', 'image-analysis-runtime.cjs'))).toBe(false);
});
@@ -56,7 +56,7 @@ describe('installDashboardCliproxyVersion', () => {
success: true,
restarted: true,
port: 8317,
message: 'Successfully installed CLIProxy Plus v6.7.1 and restarted it on port 8317',
message: 'Successfully installed CLIProxy v6.7.1 and restarted it on port 8317',
});
expect(calls.isCliproxyRunning).toBe(0);
expect(calls.installCliproxyVersion).toBe(1);
@@ -71,7 +71,7 @@ describe('installDashboardCliproxyVersion', () => {
expect(result).toEqual<DashboardCliproxyInstallResult>({
success: true,
restarted: false,
message: 'Successfully installed CLIProxy Plus v6.7.1',
message: 'Successfully installed CLIProxy v6.7.1',
});
expect(calls.isCliproxyRunning).toBe(1);
expect(calls.installCliproxyVersion).toBe(1);
@@ -118,8 +118,8 @@ describe('installDashboardCliproxyVersion', () => {
expect(result).toEqual<DashboardCliproxyInstallResult>({
success: false,
restarted: false,
error: 'Installed CLIProxy Plus v6.7.1, but restart failed',
message: 'Installed CLIProxy Plus v6.7.1, but failed to restart it',
error: 'Installed CLIProxy v6.7.1, but restart failed',
message: 'Installed CLIProxy v6.7.1, but failed to restart it',
});
});
});
@@ -10,6 +10,7 @@ let createEmptyUnifiedConfig: typeof import('../../../src/config/unified-config-
let saveUnifiedConfig: typeof import('../../../src/config/unified-config-loader').saveUnifiedConfig;
let setGlobalConfigDir: typeof import('../../../src/utils/config-manager').setGlobalConfigDir;
let writeInstalledVersion: typeof import('../../../src/cliproxy/binary/version-cache').writeInstalledVersion;
let writeVersionCache: typeof import('../../../src/cliproxy/binary/version-cache').writeVersionCache;
let writeVersionListCache: typeof import('../../../src/cliproxy/binary/version-cache').writeVersionListCache;
let server: Server;
@@ -25,7 +26,7 @@ beforeAll(async () => {
({ setGlobalConfigDir } = await import('../../../src/utils/config-manager'));
({ createEmptyUnifiedConfig } = await import('../../../src/config/unified-config-types'));
({ saveUnifiedConfig } = await import('../../../src/config/unified-config-loader'));
({ writeInstalledVersion, writeVersionListCache } = await import(
({ writeInstalledVersion, writeVersionCache, writeVersionListCache } = await import(
'../../../src/cliproxy/binary/version-cache'
));
@@ -39,6 +40,7 @@ beforeAll(async () => {
saveUnifiedConfig(config);
writeInstalledVersion(plusBinDir, '6.6.80');
writeVersionCache('6.6.89', 'plus');
writeVersionListCache(
{
versions: ['6.6.89', '6.6.88', '6.6.81', '6.6.80'],
@@ -94,6 +96,23 @@ afterAll(async () => {
});
describe('cliproxy-stats-routes install contract', () => {
it('routes saved plus configs through original backend for update checks', async () => {
const response = await fetch(`${baseUrl}/api/cliproxy/update-check`);
expect(response.status).toBe(200);
const body = (await response.json()) as {
backend: string;
backendLabel: string;
currentVersion: string;
latestVersion: string;
};
expect(body.backend).toBe('original');
expect(body.backendLabel).toBe('CLIProxy');
expect(body.currentVersion).toBe('6.6.80');
expect(body.latestVersion).toBe('6.6.89');
});
it('returns faultyRange in the versions response', async () => {
const response = await fetch(`${baseUrl}/api/cliproxy/versions`);
expect(response.status).toBe(200);
@@ -129,7 +129,7 @@ describe('cliproxy-stats-routes model update canonicalization', () => {
expect(persisted.env.ANTHROPIC_MODEL).toBe('claude-sonnet-4-6');
});
it('canonicalizes Codex effort suffix and syncs linked core model env vars', async () => {
it('preserves Codex effort suffixes while syncing linked core model env vars', async () => {
const settingsPath = path.join(tempHome, '.ccs', 'cliproxy', 'codex.settings.json');
writeSettings(settingsPath, {
ANTHROPIC_BASE_URL: 'http://127.0.0.1:8317/api/provider/codex',
@@ -148,14 +148,14 @@ describe('cliproxy-stats-routes model update canonicalization', () => {
expect(response.status).toBe(200);
const body = (await response.json()) as { model: string };
expect(body.model).toBe('gpt-5.3-codex');
expect(body.model).toBe('gpt-5.3-codex-xhigh');
const persisted = JSON.parse(fs.readFileSync(settingsPath, 'utf8')) as {
env: Record<string, string>;
};
expect(persisted.env.ANTHROPIC_MODEL).toBe('gpt-5.3-codex');
expect(persisted.env.ANTHROPIC_DEFAULT_OPUS_MODEL).toBe('gpt-5.3-codex');
expect(persisted.env.ANTHROPIC_DEFAULT_SONNET_MODEL).toBe('gpt-5.3-codex');
expect(persisted.env.ANTHROPIC_MODEL).toBe('gpt-5.3-codex-xhigh');
expect(persisted.env.ANTHROPIC_DEFAULT_OPUS_MODEL).toBe('gpt-5.3-codex-xhigh');
expect(persisted.env.ANTHROPIC_DEFAULT_SONNET_MODEL).toBe('gpt-5.3-codex-xhigh');
expect(persisted.env.ANTHROPIC_DEFAULT_HAIKU_MODEL).toBe('gpt-5.4-mini');
});
@@ -154,6 +154,83 @@ describe('settings-routes model canonicalization', () => {
expect(persisted.presets[0]?.haiku).toBe('qwen3-coder-plus');
});
it('preserves codex effort suffixes on PUT /:profile while still normalizing legacy aliases', async () => {
const settingsPath = path.join(tempHome, '.ccs', 'codex.settings.json');
writeSettings(settingsPath, { env: {} });
const response = await fetch(`${baseUrl}/api/settings/codex`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
settings: {
env: {
ANTHROPIC_BASE_URL: 'http://127.0.0.1:8317/api/provider/codex',
ANTHROPIC_AUTH_TOKEN: 'ccs-internal-managed',
ANTHROPIC_MODEL: 'gpt-5.3-codex-xhigh',
ANTHROPIC_DEFAULT_OPUS_MODEL: 'gpt-5-codex-high',
ANTHROPIC_DEFAULT_SONNET_MODEL: 'gpt-5.3-codex-high',
ANTHROPIC_DEFAULT_HAIKU_MODEL: 'gpt-5-mini-medium',
},
presets: [
{
name: 'legacy-codex',
default: 'gpt-5.3-codex-xhigh',
opus: 'gpt-5-codex-high',
sonnet: 'gpt-5.3-codex-high',
haiku: 'gpt-5-mini-medium',
},
],
},
}),
});
expect(response.status).toBe(200);
const persisted = JSON.parse(fs.readFileSync(settingsPath, 'utf8')) as {
env: Record<string, string>;
presets: Array<Record<string, string>>;
};
expect(persisted.env.ANTHROPIC_MODEL).toBe('gpt-5.3-codex-xhigh');
expect(persisted.env.ANTHROPIC_DEFAULT_OPUS_MODEL).toBe('gpt-5.4-high');
expect(persisted.env.ANTHROPIC_DEFAULT_SONNET_MODEL).toBe('gpt-5.3-codex-high');
expect(persisted.env.ANTHROPIC_DEFAULT_HAIKU_MODEL).toBe('gpt-5.4-mini-medium');
expect(persisted.presets[0]?.default).toBe('gpt-5.3-codex-xhigh');
expect(persisted.presets[0]?.opus).toBe('gpt-5.4-high');
expect(persisted.presets[0]?.sonnet).toBe('gpt-5.3-codex-high');
expect(persisted.presets[0]?.haiku).toBe('gpt-5.4-mini-medium');
});
it('preserves codex effort suffixes on GET /:profile/raw canonicalization', async () => {
const settingsPath = path.join(tempHome, '.ccs', 'codex.settings.json');
writeSettings(settingsPath, {
env: {
ANTHROPIC_BASE_URL: 'http://127.0.0.1:8317/api/provider/codex',
ANTHROPIC_AUTH_TOKEN: 'ccs-internal-managed',
ANTHROPIC_MODEL: 'gpt-5-codex-high',
ANTHROPIC_DEFAULT_OPUS_MODEL: 'gpt-5.3-codex-xhigh',
ANTHROPIC_DEFAULT_SONNET_MODEL: 'gpt-5.3-codex-high',
ANTHROPIC_DEFAULT_HAIKU_MODEL: 'gpt-5-mini-medium',
},
});
const response = await fetch(`${baseUrl}/api/settings/codex/raw`);
expect(response.status).toBe(200);
const body = (await response.json()) as { settings: { env: Record<string, string> } };
expect(body.settings.env.ANTHROPIC_MODEL).toBe('gpt-5.4-high');
expect(body.settings.env.ANTHROPIC_DEFAULT_OPUS_MODEL).toBe('gpt-5.3-codex-xhigh');
expect(body.settings.env.ANTHROPIC_DEFAULT_SONNET_MODEL).toBe('gpt-5.3-codex-high');
expect(body.settings.env.ANTHROPIC_DEFAULT_HAIKU_MODEL).toBe('gpt-5.4-mini-medium');
const persisted = JSON.parse(fs.readFileSync(settingsPath, 'utf8')) as {
env: Record<string, string>;
};
expect(persisted.env.ANTHROPIC_MODEL).toBe('gpt-5.4-high');
expect(persisted.env.ANTHROPIC_DEFAULT_OPUS_MODEL).toBe('gpt-5.3-codex-xhigh');
expect(persisted.env.ANTHROPIC_DEFAULT_SONNET_MODEL).toBe('gpt-5.3-codex-high');
expect(persisted.env.ANTHROPIC_DEFAULT_HAIKU_MODEL).toBe('gpt-5.4-mini-medium');
});
it('canonicalizes AGY preset values on POST /:profile/presets', async () => {
const settingsPath = path.join(tempHome, '.ccs', 'agy.settings.json');
writeSettings(settingsPath, {
+45 -9
View File
@@ -19,7 +19,13 @@ import { useTranslation } from 'react-i18next';
import { useCreateVariant, useCliproxyAuth } from '@/hooks/use-cliproxy';
import { usePrivacy } from '@/contexts/privacy-context';
import { formatAccountDisplayName } from '@/lib/account-identity';
import { CLIPROXY_PROVIDERS, getProviderDisplayName } from '@/lib/provider-config';
import {
CLIPROXY_PROVIDERS,
CLIPROXY_PROVIDER_SECTIONS,
getProviderDisplayName,
getProviderSection,
isPlusExtraProvider,
} from '@/lib/provider-config';
import { isDeniedAgyModelId } from '@/lib/utils';
const singleProviderSchema = z.object({
@@ -104,6 +110,8 @@ export function CliproxyDialog({ open, onClose }: CliproxyDialogProps) {
});
const selectedProvider = useWatch({ control: singleForm.control, name: 'provider' });
const compositeTiers = useWatch({ control: compositeForm.control, name: 'tiers' });
const selectedProviderSection = getProviderSection(selectedProvider);
const providerAuth = authData?.authStatus.find((s) => s.provider === selectedProvider);
const providerAccounts = providerAuth?.accounts || [];
@@ -197,10 +205,16 @@ export function CliproxyDialog({ open, onClose }: CliproxyDialogProps) {
className="flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
>
<option value="">{t('cliproxyDialog.selectProvider')}</option>
{providerOptions.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
{CLIPROXY_PROVIDER_SECTIONS.map((section) => (
<optgroup key={section.id} label={t(section.labelKey)}>
{providerOptions
.filter((opt) => section.providers.includes(opt.value))
.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</optgroup>
))}
</select>
{singleForm.formState.errors.provider && (
@@ -208,6 +222,14 @@ export function CliproxyDialog({ open, onClose }: CliproxyDialogProps) {
{singleForm.formState.errors.provider.message}
</span>
)}
{selectedProviderSection && (
<p className="mt-2 text-xs text-muted-foreground">
{t(selectedProviderSection.hintKey)}
{isPlusExtraProvider(selectedProvider)
? ` ${t('providerConfig.plusTrackNote')}`
: ''}
</p>
)}
</div>
{selectedProvider && providerAccounts.length > 0 && (
@@ -297,12 +319,26 @@ export function CliproxyDialog({ open, onClose }: CliproxyDialogProps) {
{...compositeForm.register(`tiers.${tier}.provider`)}
className="flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
>
{providerOptions.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
{CLIPROXY_PROVIDER_SECTIONS.map((section) => (
<optgroup key={section.id} label={t(section.labelKey)}>
{providerOptions
.filter((opt) => section.providers.includes(opt.value))
.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</optgroup>
))}
</select>
{compositeTiers?.[tier]?.provider && (
<p className="mt-2 text-xs text-muted-foreground">
{t(getProviderSection(compositeTiers[tier].provider)?.hintKey || '')}
{isPlusExtraProvider(compositeTiers[tier].provider)
? ` ${t('providerConfig.plusTrackNote')}`
: ''}
</p>
)}
</div>
<div>
<Label htmlFor={`${tier}-model`}>{t('cliproxyDialog.model')}</Label>
@@ -3,7 +3,7 @@
* Phase 05: Dashboard UI full CRUD for composite variants
*/
import { useForm } from 'react-hook-form';
import { useForm, useWatch } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import * as z from 'zod';
import { useEffect } from 'react';
@@ -15,7 +15,13 @@ import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs';
import { toast } from 'sonner';
import { useTranslation } from 'react-i18next';
import { useUpdateVariant } from '@/hooks/use-cliproxy';
import { CLIPROXY_PROVIDERS, getProviderDisplayName } from '@/lib/provider-config';
import {
CLIPROXY_PROVIDERS,
CLIPROXY_PROVIDER_SECTIONS,
getProviderDisplayName,
getProviderSection,
isPlusExtraProvider,
} from '@/lib/provider-config';
import type { UpdateVariant, Variant } from '@/lib/api-client';
import { isDeniedAgyModelId } from '@/lib/utils';
@@ -137,6 +143,8 @@ export function CliproxyEditDialog({ variant, open, onOpenChange }: CliproxyEdit
const compositeForm = useForm<CompositeFormData>({
resolver: zodResolver(compositeSchema),
});
const selectedProvider = useWatch({ control: singleForm.control, name: 'provider' });
const compositeTiers = useWatch({ control: compositeForm.control, name: 'tiers' });
// Pre-populate form when variant changes
useEffect(() => {
@@ -318,12 +326,26 @@ export function CliproxyEditDialog({ variant, open, onOpenChange }: CliproxyEdit
{...compositeForm.register(`tiers.${tier}.provider`)}
className="flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
>
{providerOptions.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
{CLIPROXY_PROVIDER_SECTIONS.map((section) => (
<optgroup key={section.id} label={t(section.labelKey)}>
{providerOptions
.filter((opt) => section.providers.includes(opt.value))
.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</optgroup>
))}
</select>
{compositeTiers?.[tier]?.provider && (
<p className="mt-2 text-xs text-muted-foreground">
{t(getProviderSection(compositeTiers[tier].provider)?.hintKey || '')}
{isPlusExtraProvider(compositeTiers[tier].provider)
? ` ${t('providerConfig.plusTrackNote')}`
: ''}
</p>
)}
</div>
<div>
<Label htmlFor={`edit-${tier}-model`}>{t('cliproxyDialog.model')}</Label>
@@ -399,12 +421,26 @@ export function CliproxyEditDialog({ variant, open, onOpenChange }: CliproxyEdit
{...singleForm.register('provider')}
className="flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
>
{providerOptions.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
{CLIPROXY_PROVIDER_SECTIONS.map((section) => (
<optgroup key={section.id} label={t(section.labelKey)}>
{providerOptions
.filter((opt) => section.providers.includes(opt.value))
.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</optgroup>
))}
</select>
{selectedProvider && (
<p className="mt-2 text-xs text-muted-foreground">
{t(getProviderSection(selectedProvider)?.hintKey || '')}
{isPlusExtraProvider(selectedProvider)
? ` ${t('providerConfig.plusTrackNote')}`
: ''}
</p>
)}
</div>
<div>
@@ -304,6 +304,7 @@ export function ProviderEditor({
<ProviderInfoTab
provider={provider}
displayName={displayName}
baseProvider={baseProvider}
defaultTarget={defaultTarget}
data={data}
authStatus={authStatus}
@@ -229,7 +229,8 @@ export function ModelConfigSection({
{provider === 'codex' && (
<p className="text-[11px] text-muted-foreground mb-3 rounded-md border bg-muted/30 px-2.5 py-2">
Codex tip: suffixes <code>-medium</code>, <code>-high</code>, and <code>-xhigh</code>{' '}
pin reasoning effort. Unsuffixed models use Thinking settings.
pin reasoning effort. Select a suffixed model to pin effort; unsuffixed models use
Thinking settings.
</p>
)}
<div className="space-y-4">
@@ -10,11 +10,13 @@ import { Info, Shield } from 'lucide-react';
import { UsageCommand } from './usage-command';
import type { SettingsResponse } from './types';
import type { AuthStatus, CliTarget } from '@/lib/api-client';
import { getProviderSection, isPlusExtraProvider } from '@/lib/provider-config';
import { useTranslation } from 'react-i18next';
interface ProviderInfoTabProps {
provider: string;
displayName: string;
baseProvider?: string;
defaultTarget?: CliTarget;
data?: SettingsResponse;
authStatus: AuthStatus;
@@ -24,6 +26,7 @@ interface ProviderInfoTabProps {
export function ProviderInfoTab({
provider,
displayName,
baseProvider,
defaultTarget,
data,
authStatus,
@@ -33,6 +36,8 @@ export function ProviderInfoTab({
const resolvedTarget = defaultTarget || 'claude';
const isDroidTarget = resolvedTarget === 'droid';
const isCodexProvider = provider === 'codex';
const sectionProvider = baseProvider || authStatus.provider || provider;
const providerSection = getProviderSection(sectionProvider);
const managementPrefix =
resolvedTarget === 'claude' ? `ccs ${provider}` : `ccs ${provider} --target claude`;
const changeModelCommand = `${managementPrefix} --config`;
@@ -101,6 +106,22 @@ export function ProviderInfoTab({
</span>
<span className="font-mono">{resolvedTarget}</span>
</div>
{providerSection && (
<div className="grid grid-cols-[100px_1fr] gap-2 text-sm items-start">
<span className="font-medium text-muted-foreground">
{t('providerConfig.trackLabel')}
</span>
<div className="space-y-1">
<span className="font-mono">{t(providerSection.labelKey)}</span>
<p className="text-xs text-muted-foreground">
{t(providerSection.hintKey)}
{isPlusExtraProvider(sectionProvider)
? ` ${t('providerConfig.plusTrackNote')}`
: ''}
</p>
</div>
</div>
)}
</div>
</div>
@@ -12,7 +12,8 @@ import { Badge } from '@/components/ui/badge';
import { SearchableSelect } from '@/components/ui/searchable-select';
import { Skeleton } from '@/components/ui/skeleton';
import type { CliproxyProviderRoutingHints } from '@/lib/api-client';
import { getCodexEffortDisplay } from '@/lib/codex-effort';
import type { CodexEffort } from '@/lib/codex-effort';
import { getCodexEffortDisplay, getCodexEffortVariants } from '@/lib/codex-effort';
import { getResolvedCatalogModels, getSupplementalCatalogModels } from '@/lib/model-catalogs';
import { cn } from '@/lib/utils';
@@ -35,6 +36,8 @@ export interface ModelEntry {
sonnet: string;
haiku: string;
};
/** Highest codex reasoning-effort suffix this model supports in the dashboard UI. */
codexMaxEffort?: CodexEffort;
}
/** Provider catalog */
@@ -313,6 +316,14 @@ function getPreferredOptionValue(
return routingHint?.recommendedModelId ?? modelId;
}
function getModelOptionValues(
codexMaxEffort: CodexEffort | undefined,
optionValue: string,
isCodexProvider: boolean
): string[] {
return isCodexProvider ? getCodexEffortVariants(optionValue, codexMaxEffort) : [optionValue];
}
export function FlexibleModelSelector({
label,
description,
@@ -342,55 +353,64 @@ export function FlexibleModelSelector({
const recommendedOptionValues = useMemo(
() =>
new Set(
resolvedCatalogModels.map((model) =>
getPreferredOptionValue(model.id, routingHints.get(model.id.toLowerCase()))
resolvedCatalogModels.flatMap((model) =>
getModelOptionValues(
model.codexMaxEffort,
getPreferredOptionValue(model.id, routingHints.get(model.id.toLowerCase())),
isCodexProvider
)
)
),
[resolvedCatalogModels, routingHints]
[isCodexProvider, resolvedCatalogModels, routingHints]
);
const selectedRoutingHint = useMemo(
() => routingHints.get(normalizeModelValue(value, routing).toLowerCase()),
[routing, routingHints, value]
);
const recommendedOptions = resolvedCatalogModels.map((model) => ({
value: getPreferredOptionValue(model.id, routingHints.get(model.id.toLowerCase())),
groupKey: 'recommended',
searchText: `${model.id} ${model.name} ${routingHints.get(model.id.toLowerCase())?.recommendedModelId ?? ''}`,
keywords: [model.tier ?? '', catalog?.provider ?? ''],
triggerContent: (
<div className="flex min-w-0 items-center gap-2">
<span className="truncate font-mono text-xs">
{getPreferredOptionValue(model.id, routingHints.get(model.id.toLowerCase()))}
</span>
{routingHints.get(model.id.toLowerCase())?.pinnedAvailable ? (
<Badge variant="secondary" className="text-[9px] h-4 px-1 uppercase">
{routingHints.get(model.id.toLowerCase())?.prefix}
</Badge>
) : null}
{isCodexProvider && <CodexEffortBadge modelId={model.id} />}
</div>
),
itemContent: (
<div className="flex min-w-0 items-center gap-2">
<span className="truncate font-mono text-xs">
{getPreferredOptionValue(model.id, routingHints.get(model.id.toLowerCase()))}
</span>
{model.tier === 'paid' && <PaidBadge label={t('providerModelSelector.paid')} />}
{routingHints.get(model.id.toLowerCase())?.unprefixedStatus === 'shadowed' ? (
<Badge variant="outline" className="text-[9px] h-4 px-1">
{t('providerModelSelector.shadowed')}
</Badge>
) : null}
{routingHints.get(model.id.toLowerCase())?.unprefixedStatus === 'prefix-only' ? (
<Badge variant="outline" className="text-[9px] h-4 px-1">
{t('providerModelSelector.prefixOnly')}
</Badge>
) : null}
{isCodexProvider && <CodexEffortBadge modelId={model.id} />}
</div>
),
}));
const recommendedOptions = resolvedCatalogModels.flatMap((model) => {
const routingHint = routingHints.get(model.id.toLowerCase());
const optionValues = getModelOptionValues(
model.codexMaxEffort,
getPreferredOptionValue(model.id, routingHint),
isCodexProvider
);
return optionValues.map((optionValue) => ({
value: optionValue,
groupKey: 'recommended',
searchText: `${optionValue} ${model.id} ${model.name} ${routingHint?.recommendedModelId ?? ''}`,
keywords: [model.tier ?? '', catalog?.provider ?? ''],
triggerContent: (
<div className="flex min-w-0 items-center gap-2">
<span className="truncate font-mono text-xs">{optionValue}</span>
{routingHint?.pinnedAvailable ? (
<Badge variant="secondary" className="text-[9px] h-4 px-1 uppercase">
{routingHint.prefix}
</Badge>
) : null}
{isCodexProvider && <CodexEffortBadge modelId={optionValue} />}
</div>
),
itemContent: (
<div className="flex min-w-0 items-center gap-2">
<span className="truncate font-mono text-xs">{optionValue}</span>
{model.tier === 'paid' && <PaidBadge label={t('providerModelSelector.paid')} />}
{routingHint?.unprefixedStatus === 'shadowed' ? (
<Badge variant="outline" className="text-[9px] h-4 px-1">
{t('providerModelSelector.shadowed')}
</Badge>
) : null}
{routingHint?.unprefixedStatus === 'prefix-only' ? (
<Badge variant="outline" className="text-[9px] h-4 px-1">
{t('providerModelSelector.prefixOnly')}
</Badge>
) : null}
{isCodexProvider && <CodexEffortBadge modelId={optionValue} />}
</div>
),
}));
});
const allModelOptions = supplementalModels
.filter((model) => !catalogModelIds.has(model.id))
@@ -400,43 +420,48 @@ export function FlexibleModelSelector({
getPreferredOptionValue(model.id, routingHints.get(model.id.toLowerCase()))
)
)
.map((model) => ({
value: getPreferredOptionValue(model.id, routingHints.get(model.id.toLowerCase())),
groupKey: 'all',
searchText: `${model.id} ${routingHints.get(model.id.toLowerCase())?.recommendedModelId ?? ''}`,
keywords: [model.owned_by],
triggerContent: (
<div className="flex min-w-0 items-center gap-2">
<span className="truncate font-mono text-xs">
{getPreferredOptionValue(model.id, routingHints.get(model.id.toLowerCase()))}
</span>
{routingHints.get(model.id.toLowerCase())?.pinnedAvailable ? (
<Badge variant="secondary" className="text-[9px] h-4 px-1 uppercase">
{routingHints.get(model.id.toLowerCase())?.prefix}
</Badge>
) : null}
{isCodexProvider && <CodexEffortBadge modelId={model.id} />}
</div>
),
itemContent: (
<div className="flex min-w-0 items-center gap-2">
<span className="truncate font-mono text-xs">
{getPreferredOptionValue(model.id, routingHints.get(model.id.toLowerCase()))}
</span>
{routingHints.get(model.id.toLowerCase())?.unprefixedStatus === 'shadowed' ? (
<Badge variant="outline" className="text-[9px] h-4 px-1">
{t('providerModelSelector.shadowed')}
</Badge>
) : null}
{routingHints.get(model.id.toLowerCase())?.unprefixedStatus === 'prefix-only' ? (
<Badge variant="outline" className="text-[9px] h-4 px-1">
{t('providerModelSelector.prefixOnly')}
</Badge>
) : null}
{isCodexProvider && <CodexEffortBadge modelId={model.id} />}
</div>
),
}));
.flatMap((model) => {
const routingHint = routingHints.get(model.id.toLowerCase());
const optionValues = getModelOptionValues(
undefined,
getPreferredOptionValue(model.id, routingHint),
isCodexProvider
);
return optionValues.map((optionValue) => ({
value: optionValue,
groupKey: 'all',
searchText: `${optionValue} ${model.id} ${routingHint?.recommendedModelId ?? ''}`,
keywords: [model.owned_by],
triggerContent: (
<div className="flex min-w-0 items-center gap-2">
<span className="truncate font-mono text-xs">{optionValue}</span>
{routingHint?.pinnedAvailable ? (
<Badge variant="secondary" className="text-[9px] h-4 px-1 uppercase">
{routingHint.prefix}
</Badge>
) : null}
{isCodexProvider && <CodexEffortBadge modelId={optionValue} />}
</div>
),
itemContent: (
<div className="flex min-w-0 items-center gap-2">
<span className="truncate font-mono text-xs">{optionValue}</span>
{routingHint?.unprefixedStatus === 'shadowed' ? (
<Badge variant="outline" className="text-[9px] h-4 px-1">
{t('providerModelSelector.shadowed')}
</Badge>
) : null}
{routingHint?.unprefixedStatus === 'prefix-only' ? (
<Badge variant="outline" className="text-[9px] h-4 px-1">
{t('providerModelSelector.prefixOnly')}
</Badge>
) : null}
{isCodexProvider && <CodexEffortBadge modelId={optionValue} />}
</div>
),
}));
});
const selectedValueMissing =
Boolean(value) &&
!recommendedOptions.some((option) => option.value === value) &&
+43 -1
View File
@@ -1,14 +1,56 @@
export type CodexEffort = 'medium' | 'high' | 'xhigh';
const CODEX_EFFORT_SUFFIX_REGEX = /-(medium|high|xhigh)$/i;
const CODEX_EFFORTS_IN_ORDER: readonly CodexEffort[] = ['medium', 'high', 'xhigh'];
function trimModelId(modelId: string | undefined): string {
return modelId?.trim() ?? '';
}
export function parseCodexEffort(modelId: string | undefined): CodexEffort | undefined {
if (!modelId) return undefined;
const match = modelId.trim().match(CODEX_EFFORT_SUFFIX_REGEX);
const match = trimModelId(modelId).match(CODEX_EFFORT_SUFFIX_REGEX);
if (!match?.[1]) return undefined;
return match[1].toLowerCase() as CodexEffort;
}
export function stripCodexEffortSuffix(modelId: string | undefined): string {
return trimModelId(modelId).replace(CODEX_EFFORT_SUFFIX_REGEX, '');
}
export function applyCodexEffortSuffix(
modelId: string | undefined,
effort: CodexEffort | undefined
): string {
const normalizedModelId = stripCodexEffortSuffix(modelId);
if (!normalizedModelId || !effort) {
return normalizedModelId;
}
return `${normalizedModelId}-${effort}`;
}
export function getCodexEffortVariants(
modelId: string,
maxEffort: CodexEffort | undefined
): string[] {
if (!maxEffort) {
const explicitEffort = parseCodexEffort(modelId);
return [applyCodexEffortSuffix(modelId, explicitEffort)];
}
const normalizedModelId = stripCodexEffortSuffix(modelId);
const variantIds = [normalizedModelId];
for (const effort of CODEX_EFFORTS_IN_ORDER) {
variantIds.push(applyCodexEffortSuffix(normalizedModelId, effort));
if (effort === maxEffort) {
break;
}
}
return variantIds;
}
export function getCodexEffortDisplay(
modelId: string | undefined,
effortLabels?: { pinned: (effort: string) => string; auto: string }
+50 -12
View File
@@ -977,10 +977,13 @@ const resources = {
backendBinary: 'Backend Binary',
stopProxyToSwitch: 'Stop the running proxy in Instance Status to switch backend.',
default: 'Default',
plusDesc: 'Full provider support including Kiro and GitHub Copilot',
originalDesc: 'Original binary (Gemini, Codex, Antigravity only)',
plusDesc:
'Optional track for extra providers. Still supported, but currently community-maintained instead of upstream-maintained.',
originalDesc: 'Default, always-available backend for the core provider track.',
plusFallbackNotice:
'The Plus provider track is not deprecated, but local CLIProxy still falls back to the original backend while the maintained fork path is being brought back.',
variantsIncompatible:
'Existing Kiro/Copilot variants will not work with CLIProxyAPI. Switch to CLIProxyAPIPlus or remove those variants.',
'Existing plus-extra variants ({{providers}}) will not run on the original backend. Keep them visible for reference, but switch to Plus before using them.',
safety: 'Safety',
agyModeTitle: 'Antigravity + Gemini Power User Mode',
agyModeDesc:
@@ -2234,6 +2237,13 @@ const resources = {
},
providerConfig: {
defaultDeviceCodeInstruction: 'Complete the authorization in your browser.',
trackLabel: 'Track',
sectionCoreLabel: 'Core / original backend',
sectionCoreHint: 'Default, always-available provider track',
sectionPlusLabel: 'Plus extras / community-maintained',
sectionPlusHint: 'Still supported, but separated from the default backend for now',
plusTrackNote:
'Requires the optional Plus backend while that track remains community-maintained.',
},
// ========================================
@@ -3455,10 +3465,12 @@ const resources = {
backendBinary: '后端二进制',
stopProxyToSwitch: '请先在实例状态中停止正在运行的代理,再切换后端。',
default: '默认',
plusDesc: '完整支持包括 Kiro 和 GitHub Copilot 在内的提供商',
originalDesc: '原版二进制(仅 Gemini、Codex、Antigravity',
plusDesc: '额外提供商的可选线路。仍受支持,但目前由社区维护而非上游维护。',
originalDesc: '核心提供商线路的默认、始终可用后端。',
plusFallbackNotice:
'Plus 提供商线路并未弃用,但在受维护的 fork 恢复之前,本地 CLIProxy 仍会回退到原始后端。',
variantsIncompatible:
'现有 Kiro/Copilot 变体与 CLIProxyAPI 不兼容。请切换到 CLIProxyAPIPlus 或移除这些变体。',
'现有 plus 扩展变体({{providers}})无法在原始后端上运行。可以保留作参考,但使用前请切换到 Plus。',
safety: '安全',
agyModeTitle: 'Antigravity + Gemini 高级模式',
agyModeDesc: '跳过 AGY 责任确认清单,以及 Gemini Dashboard 中输入风险短语的步骤。',
@@ -4668,6 +4680,12 @@ const resources = {
},
providerConfig: {
defaultDeviceCodeInstruction: '请在浏览器中完成授权。',
trackLabel: '分组',
sectionCoreLabel: '核心 / 原始后端',
sectionCoreHint: '默认且始终可用的提供商线路',
sectionPlusLabel: 'Plus 扩展 / 社区维护',
sectionPlusHint: '仍然受支持,但目前与默认后端分开显示',
plusTrackNote: '需要可选的 Plus 后端;当前这条线路由社区维护。',
},
profileEditorSections: {
imageAnalysis: '图片分析',
@@ -5958,10 +5976,13 @@ const resources = {
stopProxyToSwitch:
'Dừng proxy đang chạy trong Trạng thái phiên bản trước khi chuyển backend.',
default: 'Mặc định',
plusDesc: 'Hỗ trợ đầy đủ nhà cung cấp, bao gồm Kiro và GitHub Copilot',
originalDesc: 'Binary gốc (chỉ Gemini, Codex, Antigravity)',
plusDesc:
'Nhánh tùy chọn cho các nhà cung cấp bổ sung. Vẫn được hỗ trợ nhưng hiện do cộng đồng duy trì thay vì upstream.',
originalDesc: 'Backend mặc định, luôn sẵn sàng cho nhóm nhà cung cấp cốt lõi.',
plusFallbackNotice:
'Nhánh nhà cung cấp Plus chưa bị khai tử, nhưng CLIProxy cục bộ vẫn quay về backend gốc cho tới khi đường dẫn fork được duy trì được bật lại.',
variantsIncompatible:
'Các biến thể Kiro/Copilot hiện tại sẽ không hoạt động với CLIProxyAPI. Chuyển sang CLIProxyAPIPlus hoặc xóa các biến thể đó.',
'Các biến thể plus-extra hiện có ({{providers}}) sẽ không chạy trên backend gốc. Có thể giữ lại để tham chiếu, nhưng hãy chuyển sang Plus trước khi dùng.',
safety: 'An toàn',
agyModeTitle: 'Chế độ power user Antigravity + Gemini',
agyModeDesc:
@@ -7194,6 +7215,12 @@ const resources = {
},
providerConfig: {
defaultDeviceCodeInstruction: 'Hoàn tất việc cấp quyền trong trình duyệt của bạn.',
trackLabel: 'Nhóm',
sectionCoreLabel: 'Core / backend gốc',
sectionCoreHint: 'Nhóm nhà cung cấp mặc định, luôn sẵn sàng',
sectionPlusLabel: 'Plus extras / cộng đồng duy trì',
sectionPlusHint: 'Vẫn được hỗ trợ nhưng hiện được tách khỏi backend mặc định',
plusTrackNote: 'Cần backend Plus tùy chọn; hiện tại nhánh này do cộng đồng duy trì.',
},
profileEditorSections: {
imageAnalysis: 'Phân tích hình ảnh',
@@ -8497,10 +8524,13 @@ const resources = {
stopProxyToSwitch:
'バックエンドを切り替える前に、インスタンス状態から実行中のプロキシを停止してください。',
default: 'デフォルト',
plusDesc: 'Kiro と GitHub Copilot を含むすべてのプロバイダーをサポート',
originalDesc: '元のバイナリ(Gemini、Codex、Antigravity のみ)',
plusDesc:
'追加プロバイダー向けのオプショントラックです。引き続きサポートされていますが、現在は upstream ではなくコミュニティ保守です。',
originalDesc: 'コアプロバイダートラック向けの既定かつ常時利用可能な backend。',
plusFallbackNotice:
'Plus プロバイダートラックは廃止ではありませんが、保守中の fork が戻るまではローカル CLIProxy は引き続きオリジナル backend にフォールバックします。',
variantsIncompatible:
'既存の Kiro/Copilot バリアントは CLIProxyAPI では動作しません。CLIProxyAPIPlus 切り替えるか、それらのバリアントを削除してください。',
'既存の plus-extra バリアント({{providers}})はオリジナル backend では動作しません。参照用に残すことはできますが、使用前に Plus 切り替えてください。',
safety: '安全設定',
agyModeTitle: 'Antigravity + Gemini パワーユーザーモード',
agyModeDesc:
@@ -10032,6 +10062,14 @@ const resources = {
},
providerConfig: {
defaultDeviceCodeInstruction: 'ブラウザーで認証を完了してください。',
trackLabel: 'トラック',
sectionCoreLabel: 'コア / オリジナル backend',
sectionCoreHint: '既定で常に利用できるプロバイダートラック',
sectionPlusLabel: 'Plus 拡張 / コミュニティ保守',
sectionPlusHint:
'引き続きサポートされていますが、当面は既定の backend から分離されています',
plusTrackNote:
'このトラックはオプションの Plus backend が必要で、現在はコミュニティ保守です。',
},
updatesSpotlight: {
openUpdatesCenter: '更新センターを開く',
+9
View File
@@ -263,6 +263,7 @@ export const MODEL_CATALOGS: Record<string, ProviderCatalog> = {
id: 'gpt-5-codex',
name: 'GPT-5 Codex',
description: 'Cross-plan safe Codex default',
codexMaxEffort: 'xhigh',
presetMapping: {
default: 'gpt-5-codex',
opus: 'gpt-5-codex',
@@ -274,6 +275,7 @@ export const MODEL_CATALOGS: Record<string, ProviderCatalog> = {
id: 'gpt-5-codex-mini',
name: 'GPT-5 Codex Mini',
description: 'Faster and cheaper Codex option',
codexMaxEffort: 'high',
presetMapping: {
default: 'gpt-5-codex-mini',
opus: 'gpt-5-codex',
@@ -285,6 +287,7 @@ export const MODEL_CATALOGS: Record<string, ProviderCatalog> = {
id: 'gpt-5-mini',
name: 'GPT-5 Mini',
description: 'Legacy mini model ID kept for backwards compatibility',
codexMaxEffort: 'high',
presetMapping: {
default: 'gpt-5-mini',
opus: 'gpt-5-codex',
@@ -296,6 +299,7 @@ export const MODEL_CATALOGS: Record<string, ProviderCatalog> = {
id: 'gpt-5.1-codex-mini',
name: 'GPT-5.1 Codex Mini',
description: 'Legacy fast Codex mini model',
codexMaxEffort: 'high',
presetMapping: {
default: 'gpt-5.1-codex-mini',
opus: 'gpt-5.1-codex-max',
@@ -307,6 +311,7 @@ export const MODEL_CATALOGS: Record<string, ProviderCatalog> = {
id: 'gpt-5.1-codex-max',
name: 'GPT-5.1 Codex Max',
description: 'Higher-effort Codex model with xhigh support',
codexMaxEffort: 'xhigh',
presetMapping: {
default: 'gpt-5.1-codex-max',
opus: 'gpt-5.1-codex-max',
@@ -318,6 +323,7 @@ export const MODEL_CATALOGS: Record<string, ProviderCatalog> = {
id: 'gpt-5.2-codex',
name: 'GPT-5.2 Codex',
description: 'Cross-plan Codex model with xhigh support',
codexMaxEffort: 'xhigh',
presetMapping: {
default: 'gpt-5.2-codex',
opus: 'gpt-5.2-codex',
@@ -330,6 +336,7 @@ export const MODEL_CATALOGS: Record<string, ProviderCatalog> = {
name: 'GPT-5.3 Codex',
tier: 'paid',
description: 'Paid Codex plans only',
codexMaxEffort: 'xhigh',
presetMapping: {
default: 'gpt-5.3-codex',
opus: 'gpt-5.3-codex',
@@ -342,6 +349,7 @@ export const MODEL_CATALOGS: Record<string, ProviderCatalog> = {
name: 'GPT-5.3 Codex Spark',
tier: 'paid',
description: 'Paid Codex plans only, ultra-fast coding model',
codexMaxEffort: 'xhigh',
presetMapping: {
default: 'gpt-5.3-codex-spark',
opus: 'gpt-5.3-codex',
@@ -354,6 +362,7 @@ export const MODEL_CATALOGS: Record<string, ProviderCatalog> = {
name: 'GPT-5.4',
tier: 'paid',
description: 'Paid Codex plans only, latest GPT-5 family model',
codexMaxEffort: 'xhigh',
presetMapping: {
default: 'gpt-5.4',
opus: 'gpt-5.4',
+80
View File
@@ -10,6 +10,7 @@ import {
getProvidersByOAuthFlow,
} from '../../../src/cliproxy/provider-capabilities';
import type { AiProviderFamilyId, AiProviderModelAlias } from '../../../src/cliproxy/ai-providers';
import { PLUS_ONLY_PROVIDERS } from '../../../src/cliproxy/types';
import i18n from './i18n';
// Monorepo contract: UI consumes provider capability constants directly from backend
@@ -19,6 +20,39 @@ import i18n from './i18n';
export const CLIPROXY_PROVIDERS = CLIPROXY_PROVIDER_IDS;
export type CLIProxyProvider = (typeof CLIPROXY_PROVIDERS)[number];
export type ProviderVisualId = CLIProxyProvider | 'openai' | 'vertex';
export type CLIProxyProviderSectionId = 'core' | 'plus-extra';
export interface CLIProxyProviderSection {
id: CLIProxyProviderSectionId;
labelKey: string;
hintKey: string;
providers: readonly CLIProxyProvider[];
}
const PLUS_ONLY_PROVIDER_SET = new Set<CLIProxyProvider>(PLUS_ONLY_PROVIDERS);
export const CORE_CLIPROXY_PROVIDERS: readonly CLIProxyProvider[] = Object.freeze(
CLIPROXY_PROVIDERS.filter((provider) => !PLUS_ONLY_PROVIDER_SET.has(provider))
);
export const PLUS_EXTRA_CLIPROXY_PROVIDERS: readonly CLIProxyProvider[] = Object.freeze(
CLIPROXY_PROVIDERS.filter((provider) => PLUS_ONLY_PROVIDER_SET.has(provider))
);
export const CLIPROXY_PROVIDER_SECTIONS: readonly CLIProxyProviderSection[] = Object.freeze([
{
id: 'core',
labelKey: 'providerConfig.sectionCoreLabel',
hintKey: 'providerConfig.sectionCoreHint',
providers: CORE_CLIPROXY_PROVIDERS,
},
{
id: 'plus-extra',
labelKey: 'providerConfig.sectionPlusLabel',
hintKey: 'providerConfig.sectionPlusHint',
providers: PLUS_EXTRA_CLIPROXY_PROVIDERS,
},
]);
/** Check if a string is a backend-supported CLIProxy provider. */
export function isValidProvider(provider: string): provider is CLIProxyProvider {
@@ -259,6 +293,52 @@ export function getProviderDisplayName(provider: unknown): string {
return PROVIDER_NAMES[normalized] || i18n.t('toasts.providerUnknown', { provider: normalized });
}
export function isPlusExtraProvider(provider: unknown): boolean {
const normalized = normalizeProviderInput(provider);
return isValidProvider(normalized) && PLUS_ONLY_PROVIDER_SET.has(normalized);
}
export function getProviderSection(provider: unknown): CLIProxyProviderSection | null {
const normalized = normalizeProviderInput(provider);
if (!isValidProvider(normalized)) {
return null;
}
return (
CLIPROXY_PROVIDER_SECTIONS.find((section) => section.providers.includes(normalized)) || null
);
}
interface VariantLike {
provider?: unknown;
tiers?: Record<string, { provider?: unknown } | undefined> | null;
}
export function variantUsesPlusExtraProvider(variant: VariantLike | null | undefined): boolean {
if (!variant) {
return false;
}
if (variant.tiers) {
return Object.values(variant.tiers).some((tier) => isPlusExtraProvider(tier?.provider));
}
return isPlusExtraProvider(variant.provider);
}
export function groupProvidersBySection<T>(
items: readonly T[],
getProvider: (item: T) => unknown
): Array<CLIProxyProviderSection & { items: T[] }> {
return CLIPROXY_PROVIDER_SECTIONS.map((section) => ({
...section,
items: items.filter((item) => {
const normalized = normalizeProviderInput(getProvider(item));
return isValidProvider(normalized) && section.providers.includes(normalized);
}),
})).filter((section) => section.items.length > 0);
}
/** Map provider to user-facing short description */
export function getProviderDescription(provider: unknown): string {
const normalized = normalizeProviderInput(provider);
+31 -9
View File
@@ -33,7 +33,11 @@ import {
} from '@/hooks/use-cliproxy';
import type { AuthStatus, Variant } from '@/lib/api-client';
import { buildUiCatalogs } from '@/lib/model-catalogs';
import { getProviderDisplayName, isValidProvider } from '@/lib/provider-config';
import {
getProviderDisplayName,
groupProvidersBySection,
isValidProvider,
} from '@/lib/provider-config';
import { cn } from '@/lib/utils';
import { useTranslation } from 'react-i18next';
@@ -261,6 +265,10 @@ export function CliproxyPage() {
});
const providers = useMemo(() => authData?.authStatus || [], [authData?.authStatus]);
const providerSections = useMemo(
() => groupProvidersBySection(providers, (status) => status.provider),
[providers]
);
const isRemoteMode = authData?.source === 'remote';
const variants = useMemo(() => variantsData?.variants || [], [variantsData?.variants]);
const catalogs = useMemo(() => buildUiCatalogs(catalogData?.catalogs), [catalogData?.catalogs]);
@@ -393,14 +401,28 @@ export function CliproxyPage() {
))}
</div>
) : (
<div className="space-y-1">
{providers.map((status) => (
<ProviderSidebarItem
key={status.provider}
status={status}
isSelected={effectiveProvider === status.provider}
onSelect={() => handleSelectProvider(status.provider)}
/>
<div className="space-y-4">
{providerSections.map((section) => (
<div key={section.id} className="space-y-1">
<div className="px-3">
<div className="text-[11px] font-medium uppercase tracking-wide text-muted-foreground">
{t(section.labelKey)}
</div>
<p className="mt-1 text-[11px] leading-relaxed text-muted-foreground">
{t(section.hintKey)}
</p>
</div>
<div className="space-y-1">
{section.items.map((status) => (
<ProviderSidebarItem
key={status.provider}
status={status}
isSelected={effectiveProvider === status.provider}
onSelect={() => handleSelectProvider(status.provider)}
/>
))}
</div>
</div>
))}
</div>
)}
+32 -9
View File
@@ -29,15 +29,18 @@ import { ProxyStatusWidget } from '@/components/monitoring/proxy-status-widget';
import { api } from '@/lib/api-client';
import { CLIPROXY_DEFAULT_PORT } from '@/lib/preset-utils';
import { RISK_ACK_PHRASE } from '@/components/account/antigravity-responsibility-constants';
import {
CORE_CLIPROXY_PROVIDERS,
PLUS_EXTRA_CLIPROXY_PROVIDERS,
getProviderDisplayName,
variantUsesPlusExtraProvider,
} from '@/lib/provider-config';
import { toast } from 'sonner';
import { useTranslation } from 'react-i18next';
/** LocalStorage key for debug mode preference */
const DEBUG_MODE_KEY = 'ccs_debug_mode';
/** Providers only available on CLIProxyAPIPlus */
const PLUS_ONLY_PROVIDERS = ['kiro', 'ghcp'];
function normalizeRiskAckPhrase(value: string): string {
return value.trim().replace(/\s+/g, ' ').toUpperCase();
}
@@ -191,11 +194,13 @@ export default function ProxySection() {
}, [isAgyConfirmPhraseValid, persistAgyAckBypass, t]);
// Backend state (loaded from API) + mutation hook for proper query invalidation
const [backend, setBackend] = useState<'original' | 'plus'>('plus');
const [backend, setBackend] = useState<'original' | 'plus'>('original');
const [hasKiroGhcpVariants, setHasKiroGhcpVariants] = useState(false);
const updateBackendMutation = useUpdateBackend();
const { data: proxyStatus } = useProxyStatus();
const isProxyRunning = proxyStatus?.running ?? false;
const coreProviderNames = CORE_CLIPROXY_PROVIDERS.map(getProviderDisplayName).join(', ');
const plusProviderNames = PLUS_EXTRA_CLIPROXY_PROVIDERS.map(getProviderDisplayName).join(', ');
// Fetch backend setting
const fetchBackend = useCallback(async () => {
@@ -211,7 +216,9 @@ export default function ProxySection() {
const checkPlusOnlyVariants = useCallback(async () => {
try {
const result = await api.cliproxy.list();
const hasIncompatible = result.variants.some((v) => PLUS_ONLY_PROVIDERS.includes(v.provider));
const hasIncompatible = result.variants.some((variant) =>
variantUsesPlusExtraProvider(variant)
);
setHasKiroGhcpVariants(hasIncompatible);
} catch (err) {
console.error('[Proxy] Failed to check variants:', err);
@@ -490,11 +497,11 @@ export default function ProxySection() {
>
<div className="flex items-center gap-3 mb-2">
<span className="font-medium">{t('settingsProxy.backendPlusApi')}</span>
<span className="text-xs px-1.5 py-0.5 rounded bg-green-100 text-green-700 dark:bg-green-900/50 dark:text-green-400">
{t('settingsProxy.default')}
</span>
</div>
<p className="text-xs text-muted-foreground">{t('settingsProxy.plusDesc')}</p>
<p className="mt-2 text-[11px] leading-relaxed text-muted-foreground">
{plusProviderNames}
</p>
</button>
{/* Original Backend Card */}
@@ -509,15 +516,31 @@ export default function ProxySection() {
>
<div className="flex items-center gap-3 mb-2">
<span className="font-medium">{t('settingsProxy.backendApi')}</span>
<span className="text-xs px-1.5 py-0.5 rounded bg-green-100 text-green-700 dark:bg-green-900/50 dark:text-green-400">
{t('settingsProxy.default')}
</span>
</div>
<p className="text-xs text-muted-foreground">{t('settingsProxy.originalDesc')}</p>
<p className="mt-2 text-[11px] leading-relaxed text-muted-foreground">
{coreProviderNames}
</p>
</button>
</div>
{backend === 'plus' && (
<Alert className="py-2 border-amber-200 bg-amber-50 dark:border-amber-900/50 dark:bg-amber-900/20 [&>svg]:top-2.5">
<AlertTriangle className="h-4 w-4 text-amber-600" />
<AlertDescription className="text-amber-700 dark:text-amber-400">
{t('settingsProxy.plusFallbackNotice')}
</AlertDescription>
</Alert>
)}
{/* Warning when original backend selected with Kiro/ghcp variants */}
{backend === 'original' && hasKiroGhcpVariants && (
<Alert variant="destructive" className="py-2">
<AlertTriangle className="h-4 w-4" />
<AlertDescription>{t('settingsProxy.variantsIncompatible')}</AlertDescription>
<AlertDescription>
{t('settingsProxy.variantsIncompatible', { providers: plusProviderNames })}
</AlertDescription>
</Alert>
)}
</div>
@@ -32,6 +32,7 @@ const THINKING_LEVELS = [
{ value: 'medium', label: 'Medium (8K tokens)' },
{ value: 'high', label: 'High (24K tokens)' },
{ value: 'xhigh', label: 'Extra High (32K tokens)' },
{ value: 'max', label: 'Max (adaptive ceiling)' },
{ value: 'auto', label: 'Auto (dynamic)' },
];
@@ -51,4 +51,48 @@ describe('ProviderInfoTab', () => {
expect(screen.queryByText('Change model')).not.toBeInTheDocument();
expect(screen.getByText('ccs custom-provider --auth --add')).toBeInTheDocument();
});
it('shows the plus-extra track note for community-maintained providers', () => {
render(
<ProviderInfoTab
provider="cursor"
displayName="Cursor"
defaultTarget="claude"
authStatus={{
...authenticatedStatus,
provider: 'cursor',
displayName: 'Cursor',
}}
supportsModelConfig
/>
);
expect(screen.getByText('Track')).toBeInTheDocument();
expect(screen.getByText('Plus extras / community-maintained')).toBeInTheDocument();
expect(
screen.getByText(
/Requires the optional Plus backend while that track remains community-maintained\./
)
).toBeInTheDocument();
});
it('uses the base provider when rendering variant track metadata', () => {
render(
<ProviderInfoTab
provider="my-cursor"
baseProvider="cursor"
displayName="My Cursor Variant"
defaultTarget="claude"
authStatus={{
...authenticatedStatus,
provider: 'cursor',
displayName: 'Cursor',
}}
supportsModelConfig
/>
);
expect(screen.getByText('Track')).toBeInTheDocument();
expect(screen.getByText('Plus extras / community-maintained')).toBeInTheDocument();
});
});
@@ -151,4 +151,49 @@ describe('useProviderEditor', () => {
ANTHROPIC_DEFAULT_HAIKU_MODEL: 'claude-haiku-4-5-20251001',
});
});
it('preserves explicit codex effort suffixes in editor state updates', async () => {
vi.stubGlobal(
'fetch',
vi.fn((input: RequestInfo | URL) => {
const url = String(input);
if (url.includes('/api/settings/codex/raw')) {
return Promise.resolve(
createJsonResponse({
profile: 'codex',
settings: {
env: {
ANTHROPIC_MODEL: 'gpt-5.3-codex-high',
ANTHROPIC_DEFAULT_OPUS_MODEL: 'gpt-5.3-codex-xhigh',
ANTHROPIC_DEFAULT_SONNET_MODEL: 'gpt-5.3-codex-high',
ANTHROPIC_DEFAULT_HAIKU_MODEL: 'gpt-5.4-mini-medium',
},
},
mtime: 1,
path: '~/.ccs/codex.settings.json',
})
);
}
return Promise.reject(new Error(`Unexpected fetch: ${url}`));
})
);
const { result } = renderHook(() => useProviderEditor('codex'), { wrapper });
await waitFor(() => expect(result.current.currentModel).toBe('gpt-5.3-codex-high'));
act(() => {
result.current.updateEnvValue('ANTHROPIC_MODEL', 'gpt-5.3-codex-xhigh');
});
const nextSettings = JSON.parse(result.current.rawJsonContent);
expect(nextSettings.env).toMatchObject({
ANTHROPIC_MODEL: 'gpt-5.3-codex-xhigh',
ANTHROPIC_DEFAULT_OPUS_MODEL: 'gpt-5.3-codex-xhigh',
ANTHROPIC_DEFAULT_SONNET_MODEL: 'gpt-5.3-codex-high',
ANTHROPIC_DEFAULT_HAIKU_MODEL: 'gpt-5.4-mini-medium',
});
});
});
@@ -1,7 +1,7 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { FlexibleModelSelector } from '@/components/cliproxy/provider-model-selector';
import { buildUiCatalogs } from '@/lib/model-catalogs';
import { MODEL_CATALOGS, buildUiCatalogs } from '@/lib/model-catalogs';
import { render, screen, userEvent } from '@tests/setup/test-utils';
const noisyAgyModels = [
@@ -87,4 +87,68 @@ describe('FlexibleModelSelector', () => {
expect(screen.getAllByText('gemini-3.1-pro-preview').length).toBeGreaterThan(0);
expect(screen.getByText('gemini-3.1-pro-high')).toBeInTheDocument();
});
it('offers codex effort-suffixed variants as first-class selectable options', async () => {
const onChange = vi.fn();
render(
<FlexibleModelSelector
label="Primary model"
value={undefined}
onChange={onChange}
catalog={MODEL_CATALOGS.codex}
allModels={[]}
/>
);
await userEvent.click(screen.getByRole('button', { name: /select model/i }));
expect(screen.getByText('gpt-5.3-codex-high')).toBeInTheDocument();
expect(screen.getByText('gpt-5.3-codex-xhigh')).toBeInTheDocument();
await userEvent.click(screen.getByText('gpt-5.3-codex-high'));
expect(onChange).toHaveBeenCalledWith('gpt-5.3-codex-high');
});
it('does not relegate saved codex effort variants to the legacy current-value fallback', async () => {
render(
<FlexibleModelSelector
label="Primary model"
value="gpt-5.3-codex-high"
onChange={vi.fn()}
catalog={MODEL_CATALOGS.codex}
allModels={[]}
/>
);
expect(screen.getByRole('button', { name: /gpt-5\.3-codex-high/i })).toBeInTheDocument();
await userEvent.click(screen.getByRole('button', { name: /gpt-5\.3-codex-high/i }));
expect(screen.queryByText('Current value')).not.toBeInTheDocument();
expect(screen.getByText('gpt-5.3-codex')).toBeInTheDocument();
expect(screen.getAllByText('gpt-5.3-codex-high').length).toBeGreaterThan(0);
});
it('preserves explicit suffixes on supplemental codex models outside the static catalog', async () => {
const onChange = vi.fn();
render(
<FlexibleModelSelector
label="Primary model"
value={undefined}
onChange={onChange}
catalog={MODEL_CATALOGS.codex}
allModels={[{ id: 'gpt-5.5-codex-high', owned_by: 'openai' }]}
/>
);
await userEvent.click(screen.getByRole('button', { name: /select model/i }));
expect(screen.getByText(/All Models \(1\)/i)).toBeInTheDocument();
expect(screen.getByText('gpt-5.5-codex-high')).toBeInTheDocument();
await userEvent.click(screen.getByText('gpt-5.5-codex-high'));
expect(onChange).toHaveBeenCalledWith('gpt-5.5-codex-high');
});
});
+48 -11
View File
@@ -2,6 +2,23 @@ import { beforeEach, describe, expect, it, vi } from 'vitest';
import { render, screen, userEvent } from '@tests/setup/test-utils';
const hookState = vi.hoisted(() => ({
authData: {
authStatus: [
{
provider: 'gemini',
displayName: 'Gemini',
authenticated: true,
accounts: [{ id: 'acct-1', provider: 'gemini' }],
},
{
provider: 'ghcp',
displayName: 'GitHub Copilot (OAuth)',
authenticated: false,
accounts: [],
},
],
source: 'local' as const,
},
catalogData: undefined as
| {
catalogs: Record<
@@ -23,17 +40,7 @@ vi.mock('@/hooks/use-cliproxy', () => ({
isFetching: false,
}),
useCliproxyAuth: () => ({
data: {
authStatus: [
{
provider: 'gemini',
displayName: 'Gemini',
authenticated: true,
accounts: [{ id: 'acct-1', provider: 'gemini' }],
},
],
source: 'local',
},
data: hookState.authData,
isLoading: false,
}),
useCliproxyCatalog: () => ({
@@ -85,9 +92,39 @@ import { CliproxyPage } from '@/pages/cliproxy';
describe('CliproxyPage add-account catalog gating', () => {
beforeEach(() => {
hookState.authData = {
authStatus: [
{
provider: 'gemini',
displayName: 'Gemini',
authenticated: true,
accounts: [{ id: 'acct-1', provider: 'gemini' }],
},
{
provider: 'ghcp',
displayName: 'GitHub Copilot (OAuth)',
authenticated: false,
accounts: [],
},
],
source: 'local',
};
hookState.catalogData = undefined;
});
it('separates core providers from plus extras in the sidebar', () => {
render(<CliproxyPage />);
expect(screen.getByText('Core / original backend')).toBeInTheDocument();
expect(screen.getByText('Plus extras / community-maintained')).toBeInTheDocument();
expect(screen.getByText('Default, always-available provider track')).toBeInTheDocument();
expect(
screen.getByText('Still supported, but separated from the default backend for now')
).toBeInTheDocument();
expect(screen.getByText('Gemini')).toBeInTheDocument();
expect(screen.getByText('GitHub Copilot (OAuth)')).toBeInTheDocument();
});
it('does not pass a static fallback catalog before the catalog query resolves', async () => {
render(<CliproxyPage />);
+29 -1
View File
@@ -1,5 +1,11 @@
import { describe, expect, it } from 'vitest';
import { getCodexEffortDisplay, parseCodexEffort } from '@/lib/codex-effort';
import {
applyCodexEffortSuffix,
getCodexEffortDisplay,
getCodexEffortVariants,
parseCodexEffort,
stripCodexEffortSuffix,
} from '@/lib/codex-effort';
describe('parseCodexEffort', () => {
it('parses lowercase suffixes', () => {
@@ -38,3 +44,25 @@ describe('getCodexEffortDisplay', () => {
expect(getCodexEffortDisplay(undefined)).toBeNull();
});
});
describe('codex effort helpers', () => {
it('strips and reapplies codex effort suffixes', () => {
expect(stripCodexEffortSuffix('gpt-5.3-codex-high')).toBe('gpt-5.3-codex');
expect(applyCodexEffortSuffix('gpt-5.3-codex-high', 'xhigh')).toBe('gpt-5.3-codex-xhigh');
expect(applyCodexEffortSuffix('gpt-5.3-codex', undefined)).toBe('gpt-5.3-codex');
});
it('builds ordered codex effort variants up to the supported max level', () => {
expect(getCodexEffortVariants('gpt-5.3-codex', 'xhigh')).toEqual([
'gpt-5.3-codex',
'gpt-5.3-codex-medium',
'gpt-5.3-codex-high',
'gpt-5.3-codex-xhigh',
]);
expect(getCodexEffortVariants('gpt-5.4-mini', 'high')).toEqual([
'gpt-5.4-mini',
'gpt-5.4-mini-medium',
'gpt-5.4-mini-high',
]);
});
});
@@ -6,8 +6,11 @@ describe('codex model catalog defaults', () => {
const codexCatalog = MODEL_CATALOGS.codex;
const codex53 = codexCatalog.models.find((model) => model.id === 'gpt-5.3-codex');
const codex52 = codexCatalog.models.find((model) => model.id === 'gpt-5.2-codex');
const codexMini = codexCatalog.models.find((model) => model.id === 'gpt-5-codex-mini');
expect(codex53?.presetMapping?.haiku).toBe('gpt-5-codex-mini');
expect(codex52?.presetMapping?.haiku).toBe('gpt-5-codex-mini');
expect(codex53?.codexMaxEffort).toBe('xhigh');
expect(codexMini?.codexMaxEffort).toBe('high');
});
});
@@ -1,15 +1,22 @@
import { describe, expect, it } from 'vitest';
import {
CLIPROXY_PROVIDER_SECTIONS,
CORE_CLIPROXY_PROVIDERS,
formatRequestedUpstreamModelRules,
getProviderDescription,
getProviderDisplayName,
getProviderFallbackVisual,
getProviderLogoAsset,
getProviderSection,
getRequestedUpstreamModelRuleErrors,
getRequestedModelId,
groupProvidersBySection,
isPlusExtraProvider,
parseRequestedUpstreamModelRules,
PLUS_EXTRA_CLIPROXY_PROVIDERS,
PROVIDER_COLORS,
variantUsesPlusExtraProvider,
} from '@/lib/provider-config';
describe('provider model mapping helpers', () => {
@@ -49,6 +56,61 @@ describe('provider model mapping helpers', () => {
});
describe('provider presentation metadata', () => {
it('splits providers into core and plus-extra sections', () => {
expect(CLIPROXY_PROVIDER_SECTIONS.map((section) => section.id)).toEqual(['core', 'plus-extra']);
expect(CORE_CLIPROXY_PROVIDERS).toContain('gemini');
expect(CORE_CLIPROXY_PROVIDERS).toContain('kimi');
expect(PLUS_EXTRA_CLIPROXY_PROVIDERS).toEqual([
'kiro',
'ghcp',
'cursor',
'gitlab',
'codebuddy',
'kilo',
]);
expect(getProviderSection('gitlab')?.id).toBe('plus-extra');
expect(getProviderSection('gemini')?.id).toBe('core');
expect(isPlusExtraProvider('cursor')).toBe(true);
expect(isPlusExtraProvider('gemini')).toBe(false);
});
it('groups provider-backed data by shared section metadata', () => {
const grouped = groupProvidersBySection(
[
{ provider: 'cursor', value: 'plus' },
{ provider: 'gemini', value: 'core' },
],
(entry) => entry.provider
);
expect(grouped).toHaveLength(2);
expect(grouped[0]?.id).toBe('core');
expect(grouped[0]?.items.map((entry) => entry.provider)).toEqual(['gemini']);
expect(grouped[1]?.id).toBe('plus-extra');
expect(grouped[1]?.items.map((entry) => entry.provider)).toEqual(['cursor']);
});
it('detects plus-extra providers inside composite variants', () => {
expect(
variantUsesPlusExtraProvider({
provider: 'gemini',
tiers: {
opus: { provider: 'gemini' },
sonnet: { provider: 'cursor' },
},
})
).toBe(true);
expect(
variantUsesPlusExtraProvider({
provider: 'gemini',
tiers: {
opus: { provider: 'gemini' },
sonnet: { provider: 'kimi' },
},
})
).toBe(false);
});
it.each([
['cursor', 'Cursor', 'Cursor browser-authenticated provider', '/assets/sidebar/cursor.svg'],
['gitlab', 'GitLab Duo', 'GitLab Duo with OAuth or PAT auth', '/assets/providers/gitlab.svg'],