diff --git a/.github/pr-assets/issue-1058/codex-selector-variants.png b/.github/pr-assets/issue-1058/codex-selector-variants.png new file mode 100644 index 00000000..0775cae0 Binary files /dev/null and b/.github/pr-assets/issue-1058/codex-selector-variants.png differ diff --git a/.github/pr-assets/issue-1058/index.html b/.github/pr-assets/issue-1058/index.html new file mode 100644 index 00000000..77fcccae --- /dev/null +++ b/.github/pr-assets/issue-1058/index.html @@ -0,0 +1,138 @@ + + + + + + Issue 1058 UI Evidence + + + +
+
+

Issue 1058

+

Codex selector now exposes pinned effort variants

+

+ Capture environment: local dashboard at http://127.0.0.1:3001/cliproxy, + light theme, Codex provider editor, Model Config tab. +

+
+
+

What changed

+

The selector now offers real codex model variants like -high and -xhigh.

+
+
+

Why it matters

+

The dashboard can now emit the exact suffixed model IDs that runtime code already understands.

+
+
+

Review cue

+

Look at the opened model picker: suffixed entries appear as first-class options with pinned-effort badges.

+
+
+
+ +
+
+

Targeted evidence

+

Codex provider editor model picker

+

+ The open selector shows canonical rows plus generated pinned variants such as + gpt-5.3-codex-high, gpt-5.3-codex-xhigh, and + gpt-5.4-xhigh. +

+
+ Codex provider editor with the model selector opened and suffixed effort variants visible. +
+
+ + diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index 3ed3a780..c3c6ce9d 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -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 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index eb7f95c7..3dba111b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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 diff --git a/.github/workflows/dev-release.yml b/.github/workflows/dev-release.yml index 55e23f25..d3fb654c 100644 --- a/.github/workflows/dev-release.yml +++ b/.github/workflows/dev-release.yml @@ -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' diff --git a/.github/workflows/push-ci.yml b/.github/workflows/push-ci.yml new file mode 100644 index 00000000..2aef1166 --- /dev/null +++ b/.github/workflows/push-ci.yml @@ -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 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 466394ec..db140f7b 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -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' diff --git a/.husky/pre-push b/.husky/pre-push index 07a426a0..b47b1dd4 100755 --- a/.husky/pre-push +++ b/.husky/pre-push @@ -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" diff --git a/CLAUDE.md b/CLAUDE.md index 11175260..05003c5f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -41,6 +41,15 @@ gh pr checks ### 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 ` — 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.) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index c283c060..b72b2b00 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -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. diff --git a/README.md b/README.md index 4aab06f4..a4b71e6a 100644 --- a/README.md +++ b/README.md @@ -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) diff --git a/docs/code-standards.md b/docs/code-standards.md index 3c3b35cb..de84eebe 100644 --- a/docs/code-standards.md +++ b/docs/code-standards.md @@ -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 | --- diff --git a/docs/project-roadmap.md b/docs/project-roadmap.md index a08866ec..6b76df05 100644 --- a/docs/project-roadmap.md +++ b/docs/project-roadmap.md @@ -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..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. --- diff --git a/docs/system-architecture/provider-flows.md b/docs/system-architecture/provider-flows.md index eb19fd6f..a1853c49 100644 --- a/docs/system-architecture/provider-flows.md +++ b/docs/system-architecture/provider-flows.md @@ -257,6 +257,7 @@ async function checkRemoteProxyHealth(config: ResolvedProxyConfig): Promise 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 --> Set isPaused=true in account-manager diff --git a/package.json b/package.json index bc3dd025..c0d703e5 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/scripts/ci-parity-gate.sh b/scripts/ci-parity-gate.sh index 7731e9fd..b325a3d8 100755 --- a/scripts/ci-parity-gate.sh +++ b/scripts/ci-parity-gate.sh @@ -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." diff --git a/scripts/run-test-bucket.js b/scripts/run-test-bucket.js new file mode 100644 index 00000000..adca8e60 --- /dev/null +++ b/scripts/run-test-bucket.js @@ -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 '); + 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, +}; diff --git a/src/ccs.ts b/src/ccs.ts index b4fc0287..0a5ca3df 100644 --- a/src/ccs.ts +++ b/src/ccs.ts @@ -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 { 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 { 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 diff --git a/src/cliproxy/account-safety.ts b/src/cliproxy/account-safety.ts index f8f61e0c..d7b26607 100644 --- a/src/cliproxy/account-safety.ts +++ b/src/cliproxy/account-safety.ts @@ -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); diff --git a/src/cliproxy/binary-manager.ts b/src/cliproxy/binary-manager.ts index 452bd162..081c7b5c 100644 --- a/src/cliproxy/binary-manager.ts +++ b/src/cliproxy/binary-manager.ts @@ -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 = {}, 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 { - 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 { - 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 { - 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 { - 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 diff --git a/src/cliproxy/catalog-cache.ts b/src/cliproxy/catalog-cache.ts index 10436887..998c86c3 100644 --- a/src/cliproxy/catalog-cache.ts +++ b/src/cliproxy/catalog-cache.ts @@ -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, diff --git a/src/cliproxy/config/env-builder.ts b/src/cliproxy/config/env-builder.ts index dafd0c95..417d5f04 100644 --- a/src/cliproxy/config/env-builder.ts +++ b/src/cliproxy/config/env-builder.ts @@ -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 { 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; - - 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; } @@ -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; } diff --git a/src/cliproxy/config/thinking-config.ts b/src/cliproxy/config/thinking-config.ts index d9f2b39f..ef98234e 100644 --- a/src/cliproxy/config/thinking-config.ts +++ b/src/cliproxy/config/thinking-config.ts @@ -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; } diff --git a/src/cliproxy/executor/index.ts b/src/cliproxy/executor/index.ts index 2f534ff2..8431b764 100644 --- a/src/cliproxy/executor/index.ts +++ b/src/cliproxy/executor/index.ts @@ -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); } } } diff --git a/src/cliproxy/model-catalog.ts b/src/cliproxy/model-catalog.ts index 6c19ab7c..ee1032f5 100644 --- a/src/cliproxy/model-catalog.ts +++ b/src/cliproxy/model-catalog.ts @@ -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> = 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, diff --git a/src/cliproxy/model-config.ts b/src/cliproxy/model-config.ts index 8b99316d..843bfa1b 100644 --- a/src/cliproxy/model-config.ts +++ b/src/cliproxy/model-config.ts @@ -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); } /** diff --git a/src/cliproxy/model-id-normalizer.ts b/src/cliproxy/model-id-normalizer.ts index 192a369f..7bdaf503 100644 --- a/src/cliproxy/model-id-normalizer.ts +++ b/src/cliproxy/model-id-normalizer.ts @@ -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); } /** diff --git a/src/cliproxy/platform-detector.ts b/src/cliproxy/platform-detector.ts index f5493f50..6c73c533 100644 --- a/src/cliproxy/platform-detector.ts +++ b/src/cliproxy/platform-detector.ts @@ -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) diff --git a/src/cliproxy/quota-manager.ts b/src/cliproxy/quota-manager.ts index 31c46780..ce3b59a8 100644 --- a/src/cliproxy/quota-manager.ts +++ b/src/cliproxy/quota-manager.ts @@ -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; }> { + const { restoreExpiredQuotaPauses } = await import('./account-safety'); + restoreExpiredQuotaPauses(); + const accounts = getProviderAccounts(provider); const defaultAccount = getDefaultAccount(provider); diff --git a/src/cliproxy/services/binary-service.ts b/src/cliproxy/services/binary-service.ts index 3826e581..834df41c 100644 --- a/src/cliproxy/services/binary-service.ts +++ b/src/cliproxy/services/binary-service.ts @@ -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 { - 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 { - 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); } diff --git a/src/cliproxy/services/variant-service.ts b/src/cliproxy/services/variant-service.ts index f8970270..90361c78 100644 --- a/src/cliproxy/services/variant-service.ts +++ b/src/cliproxy/services/variant-service.ts @@ -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; diff --git a/src/cliproxy/services/variant-settings.ts b/src/cliproxy/services/variant-settings.ts index f0aa20eb..3b22998d 100644 --- a/src/cliproxy/services/variant-settings.ts +++ b/src/cliproxy/services/variant-settings.ts @@ -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); } /** diff --git a/src/cliproxy/thinking-validator.ts b/src/cliproxy/thinking-validator.ts index 5c58994f..55ff319a 100644 --- a/src/cliproxy/thinking-validator.ts +++ b/src/cliproxy/thinking-validator.ts @@ -32,6 +32,11 @@ export interface ThinkingValidationResult { /** * Named thinking level mappings to budget values (when converting level→budget) + * + * `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 = { minimal: 512, @@ -39,6 +44,7 @@ export const THINKING_LEVEL_BUDGETS: Record = { medium: 8192, high: 24576, xhigh: 32768, + max: 65536, }; /** @@ -50,12 +56,21 @@ export const THINKING_LEVEL_RANK: Record = { 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 = { min: 'minimal', lo: 'low', diff --git a/src/cliproxy/types.ts b/src/cliproxy/types.ts index ea723480..37686a50 100644 --- a/src/cliproxy/types.ts +++ b/src/cliproxy/types.ts @@ -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'; diff --git a/src/commands/cliproxy/help-subcommand.ts b/src/commands/cliproxy/help-subcommand.ts index 823b1317..f3f81797 100644 --- a/src/commands/cliproxy/help-subcommand.ts +++ b/src/commands/cliproxy/help-subcommand.ts @@ -84,7 +84,10 @@ export async function showHelp(): Promise { [ 'Options:', [ - ['--backend ', 'Use specific backend: original | plus (default: from config)'], + [ + '--backend ', + 'Use specific backend: original | plus (local default: original; plus currently falls back locally)', + ], ['--target ', '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 { console.log(dim(' Note: CLIProxy now persists by default. Use "stop" to terminate.')); console.log(dim(' Routing: use gcli/ or agy/ 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')}`); diff --git a/src/commands/cliproxy/index.ts b/src/commands/cliproxy/index.ts index 52c4cb02..b7de5ebb 100644 --- a/src/commands/cliproxy/index.ts +++ b/src/commands/cliproxy/index.ts @@ -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(); } /** diff --git a/src/commands/cliproxy/install-subcommand.ts b/src/commands/cliproxy/install-subcommand.ts index f5f59036..2e898063 100644 --- a/src/commands/cliproxy/install-subcommand.ts +++ b/src/commands/cliproxy/install-subcommand.ts @@ -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) { diff --git a/src/commands/config-thinking-command.ts b/src/commands/config-thinking-command.ts index 36b55b41..646d4571 100644 --- a/src/commands/config-thinking-command.ts +++ b/src/commands/config-thinking-command.ts @@ -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(''); diff --git a/src/config/unified-config-loader.ts b/src/config/unified-config-loader.ts index 07b79708..261b2d72 100644 --- a/src/config/unified-config-loader.ts +++ b/src/config/unified-config-loader.ts @@ -431,7 +431,7 @@ function mergeWithDefaults(partial: Partial): 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('# ----------------------------------------------------------------------------'); diff --git a/src/config/unified-config-types.ts b/src/config/unified-config-types.ts index 3d92af79..43dffcf6 100644 --- a/src/config/unified-config-types.ts +++ b/src/config/unified-config-types.ts @@ -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: {}, diff --git a/src/cursor/cursor-anthropic-translator.ts b/src/cursor/cursor-anthropic-translator.ts index 6dbe7d92..416b4246 100644 --- a/src/cursor/cursor-anthropic-translator.ts +++ b/src/cursor/cursor-anthropic-translator.ts @@ -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, }; diff --git a/src/cursor/cursor-anthropic-types.ts b/src/cursor/cursor-anthropic-types.ts index 336fb75f..9374de5b 100644 --- a/src/cursor/cursor-anthropic-types.ts +++ b/src/cursor/cursor-anthropic-types.ts @@ -41,6 +41,9 @@ export interface CursorAnthropicRequest { system?: string | AnthropicTextBlock[]; stream?: boolean; tools?: CursorTool[]; + output_config?: { + effort?: string; + }; thinking?: { type?: string; budget_tokens?: number; diff --git a/src/delegation/headless-executor.ts b/src/delegation/headless-executor.ts index 569302f2..76e4bcdd 100644 --- a/src/delegation/headless-executor.ts +++ b/src/delegation/headless-executor.ts @@ -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; + runtimeEnvVars?: NodeJS.ProcessEnv; traceEnv?: Record; } ): Promise { @@ -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 } : {}), diff --git a/src/proxy/transformers/request-transformer.ts b/src/proxy/transformers/request-transformer.ts index a8ccb98c..58e7969e 100644 --- a/src/proxy/transformers/request-transformer.ts +++ b/src/proxy/transformers/request-transformer.ts @@ -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[] { diff --git a/src/targets/droid-config-manager.ts b/src/targets/droid-config-manager.ts index 29a3943e..29f3c92d 100644 --- a/src/targets/droid-config-manager.ts +++ b/src/targets/droid-config-manager.ts @@ -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; diff --git a/src/utils/shell-executor.ts b/src/utils/shell-executor.ts index c239b3f7..a6a5fd63 100644 --- a/src/utils/shell-executor.ts +++ b/src/utils/shell-executor.ts @@ -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) { diff --git a/src/web-server/middleware/auth-middleware.ts b/src/web-server/middleware/auth-middleware.ts index fdab88c9..c3f04f04 100644 --- a/src/web-server/middleware/auth-middleware.ts +++ b/src/web-server/middleware/auth-middleware.ts @@ -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; diff --git a/src/web-server/routes/cliproxy-stats-routes.ts b/src/web-server/routes/cliproxy-stats-routes.ts index e580d993..2bf87cc6 100644 --- a/src/web-server/routes/cliproxy-stats-routes.ts +++ b/src/web-server/routes/cliproxy-stats-routes.ts @@ -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, + backend: ReturnType, getInstalledVersionFn: typeof getInstalledCliproxyVersion = getInstalledCliproxyVersion ) { const currentVersion = getInstalledVersionFn(backend); @@ -178,7 +170,7 @@ function buildUpdateCheckFallback( } function buildVersionsFallback( - backend: ReturnType, + backend: ReturnType, getInstalledVersionFn: typeof getInstalledCliproxyVersion = getInstalledCliproxyVersion ) { const currentVersion = getInstalledVersionFn(backend); @@ -206,7 +198,7 @@ interface ResolveVersionsDeps { } export async function resolveCliproxyUpdateCheckPayload( - backend: ReturnType, + backend: ReturnType, deps: ResolveUpdateCheckDeps = {} ) { const checkCliproxyUpdateFn = deps.checkCliproxyUpdateFn ?? checkCliproxyUpdate; @@ -218,7 +210,7 @@ export async function resolveCliproxyUpdateCheckPayload( } export async function resolveCliproxyVersionsPayload( - backend: ReturnType, + backend: ReturnType, deps: ResolveVersionsDeps = {} ) { const fetchAllVersionsFn = deps.fetchAllVersionsFn ?? fetchAllVersions; @@ -409,7 +401,7 @@ router.post('/proxy-stop', async (_req: Request, res: Response): Promise = */ router.get('/update-check', async (_req: Request, res: Response): Promise => { 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 => { 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 => { return; } - const backend = getConfiguredBackend(); + const backend = getStoredConfiguredBackend(); const installResult = await installDashboardCliproxyVersion(version, backend); res.json({ diff --git a/src/web-server/services/cliproxy-dashboard-install-service.ts b/src/web-server/services/cliproxy-dashboard-install-service.ts index 8ea78bb5..305d3315 100644 --- a/src/web-server/services/cliproxy-dashboard-install-service.ts +++ b/src/web-server/services/cliproxy-dashboard-install-service.ts @@ -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 { - 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 { diff --git a/tests/integration/proxy/messages-edge-cases.test.ts b/tests/integration/proxy/messages-edge-cases.test.ts index fea8ca87..faefb833 100644 --- a/tests/integration/proxy/messages-edge-cases.test.ts +++ b/tests/integration/proxy/messages-edge-cases.test.ts @@ -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 { + if (server.listening) { + return resolveListeningPort(server); + } + + return new Promise((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 ): Promise { @@ -28,9 +50,8 @@ async function startUpstream( upstreamSockets.delete(socket); }); }); - await new Promise((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 { @@ -46,8 +67,6 @@ async function requestProxy(payload: unknown, signal?: AbortSignal): Promise { - 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 () => { diff --git a/tests/integration/proxy/messages-endpoint.test.ts b/tests/integration/proxy/messages-endpoint.test.ts index 90c819eb..d8fdf275 100644 --- a/tests/integration/proxy/messages-endpoint.test.ts +++ b/tests/integration/proxy/messages-endpoint.test.ts @@ -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 { - 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 { + if (server.listening) { + return resolveListeningPort(server); + } - if (parsed.stream) { - res.writeHead(200, { 'Content-Type': 'text/event-stream' }); + return new Promise((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 { + 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 { 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 { @@ -98,8 +122,6 @@ async function requestProxy(payload: unknown): Promise { } 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 () => { diff --git a/tests/integration/proxy/request-routing.test.ts b/tests/integration/proxy/request-routing.test.ts index b9f7afe8..080405b2 100644 --- a/tests/integration/proxy/request-routing.test.ts +++ b/tests/integration/proxy/request-routing.test.ts @@ -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 { + if (server.listening) { + return resolveListeningPort(server); + } + + return new Promise((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 { - 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 { + 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 { @@ -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', diff --git a/tests/unit/cliproxy/account-safety-quota-exhaustion.test.ts b/tests/unit/cliproxy/account-safety-quota-exhaustion.test.ts index 4ad5145f..e688e009 100644 --- a/tests/unit/cliproxy/account-safety-quota-exhaustion.test.ts +++ b/tests/unit/cliproxy/account-safety-quota-exhaustion.test.ts @@ -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): 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; + }; + }; + }; + 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'); + }); }); }); diff --git a/tests/unit/cliproxy/backend-selection.test.js b/tests/unit/cliproxy/backend-selection.test.js index ff2ead96..2a36c93e 100644 --- a/tests/unit/cliproxy/backend-selection.test.js +++ b/tests/unit/cliproxy/backend-selection.test.js @@ -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')); }); }); diff --git a/tests/unit/cliproxy/backend-ui-default-ports-sync.test.ts b/tests/unit/cliproxy/backend-ui-default-ports-sync.test.ts index c8fe0d1d..5670eecf 100644 --- a/tests/unit/cliproxy/backend-ui-default-ports-sync.test.ts +++ b/tests/unit/cliproxy/backend-ui-default-ports-sync.test.ts @@ -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)); diff --git a/tests/unit/cliproxy/binary-manager-install.test.ts b/tests/unit/cliproxy/binary-manager-install.test.ts index 92140f1d..d76f1fdd 100644 --- a/tests/unit/cliproxy/binary-manager-install.test.ts +++ b/tests/unit/cliproxy/binary-manager-install.test.ts @@ -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.' ); }); }); diff --git a/tests/unit/cliproxy/composite-thinking.test.ts b/tests/unit/cliproxy/composite-thinking.test.ts index 6fb3758b..6a9de3fd 100644 --- a/tests/unit/cliproxy/composite-thinking.test.ts +++ b/tests/unit/cliproxy/composite-thinking.test.ts @@ -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', diff --git a/tests/unit/cliproxy/env-builder-provider-url.test.ts b/tests/unit/cliproxy/env-builder-provider-url.test.ts index 7f96cde9..acb41cca 100644 --- a/tests/unit/cliproxy/env-builder-provider-url.test.ts +++ b/tests/unit/cliproxy/env-builder-provider-url.test.ts @@ -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; }; - 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>; }; - 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; presets?: Array>; }; - 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', () => { diff --git a/tests/unit/cliproxy/model-catalog-compat.test.ts b/tests/unit/cliproxy/model-catalog-compat.test.ts index b6fc21cc..0590a44b 100644 --- a/tests/unit/cliproxy/model-catalog-compat.test.ts +++ b/tests/unit/cliproxy/model-catalog-compat.test.ts @@ -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, + }); + }); }); diff --git a/tests/unit/cliproxy/model-catalog.test.js b/tests/unit/cliproxy/model-catalog.test.js index 5023bf32..704c5764 100644 --- a/tests/unit/cliproxy/model-catalog.test.js +++ b/tests/unit/cliproxy/model-catalog.test.js @@ -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); }); diff --git a/tests/unit/cliproxy/model-id-normalizer.test.ts b/tests/unit/cliproxy/model-id-normalizer.test.ts index d7e969cf..638ad419 100644 --- a/tests/unit/cliproxy/model-id-normalizer.test.ts +++ b/tests/unit/cliproxy/model-id-normalizer.test.ts @@ -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'); }); }); diff --git a/tests/unit/cliproxy/thinking-validator.test.ts b/tests/unit/cliproxy/thinking-validator.test.ts index d48679be..59dfccbd 100644 --- a/tests/unit/cliproxy/thinking-validator.test.ts +++ b/tests/unit/cliproxy/thinking-validator.test.ts @@ -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', () => { diff --git a/tests/unit/cliproxy/variant-update-service.test.ts b/tests/unit/cliproxy/variant-update-service.test.ts index 7e98a4ff..abd2039c 100644 --- a/tests/unit/cliproxy/variant-update-service.test.ts +++ b/tests/unit/cliproxy/variant-update-service.test.ts @@ -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', diff --git a/tests/unit/commands/config-thinking-command.test.ts b/tests/unit/commands/config-thinking-command.test.ts index e98efedb..3c9f7f4e 100644 --- a/tests/unit/commands/config-thinking-command.test.ts +++ b/tests/unit/commands/config-thinking-command.test.ts @@ -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', () => { diff --git a/tests/unit/cursor/cursor-anthropic-translator.test.ts b/tests/unit/cursor/cursor-anthropic-translator.test.ts index 9da161fe..2fce792c 100644 --- a/tests/unit/cursor/cursor-anthropic-translator.test.ts +++ b/tests/unit/cursor/cursor-anthropic-translator.test.ts @@ -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: [ diff --git a/tests/unit/proxy/transformers/request-transformer-regressions.test.ts b/tests/unit/proxy/transformers/request-transformer-regressions.test.ts index 77b56294..4bb3192f 100644 --- a/tests/unit/proxy/transformers/request-transformer-regressions.test.ts +++ b/tests/unit/proxy/transformers/request-transformer-regressions.test.ts @@ -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({ diff --git a/tests/unit/scripts/github/ci-workflow.test.ts b/tests/unit/scripts/github/ci-workflow.test.ts new file mode 100644 index 00000000..a5319626 --- /dev/null +++ b/tests/unit/scripts/github/ci-workflow.test.ts @@ -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'); + }); +}); diff --git a/tests/unit/scripts/github/push-ci-workflow.test.ts b/tests/unit/scripts/github/push-ci-workflow.test.ts new file mode 100644 index 00000000..f498e014 --- /dev/null +++ b/tests/unit/scripts/github/push-ci-workflow.test.ts @@ -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'); + }); +}); diff --git a/tests/unit/scripts/run-test-bucket.test.js b/tests/unit/scripts/run-test-bucket.test.js new file mode 100644 index 00000000..d9c79ef2 --- /dev/null +++ b/tests/unit/scripts/run-test-bucket.test.js @@ -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); + }); +}); diff --git a/tests/unit/targets/droid-config-manager.test.ts b/tests/unit/targets/droid-config-manager.test.ts index 8f0efaa1..21128d55 100644 --- a/tests/unit/targets/droid-config-manager.test.ts +++ b/tests/unit/targets/droid-config-manager.test.ts @@ -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', diff --git a/tests/unit/targets/settings-profile-browser-launch.test.ts b/tests/unit/targets/settings-profile-browser-launch.test.ts index bc3e00cc..70b51b02 100644 --- a/tests/unit/targets/settings-profile-browser-launch.test.ts +++ b/tests/unit/targets/settings-profile-browser-launch.test.ts @@ -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; diff --git a/tests/unit/utils/claudecode-env-stripping.test.ts b/tests/unit/utils/claudecode-env-stripping.test.ts index 5fb99ded..a4212c83 100644 --- a/tests/unit/utils/claudecode-env-stripping.test.ts +++ b/tests/unit/utils/claudecode-env-stripping.test.ts @@ -21,8 +21,16 @@ type SpawnCall = { options: Record | 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 | 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 | 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[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); }); diff --git a/tests/unit/web-server/cliproxy-dashboard-install-service.test.ts b/tests/unit/web-server/cliproxy-dashboard-install-service.test.ts index 6522dd58..8c08b01e 100644 --- a/tests/unit/web-server/cliproxy-dashboard-install-service.test.ts +++ b/tests/unit/web-server/cliproxy-dashboard-install-service.test.ts @@ -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({ 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({ 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', }); }); }); diff --git a/tests/unit/web-server/cliproxy-stats-routes-install.test.ts b/tests/unit/web-server/cliproxy-stats-routes-install.test.ts index 40578506..296851dc 100644 --- a/tests/unit/web-server/cliproxy-stats-routes-install.test.ts +++ b/tests/unit/web-server/cliproxy-stats-routes-install.test.ts @@ -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); diff --git a/tests/unit/web-server/cliproxy-stats-routes-model-update.test.ts b/tests/unit/web-server/cliproxy-stats-routes-model-update.test.ts index 538152a4..3a22363c 100644 --- a/tests/unit/web-server/cliproxy-stats-routes-model-update.test.ts +++ b/tests/unit/web-server/cliproxy-stats-routes-model-update.test.ts @@ -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; }; - 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'); }); diff --git a/tests/unit/web-server/settings-routes-model-canonicalization.test.ts b/tests/unit/web-server/settings-routes-model-canonicalization.test.ts index bf70d518..ec3d9b06 100644 --- a/tests/unit/web-server/settings-routes-model-canonicalization.test.ts +++ b/tests/unit/web-server/settings-routes-model-canonicalization.test.ts @@ -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; + presets: Array>; + }; + + 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 } }; + 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; + }; + 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, { diff --git a/ui/src/components/cliproxy/cliproxy-dialog.tsx b/ui/src/components/cliproxy/cliproxy-dialog.tsx index b221b8a6..cb8ff68b 100644 --- a/ui/src/components/cliproxy/cliproxy-dialog.tsx +++ b/ui/src/components/cliproxy/cliproxy-dialog.tsx @@ -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" > - {providerOptions.map((opt) => ( - + {CLIPROXY_PROVIDER_SECTIONS.map((section) => ( + + {providerOptions + .filter((opt) => section.providers.includes(opt.value)) + .map((opt) => ( + + ))} + ))} {singleForm.formState.errors.provider && ( @@ -208,6 +222,14 @@ export function CliproxyDialog({ open, onClose }: CliproxyDialogProps) { {singleForm.formState.errors.provider.message} )} + {selectedProviderSection && ( +

+ {t(selectedProviderSection.hintKey)} + {isPlusExtraProvider(selectedProvider) + ? ` ${t('providerConfig.plusTrackNote')}` + : ''} +

+ )} {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) => ( - + {CLIPROXY_PROVIDER_SECTIONS.map((section) => ( + + {providerOptions + .filter((opt) => section.providers.includes(opt.value)) + .map((opt) => ( + + ))} + ))} + {compositeTiers?.[tier]?.provider && ( +

+ {t(getProviderSection(compositeTiers[tier].provider)?.hintKey || '')} + {isPlusExtraProvider(compositeTiers[tier].provider) + ? ` ${t('providerConfig.plusTrackNote')}` + : ''} +

+ )}
diff --git a/ui/src/components/cliproxy/cliproxy-edit-dialog.tsx b/ui/src/components/cliproxy/cliproxy-edit-dialog.tsx index 9274e975..76540428 100644 --- a/ui/src/components/cliproxy/cliproxy-edit-dialog.tsx +++ b/ui/src/components/cliproxy/cliproxy-edit-dialog.tsx @@ -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({ 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) => ( - + {CLIPROXY_PROVIDER_SECTIONS.map((section) => ( + + {providerOptions + .filter((opt) => section.providers.includes(opt.value)) + .map((opt) => ( + + ))} + ))} + {compositeTiers?.[tier]?.provider && ( +

+ {t(getProviderSection(compositeTiers[tier].provider)?.hintKey || '')} + {isPlusExtraProvider(compositeTiers[tier].provider) + ? ` ${t('providerConfig.plusTrackNote')}` + : ''} +

+ )}
@@ -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) => ( - + {CLIPROXY_PROVIDER_SECTIONS.map((section) => ( + + {providerOptions + .filter((opt) => section.providers.includes(opt.value)) + .map((opt) => ( + + ))} + ))} + {selectedProvider && ( +

+ {t(getProviderSection(selectedProvider)?.hintKey || '')} + {isPlusExtraProvider(selectedProvider) + ? ` ${t('providerConfig.plusTrackNote')}` + : ''} +

+ )}
diff --git a/ui/src/components/cliproxy/provider-editor/index.tsx b/ui/src/components/cliproxy/provider-editor/index.tsx index 72b3d7ef..65be5b3b 100644 --- a/ui/src/components/cliproxy/provider-editor/index.tsx +++ b/ui/src/components/cliproxy/provider-editor/index.tsx @@ -304,6 +304,7 @@ export function ProviderEditor({ Codex tip: suffixes -medium, -high, and -xhigh{' '} - pin reasoning effort. Unsuffixed models use Thinking settings. + pin reasoning effort. Select a suffixed model to pin effort; unsuffixed models use + Thinking settings.

)}
diff --git a/ui/src/components/cliproxy/provider-editor/provider-info-tab.tsx b/ui/src/components/cliproxy/provider-editor/provider-info-tab.tsx index 2ea26cdf..726ad564 100644 --- a/ui/src/components/cliproxy/provider-editor/provider-info-tab.tsx +++ b/ui/src/components/cliproxy/provider-editor/provider-info-tab.tsx @@ -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({ {resolvedTarget}
+ {providerSection && ( +
+ + {t('providerConfig.trackLabel')} + +
+ {t(providerSection.labelKey)} +

+ {t(providerSection.hintKey)} + {isPlusExtraProvider(sectionProvider) + ? ` ${t('providerConfig.plusTrackNote')}` + : ''} +

+
+
+ )}
diff --git a/ui/src/components/cliproxy/provider-model-selector.tsx b/ui/src/components/cliproxy/provider-model-selector.tsx index 75a2977e..471bd71a 100644 --- a/ui/src/components/cliproxy/provider-model-selector.tsx +++ b/ui/src/components/cliproxy/provider-model-selector.tsx @@ -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: ( -
- - {getPreferredOptionValue(model.id, routingHints.get(model.id.toLowerCase()))} - - {routingHints.get(model.id.toLowerCase())?.pinnedAvailable ? ( - - {routingHints.get(model.id.toLowerCase())?.prefix} - - ) : null} - {isCodexProvider && } -
- ), - itemContent: ( -
- - {getPreferredOptionValue(model.id, routingHints.get(model.id.toLowerCase()))} - - {model.tier === 'paid' && } - {routingHints.get(model.id.toLowerCase())?.unprefixedStatus === 'shadowed' ? ( - - {t('providerModelSelector.shadowed')} - - ) : null} - {routingHints.get(model.id.toLowerCase())?.unprefixedStatus === 'prefix-only' ? ( - - {t('providerModelSelector.prefixOnly')} - - ) : null} - {isCodexProvider && } -
- ), - })); + 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: ( +
+ {optionValue} + {routingHint?.pinnedAvailable ? ( + + {routingHint.prefix} + + ) : null} + {isCodexProvider && } +
+ ), + itemContent: ( +
+ {optionValue} + {model.tier === 'paid' && } + {routingHint?.unprefixedStatus === 'shadowed' ? ( + + {t('providerModelSelector.shadowed')} + + ) : null} + {routingHint?.unprefixedStatus === 'prefix-only' ? ( + + {t('providerModelSelector.prefixOnly')} + + ) : null} + {isCodexProvider && } +
+ ), + })); + }); 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: ( -
- - {getPreferredOptionValue(model.id, routingHints.get(model.id.toLowerCase()))} - - {routingHints.get(model.id.toLowerCase())?.pinnedAvailable ? ( - - {routingHints.get(model.id.toLowerCase())?.prefix} - - ) : null} - {isCodexProvider && } -
- ), - itemContent: ( -
- - {getPreferredOptionValue(model.id, routingHints.get(model.id.toLowerCase()))} - - {routingHints.get(model.id.toLowerCase())?.unprefixedStatus === 'shadowed' ? ( - - {t('providerModelSelector.shadowed')} - - ) : null} - {routingHints.get(model.id.toLowerCase())?.unprefixedStatus === 'prefix-only' ? ( - - {t('providerModelSelector.prefixOnly')} - - ) : null} - {isCodexProvider && } -
- ), - })); + .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: ( +
+ {optionValue} + {routingHint?.pinnedAvailable ? ( + + {routingHint.prefix} + + ) : null} + {isCodexProvider && } +
+ ), + itemContent: ( +
+ {optionValue} + {routingHint?.unprefixedStatus === 'shadowed' ? ( + + {t('providerModelSelector.shadowed')} + + ) : null} + {routingHint?.unprefixedStatus === 'prefix-only' ? ( + + {t('providerModelSelector.prefixOnly')} + + ) : null} + {isCodexProvider && } +
+ ), + })); + }); const selectedValueMissing = Boolean(value) && !recommendedOptions.some((option) => option.value === value) && diff --git a/ui/src/lib/codex-effort.ts b/ui/src/lib/codex-effort.ts index 9f3b26f2..ff2d311a 100644 --- a/ui/src/lib/codex-effort.ts +++ b/ui/src/lib/codex-effort.ts @@ -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 } diff --git a/ui/src/lib/i18n.ts b/ui/src/lib/i18n.ts index e794e8dc..53f26f36 100644 --- a/ui/src/lib/i18n.ts +++ b/ui/src/lib/i18n.ts @@ -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: '更新センターを開く', diff --git a/ui/src/lib/model-catalogs.ts b/ui/src/lib/model-catalogs.ts index 025ddc01..5398645e 100644 --- a/ui/src/lib/model-catalogs.ts +++ b/ui/src/lib/model-catalogs.ts @@ -263,6 +263,7 @@ export const MODEL_CATALOGS: Record = { 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 = { 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 = { 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 = { 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 = { 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 = { 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 = { 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 = { 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 = { 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', diff --git a/ui/src/lib/provider-config.ts b/ui/src/lib/provider-config.ts index 3500e4a4..00c34cc7 100644 --- a/ui/src/lib/provider-config.ts +++ b/ui/src/lib/provider-config.ts @@ -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(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 | 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( + items: readonly T[], + getProvider: (item: T) => unknown +): Array { + 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); diff --git a/ui/src/pages/cliproxy.tsx b/ui/src/pages/cliproxy.tsx index 6aba8344..962591e2 100644 --- a/ui/src/pages/cliproxy.tsx +++ b/ui/src/pages/cliproxy.tsx @@ -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() { ))} ) : ( -
- {providers.map((status) => ( - handleSelectProvider(status.provider)} - /> +
+ {providerSections.map((section) => ( +
+
+
+ {t(section.labelKey)} +
+

+ {t(section.hintKey)} +

+
+
+ {section.items.map((status) => ( + handleSelectProvider(status.provider)} + /> + ))} +
+
))}
)} diff --git a/ui/src/pages/settings/sections/proxy/index.tsx b/ui/src/pages/settings/sections/proxy/index.tsx index ce90a1d1..441de0f8 100644 --- a/ui/src/pages/settings/sections/proxy/index.tsx +++ b/ui/src/pages/settings/sections/proxy/index.tsx @@ -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() { >
{t('settingsProxy.backendPlusApi')} - - {t('settingsProxy.default')} -

{t('settingsProxy.plusDesc')}

+

+ {plusProviderNames} +

{/* Original Backend Card */} @@ -509,15 +516,31 @@ export default function ProxySection() { >
{t('settingsProxy.backendApi')} + + {t('settingsProxy.default')} +

{t('settingsProxy.originalDesc')}

+

+ {coreProviderNames} +

+ {backend === 'plus' && ( + + + + {t('settingsProxy.plusFallbackNotice')} + + + )} {/* Warning when original backend selected with Kiro/ghcp variants */} {backend === 'original' && hasKiroGhcpVariants && ( - {t('settingsProxy.variantsIncompatible')} + + {t('settingsProxy.variantsIncompatible', { providers: plusProviderNames })} + )} diff --git a/ui/src/pages/settings/sections/thinking/index.tsx b/ui/src/pages/settings/sections/thinking/index.tsx index d4c57e74..1fd52591 100644 --- a/ui/src/pages/settings/sections/thinking/index.tsx +++ b/ui/src/pages/settings/sections/thinking/index.tsx @@ -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)' }, ]; diff --git a/ui/tests/unit/components/cliproxy/provider-editor/provider-info-tab.test.tsx b/ui/tests/unit/components/cliproxy/provider-editor/provider-info-tab.test.tsx index 9d71278d..1c518ba6 100644 --- a/ui/tests/unit/components/cliproxy/provider-editor/provider-info-tab.test.tsx +++ b/ui/tests/unit/components/cliproxy/provider-editor/provider-info-tab.test.tsx @@ -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( + + ); + + 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( + + ); + + expect(screen.getByText('Track')).toBeInTheDocument(); + expect(screen.getByText('Plus extras / community-maintained')).toBeInTheDocument(); + }); }); diff --git a/ui/tests/unit/components/cliproxy/provider-editor/use-provider-editor.test.tsx b/ui/tests/unit/components/cliproxy/provider-editor/use-provider-editor.test.tsx index c1546fb2..25c3e7ab 100644 --- a/ui/tests/unit/components/cliproxy/provider-editor/use-provider-editor.test.tsx +++ b/ui/tests/unit/components/cliproxy/provider-editor/use-provider-editor.test.tsx @@ -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', + }); + }); }); diff --git a/ui/tests/unit/components/cliproxy/provider-model-selector.test.tsx b/ui/tests/unit/components/cliproxy/provider-model-selector.test.tsx index 3b8b94b1..d7c15e75 100644 --- a/ui/tests/unit/components/cliproxy/provider-model-selector.test.tsx +++ b/ui/tests/unit/components/cliproxy/provider-model-selector.test.tsx @@ -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( + + ); + + 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( + + ); + + 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( + + ); + + 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'); + }); }); diff --git a/ui/tests/unit/pages/cliproxy-page.test.tsx b/ui/tests/unit/pages/cliproxy-page.test.tsx index bd651bfb..92b57035 100644 --- a/ui/tests/unit/pages/cliproxy-page.test.tsx +++ b/ui/tests/unit/pages/cliproxy-page.test.tsx @@ -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(); + + 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(); diff --git a/ui/tests/unit/ui/lib/codex-effort.test.ts b/ui/tests/unit/ui/lib/codex-effort.test.ts index 965d1963..90b30e3e 100644 --- a/ui/tests/unit/ui/lib/codex-effort.test.ts +++ b/ui/tests/unit/ui/lib/codex-effort.test.ts @@ -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', + ]); + }); +}); diff --git a/ui/tests/unit/ui/lib/model-catalogs-codex.test.ts b/ui/tests/unit/ui/lib/model-catalogs-codex.test.ts index 8e302458..b8ceaf9a 100644 --- a/ui/tests/unit/ui/lib/model-catalogs-codex.test.ts +++ b/ui/tests/unit/ui/lib/model-catalogs-codex.test.ts @@ -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'); }); }); diff --git a/ui/tests/unit/ui/lib/provider-config.test.ts b/ui/tests/unit/ui/lib/provider-config.test.ts index b33f24b1..f83ede63 100644 --- a/ui/tests/unit/ui/lib/provider-config.test.ts +++ b/ui/tests/unit/ui/lib/provider-config.test.ts @@ -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'],