mirror of
https://github.com/tiennm99/ccs.git
synced 2026-09-03 06:18:55 +00:00
chore: merge origin/dev into pr-1061 fix branch
This commit is contained in:
Binary file not shown.
|
After Width: | Height: | Size: 210 KiB |
@@ -0,0 +1,138 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>Issue 1058 UI Evidence</title>
|
||||
<style>
|
||||
:root {
|
||||
color-scheme: light dark;
|
||||
--bg: #111111;
|
||||
--fg: #f5f1ea;
|
||||
--muted: #b8aea0;
|
||||
--card: #1a1714;
|
||||
--border: #3a332b;
|
||||
--accent: #ef4444;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
padding: 32px;
|
||||
background: var(--bg);
|
||||
color: var(--fg);
|
||||
font:
|
||||
15px/1.5 "SF Mono",
|
||||
"IBM Plex Sans",
|
||||
system-ui,
|
||||
sans-serif;
|
||||
}
|
||||
|
||||
main {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
h1,
|
||||
h2,
|
||||
p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.header,
|
||||
.shot {
|
||||
border: 1px solid var(--border);
|
||||
background: var(--card);
|
||||
border-radius: 18px;
|
||||
}
|
||||
|
||||
.header {
|
||||
padding: 24px;
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.summary {
|
||||
display: grid;
|
||||
gap: 16px;
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
.summary-card {
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 14px;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.eyebrow {
|
||||
color: var(--accent);
|
||||
font-size: 12px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.14em;
|
||||
}
|
||||
|
||||
.muted {
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.shot {
|
||||
margin-top: 24px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.shot-header {
|
||||
padding: 16px 20px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
img {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: auto;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<main>
|
||||
<section class="header">
|
||||
<p class="eyebrow">Issue 1058</p>
|
||||
<h1>Codex selector now exposes pinned effort variants</h1>
|
||||
<p class="muted">
|
||||
Capture environment: local dashboard at <code>http://127.0.0.1:3001/cliproxy</code>,
|
||||
light theme, Codex provider editor, Model Config tab.
|
||||
</p>
|
||||
<div class="summary">
|
||||
<div class="summary-card">
|
||||
<p class="eyebrow">What changed</p>
|
||||
<p>The selector now offers real codex model variants like <code>-high</code> and <code>-xhigh</code>.</p>
|
||||
</div>
|
||||
<div class="summary-card">
|
||||
<p class="eyebrow">Why it matters</p>
|
||||
<p>The dashboard can now emit the exact suffixed model IDs that runtime code already understands.</p>
|
||||
</div>
|
||||
<div class="summary-card">
|
||||
<p class="eyebrow">Review cue</p>
|
||||
<p>Look at the opened model picker: suffixed entries appear as first-class options with pinned-effort badges.</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="shot">
|
||||
<div class="shot-header">
|
||||
<p class="eyebrow">Targeted evidence</p>
|
||||
<h2>Codex provider editor model picker</h2>
|
||||
<p class="muted">
|
||||
The open selector shows canonical rows plus generated pinned variants such as
|
||||
<code>gpt-5.3-codex-high</code>, <code>gpt-5.3-codex-xhigh</code>, and
|
||||
<code>gpt-5.4-xhigh</code>.
|
||||
</p>
|
||||
</div>
|
||||
<img
|
||||
src="./codex-selector-variants.png"
|
||||
alt="Codex provider editor with the model selector opened and suffixed effort variants visible."
|
||||
/>
|
||||
</section>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
@@ -6,8 +6,9 @@
|
||||
|
||||
Use what applies. If you skipped something, add a short note instead of forcing it.
|
||||
|
||||
- [ ] `bun run validate`
|
||||
- [ ] `bun run validate:ci-parity`
|
||||
- [ ] `bun run format && bun run lint:fix && bun run validate`
|
||||
- [ ] `bun run validate:ci-parity` before requesting review
|
||||
- [ ] `bun run test:e2e` if this PR touches command routing, proxy flows, or workflow/release logic
|
||||
- [ ] `cd ui && bun run validate` if UI changed
|
||||
- [ ] Not run
|
||||
|
||||
@@ -20,6 +21,7 @@ Check what applies. Not every item is relevant for every PR.
|
||||
- [ ] Relevant `--help` output updated if CLI behavior changed
|
||||
- [ ] Tests added or updated if behavior changed
|
||||
- [ ] README or local docs updated if user-facing behavior changed
|
||||
- [ ] If a check failed, the PR body explains what failed and what changed to fix it
|
||||
- [ ] No secrets, tokens, or private config data are included
|
||||
|
||||
## Docs Impact
|
||||
|
||||
+13
-11
@@ -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
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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
|
||||
@@ -42,9 +42,12 @@ jobs:
|
||||
- name: Build package
|
||||
run: bun run build:all
|
||||
|
||||
- name: Validate (typecheck + lint + format + tests)
|
||||
- name: Validate fast gate
|
||||
run: bun run validate
|
||||
|
||||
- name: Test slow bucket
|
||||
run: bun run test:slow
|
||||
|
||||
- name: Test CLI e2e
|
||||
env:
|
||||
CCS_E2E_SKIP_BUILD: '1'
|
||||
|
||||
+2
-2
@@ -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"
|
||||
|
||||
@@ -41,6 +41,15 @@ gh pr checks <n>
|
||||
### Absolute rule
|
||||
AI MUST NOT declare a task done, close a session, or move to the next task while CI is red or still running. Leaving a PR red and moving on is the primary failure mode this protocol prevents.
|
||||
|
||||
### Dev Release vs Push CI
|
||||
|
||||
- `CI` is the pull-request quality gate for contributor branches.
|
||||
- `Push CI` is the post-merge quality signal for `dev`.
|
||||
- `Dev Release` publishes the `@dev` package after `dev` changes land.
|
||||
- A red `Dev Release` does **not** automatically mean contributor code failed. Check `Push CI` first.
|
||||
- Verified on `2026-04-22` via `gh api repos/kaitranntt/ccs/branches/dev/protection`: `dev` currently requires `typecheck`, `lint`, `format`, `build`, and `test`, has no branch restrictions, and has no required PR-review gate.
|
||||
- `dev-release.yml` currently pushes with `PAT_TOKEN` because `dev` is protected by those required status checks. Do not switch it back to `github.token` unless branch protection changes with it.
|
||||
|
||||
## Core Function
|
||||
|
||||
Multi-provider profile and runtime manager for Claude Code, Factory Droid,
|
||||
@@ -87,7 +96,7 @@ broader topic.
|
||||
| Mistake | Consequence | Correct Action |
|
||||
|---------|-------------|----------------|
|
||||
| Running `validate` without `format` first | format:check fails | Run `bun run format` BEFORE validate |
|
||||
| Assuming maintainability check is always strict | PR/feature branches run warning mode by default | Use `bun run maintainability:check:strict` before merge when touching debt-sensitive code |
|
||||
| Treating `Dev Release` as the contributor quality signal | Publish failures on `dev` look like broken code | Check PR `CI` on the branch and `Push CI` on `dev` first |
|
||||
| Using `chore:` for dev→main PR | No npm release triggered | Use `feat:` or `fix:` prefix |
|
||||
| Committing directly to `main` or `dev` | Bypasses CI/review | Always use PRs |
|
||||
| Manual version bump or git tag | Conflicts with semantic-release | Let CI handle versioning |
|
||||
@@ -208,8 +217,8 @@ Quality gates MUST pass before pushing. **Both projects have identical workflow.
|
||||
# Main project (from repo root)
|
||||
bun run format # Step 1: Fix formatting
|
||||
bun run lint:fix # Step 2: Fix lint issues
|
||||
bun run validate # Step 3: Full gate (typecheck + lint + format + maintainability + tests)
|
||||
bun run validate:ci-parity # Step 4: full CI parity gate (build + validate + base branch check)
|
||||
bun run validate # Step 3: Fast gate (typecheck + lint + format + test:fast)
|
||||
bun run validate:ci-parity # Step 4: PR-CI parity gate (branch check + build + full tests + e2e)
|
||||
|
||||
# UI project (if UI changed)
|
||||
cd ui
|
||||
@@ -221,17 +230,17 @@ bun run validate # Step 3: Final check (must pass)
|
||||
**WHY THIS ORDER:**
|
||||
- `validate` runs `format:check` which only VERIFIES—won't fix
|
||||
- If format:check fails, you skipped step 1
|
||||
- CI runs `validate` only (no auto-fix)—local must be clean
|
||||
- `validate` now uses read-only `lint`, so autofix still belongs in step 2
|
||||
- PR CI and `validate:ci-parity` both run non-mutating checks only
|
||||
|
||||
### What Validate Runs
|
||||
### What Each Gate Runs
|
||||
|
||||
| Project | Command | Runs |
|
||||
|---------|---------|------|
|
||||
| Main | `bun run validate` | typecheck + lint:fix + format:check + test:all |
|
||||
| Main | `bun run validate` | typecheck + lint + format:check + test:fast |
|
||||
| Main | `bun run validate:ci-parity` | base branch check + typecheck + lint + format:check + build:all + test:all + test:e2e |
|
||||
| UI | `bun run validate` | typecheck + lint:fix + format:check |
|
||||
|
||||
**Note:** `maintainability:check` is a SEPARATE gate — not part of `validate`. Run it explicitly via `bun run maintainability:check[:strict|:warn]` when touching debt-sensitive code or before merging to protected branches.
|
||||
|
||||
### ESLint Rules (ALL errors)
|
||||
|
||||
| Rule | Level | Notes |
|
||||
@@ -255,33 +264,19 @@ bun run validate # Step 3: Final check (must pass)
|
||||
|
||||
### Automatic Enforcement
|
||||
|
||||
- `prepublishOnly` / `prepack` runs `build:all` + `validate` + `sync-version.js`
|
||||
- CI/CD runs `bun run validate` on every PR (maintainability is warning mode on PR events)
|
||||
- `prepack` runs `build:all`
|
||||
- PR `CI` runs `typecheck`, `lint`, `format`, `build`, `test:all`, and `test:e2e`
|
||||
- `Push CI` runs the same quality suite on `dev` after merge, separate from release publishing
|
||||
- `Dev Release` still runs build + fast validation + slow tests + e2e before publishing and still requires `PAT_TOKEN` to push back to protected `dev`
|
||||
- husky `pre-commit` runs quick lint/type/format checks
|
||||
- husky `pre-push` runs the full `bun run validate:ci-parity` gate on `main`/`dev`/hotfix branches
|
||||
- husky `pre-push` runs a faster feature-branch gate (`typecheck` + `lint:fix` + `format:check` + targeted checks based on changed files) before GitHub CI handles the full matrix
|
||||
- husky `pre-push` runs a faster feature-branch gate (`typecheck` + `lint` + `format:check` + `test:fast`) plus targeted checks based on changed files
|
||||
|
||||
### Maintainability Baseline Gate
|
||||
### Maintainability Gate Status
|
||||
|
||||
- Baseline file: `docs/metrics/maintainability-baseline.json`
|
||||
- Metric collector/check script: `scripts/maintainability-baseline.js`
|
||||
- Branch-aware gate wrapper: `scripts/maintainability-check.js`
|
||||
- Enforcement path: `bun run maintainability:check` (run separately — NOT part of `bun run validate`; invoked by `validate:ci-parity` on protected branches)
|
||||
- Gate modes:
|
||||
- `strict`: protected branches (`main`, `dev`, `hotfix/*`, `kai/hotfix-*`) and equivalent CI refs
|
||||
- `warn`: pull request CI and non-protected local branches (non-blocking for parallel PR workflow)
|
||||
- override commands:
|
||||
- `bun run maintainability:check:strict`
|
||||
- `bun run maintainability:check:warn`
|
||||
- Gated metrics (must not increase vs baseline):
|
||||
- `processExitReferenceCount`
|
||||
- `synchronousFsApiReferenceCount`
|
||||
- Informational metrics (collected but not gated):
|
||||
- `largeFileCountOver350Loc`
|
||||
- Baseline update policy:
|
||||
1. Prefer reducing the metric and keeping the baseline unchanged.
|
||||
2. On protected-branch integration (strict mode), if increase is intentional and accepted, run `bun run maintainability:baseline`.
|
||||
3. Commit both the code change and `docs/metrics/maintainability-baseline.json`, and state reason in PR description.
|
||||
- The historical maintainability baseline gate is retired from the active CCS workflow.
|
||||
- `validate`, `validate:ci-parity`, PR `CI`, `Push CI`, and release workflows do **not** invoke `maintainability:check`.
|
||||
- Older roadmap references to `maintainability:baseline` / `maintainability:check` are historical context, not current repo commands.
|
||||
|
||||
## Critical Constraints (NEVER VIOLATE)
|
||||
|
||||
@@ -532,17 +527,16 @@ rm -rf ~/.ccs # Clean environment
|
||||
Optimized for iterative push-then-review workflow. Do NOT run the full gate on every push — CI is the safety net. Run the full gate once before asking for review / merge.
|
||||
|
||||
### Tier 1 — Iterative push (feature branch)
|
||||
Husky `pre-push` auto-runs: `typecheck + lint:fix + format:check + build:all` plus targeted tests based on changed files. AI does **nothing extra** at push time.
|
||||
Husky `pre-push` auto-runs: `typecheck + lint + format:check + test:fast` plus targeted checks based on changed files. AI does **nothing extra** at push time.
|
||||
|
||||
**After push (MANDATORY):** follow the [CI-First Protocol](#ci-first-protocol-mandatory) — watch CI until green. Do not move on while CI is red.
|
||||
|
||||
### Tier 2 — Before requesting review / merge
|
||||
Run ONCE, not per push:
|
||||
- [ ] `bun run validate:ci-parity` — full build + validate matches CI
|
||||
- [ ] `bun run validate:ci-parity` — branch freshness + build + full non-e2e tests + e2e
|
||||
- [ ] `gh pr checks <n>` — all checks green
|
||||
- [ ] If touching debt-sensitive code: `bun run maintainability:check:strict`
|
||||
- [ ] If strict mode fails and increase is intentional: `bun run maintainability:baseline` and commit `docs/metrics/maintainability-baseline.json`
|
||||
- [ ] If UI changed: `cd ui && bun run format && bun run validate`
|
||||
- [ ] If touching command routing, proxy flows, workflows, or release logic: `bun run test:e2e`
|
||||
|
||||
### Code / Docs / Standards (verify before merge)
|
||||
- [ ] Conventional commit format (`feat:`, `fix:`, etc.)
|
||||
|
||||
+49
-5
@@ -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.
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -599,6 +599,7 @@ return (
|
||||
bun run format
|
||||
bun run lint:fix
|
||||
bun run validate
|
||||
bun run validate:ci-parity
|
||||
|
||||
# UI project (if changed)
|
||||
cd ui
|
||||
@@ -611,7 +612,7 @@ bun run validate
|
||||
|
||||
| Project | Command | Checks |
|
||||
|---------|---------|--------|
|
||||
| Main | `bun run validate` | typecheck + lint + format:check + test |
|
||||
| Main | `bun run validate` | typecheck + lint + format:check + test:fast |
|
||||
| UI | `bun run validate` | typecheck + lint + format:check |
|
||||
|
||||
---
|
||||
|
||||
+7
-21
@@ -1,6 +1,6 @@
|
||||
# CCS Project Roadmap
|
||||
|
||||
Last Updated: 2026-04-19
|
||||
Last Updated: 2026-04-21
|
||||
|
||||
Forward-looking roadmap documenting current priorities, GitHub issues, and future feature plans.
|
||||
|
||||
@@ -41,6 +41,7 @@ All major modularization work is complete. The codebase evolved from monolithic
|
||||
|
||||
### Recent Fixes
|
||||
|
||||
- **2026-04-21**: CLIProxy quota failover now quarantines exhausted Claude and Antigravity accounts out of live rotation when a healthy fallback exists. CCS persists those quota-triggered pauses across launches, automatically resumes them after the configured cooldown window, and deliberately avoids auto-pausing the last available account so single-account setups still degrade gracefully instead of hard-locking themselves.
|
||||
- **2026-04-20**: **#1051** Browser automation now defaults safe-off for new installs and upgrades that do not already carry explicit browser settings. CCS changes both Claude Browser Attach and Codex Browser Tools to start with `enabled: false` and `policy: manual`, normalizes missing browser policies on upgrade back to `manual`, preserves explicit existing enablement, and updates status/help/docs so browser tooling is never implied to auto-expose unless users opt in.
|
||||
- **2026-04-19**: **#1051** Browser tooling now has an explicit exposure policy instead of only coarse enablement toggles. CCS adds `browser.<lane>.policy` (`auto` or `manual`) for both Claude Browser Attach and Codex Browser Tools, exposes CLI-first policy controls through `ccs browser policy`, `ccs browser enable`, and `ccs browser disable`, and adds one-run launch overrides `--browser` and `--no-browser` so users can force browser tooling on or off without editing saved config.
|
||||
- **2026-04-19**: **#1049** Browser setup now has a real remediation path instead of status/doctor-only guidance. CCS adds `ccs browser setup` as the primary one-command flow for Claude Browser Attach, shortens managed browser-path output to home-relative display paths where appropriate, and updates browser readiness guidance to point users at setup first while keeping browser doctor read-only by default.
|
||||
@@ -241,28 +242,13 @@ All criteria achieved:
|
||||
- [x] Clear domain boundaries
|
||||
- [x] Consistent naming conventions
|
||||
|
||||
## Maintainability Gate (Issue #539 Foundation)
|
||||
## Historical Maintainability Gate (Retired)
|
||||
|
||||
- Baseline metrics artifact: `docs/metrics/maintainability-baseline.json`
|
||||
- Branch-aware gate wrapper: `scripts/maintainability-check.js`
|
||||
- Generate or refresh baseline:
|
||||
- `bun run maintainability:baseline`
|
||||
- `npm run maintainability:baseline`
|
||||
- Run regression check gate:
|
||||
- `bun run maintainability:check`
|
||||
- `npm run maintainability:check`
|
||||
- `bun run maintainability:check:strict` (force strict locally)
|
||||
This section is preserved as historical context from the original Issue `#539` work.
|
||||
|
||||
The baseline/check scripts enumerate git-tracked files under `src` for deterministic results and fail fast if git file listing is unavailable.
|
||||
|
||||
Default gate behavior:
|
||||
- strict mode on protected branches (`main`, `dev`, `hotfix/*`, `kai/hotfix-*`)
|
||||
- warning mode on PR CI and non-protected branches (parallel PR friendly)
|
||||
|
||||
The check mode supports a maintainability regression gate that blocks increases in:
|
||||
- `process.exit` references
|
||||
- synchronous fs API references
|
||||
- TypeScript files over 350 LOC
|
||||
- The maintainability baseline gate is no longer part of the active CCS workflow.
|
||||
- Current contributor and CI gates are documented in `CLAUDE.md`, `CONTRIBUTING.md`, and the GitHub workflow files.
|
||||
- Do not assume `maintainability:baseline` or `maintainability:check` exist unless they are reintroduced in a future follow-up.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -257,6 +257,7 @@ async function checkRemoteProxyHealth(config: ResolvedProxyConfig): Promise<bool
|
||||
### Overview
|
||||
|
||||
Hybrid quota management enables automatic detection of exhausted accounts and failover to next available account.
|
||||
When CCS detects exhaustion and a healthy fallback exists, it also temporarily pauses the exhausted account out of CLIProxy rotation and automatically resumes that pause after the configured cooldown expires.
|
||||
|
||||
```
|
||||
+===========================================================================+
|
||||
@@ -293,6 +294,11 @@ Hybrid quota management enables automatic detection of exhausted accounts and fa
|
||||
+---> Select best account (not paused, not exhausted)
|
||||
|
|
||||
+---> Auto-failover to next account if current exhausted
|
||||
|
|
||||
+---> Temporarily pause exhausted account when fallback exists
|
||||
| - move token out of live auth discovery
|
||||
| - persist cooldown expiry across launches
|
||||
| - auto-resume only CCS-created quota pauses
|
||||
|
||||
CLI Commands:
|
||||
ccs cliproxy pause <account> --> Set isPaused=true in account-manager
|
||||
|
||||
+5
-3
@@ -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",
|
||||
|
||||
@@ -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."
|
||||
|
||||
@@ -0,0 +1,181 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const { spawnSync } = require('node:child_process');
|
||||
|
||||
const rootDir = path.resolve(__dirname, '..');
|
||||
const candidateRoots = ['tests/unit', 'tests/integration', 'tests/npm'];
|
||||
// Add a `.ts` test to `slowTests` when ANY of these apply:
|
||||
// 1. It spawns a child process (CLI, bun test, node, gh, etc.).
|
||||
// 2. It binds a port, starts a server, or talks to localhost.
|
||||
// 3. It reads a real file from `dist/` or the repo root at runtime.
|
||||
// 4. It waits on a timer > 500ms or a filesystem watcher.
|
||||
// 5. A single run consistently takes > 1500ms on reference hardware.
|
||||
// Tests that literally reference `dist/` in source are auto-forced slow by
|
||||
// `readsBuiltDist`. This list is the manual catch-all for `.ts` tests that
|
||||
// meet the criteria above without the literal `dist/` string.
|
||||
// `tests/unit/scripts/run-test-bucket.test.js` verifies every path here exists
|
||||
// (catches deletion drift) but CANNOT detect new undeclared slow tests.
|
||||
// Automated perf-budget enforcement tracked in issue #1071.
|
||||
const slowTests = [
|
||||
'tests/integration/cursor-daemon-lifecycle.test.ts',
|
||||
'tests/integration/proxy/daemon-lifecycle.test.ts',
|
||||
'tests/unit/commands/persist-command-handler.test.ts',
|
||||
'tests/unit/hooks/ccs-browser-mcp-server.test.ts',
|
||||
'tests/unit/targets/codex-runtime-integration.test.ts',
|
||||
'tests/unit/targets/codex-settings-bridge-launch.test.ts',
|
||||
'tests/unit/targets/droid-command-routing-integration.test.ts',
|
||||
'tests/unit/targets/droid-config-manager.test.ts',
|
||||
'tests/unit/targets/settings-profile-browser-launch.test.ts',
|
||||
'tests/unit/targets/settings-profile-image-analysis-launch.test.ts',
|
||||
'tests/unit/targets/settings-profile-websearch-launch.test.ts',
|
||||
'tests/unit/web-server/cursor-routes.test.ts',
|
||||
'tests/unit/web-server/websearch-routes.test.ts',
|
||||
];
|
||||
// CommonJS-heavy JS suites stay slow by default because many of them mutate
|
||||
// module cache or process state. Opt them into `test:fast` only after they are
|
||||
// proven stable in the mixed fast bucket.
|
||||
const fastJsTests = new Set([
|
||||
'tests/unit/flag-parsing-simple.test.js',
|
||||
]);
|
||||
|
||||
const filePattern = /(\.test\.(c|m)?[jt]s|\.spec\.(c|m)?[jt]s|-test\.(c|m)?[jt]s)$/;
|
||||
|
||||
function collectFiles(dir, files = []) {
|
||||
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
||||
const fullPath = path.join(dir, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
collectFiles(fullPath, files);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (filePattern.test(entry.name)) {
|
||||
files.push(path.relative(rootDir, fullPath).split(path.sep).join('/'));
|
||||
}
|
||||
}
|
||||
|
||||
return files;
|
||||
}
|
||||
|
||||
function readsBuiltDist(relativePath) {
|
||||
const source = fs.readFileSync(path.join(rootDir, relativePath), 'utf8');
|
||||
return source.includes('dist/');
|
||||
}
|
||||
|
||||
function getDiscoveredTests() {
|
||||
return candidateRoots
|
||||
.flatMap((relativeDir) => collectFiles(path.join(rootDir, relativeDir)))
|
||||
.sort();
|
||||
}
|
||||
|
||||
function shouldForceSlow(file) {
|
||||
if (file.startsWith('tests/npm/')) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (/\.(c|m)?js$/.test(file) && !fastJsTests.has(file)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return readsBuiltDist(file);
|
||||
}
|
||||
|
||||
function getSlowSet() {
|
||||
const discovered = getDiscoveredTests();
|
||||
const forceSlow = discovered.filter((file) => shouldForceSlow(file));
|
||||
return new Set([...slowTests, ...forceSlow]);
|
||||
}
|
||||
|
||||
function selectBucket(name) {
|
||||
const discovered = getDiscoveredTests();
|
||||
const slowSet = getSlowSet();
|
||||
|
||||
return name === 'slow'
|
||||
? [...slowSet].sort()
|
||||
: discovered.filter((file) => !slowSet.has(file));
|
||||
}
|
||||
|
||||
function ensureBuildForSlowBucket() {
|
||||
if (fs.existsSync(path.join(rootDir, 'dist', 'ccs.js'))) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
const build = spawnSync('bun', ['run', 'build'], {
|
||||
cwd: rootDir,
|
||||
stdio: 'inherit',
|
||||
shell: process.platform === 'win32',
|
||||
});
|
||||
|
||||
return build.status ?? 1;
|
||||
}
|
||||
|
||||
function runBucket(name) {
|
||||
const selected = selectBucket(name);
|
||||
|
||||
if (selected.length === 0) {
|
||||
console.error(`[X] No tests matched the '${name}' bucket.`);
|
||||
return 1;
|
||||
}
|
||||
|
||||
if (name === 'slow') {
|
||||
const buildStatus = ensureBuildForSlowBucket();
|
||||
if (buildStatus !== 0) {
|
||||
return buildStatus;
|
||||
}
|
||||
}
|
||||
|
||||
// Slow bucket forces sequential execution because it spawns subprocesses,
|
||||
// binds ports, and touches shared state — parallelism causes flakes.
|
||||
// Fast bucket keeps bun's default parallelism for speed.
|
||||
const bunArgs = name === 'slow'
|
||||
? ['test', '--max-concurrency=1', ...selected]
|
||||
: ['test', ...selected];
|
||||
|
||||
const result = spawnSync('bun', bunArgs, {
|
||||
cwd: rootDir,
|
||||
stdio: 'inherit',
|
||||
shell: process.platform === 'win32',
|
||||
});
|
||||
|
||||
return result.status ?? 1;
|
||||
}
|
||||
|
||||
function main(args = process.argv.slice(2)) {
|
||||
const bucket = args[0];
|
||||
|
||||
if (!['fast', 'slow', 'all'].includes(bucket)) {
|
||||
console.error('[X] Usage: node scripts/run-test-bucket.js <fast|slow|all>');
|
||||
return 1;
|
||||
}
|
||||
|
||||
if (bucket === 'all') {
|
||||
let exitCode = 0;
|
||||
|
||||
for (const name of ['fast', 'slow']) {
|
||||
const status = runBucket(name);
|
||||
if (status !== 0) {
|
||||
exitCode = status;
|
||||
}
|
||||
}
|
||||
|
||||
return exitCode;
|
||||
}
|
||||
|
||||
return runBucket(bucket);
|
||||
}
|
||||
|
||||
if (require.main === module) {
|
||||
process.exit(main());
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
slowTests,
|
||||
fastJsTests,
|
||||
readsBuiltDist,
|
||||
shouldForceSlow,
|
||||
getDiscoveredTests,
|
||||
getSlowSet,
|
||||
selectBucket,
|
||||
main,
|
||||
};
|
||||
+17
-4
@@ -80,7 +80,7 @@ import { handleError, runCleanup } from './errors';
|
||||
import { tryHandleRootCommand } from './commands/root-command-router';
|
||||
|
||||
// Import extracted utility functions
|
||||
import { execClaude } from './utils/shell-executor';
|
||||
import { execClaude, stripAnthropicRoutingEnv } from './utils/shell-executor';
|
||||
import { isDeprecatedGlmtProfileName, normalizeDeprecatedGlmtEnv } from './utils/glmt-deprecation';
|
||||
import { maybeWarnAboutResumeLaneMismatch } from './auth/resume-lane-warning';
|
||||
import { createLogger } from './services/logging';
|
||||
@@ -1363,8 +1363,21 @@ async function main(): Promise<void> {
|
||||
console.error(info(`Global env: ${envNames}`));
|
||||
}
|
||||
|
||||
// Explicitly inject effective settings env vars so stale ANTHROPIC_*
|
||||
// values from prior sessions cannot leak into the active profile.
|
||||
// For Claude target launches that already pass `--settings`, keep runtime
|
||||
// env free of ANTHROPIC routing/auth while preserving non-routing profile
|
||||
// env so nested Team/subagent sessions can still inherit model intent and
|
||||
// other profile-scoped runtime flags.
|
||||
const claudeRuntimeEnvVars: NodeJS.ProcessEnv = {
|
||||
...stripAnthropicRoutingEnv({ ...globalEnv, ...settingsEnv }),
|
||||
...(inheritedClaudeConfigDir ? { CLAUDE_CONFIG_DIR: inheritedClaudeConfigDir } : {}),
|
||||
...webSearchEnv,
|
||||
...imageAnalysisEnv,
|
||||
...(browserRuntimeEnv || {}),
|
||||
CCS_PROFILE_TYPE: 'settings',
|
||||
CCS_STRIP_INHERITED_ANTHROPIC_ENV: '1',
|
||||
};
|
||||
|
||||
// Non-Claude targets still need effective credentials injected directly.
|
||||
const envVars: NodeJS.ProcessEnv = {
|
||||
...globalEnv,
|
||||
...settingsEnv,
|
||||
@@ -1476,7 +1489,7 @@ async function main(): Promise<void> {
|
||||
settingsPath: expandedSettingsPath,
|
||||
});
|
||||
|
||||
execClaude(claudeCli, launchArgs, { ...envVars, ...traceEnv });
|
||||
execClaude(claudeCli, launchArgs, { ...claudeRuntimeEnvVars, ...traceEnv });
|
||||
} else if (profileInfo.type === 'account') {
|
||||
// NEW FLOW: Account-based profile (work, personal)
|
||||
// All platforms: Use instance isolation with CLAUDE_CONFIG_DIR
|
||||
|
||||
@@ -37,10 +37,26 @@ interface AutoPausedFile {
|
||||
sessions: AutoPausedSession[];
|
||||
}
|
||||
|
||||
interface QuotaPausedEntry {
|
||||
provider: CLIProxyProvider;
|
||||
accountId: string;
|
||||
pausedAt: string;
|
||||
until: number;
|
||||
reason: 'quota_exhausted';
|
||||
}
|
||||
|
||||
interface QuotaPausedFile {
|
||||
entries: QuotaPausedEntry[];
|
||||
}
|
||||
|
||||
function getAutoPausedPath(): string {
|
||||
return path.join(getCcsDir(), 'cliproxy', 'auto-paused.json');
|
||||
}
|
||||
|
||||
function getQuotaPausedPath(): string {
|
||||
return path.join(getCcsDir(), 'cliproxy', 'quota-paused.json');
|
||||
}
|
||||
|
||||
function loadAutoPaused(): AutoPausedFile {
|
||||
try {
|
||||
const filePath = getAutoPausedPath();
|
||||
@@ -69,6 +85,48 @@ function saveAutoPaused(data: AutoPausedFile): void {
|
||||
fs.writeFileSync(filePath, JSON.stringify(data, null, 2) + '\n', { mode: 0o600 });
|
||||
}
|
||||
|
||||
function loadQuotaPaused(): QuotaPausedFile {
|
||||
try {
|
||||
const filePath = getQuotaPausedPath();
|
||||
if (fs.existsSync(filePath)) {
|
||||
const data = JSON.parse(fs.readFileSync(filePath, 'utf-8')) as {
|
||||
entries?: unknown;
|
||||
};
|
||||
if (Array.isArray(data.entries)) {
|
||||
return {
|
||||
entries: data.entries.filter(
|
||||
(entry): entry is QuotaPausedEntry =>
|
||||
typeof entry === 'object' &&
|
||||
entry !== null &&
|
||||
typeof (entry as QuotaPausedEntry).provider === 'string' &&
|
||||
typeof (entry as QuotaPausedEntry).accountId === 'string' &&
|
||||
typeof (entry as QuotaPausedEntry).pausedAt === 'string' &&
|
||||
Number.isFinite((entry as QuotaPausedEntry).until)
|
||||
),
|
||||
};
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Corrupted or malformed file — start fresh
|
||||
}
|
||||
return { entries: [] };
|
||||
}
|
||||
|
||||
function saveQuotaPaused(data: QuotaPausedFile): void {
|
||||
const filePath = getQuotaPausedPath();
|
||||
if (data.entries.length === 0) {
|
||||
try {
|
||||
fs.unlinkSync(filePath);
|
||||
} catch {
|
||||
/* already gone */
|
||||
}
|
||||
return;
|
||||
}
|
||||
const dir = path.dirname(filePath);
|
||||
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
|
||||
fs.writeFileSync(filePath, JSON.stringify(data, null, 2) + '\n', { mode: 0o600 });
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a process is alive. NOTE: PIDs can be recycled by the OS.
|
||||
* If a stale PID is reused by an unrelated process, cleanup is deferred until that process exits.
|
||||
@@ -325,6 +383,93 @@ export function cleanupStaleAutoPauses(): void {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resume quota-paused accounts whose cooldown windows have expired.
|
||||
* Auto-resume only applies to pauses created by CCS quota handling.
|
||||
*/
|
||||
export function restoreExpiredQuotaPauses(now = Date.now()): number {
|
||||
const data = loadQuotaPaused();
|
||||
if (data.entries.length === 0) return 0;
|
||||
|
||||
const keep: QuotaPausedEntry[] = [];
|
||||
let resumed = 0;
|
||||
const registry = loadAccountsRegistry();
|
||||
|
||||
for (const entry of data.entries) {
|
||||
if (!Number.isFinite(entry.until) || entry.until > now) {
|
||||
keep.push(entry);
|
||||
continue;
|
||||
}
|
||||
|
||||
const account = registry.providers[entry.provider]?.accounts[entry.accountId];
|
||||
if (!account?.paused) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Only auto-resume the exact pause CCS created for quota cooldown.
|
||||
// Missing or changed pausedAt metadata is treated as a mismatch so we do
|
||||
// not accidentally resume a manually paused account.
|
||||
if (account.pausedAt !== entry.pausedAt) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (resumeAccount(entry.provider, entry.accountId)) {
|
||||
resumed += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Resume failures are treated as transient I/O/state issues. Keep the
|
||||
// quota-pause record so the next restore pass can retry instead of leaving
|
||||
// the account paused forever without any cooldown metadata.
|
||||
keep.push(entry);
|
||||
}
|
||||
|
||||
saveQuotaPaused({ entries: keep });
|
||||
return resumed;
|
||||
}
|
||||
|
||||
/**
|
||||
* Temporarily remove an exhausted account from CLIProxy rotation for the
|
||||
* configured cooldown window. Returns false when the account was already paused
|
||||
* or could not be paused, so callers can fall back to in-memory cooldown only.
|
||||
*/
|
||||
export function pauseAccountForQuotaCooldown(
|
||||
provider: CLIProxyProvider,
|
||||
accountId: string,
|
||||
cooldownMinutes: number,
|
||||
now = Date.now()
|
||||
): boolean {
|
||||
const registryBefore = loadAccountsRegistry();
|
||||
const accountBefore = registryBefore.providers[provider]?.accounts[accountId];
|
||||
if (!accountBefore || accountBefore.paused) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!pauseAccount(provider, accountId)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const registryAfter = loadAccountsRegistry();
|
||||
const pausedAt = registryAfter.providers[provider]?.accounts[accountId]?.pausedAt;
|
||||
if (!pausedAt) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const data = loadQuotaPaused();
|
||||
data.entries = data.entries.filter(
|
||||
(entry) => !(entry.provider === provider && entry.accountId === accountId)
|
||||
);
|
||||
data.entries.push({
|
||||
provider,
|
||||
accountId,
|
||||
pausedAt,
|
||||
until: now + cooldownMinutes * 60 * 1000,
|
||||
reason: 'quota_exhausted',
|
||||
});
|
||||
saveQuotaPaused(data);
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Enforce provider isolation by auto-pausing conflicting accounts in other providers.
|
||||
* Records paused accounts for crash recovery and session exit restore.
|
||||
@@ -553,6 +698,7 @@ export async function handleQuotaExhaustion(
|
||||
const alternative = await findHealthyAccount(provider, [accountId]);
|
||||
|
||||
if (alternative) {
|
||||
pauseAccountForQuotaCooldown(provider, accountId, cooldownMinutes);
|
||||
setDefaultAccount(provider, alternative.id);
|
||||
touchAccount(provider, alternative.id);
|
||||
writeQuotaExhausted(accountId, alternative.id, cooldownMinutes);
|
||||
|
||||
+115
-12
@@ -5,10 +5,17 @@
|
||||
* Pattern: Mirrors npm install behavior (fast check, download only when needed)
|
||||
*/
|
||||
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import { info, warn } from '../utils/ui';
|
||||
import { getBinDir, CLIPROXY_DEFAULT_PORT } from './config-generator';
|
||||
import { BinaryInfo, BinaryManagerConfig } from './types';
|
||||
import { BACKEND_CONFIG, DEFAULT_BACKEND, CLIPROXY_MAX_STABLE_VERSION } from './platform-detector';
|
||||
import {
|
||||
BACKEND_CONFIG,
|
||||
DEFAULT_BACKEND,
|
||||
CLIPROXY_MAX_STABLE_VERSION,
|
||||
getExecutableName,
|
||||
} from './platform-detector';
|
||||
import { stopProxy } from './services/proxy-lifecycle-service';
|
||||
import { waitForPortFree } from '../utils/port-utils';
|
||||
import { loadOrCreateUnifiedConfig } from '../config/unified-config-loader';
|
||||
@@ -16,6 +23,7 @@ import {
|
||||
UpdateCheckResult,
|
||||
checkForUpdates,
|
||||
deleteBinary,
|
||||
getVersionCachePath,
|
||||
getBinaryPath,
|
||||
isBinaryInstalled,
|
||||
getBinaryInfo,
|
||||
@@ -30,11 +38,72 @@ import {
|
||||
} from './binary';
|
||||
|
||||
import type { CLIProxyBackend } from './types';
|
||||
import { getVersionListCachePath } from './binary/version-cache';
|
||||
|
||||
export const CLIPROXY_PLUS_TRACKING_URL = 'https://github.com/kaitranntt/ccs/issues/1062';
|
||||
|
||||
/**
|
||||
* Get backend from config or default to 'plus'
|
||||
* Track whether we've already warned the user about the Plus fallback this
|
||||
* process lifetime. Prevents spamming the warning on every command.
|
||||
*/
|
||||
function getConfiguredBackend(): CLIProxyBackend {
|
||||
let plusFallbackWarned = false;
|
||||
|
||||
function emitPlusFallbackWarning(): void {
|
||||
if (plusFallbackWarned) return;
|
||||
plusFallbackWarned = true;
|
||||
process.stderr.write(
|
||||
`${warn(
|
||||
'CLIProxyAPIPlus upstream repo is currently unavailable; local CLIProxy is falling back to ' +
|
||||
'`backend: original`. Run `ccs config` to update your saved config. ' +
|
||||
`Tracking: ${CLIPROXY_PLUS_TRACKING_URL}`
|
||||
)}\n`
|
||||
);
|
||||
}
|
||||
|
||||
export function getPlusBackendUnavailableMessage(provider?: string): string {
|
||||
const prefix = provider
|
||||
? `${provider} requires CLIProxyAPIPlus,`
|
||||
: 'CLIProxyAPIPlus upstream repo is currently unavailable,';
|
||||
return (
|
||||
`${prefix} but local CLIProxy currently supports only \`backend: original\`. ` +
|
||||
`Tracking: ${CLIPROXY_PLUS_TRACKING_URL}`
|
||||
);
|
||||
}
|
||||
|
||||
export function resolveLocalBackend(
|
||||
backend: CLIProxyBackend = DEFAULT_BACKEND,
|
||||
options: { warnOnFallback?: boolean } = {}
|
||||
): CLIProxyBackend {
|
||||
if (backend !== 'plus') return backend;
|
||||
if (options.warnOnFallback) {
|
||||
emitPlusFallbackWarning();
|
||||
}
|
||||
return 'original';
|
||||
}
|
||||
|
||||
function copyFallbackStateIfMissing(sourcePath: string, targetPath: string): void {
|
||||
if (!fs.existsSync(sourcePath) || fs.existsSync(targetPath)) return;
|
||||
fs.mkdirSync(path.dirname(targetPath), { recursive: true });
|
||||
fs.copyFileSync(sourcePath, targetPath);
|
||||
}
|
||||
|
||||
export function syncPlusFallbackStateIfNeeded(configuredBackend: CLIProxyBackend): void {
|
||||
if (configuredBackend !== 'plus') return;
|
||||
|
||||
const plusDir = getBackendBinDir('plus');
|
||||
const originalDir = getBackendBinDir('original');
|
||||
|
||||
copyFallbackStateIfMissing(
|
||||
path.join(plusDir, getExecutableName('plus')),
|
||||
path.join(originalDir, getExecutableName('original'))
|
||||
);
|
||||
copyFallbackStateIfMissing(path.join(plusDir, '.version'), path.join(originalDir, '.version'));
|
||||
copyFallbackStateIfMissing(getVersionPinPath('plus'), getVersionPinPath('original'));
|
||||
copyFallbackStateIfMissing(getVersionCachePath('plus'), getVersionCachePath('original'));
|
||||
copyFallbackStateIfMissing(getVersionListCachePath('plus'), getVersionListCachePath('original'));
|
||||
}
|
||||
|
||||
function getConfiguredOrDefaultBackend(): CLIProxyBackend {
|
||||
try {
|
||||
const config = loadOrCreateUnifiedConfig();
|
||||
return config.cliproxy?.backend || DEFAULT_BACKEND;
|
||||
@@ -43,6 +112,24 @@ function getConfiguredBackend(): CLIProxyBackend {
|
||||
}
|
||||
}
|
||||
|
||||
export function getStoredConfiguredBackend(): CLIProxyBackend {
|
||||
return getConfiguredOrDefaultBackend();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get backend from config, with runtime fallback to 'original' when the user
|
||||
* still has `backend: plus` saved.
|
||||
*
|
||||
* Context (issue #1062): the upstream `router-for-me/CLIProxyAPIPlus` repo was
|
||||
* deleted, so any `backend: plus` install/update path hits a 404. Rather than
|
||||
* forcing users to manually edit config.yaml, we degrade to `original` at
|
||||
* runtime and warn once. This keeps existing installations working without a
|
||||
* reconfig step, while CCS self-maintains its own Plus fork (future work).
|
||||
*/
|
||||
export function getConfiguredBackend(options: { warnOnFallback?: boolean } = {}): CLIProxyBackend {
|
||||
return resolveLocalBackend(getConfiguredOrDefaultBackend(), options);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get backend-specific binary directory.
|
||||
* Stores binaries in separate dirs: bin/original/ and bin/plus/
|
||||
@@ -52,7 +139,7 @@ function getBackendBinDir(backend: CLIProxyBackend = DEFAULT_BACKEND): string {
|
||||
return `${baseDir}/${backend}`;
|
||||
}
|
||||
|
||||
/** Default configuration (uses backend from config.yaml or defaults to 'plus') */
|
||||
/** Default configuration (uses backend from config.yaml or defaults to `DEFAULT_BACKEND`) */
|
||||
function createDefaultConfig(backend: CLIProxyBackend = DEFAULT_BACKEND): BinaryManagerConfig {
|
||||
const backendConfig = BACKEND_CONFIG[backend];
|
||||
return {
|
||||
@@ -76,7 +163,9 @@ export class BinaryManager {
|
||||
private backend: CLIProxyBackend;
|
||||
|
||||
constructor(config: Partial<BinaryManagerConfig> = {}, backend?: CLIProxyBackend) {
|
||||
this.backend = backend ?? getConfiguredBackend();
|
||||
const configuredBackend = backend ?? getConfiguredOrDefaultBackend();
|
||||
syncPlusFallbackStateIfNeeded(configuredBackend);
|
||||
this.backend = resolveLocalBackend(configuredBackend, { warnOnFallback: true });
|
||||
const defaultConfig = createDefaultConfig(this.backend);
|
||||
this.config = { ...defaultConfig, ...config };
|
||||
}
|
||||
@@ -127,7 +216,9 @@ export async function ensureCLIProxyBinary(
|
||||
verbose = false,
|
||||
options: EnsureCLIProxyBinaryOptions = {}
|
||||
): Promise<string> {
|
||||
const backend = getConfiguredBackend();
|
||||
const configuredBackend = getConfiguredOrDefaultBackend();
|
||||
syncPlusFallbackStateIfNeeded(configuredBackend);
|
||||
const backend = resolveLocalBackend(configuredBackend, { warnOnFallback: true });
|
||||
|
||||
// Migrate old shared pin to backend-specific location (one-time migration)
|
||||
migrateVersionPin(backend);
|
||||
@@ -158,19 +249,25 @@ export async function ensureCLIProxyBinary(
|
||||
|
||||
/** Check if CLIProxyAPI binary is installed */
|
||||
export function isCLIProxyInstalled(backend?: CLIProxyBackend): boolean {
|
||||
const effectiveBackend = backend ?? getConfiguredBackend();
|
||||
const configuredBackend = backend ?? getConfiguredOrDefaultBackend();
|
||||
syncPlusFallbackStateIfNeeded(configuredBackend);
|
||||
const effectiveBackend = resolveLocalBackend(configuredBackend, { warnOnFallback: true });
|
||||
return new BinaryManager({}, effectiveBackend).isBinaryInstalled();
|
||||
}
|
||||
|
||||
/** Get CLIProxyAPI binary path (may not exist) */
|
||||
export function getCLIProxyPath(backend?: CLIProxyBackend): string {
|
||||
const effectiveBackend = backend ?? getConfiguredBackend();
|
||||
const configuredBackend = backend ?? getConfiguredOrDefaultBackend();
|
||||
syncPlusFallbackStateIfNeeded(configuredBackend);
|
||||
const effectiveBackend = resolveLocalBackend(configuredBackend, { warnOnFallback: true });
|
||||
return new BinaryManager({}, effectiveBackend).getBinaryPath();
|
||||
}
|
||||
|
||||
/** Get installed CLIProxyAPI version from .version file */
|
||||
export function getInstalledCliproxyVersion(backend?: CLIProxyBackend): string {
|
||||
const effectiveBackend = backend ?? getConfiguredBackend();
|
||||
const configuredBackend = backend ?? getConfiguredOrDefaultBackend();
|
||||
syncPlusFallbackStateIfNeeded(configuredBackend);
|
||||
const effectiveBackend = resolveLocalBackend(configuredBackend, { warnOnFallback: true });
|
||||
return readInstalledVersion(
|
||||
getBackendBinDir(effectiveBackend),
|
||||
BACKEND_CONFIG[effectiveBackend].fallbackVersion
|
||||
@@ -196,7 +293,9 @@ export async function installCliproxyVersion(
|
||||
backend?: CLIProxyBackend,
|
||||
deps: InstallCliproxyVersionDeps = {}
|
||||
): Promise<void> {
|
||||
const effectiveBackend = backend ?? getConfiguredBackend();
|
||||
const configuredBackend = backend ?? getConfiguredOrDefaultBackend();
|
||||
syncPlusFallbackStateIfNeeded(configuredBackend);
|
||||
const effectiveBackend = resolveLocalBackend(configuredBackend, { warnOnFallback: true });
|
||||
const manager =
|
||||
deps.createManager?.({ version, verbose, forceVersion: true }, effectiveBackend) ??
|
||||
new BinaryManager({ version, verbose, forceVersion: true }, effectiveBackend);
|
||||
@@ -237,7 +336,9 @@ export async function installCliproxyVersion(
|
||||
|
||||
/** Fetch the latest CLIProxyAPI version from GitHub API */
|
||||
export async function fetchLatestCliproxyVersion(backend?: CLIProxyBackend): Promise<string> {
|
||||
const effectiveBackend = backend ?? getConfiguredBackend();
|
||||
const configuredBackend = backend ?? getConfiguredOrDefaultBackend();
|
||||
syncPlusFallbackStateIfNeeded(configuredBackend);
|
||||
const effectiveBackend = resolveLocalBackend(configuredBackend, { warnOnFallback: true });
|
||||
const result = await new BinaryManager({}, effectiveBackend).checkForUpdates();
|
||||
return result.latestVersion;
|
||||
}
|
||||
@@ -262,7 +363,9 @@ export interface CliproxyUpdateCheckResult {
|
||||
export async function checkCliproxyUpdate(
|
||||
backend?: CLIProxyBackend
|
||||
): Promise<CliproxyUpdateCheckResult> {
|
||||
const effectiveBackend = backend ?? getConfiguredBackend();
|
||||
const configuredBackend = backend ?? getConfiguredOrDefaultBackend();
|
||||
syncPlusFallbackStateIfNeeded(configuredBackend);
|
||||
const effectiveBackend = resolveLocalBackend(configuredBackend, { warnOnFallback: true });
|
||||
const result = await new BinaryManager({}, effectiveBackend).checkForUpdates();
|
||||
|
||||
// Import isNewerVersion for stability check
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -42,7 +42,6 @@ interface ProviderSettings {
|
||||
const DEPRECATED_MODEL_PREFIX = 'gemini-claude-';
|
||||
/** Replacement prefix matching actual upstream model names */
|
||||
const UPSTREAM_MODEL_PREFIX = 'claude-';
|
||||
const CODEX_EFFORT_SUFFIX_REGEX = /-(xhigh|high|medium)$/i;
|
||||
const PRESET_MODEL_KEYS = ['default', 'opus', 'sonnet', 'haiku'] as const;
|
||||
const REQUIRED_PROVIDER_ENV_KEYS = [
|
||||
'ANTHROPIC_BASE_URL',
|
||||
@@ -62,10 +61,6 @@ function isObjectRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function stripCodexEffortSuffix(modelId: string): string {
|
||||
return modelId.replace(CODEX_EFFORT_SUFFIX_REGEX, '');
|
||||
}
|
||||
|
||||
/**
|
||||
* Migrate deprecated gemini-claude-* model names to upstream claude-* names in a settings file.
|
||||
* CLIProxyAPI registry no longer recognizes the gemini-claude-* prefix convention.
|
||||
@@ -130,58 +125,6 @@ function migrateDeprecatedModelNames(
|
||||
return migrated;
|
||||
}
|
||||
|
||||
/**
|
||||
* Migrate codex effort-suffixed model IDs in settings to canonical IDs.
|
||||
* Example: gpt-5.3-codex-xhigh -> gpt-5.3-codex
|
||||
*/
|
||||
function migrateCodexEffortSuffixes(
|
||||
settingsPath: string,
|
||||
provider: CLIProxyProvider,
|
||||
settings: ProviderSettings
|
||||
): boolean {
|
||||
if (provider !== 'codex') return false;
|
||||
if (!settings.env || typeof settings.env !== 'object') return false;
|
||||
|
||||
let migrated = false;
|
||||
|
||||
for (const key of MODEL_ENV_VAR_KEYS) {
|
||||
const value = settings.env[key];
|
||||
if (typeof value !== 'string') continue;
|
||||
const canonical = stripCodexEffortSuffix(value);
|
||||
if (canonical !== value) {
|
||||
settings.env[key] = canonical;
|
||||
migrated = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (Array.isArray(settings.presets)) {
|
||||
for (const preset of settings.presets) {
|
||||
if (!preset || typeof preset !== 'object') continue;
|
||||
const presetRecord = preset as Record<string, unknown>;
|
||||
|
||||
for (const key of PRESET_MODEL_KEYS) {
|
||||
const value = presetRecord[key];
|
||||
if (typeof value !== 'string') continue;
|
||||
const canonical = stripCodexEffortSuffix(value);
|
||||
if (canonical !== value) {
|
||||
presetRecord[key] = canonical;
|
||||
migrated = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (migrated) {
|
||||
try {
|
||||
fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + '\n', { mode: 0o600 });
|
||||
} catch {
|
||||
// Best-effort migration — don't block startup if write fails
|
||||
}
|
||||
}
|
||||
|
||||
return migrated;
|
||||
}
|
||||
|
||||
/**
|
||||
* Migrate legacy iFlow model IDs to current upstream model IDs.
|
||||
* Example: iflow-default -> qwen3-coder-plus, kimi-k2.5 -> kimi-k2
|
||||
@@ -510,8 +453,6 @@ export function getEffectiveEnvVars(
|
||||
if (settings.env && typeof settings.env === 'object') {
|
||||
// Migrate deprecated gemini-claude-* model names if present
|
||||
migrateDeprecatedModelNames(expandedPath, provider, settings);
|
||||
// Migrate codex effort suffixes to canonical IDs if present
|
||||
migrateCodexEffortSuffixes(expandedPath, provider, settings);
|
||||
// Migrate legacy iFlow placeholders to supported model IDs
|
||||
migrateIFlowPlaceholderModel(expandedPath, provider, settings);
|
||||
// Custom variant settings found - merge with global env
|
||||
@@ -545,8 +486,6 @@ export function getEffectiveEnvVars(
|
||||
if (settings.env && typeof settings.env === 'object') {
|
||||
// Migrate deprecated gemini-claude-* model names if present
|
||||
migrateDeprecatedModelNames(settingsPath, provider, settings);
|
||||
// Migrate codex effort suffixes to canonical IDs if present
|
||||
migrateCodexEffortSuffixes(settingsPath, provider, settings);
|
||||
// Migrate legacy iFlow placeholders to supported model IDs
|
||||
migrateIFlowPlaceholderModel(settingsPath, provider, settings);
|
||||
// User override found - merge with global env
|
||||
@@ -719,7 +658,6 @@ export function getRemoteEnvVars(
|
||||
const settings: ProviderSettings = JSON.parse(content);
|
||||
if (settings.env && typeof settings.env === 'object') {
|
||||
migrateDeprecatedModelNames(expandedPath, provider, settings);
|
||||
migrateCodexEffortSuffixes(expandedPath, provider, settings);
|
||||
migrateIFlowPlaceholderModel(expandedPath, provider, settings);
|
||||
userEnvVars = settings.env as Record<string, string>;
|
||||
}
|
||||
@@ -739,7 +677,6 @@ export function getRemoteEnvVars(
|
||||
const settings: ProviderSettings = JSON.parse(content);
|
||||
if (settings.env && typeof settings.env === 'object') {
|
||||
migrateDeprecatedModelNames(settingsPath, provider, settings);
|
||||
migrateCodexEffortSuffixes(settingsPath, provider, settings);
|
||||
migrateIFlowPlaceholderModel(settingsPath, provider, settings);
|
||||
userEnvVars = settings.env as Record<string, string>;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -33,7 +33,7 @@ export interface ThinkingSupport {
|
||||
/** Valid level names (for levels type) */
|
||||
levels?: string[];
|
||||
/** Maximum reasoning effort level (caps effort at this level for levels type) */
|
||||
maxLevel?: 'minimal' | 'low' | 'medium' | 'high' | 'xhigh';
|
||||
maxLevel?: 'minimal' | 'low' | 'medium' | 'high' | 'xhigh' | 'max';
|
||||
/** Whether zero/disabled thinking is allowed */
|
||||
zeroAllowed?: boolean;
|
||||
/** Whether dynamic/auto thinking is allowed */
|
||||
@@ -295,11 +295,14 @@ export const MODEL_CATALOG: Partial<Record<CLIProxyProvider, ProviderCatalog>> =
|
||||
name: 'Claude Opus 4.7',
|
||||
description: 'Latest flagship model',
|
||||
nativeImageInput: true,
|
||||
// Opus 4.7 only supports adaptive thinking on the Anthropic API; manual
|
||||
// thinking.type: "enabled" with budget_tokens is rejected with 400.
|
||||
// Expose effort levels; the proxy translates these into adaptive effort.
|
||||
// `max` is a distinct adaptive effort above `xhigh` exposed by Anthropic.
|
||||
thinking: {
|
||||
type: 'budget',
|
||||
min: 1024,
|
||||
max: 128000,
|
||||
zeroAllowed: false,
|
||||
type: 'levels',
|
||||
levels: ['low', 'medium', 'high', 'xhigh', 'max'],
|
||||
maxLevel: 'max',
|
||||
dynamicAllowed: true,
|
||||
},
|
||||
extendedContext: true,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -286,6 +286,9 @@ export async function findHealthyAccount(
|
||||
return null;
|
||||
}
|
||||
|
||||
const { restoreExpiredQuotaPauses } = await import('./account-safety');
|
||||
restoreExpiredQuotaPauses();
|
||||
|
||||
const config = loadOrCreateUnifiedConfig();
|
||||
const tierPriority = config.quota_management?.auto?.tier_priority ?? ['ultra', 'pro', 'free'];
|
||||
const threshold = config.quota_management?.auto?.exhaustion_threshold ?? 5;
|
||||
@@ -388,6 +391,11 @@ export async function preflightCheck(provider: CLIProxyProvider): Promise<Prefli
|
||||
return { proceed: true, accountId: defaultAccount?.id || '' };
|
||||
}
|
||||
|
||||
const { pauseAccountForQuotaCooldown, restoreExpiredQuotaPauses } = await import(
|
||||
'./account-safety'
|
||||
);
|
||||
restoreExpiredQuotaPauses();
|
||||
|
||||
const config = loadOrCreateUnifiedConfig();
|
||||
const quotaConfig = config.quota_management;
|
||||
|
||||
@@ -442,13 +450,32 @@ export async function preflightCheck(provider: CLIProxyProvider): Promise<Prefli
|
||||
const threshold = quotaConfig.auto?.exhaustion_threshold ?? 5;
|
||||
|
||||
if (avgQuota < threshold) {
|
||||
// Apply cooldown to exhausted account
|
||||
applyCooldown(provider, defaultAccount.id, quotaConfig.auto?.cooldown_minutes ?? 5);
|
||||
return await findAndSwitch(
|
||||
|
||||
const alternative = await findHealthyAccount(provider, [defaultAccount.id]);
|
||||
if (!alternative) {
|
||||
return {
|
||||
proceed: true,
|
||||
accountId: defaultAccount.id,
|
||||
reason: `Quota exhausted (${avgQuota.toFixed(1)}%), no alternatives available`,
|
||||
quotaPercent,
|
||||
};
|
||||
}
|
||||
|
||||
pauseAccountForQuotaCooldown(
|
||||
provider,
|
||||
defaultAccount.id,
|
||||
`Quota exhausted (${avgQuota.toFixed(1)}%)`
|
||||
quotaConfig.auto?.cooldown_minutes ?? 5
|
||||
);
|
||||
setDefaultAccount(provider, alternative.id);
|
||||
touchAccount(provider, alternative.id);
|
||||
return {
|
||||
proceed: true,
|
||||
accountId: alternative.id,
|
||||
switchedFrom: defaultAccount.id,
|
||||
reason: `Quota exhausted (${avgQuota.toFixed(1)}%)`,
|
||||
quotaPercent: alternative.lastQuota,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
@@ -471,6 +498,9 @@ export async function getQuotaStatus(provider: CLIProxyProvider): Promise<{
|
||||
isDefault: boolean;
|
||||
}>;
|
||||
}> {
|
||||
const { restoreExpiredQuotaPauses } = await import('./account-safety');
|
||||
restoreExpiredQuotaPauses();
|
||||
|
||||
const accounts = getProviderAccounts(provider);
|
||||
const defaultAccount = getDefaultAccount(provider);
|
||||
|
||||
|
||||
@@ -18,6 +18,8 @@ import {
|
||||
savePinnedVersion,
|
||||
clearPinnedVersion,
|
||||
isVersionPinned,
|
||||
resolveLocalBackend,
|
||||
syncPlusFallbackStateIfNeeded,
|
||||
} from '../binary-manager';
|
||||
import { BACKEND_CONFIG, DEFAULT_BACKEND } from '../platform-detector';
|
||||
import { CLIProxyBackend } from '../types';
|
||||
@@ -54,8 +56,10 @@ export interface LatestVersionResult {
|
||||
* Get current binary status for a specific backend
|
||||
*/
|
||||
export function getBinaryStatus(backend?: CLIProxyBackend): BinaryStatusResult {
|
||||
const effectiveBackend =
|
||||
const configuredBackend =
|
||||
backend ?? loadOrCreateUnifiedConfig().cliproxy?.backend ?? DEFAULT_BACKEND;
|
||||
syncPlusFallbackStateIfNeeded(configuredBackend);
|
||||
const effectiveBackend = resolveLocalBackend(configuredBackend, { warnOnFallback: true });
|
||||
const backendConfig = BACKEND_CONFIG[effectiveBackend];
|
||||
return {
|
||||
installed: isCLIProxyInstalled(effectiveBackend),
|
||||
@@ -71,8 +75,10 @@ export function getBinaryStatus(backend?: CLIProxyBackend): BinaryStatusResult {
|
||||
* Check for latest version
|
||||
*/
|
||||
export async function checkLatestVersion(backend?: CLIProxyBackend): Promise<LatestVersionResult> {
|
||||
const effectiveBackend =
|
||||
const configuredBackend =
|
||||
backend ?? loadOrCreateUnifiedConfig().cliproxy?.backend ?? DEFAULT_BACKEND;
|
||||
syncPlusFallbackStateIfNeeded(configuredBackend);
|
||||
const effectiveBackend = resolveLocalBackend(configuredBackend, { warnOnFallback: true });
|
||||
|
||||
try {
|
||||
// Use checkCliproxyUpdate which is backend-aware (uses correct GitHub repo)
|
||||
@@ -119,8 +125,10 @@ export async function installVersion(
|
||||
};
|
||||
}
|
||||
|
||||
const effectiveBackend =
|
||||
const configuredBackend =
|
||||
backend ?? loadOrCreateUnifiedConfig().cliproxy?.backend ?? DEFAULT_BACKEND;
|
||||
syncPlusFallbackStateIfNeeded(configuredBackend);
|
||||
const effectiveBackend = resolveLocalBackend(configuredBackend, { warnOnFallback: true });
|
||||
|
||||
try {
|
||||
await installCliproxyVersion(version, verbose, effectiveBackend);
|
||||
@@ -147,8 +155,10 @@ export async function installLatest(
|
||||
verbose = false,
|
||||
backend?: CLIProxyBackend
|
||||
): Promise<InstallResult> {
|
||||
const effectiveBackend =
|
||||
const configuredBackend =
|
||||
backend ?? loadOrCreateUnifiedConfig().cliproxy?.backend ?? DEFAULT_BACKEND;
|
||||
syncPlusFallbackStateIfNeeded(configuredBackend);
|
||||
const effectiveBackend = resolveLocalBackend(configuredBackend, { warnOnFallback: true });
|
||||
|
||||
try {
|
||||
const latestVersion = await fetchLatestCliproxyVersion(effectiveBackend);
|
||||
@@ -184,8 +194,10 @@ export async function installLatest(
|
||||
* Check if a version is pinned
|
||||
*/
|
||||
export function isPinned(backend?: CLIProxyBackend): boolean {
|
||||
const effectiveBackend =
|
||||
const configuredBackend =
|
||||
backend ?? loadOrCreateUnifiedConfig().cliproxy?.backend ?? DEFAULT_BACKEND;
|
||||
syncPlusFallbackStateIfNeeded(configuredBackend);
|
||||
const effectiveBackend = resolveLocalBackend(configuredBackend, { warnOnFallback: true });
|
||||
return isVersionPinned(effectiveBackend);
|
||||
}
|
||||
|
||||
@@ -193,8 +205,10 @@ export function isPinned(backend?: CLIProxyBackend): boolean {
|
||||
* Get pinned version if any
|
||||
*/
|
||||
export function getPinned(backend?: CLIProxyBackend): string | null {
|
||||
const effectiveBackend =
|
||||
const configuredBackend =
|
||||
backend ?? loadOrCreateUnifiedConfig().cliproxy?.backend ?? DEFAULT_BACKEND;
|
||||
syncPlusFallbackStateIfNeeded(configuredBackend);
|
||||
const effectiveBackend = resolveLocalBackend(configuredBackend, { warnOnFallback: true });
|
||||
return getPinnedVersion(effectiveBackend);
|
||||
}
|
||||
|
||||
@@ -202,7 +216,9 @@ export function getPinned(backend?: CLIProxyBackend): string | null {
|
||||
* Clear version pin
|
||||
*/
|
||||
export function clearPin(backend?: CLIProxyBackend): void {
|
||||
const effectiveBackend =
|
||||
const configuredBackend =
|
||||
backend ?? loadOrCreateUnifiedConfig().cliproxy?.backend ?? DEFAULT_BACKEND;
|
||||
syncPlusFallbackStateIfNeeded(configuredBackend);
|
||||
const effectiveBackend = resolveLocalBackend(configuredBackend, { warnOnFallback: true });
|
||||
clearPinnedVersion(effectiveBackend);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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<string, number> = {
|
||||
minimal: 512,
|
||||
@@ -39,6 +44,7 @@ export const THINKING_LEVEL_BUDGETS: Record<string, number> = {
|
||||
medium: 8192,
|
||||
high: 24576,
|
||||
xhigh: 32768,
|
||||
max: 65536,
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -50,12 +56,21 @@ export const THINKING_LEVEL_RANK: Record<string, number> = {
|
||||
medium: 3,
|
||||
high: 4,
|
||||
xhigh: 5,
|
||||
max: 6,
|
||||
};
|
||||
|
||||
/**
|
||||
* Valid thinking level names
|
||||
*/
|
||||
export const VALID_THINKING_LEVELS = ['minimal', 'low', 'medium', 'high', 'xhigh', 'auto'] as const;
|
||||
export const VALID_THINKING_LEVELS = [
|
||||
'minimal',
|
||||
'low',
|
||||
'medium',
|
||||
'high',
|
||||
'xhigh',
|
||||
'max',
|
||||
'auto',
|
||||
] as const;
|
||||
export type ThinkingLevel = (typeof VALID_THINKING_LEVELS)[number];
|
||||
|
||||
/**
|
||||
@@ -121,7 +136,12 @@ function findClosestLevel(input: string, validLevels: string[]): string | undefi
|
||||
}
|
||||
}
|
||||
|
||||
// Common aliases
|
||||
// Common aliases.
|
||||
//
|
||||
// `max: 'xhigh'` is a graceful fallback for models whose levels list does not
|
||||
// include `max` (e.g. Codex `gpt-5.4`). Exact match above takes priority, so
|
||||
// Opus 4.7 (which has `max` in its validLevels) returns `max` directly while
|
||||
// Codex still maps `max` → `xhigh`.
|
||||
const aliases: Record<string, string> = {
|
||||
min: 'minimal',
|
||||
lo: 'low',
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -84,7 +84,10 @@ export async function showHelp(): Promise<void> {
|
||||
[
|
||||
'Options:',
|
||||
[
|
||||
['--backend <type>', 'Use specific backend: original | plus (default: from config)'],
|
||||
[
|
||||
'--backend <type>',
|
||||
'Use specific backend: original | plus (local default: original; plus currently falls back locally)',
|
||||
],
|
||||
['--target <cli>', 'Default target for created/edited variants: claude | droid'],
|
||||
['--verbose, -v', 'Show detailed diagnostics including routing hints and quota fetches'],
|
||||
],
|
||||
@@ -102,6 +105,11 @@ export async function showHelp(): Promise<void> {
|
||||
|
||||
console.log(dim(' Note: CLIProxy now persists by default. Use "stop" to terminate.'));
|
||||
console.log(dim(' Routing: use gcli/<model> or agy/<model> to keep overlapping models pinned.'));
|
||||
console.log(
|
||||
dim(
|
||||
' Backend: local CLIProxy currently uses original by default; saved plus configs fall back locally.'
|
||||
)
|
||||
);
|
||||
console.log('');
|
||||
console.log(subheader('Notes:'));
|
||||
console.log(` Default fallback version: ${color(getFallbackVersion(), 'info')}`);
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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('');
|
||||
|
||||
|
||||
@@ -431,7 +431,7 @@ function mergeWithDefaults(partial: Partial<UnifiedConfig>): UnifiedConfig {
|
||||
backend:
|
||||
partial.cliproxy?.backend === 'original' || partial.cliproxy?.backend === 'plus'
|
||||
? partial.cliproxy.backend
|
||||
: undefined, // Invalid values become undefined (defaults to 'plus' at runtime)
|
||||
: undefined, // Invalid values become undefined (defaults to 'original' at runtime)
|
||||
// Auto-sync - default to true
|
||||
auto_sync: partial.cliproxy?.auto_sync ?? defaults.cliproxy.auto_sync ?? true,
|
||||
routing: {
|
||||
@@ -925,7 +925,9 @@ function generateYamlWithComments(config: UnifiedConfig): string {
|
||||
lines.push(
|
||||
'# Modes: auto (use tier_defaults), off (disable), manual (--thinking/--effort flags)'
|
||||
);
|
||||
lines.push('# Levels: minimal (512), low (1K), medium (8K), high (24K), xhigh (32K), auto');
|
||||
lines.push(
|
||||
'# Levels: minimal (512), low (1K), medium (8K), high (24K), xhigh (32K), max (adaptive ceiling), auto'
|
||||
);
|
||||
lines.push('# Override: Set global override value (number or level name)');
|
||||
lines.push('# Provider overrides: Per-provider tier defaults');
|
||||
lines.push('# ----------------------------------------------------------------------------');
|
||||
|
||||
@@ -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: {},
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -41,6 +41,9 @@ export interface CursorAnthropicRequest {
|
||||
system?: string | AnthropicTextBlock[];
|
||||
stream?: boolean;
|
||||
tools?: CursorTool[];
|
||||
output_config?: {
|
||||
effort?: string;
|
||||
};
|
||||
thinking?: {
|
||||
type?: string;
|
||||
budget_tokens?: number;
|
||||
|
||||
@@ -16,8 +16,13 @@ import { type ExecutionOptions, type ExecutionResult, type StreamMessage } from
|
||||
import { StreamBuffer, formatToolVerbose } from './executor/stream-parser';
|
||||
import { buildExecutionResult } from './executor/result-aggregator';
|
||||
import { getCcsDir, getModelDisplayName, loadSettings } from '../utils/config-manager';
|
||||
import { getGlobalEnvConfig } from '../config/unified-config-loader';
|
||||
import { getProfileLookupCandidates } from '../utils/profile-compat';
|
||||
import { getClaudeLaunchEnvOverrides, stripClaudeCodeEnv } from '../utils/shell-executor';
|
||||
import {
|
||||
getClaudeLaunchEnvOverrides,
|
||||
stripAnthropicRoutingEnv,
|
||||
stripClaudeCodeEnv,
|
||||
} from '../utils/shell-executor';
|
||||
import { resolveProfileContinuityInheritance } from '../auth/profile-continuity-inheritance';
|
||||
import {
|
||||
appendThirdPartyImageAnalysisToolArgs,
|
||||
@@ -38,6 +43,11 @@ import {
|
||||
import { resolveCliproxyBridgeMetadata } from '../api/services';
|
||||
import { ensureCliproxyService } from '../cliproxy';
|
||||
import { CLIPROXY_DEFAULT_PORT } from '../cliproxy/config/port-manager';
|
||||
import {
|
||||
buildOpenAICompatProxyEnv,
|
||||
resolveOpenAICompatProfileConfig,
|
||||
startOpenAICompatProxy,
|
||||
} from '../proxy';
|
||||
import {
|
||||
appendThirdPartyWebSearchToolArgs,
|
||||
appendWebSearchTrace,
|
||||
@@ -129,6 +139,14 @@ export class HeadlessExecutor {
|
||||
syncImageAnalysisMcpToConfigDir(inheritedClaudeConfigDir);
|
||||
|
||||
const settings = loadSettings(settingsPath);
|
||||
const globalEnvConfig = getGlobalEnvConfig();
|
||||
const globalEnv = globalEnvConfig.enabled ? globalEnvConfig.env : {};
|
||||
const settingsEnv = settings.env || {};
|
||||
const openAICompatProfile = resolveOpenAICompatProfileConfig(
|
||||
profile,
|
||||
settingsPath,
|
||||
settingsEnv
|
||||
);
|
||||
const cliproxyBridge = resolveCliproxyBridgeMetadata(settings);
|
||||
let imageAnalysisFallbackHookReady: boolean | undefined;
|
||||
if (imageAnalysisMcpReady) {
|
||||
@@ -207,6 +225,33 @@ export class HeadlessExecutor {
|
||||
}
|
||||
}
|
||||
|
||||
let runtimeEnvVars: NodeJS.ProcessEnv = {
|
||||
...stripAnthropicRoutingEnv({ ...globalEnv, ...settingsEnv }),
|
||||
...(inheritedClaudeConfigDir ? { CLAUDE_CONFIG_DIR: inheritedClaudeConfigDir } : {}),
|
||||
CCS_PROFILE_TYPE: 'settings',
|
||||
CCS_STRIP_INHERITED_ANTHROPIC_ENV: '1',
|
||||
};
|
||||
|
||||
if (openAICompatProfile) {
|
||||
const proxyStart = await startOpenAICompatProxy(openAICompatProfile, {
|
||||
insecure: openAICompatProfile.insecure,
|
||||
});
|
||||
if (!proxyStart.success) {
|
||||
throw new Error(proxyStart.error || 'Failed to start local OpenAI-compatible proxy');
|
||||
}
|
||||
|
||||
runtimeEnvVars = {
|
||||
...runtimeEnvVars,
|
||||
...buildOpenAICompatProxyEnv(
|
||||
openAICompatProfile,
|
||||
proxyStart.port,
|
||||
proxyStart.authToken || '',
|
||||
inheritedClaudeConfigDir
|
||||
),
|
||||
};
|
||||
delete runtimeEnvVars.ANTHROPIC_API_KEY;
|
||||
}
|
||||
|
||||
// Smart slash command detection and preservation
|
||||
const processedPrompt = this._processSlashCommand(enhancedPrompt);
|
||||
|
||||
@@ -321,6 +366,7 @@ export class HeadlessExecutor {
|
||||
sessionMgr,
|
||||
claudeConfigDir: inheritedClaudeConfigDir,
|
||||
imageAnalysisEnv,
|
||||
runtimeEnvVars,
|
||||
traceEnv,
|
||||
});
|
||||
}
|
||||
@@ -340,6 +386,7 @@ export class HeadlessExecutor {
|
||||
sessionMgr: SessionManager;
|
||||
claudeConfigDir?: string;
|
||||
imageAnalysisEnv?: Record<string, string>;
|
||||
runtimeEnvVars?: NodeJS.ProcessEnv;
|
||||
traceEnv?: Record<string, string>;
|
||||
}
|
||||
): Promise<ExecutionResult> {
|
||||
@@ -352,6 +399,7 @@ export class HeadlessExecutor {
|
||||
sessionMgr,
|
||||
claudeConfigDir,
|
||||
imageAnalysisEnv = {},
|
||||
runtimeEnvVars = {},
|
||||
traceEnv = {},
|
||||
} = ctx;
|
||||
|
||||
@@ -368,9 +416,10 @@ export class HeadlessExecutor {
|
||||
// Strip Claude Code nested session guard env var to allow CCS delegation
|
||||
// (Claude Code v2.1.39+ sets CLAUDECODE to detect nested sessions)
|
||||
const cleanEnv = stripClaudeCodeEnv({
|
||||
...process.env,
|
||||
...stripAnthropicRoutingEnv(process.env),
|
||||
...getClaudeLaunchEnvOverrides(),
|
||||
...getWebSearchHookEnv(),
|
||||
...runtimeEnvVars,
|
||||
...imageAnalysisEnv,
|
||||
...traceEnv,
|
||||
...(claudeConfigDir ? { CLAUDE_CONFIG_DIR: claudeConfigDir } : {}),
|
||||
|
||||
@@ -396,7 +396,7 @@ function mapThinkingToReasoning(
|
||||
};
|
||||
}
|
||||
|
||||
const VALID_EFFORT_LEVELS = new Set(['low', 'medium', 'high', 'max']);
|
||||
const VALID_EFFORT_LEVELS = new Set(['low', 'medium', 'high', 'xhigh', 'max']);
|
||||
|
||||
function resolveOutputConfigEffort(
|
||||
outputConfig: AnthropicOutputConfig | undefined
|
||||
@@ -416,7 +416,7 @@ function resolveOutputConfigEffort(
|
||||
* for Codex; for generic OpenAI-compat providers we clamp to high.
|
||||
*/
|
||||
function toOpenAIEffort(effort: string): string {
|
||||
return effort === 'max' ? 'high' : effort;
|
||||
return effort === 'max' || effort === 'xhigh' ? 'high' : effort;
|
||||
}
|
||||
|
||||
function transformMessages(messagesValue: unknown): OpenAIMessage[] {
|
||||
|
||||
@@ -9,6 +9,9 @@ import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import * as os from 'os';
|
||||
import * as lockfile from 'proper-lockfile';
|
||||
import { getModelThinkingSupport } from '../cliproxy/model-catalog';
|
||||
import { validateThinking } from '../cliproxy/thinking-validator';
|
||||
import { stripModelConfigurationSuffixes } from '../shared/extended-context-utils';
|
||||
|
||||
const CCS_MODEL_PREFIX = 'ccs-';
|
||||
const CCS_DISPLAY_PREFIX = 'CCS ';
|
||||
@@ -144,6 +147,24 @@ function toAnthropicBudget(value: string | number): number {
|
||||
return DROID_ANTHROPIC_BUDGET_BY_EFFORT[normalized] ?? DROID_ANTHROPIC_BUDGET_BY_EFFORT.high;
|
||||
}
|
||||
|
||||
function resolveAnthropicModelId(model: string): string {
|
||||
return stripModelConfigurationSuffixes(model);
|
||||
}
|
||||
|
||||
function usesAnthropicAdaptiveThinking(model: string): boolean {
|
||||
return getModelThinkingSupport('claude', resolveAnthropicModelId(model))?.type === 'levels';
|
||||
}
|
||||
|
||||
function toAnthropicAdaptiveEffort(model: string, value: string | number): string | undefined {
|
||||
const validation = validateThinking('claude', resolveAnthropicModelId(model), value);
|
||||
if (isReasoningOffValue(validation.value)) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const normalized = String(validation.value).trim().toLowerCase();
|
||||
return normalized === 'auto' ? undefined : normalized;
|
||||
}
|
||||
|
||||
function toReasoningEffort(value: string | number): string {
|
||||
if (typeof value === 'number') {
|
||||
if (value <= 4000) return 'low';
|
||||
@@ -181,12 +202,35 @@ function applyReasoningOverride(
|
||||
|
||||
if (isReasoningOffValue(reasoningOverride)) {
|
||||
delete extraArgs.thinking;
|
||||
delete extraArgs.output_config;
|
||||
} else if (usesAnthropicAdaptiveThinking(entry.model)) {
|
||||
const thinking = isObject(extraArgs.thinking) ? { ...extraArgs.thinking } : {};
|
||||
const outputConfig = isObject(extraArgs.output_config) ? { ...extraArgs.output_config } : {};
|
||||
const effort = toAnthropicAdaptiveEffort(entry.model, reasoningOverride);
|
||||
|
||||
thinking.type = 'adaptive';
|
||||
delete thinking.budget_tokens;
|
||||
delete thinking.budgetTokens;
|
||||
|
||||
if (effort) {
|
||||
outputConfig.effort = effort;
|
||||
} else {
|
||||
delete outputConfig.effort;
|
||||
}
|
||||
|
||||
extraArgs.thinking = thinking;
|
||||
if (Object.keys(outputConfig).length > 0) {
|
||||
extraArgs.output_config = outputConfig;
|
||||
} else {
|
||||
delete extraArgs.output_config;
|
||||
}
|
||||
} else {
|
||||
const thinking = isObject(extraArgs.thinking) ? { ...extraArgs.thinking } : {};
|
||||
thinking.type = 'enabled';
|
||||
thinking.budget_tokens = toAnthropicBudget(reasoningOverride);
|
||||
delete thinking.budgetTokens;
|
||||
extraArgs.thinking = thinking;
|
||||
delete extraArgs.output_config;
|
||||
}
|
||||
} else if (provider === 'openai') {
|
||||
delete extraArgs.reasoning_effort;
|
||||
|
||||
+83
-22
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -38,7 +38,11 @@ import {
|
||||
} from '../../cliproxy/config-generator';
|
||||
import { getProxyStatus as getProxyProcessStatus, stopProxy } from '../../cliproxy/session-tracker';
|
||||
import { ensureCliproxyService } from '../../cliproxy/service-manager';
|
||||
import { checkCliproxyUpdate, getInstalledCliproxyVersion } from '../../cliproxy/binary-manager';
|
||||
import {
|
||||
checkCliproxyUpdate,
|
||||
getInstalledCliproxyVersion,
|
||||
getStoredConfiguredBackend,
|
||||
} from '../../cliproxy/binary-manager';
|
||||
import {
|
||||
fetchAllVersions,
|
||||
isNewerVersion,
|
||||
@@ -47,9 +51,7 @@ import {
|
||||
import {
|
||||
CLIPROXY_MAX_STABLE_VERSION,
|
||||
CLIPROXY_FAULTY_RANGE,
|
||||
DEFAULT_BACKEND,
|
||||
} from '../../cliproxy/platform-detector';
|
||||
import { loadOrCreateUnifiedConfig } from '../../config/unified-config-loader';
|
||||
import { CLIPROXY_DEFAULT_PORT } from '../../cliproxy/config/port-manager';
|
||||
import {
|
||||
MODEL_ENV_VAR_KEYS,
|
||||
@@ -143,18 +145,8 @@ export function shouldCacheQuotaResult(result: {
|
||||
return !transientPatterns.some((p) => msg.includes(p));
|
||||
}
|
||||
|
||||
/** Get configured backend from config */
|
||||
function getConfiguredBackend() {
|
||||
try {
|
||||
const config = loadOrCreateUnifiedConfig();
|
||||
return config.cliproxy?.backend || DEFAULT_BACKEND;
|
||||
} catch {
|
||||
return DEFAULT_BACKEND;
|
||||
}
|
||||
}
|
||||
|
||||
function buildUpdateCheckFallback(
|
||||
backend: ReturnType<typeof getConfiguredBackend>,
|
||||
backend: ReturnType<typeof getStoredConfiguredBackend>,
|
||||
getInstalledVersionFn: typeof getInstalledCliproxyVersion = getInstalledCliproxyVersion
|
||||
) {
|
||||
const currentVersion = getInstalledVersionFn(backend);
|
||||
@@ -178,7 +170,7 @@ function buildUpdateCheckFallback(
|
||||
}
|
||||
|
||||
function buildVersionsFallback(
|
||||
backend: ReturnType<typeof getConfiguredBackend>,
|
||||
backend: ReturnType<typeof getStoredConfiguredBackend>,
|
||||
getInstalledVersionFn: typeof getInstalledCliproxyVersion = getInstalledCliproxyVersion
|
||||
) {
|
||||
const currentVersion = getInstalledVersionFn(backend);
|
||||
@@ -206,7 +198,7 @@ interface ResolveVersionsDeps {
|
||||
}
|
||||
|
||||
export async function resolveCliproxyUpdateCheckPayload(
|
||||
backend: ReturnType<typeof getConfiguredBackend>,
|
||||
backend: ReturnType<typeof getStoredConfiguredBackend>,
|
||||
deps: ResolveUpdateCheckDeps = {}
|
||||
) {
|
||||
const checkCliproxyUpdateFn = deps.checkCliproxyUpdateFn ?? checkCliproxyUpdate;
|
||||
@@ -218,7 +210,7 @@ export async function resolveCliproxyUpdateCheckPayload(
|
||||
}
|
||||
|
||||
export async function resolveCliproxyVersionsPayload(
|
||||
backend: ReturnType<typeof getConfiguredBackend>,
|
||||
backend: ReturnType<typeof getStoredConfiguredBackend>,
|
||||
deps: ResolveVersionsDeps = {}
|
||||
) {
|
||||
const fetchAllVersionsFn = deps.fetchAllVersionsFn ?? fetchAllVersions;
|
||||
@@ -409,7 +401,7 @@ router.post('/proxy-stop', async (_req: Request, res: Response): Promise<void> =
|
||||
*/
|
||||
router.get('/update-check', async (_req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const backend = getConfiguredBackend();
|
||||
const backend = getStoredConfiguredBackend();
|
||||
const result = await resolveCliproxyUpdateCheckPayload(backend);
|
||||
|
||||
res.json(result);
|
||||
@@ -1019,7 +1011,7 @@ router.get('/quota/:provider/:accountId', async (req: Request, res: Response): P
|
||||
*/
|
||||
router.get('/versions', async (_req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const backend = getConfiguredBackend();
|
||||
const backend = getStoredConfiguredBackend();
|
||||
res.json(await resolveCliproxyVersionsPayload(backend));
|
||||
} catch (error) {
|
||||
console.error(`[cliproxy-stats] ${(error as Error).message}`);
|
||||
@@ -1073,7 +1065,7 @@ router.post('/install', async (req: Request, res: Response): Promise<void> => {
|
||||
return;
|
||||
}
|
||||
|
||||
const backend = getConfiguredBackend();
|
||||
const backend = getStoredConfiguredBackend();
|
||||
const installResult = await installDashboardCliproxyVersion(version, backend);
|
||||
|
||||
res.json({
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
import { installCliproxyVersion } from '../../cliproxy/binary-manager';
|
||||
import {
|
||||
installCliproxyVersion,
|
||||
resolveLocalBackend,
|
||||
syncPlusFallbackStateIfNeeded,
|
||||
} from '../../cliproxy/binary-manager';
|
||||
import { ensureCliproxyService, type ServiceStartResult } from '../../cliproxy/service-manager';
|
||||
import { getProxyStatus as getProxyProcessStatus } from '../../cliproxy/session-tracker';
|
||||
import { isCliproxyRunning } from '../../cliproxy/stats-fetcher';
|
||||
@@ -52,12 +56,14 @@ export async function installDashboardCliproxyVersion(
|
||||
backend: CLIProxyBackend,
|
||||
deps: InstallDashboardCliproxyVersionDeps = defaultDeps
|
||||
): Promise<DashboardCliproxyInstallResult> {
|
||||
const backendLabel = backend === 'plus' ? 'CLIProxy Plus' : 'CLIProxy';
|
||||
syncPlusFallbackStateIfNeeded(backend);
|
||||
const effectiveBackend = resolveLocalBackend(backend, { warnOnFallback: true });
|
||||
const backendLabel = effectiveBackend === 'plus' ? 'CLIProxy Plus' : 'CLIProxy';
|
||||
const shouldRestoreService = await wasProxyRunning(deps);
|
||||
|
||||
// The installer owns the stop-and-replace lifecycle, including best-effort
|
||||
// shutdown for tracked and untracked proxies before swapping the binary.
|
||||
await deps.installCliproxyVersion(version, true, backend);
|
||||
await deps.installCliproxyVersion(version, true, effectiveBackend);
|
||||
|
||||
if (!shouldRestoreService) {
|
||||
return {
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'bun:test';
|
||||
import getPort from 'get-port';
|
||||
import * as fs from 'fs';
|
||||
import * as http from 'http';
|
||||
import * as os from 'os';
|
||||
@@ -16,6 +15,29 @@ let tempDir: string;
|
||||
let originalTimeoutEnv: string | undefined;
|
||||
let originalCcsHome: string | undefined;
|
||||
|
||||
function resolveListeningPort(server: http.Server): number {
|
||||
const address = server.address();
|
||||
if (!address || typeof address === 'string') {
|
||||
throw new Error('Failed to resolve server port');
|
||||
}
|
||||
return address.port;
|
||||
}
|
||||
|
||||
async function waitForServerListening(server: http.Server): Promise<number> {
|
||||
if (server.listening) {
|
||||
return resolveListeningPort(server);
|
||||
}
|
||||
|
||||
return new Promise<number>((resolve, reject) => {
|
||||
const onError = (error: Error) => reject(error);
|
||||
server.once('error', onError);
|
||||
server.once('listening', () => {
|
||||
server.off('error', onError);
|
||||
resolve(resolveListeningPort(server));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function startUpstream(
|
||||
handler: (req: http.IncomingMessage, res: http.ServerResponse) => Promise<void> | void
|
||||
): Promise<void> {
|
||||
@@ -28,9 +50,8 @@ async function startUpstream(
|
||||
upstreamSockets.delete(socket);
|
||||
});
|
||||
});
|
||||
await new Promise<void>((resolve) =>
|
||||
upstreamServer.listen(upstreamPort, '127.0.0.1', () => resolve())
|
||||
);
|
||||
upstreamServer.listen(0, '127.0.0.1');
|
||||
upstreamPort = await waitForServerListening(upstreamServer);
|
||||
}
|
||||
|
||||
async function requestProxy(payload: unknown, signal?: AbortSignal): Promise<Response> {
|
||||
@@ -46,8 +67,6 @@ async function requestProxy(payload: unknown, signal?: AbortSignal): Promise<Res
|
||||
}
|
||||
|
||||
beforeEach(async () => {
|
||||
upstreamPort = await getPort();
|
||||
proxyPort = await getPort();
|
||||
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-proxy-edge-'));
|
||||
originalTimeoutEnv = process.env.CCS_OPENAI_PROXY_REQUEST_TIMEOUT_MS;
|
||||
originalCcsHome = process.env.CCS_HOME;
|
||||
@@ -94,9 +113,10 @@ describe('openai proxy message edge cases', () => {
|
||||
};
|
||||
proxyServer = startOpenAICompatProxyServer({
|
||||
profile,
|
||||
port: proxyPort,
|
||||
port: 0,
|
||||
authToken: 'test-proxy-token',
|
||||
});
|
||||
proxyPort = await waitForServerListening(proxyServer);
|
||||
}
|
||||
|
||||
it('preserves rate-limit errors from the upstream provider', async () => {
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'bun:test';
|
||||
import getPort from 'get-port';
|
||||
import * as http from 'http';
|
||||
import { startOpenAICompatProxyServer } from '../../../src/proxy/server/proxy-server';
|
||||
import type { OpenAICompatProfileConfig } from '../../../src/proxy/profile-router';
|
||||
@@ -10,52 +9,64 @@ let upstreamBody: unknown;
|
||||
let upstreamPort: number;
|
||||
let proxyPort: number;
|
||||
|
||||
function startMockUpstream(): Promise<void> {
|
||||
return new Promise((resolve) => {
|
||||
upstreamServer = http.createServer(async (req, res) => {
|
||||
if (req.method !== 'POST' || req.url !== '/v1/chat/completions') {
|
||||
res.writeHead(404).end();
|
||||
return;
|
||||
}
|
||||
function resolveListeningPort(server: http.Server): number {
|
||||
const address = server.address();
|
||||
if (!address || typeof address === 'string') {
|
||||
throw new Error('Failed to resolve server port');
|
||||
}
|
||||
return address.port;
|
||||
}
|
||||
|
||||
let body = '';
|
||||
for await (const chunk of req) {
|
||||
body += chunk.toString();
|
||||
}
|
||||
upstreamBody = JSON.parse(body);
|
||||
const parsed = upstreamBody as {
|
||||
stream?: boolean;
|
||||
messages?: Array<{ role?: string; content?: string | Array<{ type?: string; text?: string }> }>;
|
||||
};
|
||||
async function waitForServerListening(server: http.Server): Promise<number> {
|
||||
if (server.listening) {
|
||||
return resolveListeningPort(server);
|
||||
}
|
||||
|
||||
if (parsed.stream) {
|
||||
res.writeHead(200, { 'Content-Type': 'text/event-stream' });
|
||||
return new Promise<number>((resolve, reject) => {
|
||||
const onError = (error: Error) => reject(error);
|
||||
server.once('error', onError);
|
||||
server.once('listening', () => {
|
||||
server.off('error', onError);
|
||||
resolve(resolveListeningPort(server));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
if (parsed.messages?.[0]?.content === 'interleaved tool fragments') {
|
||||
res.write(
|
||||
'data: {"id":"chatcmpl_1","model":"hf-model","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"id":"call_1","type":"function","function":{"name":"search"}}]}}]}\n\n'
|
||||
);
|
||||
res.write(
|
||||
'data: {"id":"chatcmpl_1","model":"hf-model","choices":[{"index":0,"delta":{"tool_calls":[{"index":1,"id":"call_2","type":"function","function":{"name":"open"}}]}}]}\n\n'
|
||||
);
|
||||
res.write(
|
||||
'data: {"id":"chatcmpl_1","model":"hf-model","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"function":{"arguments":"{\\"path\\":\\"a.ts\\"}"}}]}}]}\n\n'
|
||||
);
|
||||
res.write(
|
||||
'data: {"id":"chatcmpl_1","model":"hf-model","choices":[{"index":0,"delta":{"tool_calls":[{"index":1,"function":{"arguments":"{\\"path\\":\\"b.ts\\"}"}}]}}]}\n\n'
|
||||
);
|
||||
res.write(
|
||||
'data: {"id":"chatcmpl_1","model":"hf-model","choices":[{"index":0,"delta":{},"finish_reason":"tool_calls"}],"usage":{"prompt_tokens":9,"completion_tokens":4}}\n\n'
|
||||
);
|
||||
res.end('data: [DONE]\n\n');
|
||||
return;
|
||||
}
|
||||
async function startMockUpstream(): Promise<void> {
|
||||
upstreamServer = http.createServer(async (req, res) => {
|
||||
if (req.method !== 'POST' || req.url !== '/v1/chat/completions') {
|
||||
res.writeHead(404).end();
|
||||
return;
|
||||
}
|
||||
|
||||
let body = '';
|
||||
for await (const chunk of req) {
|
||||
body += chunk.toString();
|
||||
}
|
||||
upstreamBody = JSON.parse(body);
|
||||
const parsed = upstreamBody as {
|
||||
stream?: boolean;
|
||||
messages?: Array<{
|
||||
role?: string;
|
||||
content?: string | Array<{ type?: string; text?: string }>;
|
||||
}>;
|
||||
};
|
||||
|
||||
if (parsed.stream) {
|
||||
res.writeHead(200, { 'Content-Type': 'text/event-stream' });
|
||||
|
||||
if (parsed.messages?.[0]?.content === 'interleaved tool fragments') {
|
||||
res.write(
|
||||
'data: {"id":"chatcmpl_1","model":"hf-model","choices":[{"index":0,"delta":{"role":"assistant","content":"Hello"}}]}\n\n'
|
||||
'data: {"id":"chatcmpl_1","model":"hf-model","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"id":"call_1","type":"function","function":{"name":"search"}}]}}]}\n\n'
|
||||
);
|
||||
res.write(
|
||||
'data: {"id":"chatcmpl_1","model":"hf-model","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"id":"call_1","type":"function","function":{"name":"search","arguments":"{\\"q\\":\\"docs\\"}"}}]}}]}\n\n'
|
||||
'data: {"id":"chatcmpl_1","model":"hf-model","choices":[{"index":0,"delta":{"tool_calls":[{"index":1,"id":"call_2","type":"function","function":{"name":"open"}}]}}]}\n\n'
|
||||
);
|
||||
res.write(
|
||||
'data: {"id":"chatcmpl_1","model":"hf-model","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"function":{"arguments":"{\\"path\\":\\"a.ts\\"}"}}]}}]}\n\n'
|
||||
);
|
||||
res.write(
|
||||
'data: {"id":"chatcmpl_1","model":"hf-model","choices":[{"index":0,"delta":{"tool_calls":[{"index":1,"function":{"arguments":"{\\"path\\":\\"b.ts\\"}"}}]}}]}\n\n'
|
||||
);
|
||||
res.write(
|
||||
'data: {"id":"chatcmpl_1","model":"hf-model","choices":[{"index":0,"delta":{},"finish_reason":"tool_calls"}],"usage":{"prompt_tokens":9,"completion_tokens":4}}\n\n'
|
||||
@@ -64,25 +75,38 @@ function startMockUpstream(): Promise<void> {
|
||||
return;
|
||||
}
|
||||
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||
res.end(
|
||||
JSON.stringify({
|
||||
id: 'chatcmpl_1',
|
||||
model: 'hf-model',
|
||||
choices: [
|
||||
{
|
||||
index: 0,
|
||||
message: { role: 'assistant', content: 'Plain answer' },
|
||||
finish_reason: 'stop',
|
||||
},
|
||||
],
|
||||
usage: { prompt_tokens: 2, completion_tokens: 3 },
|
||||
})
|
||||
res.write(
|
||||
'data: {"id":"chatcmpl_1","model":"hf-model","choices":[{"index":0,"delta":{"role":"assistant","content":"Hello"}}]}\n\n'
|
||||
);
|
||||
});
|
||||
res.write(
|
||||
'data: {"id":"chatcmpl_1","model":"hf-model","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"id":"call_1","type":"function","function":{"name":"search","arguments":"{\\"q\\":\\"docs\\"}"}}]}}]}\n\n'
|
||||
);
|
||||
res.write(
|
||||
'data: {"id":"chatcmpl_1","model":"hf-model","choices":[{"index":0,"delta":{},"finish_reason":"tool_calls"}],"usage":{"prompt_tokens":9,"completion_tokens":4}}\n\n'
|
||||
);
|
||||
res.end('data: [DONE]\n\n');
|
||||
return;
|
||||
}
|
||||
|
||||
upstreamServer.listen(upstreamPort, '127.0.0.1', () => resolve());
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||
res.end(
|
||||
JSON.stringify({
|
||||
id: 'chatcmpl_1',
|
||||
model: 'hf-model',
|
||||
choices: [
|
||||
{
|
||||
index: 0,
|
||||
message: { role: 'assistant', content: 'Plain answer' },
|
||||
finish_reason: 'stop',
|
||||
},
|
||||
],
|
||||
usage: { prompt_tokens: 2, completion_tokens: 3 },
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
upstreamServer.listen(0, '127.0.0.1');
|
||||
upstreamPort = await waitForServerListening(upstreamServer);
|
||||
}
|
||||
|
||||
async function requestProxy(payload: unknown): Promise<Response> {
|
||||
@@ -98,8 +122,6 @@ async function requestProxy(payload: unknown): Promise<Response> {
|
||||
}
|
||||
|
||||
beforeEach(async () => {
|
||||
upstreamPort = await getPort();
|
||||
proxyPort = await getPort();
|
||||
upstreamBody = undefined;
|
||||
await startMockUpstream();
|
||||
const profile: OpenAICompatProfileConfig = {
|
||||
@@ -112,9 +134,10 @@ beforeEach(async () => {
|
||||
};
|
||||
proxyServer = startOpenAICompatProxyServer({
|
||||
profile,
|
||||
port: proxyPort,
|
||||
port: 0,
|
||||
authToken: 'test-proxy-token',
|
||||
});
|
||||
proxyPort = await waitForServerListening(proxyServer);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'bun:test';
|
||||
import getPort from 'get-port';
|
||||
import * as fs from 'fs';
|
||||
import * as http from 'http';
|
||||
import * as os from 'os';
|
||||
@@ -13,40 +12,61 @@ let proxyServer: http.Server;
|
||||
let upstreamServers: http.Server[] = [];
|
||||
let proxyPort: number;
|
||||
|
||||
function startMockUpstream(
|
||||
port: number,
|
||||
function resolveListeningPort(server: http.Server): number {
|
||||
const address = server.address();
|
||||
if (!address || typeof address === 'string') {
|
||||
throw new Error('Failed to resolve server port');
|
||||
}
|
||||
return address.port;
|
||||
}
|
||||
|
||||
async function waitForServerListening(server: http.Server): Promise<number> {
|
||||
if (server.listening) {
|
||||
return resolveListeningPort(server);
|
||||
}
|
||||
|
||||
return new Promise<number>((resolve, reject) => {
|
||||
const onError = (error: Error) => reject(error);
|
||||
server.once('error', onError);
|
||||
server.once('listening', () => {
|
||||
server.off('error', onError);
|
||||
resolve(resolveListeningPort(server));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function startMockUpstream(
|
||||
hitLabel: string,
|
||||
hits: string[],
|
||||
bodies: Array<{ label: string; body: unknown }>
|
||||
): Promise<void> {
|
||||
return new Promise((resolve) => {
|
||||
const server = http.createServer(async (req, res) => {
|
||||
let body = '';
|
||||
for await (const chunk of req) {
|
||||
body += chunk.toString();
|
||||
}
|
||||
hits.push(hitLabel);
|
||||
bodies.push({ label: hitLabel, body: JSON.parse(body) });
|
||||
): Promise<number> {
|
||||
const server = http.createServer(async (req, res) => {
|
||||
let body = '';
|
||||
for await (const chunk of req) {
|
||||
body += chunk.toString();
|
||||
}
|
||||
hits.push(hitLabel);
|
||||
bodies.push({ label: hitLabel, body: JSON.parse(body) });
|
||||
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||
res.end(
|
||||
JSON.stringify({
|
||||
id: `chatcmpl_${hitLabel}`,
|
||||
model: hitLabel,
|
||||
choices: [
|
||||
{
|
||||
index: 0,
|
||||
message: { role: 'assistant', content: `Reply from ${hitLabel}` },
|
||||
finish_reason: 'stop',
|
||||
},
|
||||
],
|
||||
usage: { prompt_tokens: 2, completion_tokens: 3 },
|
||||
})
|
||||
);
|
||||
});
|
||||
upstreamServers.push(server);
|
||||
server.listen(port, '127.0.0.1', () => resolve());
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||
res.end(
|
||||
JSON.stringify({
|
||||
id: `chatcmpl_${hitLabel}`,
|
||||
model: hitLabel,
|
||||
choices: [
|
||||
{
|
||||
index: 0,
|
||||
message: { role: 'assistant', content: `Reply from ${hitLabel}` },
|
||||
finish_reason: 'stop',
|
||||
},
|
||||
],
|
||||
usage: { prompt_tokens: 2, completion_tokens: 3 },
|
||||
})
|
||||
);
|
||||
});
|
||||
upstreamServers.push(server);
|
||||
server.listen(0, '127.0.0.1');
|
||||
return waitForServerListening(server);
|
||||
}
|
||||
|
||||
function writeSettings(profileName: string, env: Record<string, string>): string {
|
||||
@@ -71,7 +91,7 @@ beforeEach(async () => {
|
||||
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-proxy-routing-'));
|
||||
fs.mkdirSync(path.join(tempDir, '.ccs'), { recursive: true });
|
||||
process.env.CCS_HOME = tempDir;
|
||||
proxyPort = await getPort();
|
||||
proxyPort = 0;
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
@@ -97,12 +117,10 @@ afterEach(async () => {
|
||||
|
||||
describe('openai proxy request routing', () => {
|
||||
it('routes explicit profile:model selectors to the matching upstream profile', async () => {
|
||||
const primaryPort = await getPort();
|
||||
const secondaryPort = await getPort();
|
||||
const hits: string[] = [];
|
||||
const bodies: Array<{ label: string; body: unknown }> = [];
|
||||
await startMockUpstream(primaryPort, 'primary', hits, bodies);
|
||||
await startMockUpstream(secondaryPort, 'secondary', hits, bodies);
|
||||
const primaryPort = await startMockUpstream('primary', hits, bodies);
|
||||
const secondaryPort = await startMockUpstream('secondary', hits, bodies);
|
||||
|
||||
const primarySettings = writeSettings('hf', {
|
||||
ANTHROPIC_BASE_URL: `http://127.0.0.1:${primaryPort}`,
|
||||
@@ -133,9 +151,10 @@ describe('openai proxy request routing', () => {
|
||||
};
|
||||
proxyServer = startOpenAICompatProxyServer({
|
||||
profile,
|
||||
port: proxyPort,
|
||||
port: 0,
|
||||
authToken: 'test-proxy-token',
|
||||
});
|
||||
proxyPort = await waitForServerListening(proxyServer);
|
||||
|
||||
const response = await requestProxy({
|
||||
model: 'deepseek:deepseek-reasoner',
|
||||
@@ -150,12 +169,10 @@ describe('openai proxy request routing', () => {
|
||||
});
|
||||
|
||||
it('routes thinking requests through the configured think scenario', async () => {
|
||||
const primaryPort = await getPort();
|
||||
const thinkPort = await getPort();
|
||||
const hits: string[] = [];
|
||||
const bodies: Array<{ label: string; body: unknown }> = [];
|
||||
await startMockUpstream(primaryPort, 'primary', hits, bodies);
|
||||
await startMockUpstream(thinkPort, 'thinker', hits, bodies);
|
||||
const primaryPort = await startMockUpstream('primary', hits, bodies);
|
||||
const thinkPort = await startMockUpstream('thinker', hits, bodies);
|
||||
|
||||
const primarySettings = writeSettings('hf', {
|
||||
ANTHROPIC_BASE_URL: `http://127.0.0.1:${primaryPort}`,
|
||||
@@ -197,9 +214,10 @@ describe('openai proxy request routing', () => {
|
||||
};
|
||||
proxyServer = startOpenAICompatProxyServer({
|
||||
profile,
|
||||
port: proxyPort,
|
||||
port: 0,
|
||||
authToken: 'test-proxy-token',
|
||||
});
|
||||
proxyPort = await waitForServerListening(proxyServer);
|
||||
|
||||
const response = await requestProxy({
|
||||
model: 'hf-default',
|
||||
@@ -216,12 +234,10 @@ describe('openai proxy request routing', () => {
|
||||
});
|
||||
|
||||
it('routes adaptive thinking requests through the configured think scenario', async () => {
|
||||
const primaryPort = await getPort();
|
||||
const thinkPort = await getPort();
|
||||
const hits: string[] = [];
|
||||
const bodies: Array<{ label: string; body: unknown }> = [];
|
||||
await startMockUpstream(primaryPort, 'primary', hits, bodies);
|
||||
await startMockUpstream(thinkPort, 'thinker', hits, bodies);
|
||||
const primaryPort = await startMockUpstream('primary', hits, bodies);
|
||||
const thinkPort = await startMockUpstream('thinker', hits, bodies);
|
||||
|
||||
const primarySettings = writeSettings('hf', {
|
||||
ANTHROPIC_BASE_URL: `http://127.0.0.1:${primaryPort}`,
|
||||
@@ -263,9 +279,10 @@ describe('openai proxy request routing', () => {
|
||||
};
|
||||
proxyServer = startOpenAICompatProxyServer({
|
||||
profile,
|
||||
port: proxyPort,
|
||||
port: 0,
|
||||
authToken: 'test-proxy-token',
|
||||
});
|
||||
proxyPort = await waitForServerListening(proxyServer);
|
||||
|
||||
const response = await requestProxy({
|
||||
model: 'hf-default',
|
||||
|
||||
@@ -17,6 +17,8 @@ import {
|
||||
handleQuotaExhaustion,
|
||||
writeQuotaWarning,
|
||||
maskEmail,
|
||||
pauseAccountForQuotaCooldown,
|
||||
restoreExpiredQuotaPauses,
|
||||
} from '../../../src/cliproxy/account-safety';
|
||||
import { sanitizeEmail } from '../../../src/cliproxy/auth-utils';
|
||||
|
||||
@@ -84,6 +86,12 @@ function writeClaudeAuth(accountId: string, accessToken: string): void {
|
||||
);
|
||||
}
|
||||
|
||||
function writeAuthToken(tokenFile: string, payload: Record<string, unknown>): void {
|
||||
const authDir = path.join(tmpDir, '.ccs', 'cliproxy', 'auth');
|
||||
fs.mkdirSync(authDir, { recursive: true });
|
||||
fs.writeFileSync(path.join(authDir, tokenFile), JSON.stringify(payload, null, 2));
|
||||
}
|
||||
|
||||
describe('Quota Exhaustion Handlers', () => {
|
||||
describe('writeQuotaWarning', () => {
|
||||
it('should write to stderr with box format', async () => {
|
||||
@@ -247,10 +255,13 @@ describe('Quota Exhaustion Handlers', () => {
|
||||
});
|
||||
|
||||
const result = await handleQuotaExhaustion('agy', 'only@gmail.com', 10);
|
||||
const { getAccount } = await import('../../../src/cliproxy/account-manager');
|
||||
|
||||
// Should return gracefully with null switched
|
||||
expect(result.switchedTo).toBeNull();
|
||||
expect(result.reason).toContain('no alternatives');
|
||||
expect(getAccount('agy', 'only@gmail.com')?.paused).not.toBe(true);
|
||||
expect(fs.existsSync(path.join(tmpDir, '.ccs', 'cliproxy', 'quota-paused.json'))).toBe(false);
|
||||
});
|
||||
|
||||
it('should switch Claude accounts when fallback quota endpoint returns 404', async () => {
|
||||
@@ -305,6 +316,18 @@ describe('Quota Exhaustion Handlers', () => {
|
||||
|
||||
expect(result.switchedTo).toBe('fallback@example.com');
|
||||
expect(getDefaultAccount('claude')?.id).toBe('fallback@example.com');
|
||||
expect(fs.existsSync(path.join(tmpDir, '.ccs', 'cliproxy', 'quota-paused.json'))).toBe(true);
|
||||
expect(
|
||||
fs.existsSync(
|
||||
path.join(
|
||||
tmpDir,
|
||||
'.ccs',
|
||||
'cliproxy',
|
||||
'auth-paused',
|
||||
`claude-${sanitizeEmail('exhausted@example.com')}.json`
|
||||
)
|
||||
)
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('should write warning to stderr', async () => {
|
||||
@@ -389,5 +412,121 @@ describe('Quota Exhaustion Handlers', () => {
|
||||
expect(result).toBeDefined();
|
||||
expect(result.switchedTo).toBeNull();
|
||||
});
|
||||
|
||||
it('auto-resumes quota-paused accounts after cooldown expiry', async () => {
|
||||
writeRegistry({
|
||||
agy: {
|
||||
default: 'cooldown@gmail.com',
|
||||
accounts: {
|
||||
'cooldown@gmail.com': {
|
||||
email: 'cooldown@gmail.com',
|
||||
tokenFile: 'agy-cooldown.json',
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
writeAuthToken('agy-cooldown.json', {
|
||||
type: 'agy',
|
||||
email: 'cooldown@gmail.com',
|
||||
access_token: 'token',
|
||||
});
|
||||
|
||||
const now = Date.now();
|
||||
expect(pauseAccountForQuotaCooldown('agy', 'cooldown@gmail.com', 5, now)).toBe(true);
|
||||
|
||||
const { getAccount } = await import('../../../src/cliproxy/account-manager');
|
||||
expect(getAccount('agy', 'cooldown@gmail.com')?.paused).toBe(true);
|
||||
|
||||
const resumed = restoreExpiredQuotaPauses(now + 6 * 60 * 1000);
|
||||
|
||||
expect(resumed).toBe(1);
|
||||
expect(getAccount('agy', 'cooldown@gmail.com')?.paused).not.toBe(true);
|
||||
expect(fs.existsSync(path.join(tmpDir, '.ccs', 'cliproxy', 'quota-paused.json'))).toBe(false);
|
||||
});
|
||||
|
||||
it('does not auto-resume quota-paused accounts when pausedAt metadata is missing', async () => {
|
||||
writeRegistry({
|
||||
agy: {
|
||||
default: 'cooldown@gmail.com',
|
||||
accounts: {
|
||||
'cooldown@gmail.com': {
|
||||
email: 'cooldown@gmail.com',
|
||||
tokenFile: 'agy-cooldown.json',
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
writeAuthToken('agy-cooldown.json', {
|
||||
type: 'agy',
|
||||
email: 'cooldown@gmail.com',
|
||||
access_token: 'token',
|
||||
});
|
||||
|
||||
const now = Date.now();
|
||||
expect(pauseAccountForQuotaCooldown('agy', 'cooldown@gmail.com', 5, now)).toBe(true);
|
||||
|
||||
const registryPath = path.join(tmpDir, '.ccs', 'cliproxy', 'accounts.json');
|
||||
const registry = JSON.parse(fs.readFileSync(registryPath, 'utf8')) as {
|
||||
providers: {
|
||||
agy: {
|
||||
default: string;
|
||||
accounts: Record<string, { paused?: boolean; pausedAt?: string }>;
|
||||
};
|
||||
};
|
||||
};
|
||||
delete registry.providers.agy.accounts['cooldown@gmail.com']?.pausedAt;
|
||||
fs.writeFileSync(registryPath, JSON.stringify(registry, null, 2));
|
||||
|
||||
const resumed = restoreExpiredQuotaPauses(now + 6 * 60 * 1000);
|
||||
const { getAccount } = await import('../../../src/cliproxy/account-manager');
|
||||
|
||||
expect(resumed).toBe(0);
|
||||
expect(getAccount('agy', 'cooldown@gmail.com')?.paused).toBe(true);
|
||||
expect(
|
||||
fs.existsSync(path.join(tmpDir, '.ccs', 'cliproxy', 'auth-paused', 'agy-cooldown.json'))
|
||||
).toBe(true);
|
||||
expect(fs.existsSync(path.join(tmpDir, '.ccs', 'cliproxy', 'quota-paused.json'))).toBe(false);
|
||||
});
|
||||
|
||||
it('keeps quota-paused entries when auto-resume fails and retries later', async () => {
|
||||
writeRegistry({
|
||||
agy: {
|
||||
default: 'cooldown@gmail.com',
|
||||
accounts: {
|
||||
'cooldown@gmail.com': {
|
||||
email: 'cooldown@gmail.com',
|
||||
tokenFile: 'agy-cooldown.json',
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
writeAuthToken('agy-cooldown.json', {
|
||||
type: 'agy',
|
||||
email: 'cooldown@gmail.com',
|
||||
access_token: 'token',
|
||||
});
|
||||
|
||||
const now = Date.now();
|
||||
expect(pauseAccountForQuotaCooldown('agy', 'cooldown@gmail.com', 5, now)).toBe(true);
|
||||
|
||||
fs.rmSync(path.join(tmpDir, '.ccs', 'cliproxy', 'auth'), {
|
||||
recursive: true,
|
||||
force: true,
|
||||
});
|
||||
|
||||
const resumed = restoreExpiredQuotaPauses(now + 6 * 60 * 1000);
|
||||
const { getAccount } = await import('../../../src/cliproxy/account-manager');
|
||||
const quotaPausedPath = path.join(tmpDir, '.ccs', 'cliproxy', 'quota-paused.json');
|
||||
const quotaPaused = JSON.parse(fs.readFileSync(quotaPausedPath, 'utf8')) as {
|
||||
entries?: Array<{ accountId?: string }>;
|
||||
};
|
||||
|
||||
expect(resumed).toBe(0);
|
||||
expect(getAccount('agy', 'cooldown@gmail.com')?.paused).toBe(true);
|
||||
expect(
|
||||
fs.existsSync(path.join(tmpDir, '.ccs', 'cliproxy', 'auth-paused', 'agy-cooldown.json'))
|
||||
).toBe(true);
|
||||
expect(quotaPaused.entries?.map((entry) => entry.accountId)).toContain('cooldown@gmail.com');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -28,8 +28,8 @@ describe('Backend Selection', () => {
|
||||
});
|
||||
|
||||
describe('DEFAULT_BACKEND', () => {
|
||||
it('defaults to plus backend for backward compatibility', () => {
|
||||
assert.strictEqual(platformDetector.DEFAULT_BACKEND, 'plus');
|
||||
it('defaults to original backend (Plus upstream deleted, issue #1062)', () => {
|
||||
assert.strictEqual(platformDetector.DEFAULT_BACKEND, 'original');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -45,9 +45,10 @@ describe('Backend Selection', () => {
|
||||
assert(info.binaryName.startsWith('CLIProxyAPIPlus_6.6.51-0_'));
|
||||
});
|
||||
|
||||
it('uses plus backend by default', () => {
|
||||
it('uses original backend by default (Plus upstream deleted, issue #1062)', () => {
|
||||
const info = platformDetector.detectPlatform();
|
||||
assert(info.binaryName.includes('CLIProxyAPIPlus'));
|
||||
assert(info.binaryName.startsWith('CLIProxyAPI_'));
|
||||
assert(!info.binaryName.includes('CLIProxyAPIPlus'));
|
||||
});
|
||||
|
||||
it('uses fallback version when version not specified', () => {
|
||||
@@ -76,9 +77,10 @@ describe('Backend Selection', () => {
|
||||
assert.strictEqual(name, expected);
|
||||
});
|
||||
|
||||
it('defaults to plus backend', () => {
|
||||
it('defaults to original backend (Plus upstream deleted, issue #1062)', () => {
|
||||
const name = platformDetector.getExecutableName();
|
||||
assert(name.includes('cli-proxy-api-plus'));
|
||||
const expected = isWindows ? 'cli-proxy-api.exe' : 'cli-proxy-api';
|
||||
assert.strictEqual(name, expected);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -94,9 +96,10 @@ describe('Backend Selection', () => {
|
||||
assert(url.includes('router-for-me/CLIProxyAPIPlus/releases'));
|
||||
});
|
||||
|
||||
it('defaults to plus backend', () => {
|
||||
it('defaults to original backend (Plus upstream deleted, issue #1062)', () => {
|
||||
const url = platformDetector.getDownloadUrl();
|
||||
assert(url.includes('CLIProxyAPIPlus'));
|
||||
assert(url.includes('router-for-me/CLIProxyAPI/releases'));
|
||||
assert(!url.includes('CLIProxyAPIPlus'));
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -19,9 +19,12 @@ import {
|
||||
} from '../../../ui/src/lib/default-ports';
|
||||
import {
|
||||
CLIPROXY_PROVIDERS as UI_CLIPROXY_PROVIDERS,
|
||||
CORE_CLIPROXY_PROVIDERS as UI_CORE_CLIPROXY_PROVIDERS,
|
||||
DEVICE_CODE_PROVIDERS as UI_DEVICE_CODE_PROVIDERS,
|
||||
PLUS_EXTRA_CLIPROXY_PROVIDERS as UI_PLUS_EXTRA_CLIPROXY_PROVIDERS,
|
||||
PROVIDER_METADATA as UI_PROVIDER_METADATA,
|
||||
} from '../../../ui/src/lib/provider-config';
|
||||
import { PLUS_ONLY_PROVIDERS as BACKEND_PLUS_ONLY_PROVIDERS } from '../../../src/cliproxy/types';
|
||||
|
||||
function sorted(values: readonly string[]): string[] {
|
||||
return [...values].sort((a, b) => a.localeCompare(b));
|
||||
@@ -44,6 +47,17 @@ describe('Default Port Sync', () => {
|
||||
expect(sorted(UI_DEVICE_CODE_PROVIDERS)).toEqual(sorted(getProvidersByOAuthFlow('device_code')));
|
||||
});
|
||||
|
||||
test('plus-extra providers are synced between backend and UI', () => {
|
||||
expect(sorted(UI_PLUS_EXTRA_CLIPROXY_PROVIDERS)).toEqual(sorted(BACKEND_PLUS_ONLY_PROVIDERS));
|
||||
expect(sorted(UI_CORE_CLIPROXY_PROVIDERS)).toEqual(
|
||||
sorted(
|
||||
BACKEND_CLIPROXY_PROVIDER_IDS.filter(
|
||||
(provider) => !BACKEND_PLUS_ONLY_PROVIDERS.includes(provider)
|
||||
)
|
||||
)
|
||||
);
|
||||
});
|
||||
|
||||
test('Provider display names are synced between backend and UI', () => {
|
||||
for (const provider of BACKEND_CLIPROXY_PROVIDER_IDS) {
|
||||
expect(UI_PROVIDER_METADATA[provider].displayName).toBe(getBackendProviderDisplayName(provider));
|
||||
|
||||
@@ -25,6 +25,80 @@ afterEach(() => {
|
||||
});
|
||||
|
||||
describe('installCliproxyVersion', () => {
|
||||
it('degrades explicit plus backend requests to original before install flows run', async () => {
|
||||
let seenBackend: string | undefined;
|
||||
|
||||
const binaryManager = await import(
|
||||
`../../../src/cliproxy/binary-manager?binary-manager-explicit-plus=${Date.now()}`
|
||||
);
|
||||
|
||||
await binaryManager.installCliproxyVersion('6.7.1', false, 'plus', {
|
||||
createManager: (_config: unknown, backend: string) => {
|
||||
seenBackend = backend;
|
||||
return {
|
||||
isBinaryInstalled: () => false,
|
||||
deleteBinary: () => undefined,
|
||||
ensureBinary: async () => '/tmp/ccs-bin/original/cliproxy',
|
||||
};
|
||||
},
|
||||
stopProxyFn: async () => ({ stopped: false, error: 'No active CLIProxy session found' }),
|
||||
waitForPortFreeFn: async () => true,
|
||||
formatInfo: (message: string) => message,
|
||||
formatWarn: (message: string) => message,
|
||||
getInstalledVersion: () => '6.6.80',
|
||||
});
|
||||
|
||||
expect(seenBackend).toBe('original');
|
||||
});
|
||||
|
||||
it('returns original and emits a real warning when plus backend is resolved locally', async () => {
|
||||
const binaryManager = await import(
|
||||
`../../../src/cliproxy/binary-manager?binary-manager-warning=${Date.now()}`
|
||||
);
|
||||
|
||||
const writes: string[] = [];
|
||||
const originalWrite = process.stderr.write.bind(process.stderr);
|
||||
process.stderr.write = ((chunk: string | Uint8Array) => {
|
||||
writes.push(typeof chunk === 'string' ? chunk : Buffer.from(chunk).toString('utf8'));
|
||||
return true;
|
||||
}) as typeof process.stderr.write;
|
||||
|
||||
try {
|
||||
expect(binaryManager.resolveLocalBackend('plus', { warnOnFallback: true })).toBe('original');
|
||||
} finally {
|
||||
process.stderr.write = originalWrite;
|
||||
}
|
||||
|
||||
expect(writes.join('')).toContain('CLIProxyAPIPlus upstream repo is currently unavailable');
|
||||
expect(writes.join('')).toContain('backend: original');
|
||||
});
|
||||
|
||||
it('reuses plus binary and pin state when local runtime falls back to original', async () => {
|
||||
const { createEmptyUnifiedConfig } = await import('../../../src/config/unified-config-types');
|
||||
const { saveUnifiedConfig } = await import('../../../src/config/unified-config-loader');
|
||||
const { savePinnedVersion } = await import('../../../src/cliproxy/binary/version-cache');
|
||||
const { getExecutableName } = await import('../../../src/cliproxy/platform-detector');
|
||||
const binaryService = await import(
|
||||
`../../../src/cliproxy/services/binary-service?binary-service-plus-migration=${Date.now()}`
|
||||
);
|
||||
|
||||
const config = createEmptyUnifiedConfig();
|
||||
config.cliproxy = { ...config.cliproxy, backend: 'plus' };
|
||||
saveUnifiedConfig(config);
|
||||
|
||||
const plusBinDir = path.join(tempHome, '.ccs', 'cliproxy', 'bin', 'plus');
|
||||
fs.mkdirSync(plusBinDir, { recursive: true });
|
||||
fs.writeFileSync(path.join(plusBinDir, getExecutableName('plus')), 'fake-binary');
|
||||
fs.writeFileSync(path.join(plusBinDir, '.version'), '6.6.80-0');
|
||||
savePinnedVersion('6.6.80-0', 'plus');
|
||||
|
||||
const status = binaryService.getBinaryStatus();
|
||||
|
||||
expect(status.installed).toBe(true);
|
||||
expect(status.pinnedVersion).toBe('6.6.80-0');
|
||||
expect(status.binaryPath).toContain('/original/');
|
||||
});
|
||||
|
||||
it('attempts to stop the proxy even when there is no tracked running session', async () => {
|
||||
const calls = {
|
||||
stopProxy: 0,
|
||||
@@ -78,7 +152,7 @@ describe('installCliproxyVersion', () => {
|
||||
skipAutoUpdate: true,
|
||||
})
|
||||
).rejects.toThrow(
|
||||
'CLIProxy Plus binary is not installed locally. Run "ccs cliproxy install" when you have network access.'
|
||||
'CLIProxy binary is not installed locally. Run "ccs cliproxy install" when you have network access.'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -538,6 +538,20 @@ describe('applyThinkingConfig - composite variant integration', () => {
|
||||
expect(result.ANTHROPIC_DEFAULT_OPUS_MODEL).toBe('claude-opus-4-6-thinking(32768)');
|
||||
});
|
||||
|
||||
it('rewrites legacy budget suffixes for claude level-based models', () => {
|
||||
const envVars: NodeJS.ProcessEnv = {
|
||||
ANTHROPIC_MODEL: 'claude-opus-4-7(32768)',
|
||||
ANTHROPIC_DEFAULT_OPUS_MODEL: 'claude-opus-4-7(32768)',
|
||||
ANTHROPIC_DEFAULT_SONNET_MODEL: 'claude-sonnet-4-6',
|
||||
ANTHROPIC_DEFAULT_HAIKU_MODEL: 'claude-haiku-4-5-20251001',
|
||||
};
|
||||
|
||||
const result = applyThinkingConfig(envVars, 'claude' as CLIProxyProvider, 'max');
|
||||
|
||||
expect(result.ANTHROPIC_MODEL).toBe('claude-opus-4-7(max)');
|
||||
expect(result.ANTHROPIC_DEFAULT_OPUS_MODEL).toBe('claude-opus-4-7(max)');
|
||||
});
|
||||
|
||||
it('should use codex effort suffix style when provider is codex', () => {
|
||||
const envVars: NodeJS.ProcessEnv = {
|
||||
ANTHROPIC_MODEL: 'gpt-5.3-codex',
|
||||
|
||||
@@ -40,7 +40,7 @@ describe('getEffectiveEnvVars local provider URL normalization', () => {
|
||||
fs.rmSync(tempHome, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('rewrites local root URL to provider endpoint', () => {
|
||||
it('rewrites local root URL to provider endpoint without stripping codex effort suffixes', () => {
|
||||
writeSettings(settingsPath, {
|
||||
ANTHROPIC_BASE_URL: 'http://127.0.0.1:8317',
|
||||
ANTHROPIC_AUTH_TOKEN: 'ccs-internal-managed',
|
||||
@@ -52,18 +52,18 @@ describe('getEffectiveEnvVars local provider URL normalization', () => {
|
||||
|
||||
const env = getEffectiveEnvVars('codex', 8317, settingsPath);
|
||||
expect(env.ANTHROPIC_BASE_URL).toBe('http://127.0.0.1:8317/api/provider/codex');
|
||||
expect(env.ANTHROPIC_MODEL).toBe('gpt-5.3-codex');
|
||||
expect(env.ANTHROPIC_DEFAULT_OPUS_MODEL).toBe('gpt-5.3-codex');
|
||||
expect(env.ANTHROPIC_DEFAULT_SONNET_MODEL).toBe('gpt-5.3-codex');
|
||||
expect(env.ANTHROPIC_DEFAULT_HAIKU_MODEL).toBe('gpt-5.4-mini');
|
||||
expect(env.ANTHROPIC_MODEL).toBe('gpt-5.3-codex-xhigh');
|
||||
expect(env.ANTHROPIC_DEFAULT_OPUS_MODEL).toBe('gpt-5.3-codex-xhigh');
|
||||
expect(env.ANTHROPIC_DEFAULT_SONNET_MODEL).toBe('gpt-5.3-codex-high');
|
||||
expect(env.ANTHROPIC_DEFAULT_HAIKU_MODEL).toBe('gpt-5.4-mini-medium');
|
||||
|
||||
const persisted = JSON.parse(fs.readFileSync(settingsPath, 'utf-8')) as {
|
||||
env: Record<string, string>;
|
||||
};
|
||||
expect(persisted.env.ANTHROPIC_MODEL).toBe('gpt-5.3-codex');
|
||||
expect(persisted.env.ANTHROPIC_DEFAULT_OPUS_MODEL).toBe('gpt-5.3-codex');
|
||||
expect(persisted.env.ANTHROPIC_DEFAULT_SONNET_MODEL).toBe('gpt-5.3-codex');
|
||||
expect(persisted.env.ANTHROPIC_DEFAULT_HAIKU_MODEL).toBe('gpt-5.4-mini');
|
||||
expect(persisted.env.ANTHROPIC_MODEL).toBe('gpt-5.3-codex-xhigh');
|
||||
expect(persisted.env.ANTHROPIC_DEFAULT_OPUS_MODEL).toBe('gpt-5.3-codex-xhigh');
|
||||
expect(persisted.env.ANTHROPIC_DEFAULT_SONNET_MODEL).toBe('gpt-5.3-codex-high');
|
||||
expect(persisted.env.ANTHROPIC_DEFAULT_HAIKU_MODEL).toBe('gpt-5.4-mini-medium');
|
||||
});
|
||||
|
||||
it('rewrites wrong local provider path to the requested provider', () => {
|
||||
@@ -162,7 +162,7 @@ describe('getEffectiveEnvVars local provider URL normalization', () => {
|
||||
expect(persisted.presets?.[0]?.haiku).toBe('claude-sonnet-4-6');
|
||||
});
|
||||
|
||||
it('migrates codex preset model mappings to canonical IDs', () => {
|
||||
it('preserves codex preset effort suffixes while loading provider env vars', () => {
|
||||
writeSettings(
|
||||
settingsPath,
|
||||
{
|
||||
@@ -191,10 +191,10 @@ describe('getEffectiveEnvVars local provider URL normalization', () => {
|
||||
const persisted = JSON.parse(fs.readFileSync(settingsPath, 'utf-8')) as {
|
||||
presets: Array<Record<string, string>>;
|
||||
};
|
||||
expect(persisted.presets[0]?.default).toBe('gpt-5.3-codex');
|
||||
expect(persisted.presets[0]?.opus).toBe('gpt-5.3-codex');
|
||||
expect(persisted.presets[0]?.sonnet).toBe('gpt-5.3-codex');
|
||||
expect(persisted.presets[0]?.haiku).toBe('gpt-5-mini');
|
||||
expect(persisted.presets[0]?.default).toBe('gpt-5.3-codex-xhigh');
|
||||
expect(persisted.presets[0]?.opus).toBe('gpt-5.3-codex-xhigh');
|
||||
expect(persisted.presets[0]?.sonnet).toBe('gpt-5.3-codex-high');
|
||||
expect(persisted.presets[0]?.haiku).toBe('gpt-5-mini-medium');
|
||||
});
|
||||
|
||||
it('migrates iflow placeholder model IDs to a supported default', () => {
|
||||
@@ -424,7 +424,7 @@ describe('getEffectiveEnvVars local provider URL normalization', () => {
|
||||
expect(repaired.presets?.[0]?.haiku).toBe('claude-sonnet-4-6');
|
||||
});
|
||||
|
||||
it('migrates codex effort-suffixed preset IDs during ensureProviderSettings', () => {
|
||||
it('preserves codex effort-suffixed IDs during ensureProviderSettings', () => {
|
||||
process.env.CCS_HOME = tempHome;
|
||||
const codexSettingsPath = path.join(tempHome, '.ccs', 'codex.settings.json');
|
||||
fs.mkdirSync(path.dirname(codexSettingsPath), { recursive: true });
|
||||
@@ -461,14 +461,14 @@ describe('getEffectiveEnvVars local provider URL normalization', () => {
|
||||
env?: Record<string, string>;
|
||||
presets?: Array<Record<string, string>>;
|
||||
};
|
||||
expect(repaired.env?.ANTHROPIC_MODEL).toBe('gpt-5.3-codex');
|
||||
expect(repaired.env?.ANTHROPIC_DEFAULT_OPUS_MODEL).toBe('gpt-5.3-codex');
|
||||
expect(repaired.env?.ANTHROPIC_DEFAULT_SONNET_MODEL).toBe('gpt-5.3-codex');
|
||||
expect(repaired.env?.ANTHROPIC_DEFAULT_HAIKU_MODEL).toBe('gpt-5.4-mini');
|
||||
expect(repaired.presets?.[0]?.default).toBe('gpt-5.3-codex');
|
||||
expect(repaired.presets?.[0]?.opus).toBe('gpt-5.3-codex');
|
||||
expect(repaired.presets?.[0]?.sonnet).toBe('gpt-5.3-codex');
|
||||
expect(repaired.presets?.[0]?.haiku).toBe('gpt-5.4-mini');
|
||||
expect(repaired.env?.ANTHROPIC_MODEL).toBe('gpt-5.3-codex-xhigh');
|
||||
expect(repaired.env?.ANTHROPIC_DEFAULT_OPUS_MODEL).toBe('gpt-5.3-codex-xhigh');
|
||||
expect(repaired.env?.ANTHROPIC_DEFAULT_SONNET_MODEL).toBe('gpt-5.3-codex-high');
|
||||
expect(repaired.env?.ANTHROPIC_DEFAULT_HAIKU_MODEL).toBe('gpt-5.4-mini-medium');
|
||||
expect(repaired.presets?.[0]?.default).toBe('gpt-5.3-codex-xhigh');
|
||||
expect(repaired.presets?.[0]?.opus).toBe('gpt-5.3-codex-xhigh');
|
||||
expect(repaired.presets?.[0]?.sonnet).toBe('gpt-5.3-codex-high');
|
||||
expect(repaired.presets?.[0]?.haiku).toBe('gpt-5.4-mini-medium');
|
||||
});
|
||||
|
||||
it('recovers malformed provider settings files by writing defaults and backup copy', () => {
|
||||
|
||||
@@ -58,4 +58,24 @@ describe('model-catalog compatibility lookups', () => {
|
||||
|
||||
expect(catalog?.models.map((model) => model.id)).toEqual(['gemini-2.5-pro']);
|
||||
});
|
||||
|
||||
it('preserves static maxLevel when live thinking metadata omits it', () => {
|
||||
const catalog = mergeCatalog('claude', [
|
||||
{
|
||||
id: 'claude-opus-4-7',
|
||||
display_name: 'Claude Opus 4.7',
|
||||
thinking: {
|
||||
levels: ['low', 'medium', 'high', 'xhigh', 'max'],
|
||||
dynamic_allowed: true,
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
expect(catalog?.models[0]?.thinking).toMatchObject({
|
||||
type: 'levels',
|
||||
levels: ['low', 'medium', 'high', 'xhigh', 'max'],
|
||||
maxLevel: 'max',
|
||||
dynamicAllowed: true,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -147,9 +147,11 @@ describe('Model Catalog', () => {
|
||||
const opus47 = MODEL_CATALOG.claude.models.find((m) => m.id === 'claude-opus-4-7');
|
||||
assert(opus47, 'Should include Claude Opus 4.7');
|
||||
assert.strictEqual(opus47.name, 'Claude Opus 4.7');
|
||||
// Claude provider differs from AGY: zero thinking budget is disallowed,
|
||||
// and extended (1M) context is available on the Anthropic API.
|
||||
assert.strictEqual(opus47.thinking.zeroAllowed, false);
|
||||
// Opus 4.7 requires adaptive thinking (type: 'levels'); manual budget_tokens
|
||||
// is rejected by the Anthropic API with 400. Extended (1M) context is available.
|
||||
assert.strictEqual(opus47.thinking.type, 'levels');
|
||||
assert.deepStrictEqual(opus47.thinking.levels, ['low', 'medium', 'high', 'xhigh', 'max']);
|
||||
assert.strictEqual(opus47.thinking.maxLevel, 'max');
|
||||
assert.strictEqual(opus47.extendedContext, true);
|
||||
});
|
||||
|
||||
|
||||
@@ -83,7 +83,9 @@ describe('model-id-normalizer', () => {
|
||||
});
|
||||
|
||||
it('applies provider canonicalization for codex and antigravity', () => {
|
||||
expect(canonicalizeModelIdForProvider('gpt-5.3-codex-xhigh', 'codex')).toBe('gpt-5.3-codex');
|
||||
expect(canonicalizeModelIdForProvider('gpt-5.3-codex-xhigh', 'codex')).toBe(
|
||||
'gpt-5.3-codex-xhigh'
|
||||
);
|
||||
expect(canonicalizeModelIdForProvider('claude-sonnet-4.6-thinking', 'agy')).toBe(
|
||||
'claude-sonnet-4-6'
|
||||
);
|
||||
@@ -94,7 +96,7 @@ describe('model-id-normalizer', () => {
|
||||
|
||||
it('trims and canonicalizes provider model IDs with surrounding whitespace', () => {
|
||||
expect(canonicalizeModelIdForProvider(' gpt-5.3-codex-high ', 'codex')).toBe(
|
||||
'gpt-5.3-codex'
|
||||
'gpt-5.3-codex-high'
|
||||
);
|
||||
expect(canonicalizeModelIdForProvider(' claude-sonnet-4.6-thinking ', 'agy')).toBe(
|
||||
'claude-sonnet-4-6'
|
||||
@@ -120,9 +122,11 @@ describe('model-id-normalizer', () => {
|
||||
it('normalizes legacy codex aliases to the current supported model IDs', () => {
|
||||
expect(normalizeCodexLegacyModelAliases('gpt-5-codex')).toBe('gpt-5.4');
|
||||
expect(normalizeCodexLegacyModelAliases('gpt-5-codex-mini[1m]')).toBe('gpt-5.4-mini[1m]');
|
||||
expect(normalizeCodexLegacyModelAliases('gpt-5-codex-high')).toBe('gpt-5.4-high');
|
||||
expect(normalizeCodexLegacyModelAliases('gpt-5-codex-high[1m]')).toBe('gpt-5.4-high[1m]');
|
||||
expect(normalizeModelIdForProvider('gpt-5.2-codex', 'codex')).toBe('gpt-5.2');
|
||||
expect(normalizeModelIdForProvider('gpt-5.1-codex-mini', 'codex')).toBe('gpt-5.4-mini');
|
||||
expect(canonicalizeModelIdForProvider('gpt-5-codex-high', 'codex')).toBe('gpt-5.4');
|
||||
expect(canonicalizeModelIdForProvider('gpt-5-codex-high', 'codex')).toBe('gpt-5.4-high');
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -29,6 +29,7 @@ describe('Thinking Validator', () => {
|
||||
expect(VALID_THINKING_LEVELS).toContain('medium');
|
||||
expect(VALID_THINKING_LEVELS).toContain('high');
|
||||
expect(VALID_THINKING_LEVELS).toContain('xhigh');
|
||||
expect(VALID_THINKING_LEVELS).toContain('max');
|
||||
expect(VALID_THINKING_LEVELS).toContain('auto');
|
||||
});
|
||||
|
||||
@@ -38,6 +39,24 @@ describe('Thinking Validator', () => {
|
||||
expect(THINKING_LEVEL_BUDGETS.medium).toBe(8192);
|
||||
expect(THINKING_LEVEL_BUDGETS.high).toBe(24576);
|
||||
expect(THINKING_LEVEL_BUDGETS.xhigh).toBe(32768);
|
||||
// `max` sits above xhigh — unconstrained thinking on Opus 4.7 / Mythos
|
||||
expect(THINKING_LEVEL_BUDGETS.max).toBeGreaterThan(THINKING_LEVEL_BUDGETS.xhigh);
|
||||
});
|
||||
|
||||
it('should treat max as a distinct top tier on models that list it (Opus 4.7)', () => {
|
||||
// Opus 4.7 exposes both xhigh and max; max must not collapse into xhigh.
|
||||
const result = validateThinking('claude', 'claude-opus-4-7', 'max');
|
||||
expect(result.valid).toBe(true);
|
||||
expect(result.value).toBe('max');
|
||||
expect(result.warning).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should still alias max -> xhigh for models without a max level (backcompat)', () => {
|
||||
// Codex catalog uses ['low','medium','high','xhigh'] with maxLevel 'xhigh'.
|
||||
// User input "max" should map down to xhigh rather than be rejected.
|
||||
const result = validateThinking('codex', 'gpt-5.4', 'max');
|
||||
expect(result.valid).toBe(true);
|
||||
expect(result.value).toBe('xhigh');
|
||||
});
|
||||
|
||||
it('should export off values', () => {
|
||||
|
||||
@@ -6,7 +6,10 @@ import * as fs from 'fs';
|
||||
import * as os from 'os';
|
||||
import * as path from 'path';
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'bun:test';
|
||||
import { updateVariant } from '../../../src/cliproxy/services/variant-service';
|
||||
import {
|
||||
updateVariant,
|
||||
validateProviderBackend,
|
||||
} from '../../../src/cliproxy/services/variant-service';
|
||||
import { loadOrCreateUnifiedConfig } from '../../../src/config/unified-config-loader';
|
||||
|
||||
describe('updateVariant - provider/model consistency', () => {
|
||||
@@ -50,6 +53,7 @@ preferences:
|
||||
telemetry: false
|
||||
auto_update: true
|
||||
cliproxy:
|
||||
backend: plus
|
||||
oauth_accounts: {}
|
||||
providers:
|
||||
- gemini
|
||||
@@ -92,6 +96,25 @@ cliproxy:
|
||||
expect(result.error).toContain('denylist');
|
||||
});
|
||||
|
||||
it('reports plus-only providers as temporarily unavailable on local CLIProxy', () => {
|
||||
const error = validateProviderBackend('ghcp');
|
||||
expect(error).toContain('currently supports only `backend: original`');
|
||||
expect(error).toContain('issues/1062');
|
||||
});
|
||||
|
||||
it('leaves the settings file unchanged when a plus-only provider update is rejected', () => {
|
||||
const settingsPath = path.join(tmpDir, 'gemini-demo.settings.json');
|
||||
const before = fs.readFileSync(settingsPath, 'utf-8');
|
||||
|
||||
const result = updateVariant('demo', {
|
||||
provider: 'ghcp',
|
||||
model: 'gpt-5.4-mini',
|
||||
});
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(fs.readFileSync(settingsPath, 'utf-8')).toBe(before);
|
||||
});
|
||||
|
||||
it('updates provider and regenerates provider-specific core env in same settings file', () => {
|
||||
const result = updateVariant('demo', {
|
||||
provider: 'codex',
|
||||
|
||||
@@ -35,6 +35,7 @@ describe('config thinking override normalization', () => {
|
||||
|
||||
it('accepts valid levels', () => {
|
||||
expect(parseThinkingOverrideInput('High')).toEqual({ value: 'high' });
|
||||
expect(parseThinkingOverrideInput('Max')).toEqual({ value: 'max' });
|
||||
});
|
||||
|
||||
it('validates numeric bounds', () => {
|
||||
|
||||
@@ -90,6 +90,16 @@ describe('translateAnthropicRequest', () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it('maps adaptive anthropic thinking into Cursor reasoning effort', () => {
|
||||
const translated = translateAnthropicRequest({
|
||||
thinking: { type: 'adaptive' },
|
||||
output_config: { effort: 'xhigh' },
|
||||
messages: [{ role: 'user', content: 'hello' }],
|
||||
});
|
||||
|
||||
expect(translated.reasoning_effort).toBe('high');
|
||||
});
|
||||
|
||||
it('preserves mixed user text around tool_result blocks in order', () => {
|
||||
const translated = translateAnthropicRequest({
|
||||
messages: [
|
||||
|
||||
@@ -30,6 +30,17 @@ describe('ProxyRequestTransformer regressions', () => {
|
||||
expect(result.reasoning).toEqual({ enabled: true, effort: 'high' });
|
||||
});
|
||||
|
||||
it('explicitly normalizes anthropic xhigh adaptive effort for OpenAI-compatible upstreams', () => {
|
||||
const result = new ProxyRequestTransformer().transform({
|
||||
messages: [{ role: 'user', content: 'hello' }],
|
||||
thinking: { type: 'adaptive' },
|
||||
output_config: { effort: 'xhigh' },
|
||||
});
|
||||
|
||||
expect(result.reasoning_effort).toBe('high');
|
||||
expect(result.reasoning).toEqual({ enabled: true, effort: 'high' });
|
||||
});
|
||||
|
||||
it('rejects unsupported thinking types instead of silently dropping them', () => {
|
||||
expect(() =>
|
||||
new ProxyRequestTransformer().transform({
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
import * as fs from 'node:fs';
|
||||
import * as path from 'node:path';
|
||||
|
||||
function resolvePath(relativePath: string) {
|
||||
return path.resolve(import.meta.dir, relativePath);
|
||||
}
|
||||
|
||||
describe('pr ci workflow', () => {
|
||||
test('keeps full coverage on pull requests', () => {
|
||||
const workflowPath = resolvePath('../../../../.github/workflows/ci.yml');
|
||||
|
||||
expect(fs.existsSync(workflowPath)).toBe(true);
|
||||
|
||||
const workflow = fs.readFileSync(workflowPath, 'utf8');
|
||||
|
||||
expect(workflow).toContain('name: CI');
|
||||
expect(workflow).toContain('pull_request:');
|
||||
expect(workflow).toContain('branches: [main, dev]');
|
||||
expect(workflow).toContain('group: ci-${{ github.ref }}');
|
||||
expect(workflow).toContain('cancel-in-progress: true');
|
||||
expect(workflow).toContain('fail-fast: false');
|
||||
expect(workflow).toContain('runs-on: [self-hosted, linux, x64]');
|
||||
expect(workflow).toContain("cmd: 'bun run typecheck'");
|
||||
expect(workflow).toContain("cmd: 'bun run lint'");
|
||||
expect(workflow).toContain("cmd: 'bun run format:check'");
|
||||
expect(workflow).toContain("key: ${{ runner.os }}-bun-cache-v2-${{ hashFiles('bun.lock', 'ui/bun.lock') }}");
|
||||
expect(workflow).not.toContain('restore-keys:');
|
||||
expect(workflow).toContain('name: dist');
|
||||
expect(workflow).toContain('path: dist/');
|
||||
expect(workflow).toContain('needs: [build]');
|
||||
expect(workflow).toContain('run: bun run test:all');
|
||||
expect(workflow).toContain("CCS_E2E_SKIP_BUILD: '1'");
|
||||
expect(workflow).toContain('run: bun run test:e2e');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,34 @@
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
import * as fs from 'node:fs';
|
||||
import * as path from 'node:path';
|
||||
|
||||
function resolvePath(relativePath: string) {
|
||||
return path.resolve(import.meta.dir, relativePath);
|
||||
}
|
||||
|
||||
describe('push ci workflow', () => {
|
||||
test('keeps dev push quality checks separate from release automation', () => {
|
||||
const workflowPath = resolvePath('../../../../.github/workflows/push-ci.yml');
|
||||
|
||||
expect(fs.existsSync(workflowPath)).toBe(true);
|
||||
|
||||
const workflow = fs.readFileSync(workflowPath, 'utf8');
|
||||
|
||||
expect(workflow).toContain('name: Push CI');
|
||||
expect(workflow).toContain('push:');
|
||||
expect(workflow).toContain('branches: [dev]');
|
||||
expect(workflow).toContain('group: push-ci-${{ github.ref }}');
|
||||
expect(workflow).toContain('cancel-in-progress: true');
|
||||
expect(workflow).toContain('runs-on: [self-hosted, linux, x64]');
|
||||
expect(workflow).toContain("key: ${{ runner.os }}-bun-cache-v2-${{ hashFiles('bun.lock', 'ui/bun.lock') }}");
|
||||
expect(workflow).not.toContain('restore-keys:');
|
||||
expect(workflow).toContain("name: ${{ matrix.check.name }}");
|
||||
expect(workflow).toContain("cmd: 'bun run typecheck'");
|
||||
expect(workflow).toContain("cmd: 'bun run lint'");
|
||||
expect(workflow).toContain("cmd: 'bun run format:check'");
|
||||
expect(workflow).toContain('run: bun run build:all');
|
||||
expect(workflow).toContain('run: bun run test:all');
|
||||
expect(workflow).toContain("CCS_E2E_SKIP_BUILD: '1'");
|
||||
expect(workflow).toContain('run: bun run test:e2e');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,28 @@
|
||||
const { describe, expect, test } = require('bun:test');
|
||||
const path = require('node:path');
|
||||
const bucket = require('../../../scripts/run-test-bucket.js');
|
||||
|
||||
describe('run-test-bucket', () => {
|
||||
test('all declared slow tests still exist on disk', () => {
|
||||
for (const relativePath of bucket.slowTests) {
|
||||
const absolutePath = path.resolve(__dirname, '../../../', relativePath);
|
||||
expect(Bun.file(absolutePath).exists()).resolves.toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
test('forces npm tests into the slow bucket', () => {
|
||||
expect(bucket.shouldForceSlow('tests/npm/cli.test.js')).toBe(true);
|
||||
});
|
||||
|
||||
test('keeps dist-independent javascript tests in the fast bucket', () => {
|
||||
expect(bucket.shouldForceSlow('tests/unit/flag-parsing-simple.test.js')).toBe(false);
|
||||
});
|
||||
|
||||
test('keeps non-allowlisted javascript tests in the slow bucket', () => {
|
||||
expect(bucket.shouldForceSlow('tests/unit/commands/persist-command.test.js')).toBe(true);
|
||||
});
|
||||
|
||||
test('still forces dist-dependent tests into the slow bucket', () => {
|
||||
expect(bucket.shouldForceSlow('tests/unit/config-dir-override.test.js')).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -143,6 +143,23 @@ describe('droid-config-manager', () => {
|
||||
expect(settings.customModels[0].extraArgs?.thinking?.budget_tokens).toBe(40960);
|
||||
});
|
||||
|
||||
it('writes adaptive anthropic thinking for claude-opus-4-7 overrides', async () => {
|
||||
await upsertCcsModel('claude', {
|
||||
model: 'claude-opus-4-7',
|
||||
displayName: 'CCS claude',
|
||||
baseUrl: 'https://api.anthropic.com',
|
||||
apiKey: 'anthropic-key',
|
||||
provider: 'anthropic',
|
||||
reasoningOverride: 'max',
|
||||
});
|
||||
|
||||
const settingsPath = path.join(tmpDir, '.factory', 'settings.json');
|
||||
const settings = JSON.parse(fs.readFileSync(settingsPath, 'utf8'));
|
||||
expect(settings.customModels[0].extraArgs?.thinking?.type).toBe('adaptive');
|
||||
expect(settings.customModels[0].extraArgs?.thinking?.budget_tokens).toBeUndefined();
|
||||
expect(settings.customModels[0].extraArgs?.output_config?.effort).toBe('max');
|
||||
});
|
||||
|
||||
it('should clear prior reasoning config when override disables thinking', async () => {
|
||||
await upsertCcsModel('glm', {
|
||||
model: 'glm-4.7',
|
||||
|
||||
@@ -136,6 +136,13 @@ printf "%s\n" "$@" > "${claudeArgsLogPath}"
|
||||
printf "port=%s\n" "$CCS_BROWSER_DEVTOOLS_PORT"
|
||||
printf "httpUrl=%s\n" "$CCS_BROWSER_DEVTOOLS_HTTP_URL"
|
||||
printf "wsUrl=%s\n" "$CCS_BROWSER_DEVTOOLS_WS_URL"
|
||||
printf "stripAnthropic=%s\n" "$CCS_STRIP_INHERITED_ANTHROPIC_ENV"
|
||||
printf "anthropicBaseUrl=%s\n" "$ANTHROPIC_BASE_URL"
|
||||
printf "anthropicAuthToken=%s\n" "$ANTHROPIC_AUTH_TOKEN"
|
||||
printf "anthropicApiKey=%s\n" "$ANTHROPIC_API_KEY"
|
||||
printf "anthropicModel=%s\n" "$ANTHROPIC_MODEL"
|
||||
printf "anthropicSonnet=%s\n" "$ANTHROPIC_DEFAULT_SONNET_MODEL"
|
||||
printf "maxOutputTokens=%s\n" "$CLAUDE_CODE_MAX_OUTPUT_TOKENS"
|
||||
} > "${claudeEnvLogPath}"
|
||||
exit 0
|
||||
`,
|
||||
@@ -187,6 +194,69 @@ exit 0
|
||||
expect(fs.existsSync(claudeArgsLogPath)).toBe(true);
|
||||
});
|
||||
|
||||
it('passes selective Anthropic env stripping to settings-profile Claude launches while preserving model defaults', () => {
|
||||
if (process.platform === 'win32') return;
|
||||
|
||||
fs.writeFileSync(
|
||||
settingsPath,
|
||||
JSON.stringify(
|
||||
{
|
||||
env: {
|
||||
ANTHROPIC_BASE_URL: 'https://api.z.ai/api/anthropic',
|
||||
ANTHROPIC_AUTH_TOKEN: 'profile-token',
|
||||
ANTHROPIC_MODEL: 'gpt-5.4',
|
||||
ANTHROPIC_DEFAULT_SONNET_MODEL: 'gpt-5.4',
|
||||
CLAUDE_CODE_MAX_OUTPUT_TOKENS: '12345',
|
||||
},
|
||||
},
|
||||
null,
|
||||
2
|
||||
) + '\n'
|
||||
);
|
||||
|
||||
const originalCcsHome = process.env.CCS_HOME;
|
||||
process.env.CCS_HOME = tmpHome;
|
||||
|
||||
try {
|
||||
mutateUnifiedConfig((config) => {
|
||||
config.global_env = {
|
||||
enabled: true,
|
||||
env: {
|
||||
ANTHROPIC_BASE_URL: 'http://127.0.0.1:9999/api/provider/global',
|
||||
ANTHROPIC_AUTH_TOKEN: 'global-routing-token',
|
||||
ANTHROPIC_API_KEY: 'global-api-key',
|
||||
CLAUDE_CODE_MAX_OUTPUT_TOKENS: '54321',
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
const result = runCcs(['glm', 'smoke'], {
|
||||
...baseEnv,
|
||||
ANTHROPIC_BASE_URL: 'http://127.0.0.1:8317/api/provider/codex',
|
||||
ANTHROPIC_AUTH_TOKEN: 'parent-routing-token',
|
||||
ANTHROPIC_API_KEY: 'parent-api-key',
|
||||
ANTHROPIC_MODEL: 'gpt-5.4',
|
||||
ANTHROPIC_DEFAULT_SONNET_MODEL: 'gpt-5.4',
|
||||
});
|
||||
|
||||
expect(result.status).toBe(0);
|
||||
const launchedEnv = fs.readFileSync(claudeEnvLogPath, 'utf8');
|
||||
expect(launchedEnv).toContain('stripAnthropic=1');
|
||||
expect(launchedEnv).toContain('anthropicBaseUrl=');
|
||||
expect(launchedEnv).toContain('anthropicAuthToken=');
|
||||
expect(launchedEnv).toContain('anthropicApiKey=');
|
||||
expect(launchedEnv).toContain('anthropicModel=gpt-5.4');
|
||||
expect(launchedEnv).toContain('anthropicSonnet=gpt-5.4');
|
||||
expect(launchedEnv).toContain('maxOutputTokens=12345');
|
||||
} finally {
|
||||
if (originalCcsHome !== undefined) {
|
||||
process.env.CCS_HOME = originalCcsHome;
|
||||
} else {
|
||||
delete process.env.CCS_HOME;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('does not auto-enable browser reuse for settings-profile launches from env overrides alone', async () => {
|
||||
if (process.platform === 'win32') return;
|
||||
|
||||
|
||||
@@ -21,8 +21,16 @@ type SpawnCall = {
|
||||
options: Record<string, unknown> | undefined;
|
||||
};
|
||||
|
||||
const STEERING_PROMPT_SNIPPET = 'prefer the CCS MCP tool WebSearch instead of Bash/curl/http fetches';
|
||||
type SpawnSyncCall = {
|
||||
command: string;
|
||||
args: string[];
|
||||
options: Record<string, unknown> | undefined;
|
||||
};
|
||||
|
||||
const STEERING_PROMPT_SNIPPET =
|
||||
'prefer the CCS MCP tool WebSearch instead of Bash/curl/http fetches';
|
||||
const spawnCalls: SpawnCall[] = [];
|
||||
const spawnSyncCalls: SpawnSyncCall[] = [];
|
||||
const originalPlatform = process.platform;
|
||||
let baselineSigintListeners: Array<(...args: unknown[]) => void> = [];
|
||||
let baselineSigtermListeners: Array<(...args: unknown[]) => void> = [];
|
||||
@@ -31,6 +39,7 @@ let originalCcsHome: string | undefined;
|
||||
let originalCcsClaudePath: string | undefined;
|
||||
let originalDisableAutoUpdater: string | undefined;
|
||||
let originalClaudeConfigDir: string | undefined;
|
||||
let originalTmux: string | undefined;
|
||||
const realSpawn = childProcess.spawn.bind(childProcess);
|
||||
const realSpawnSync = childProcess.spawnSync.bind(childProcess);
|
||||
const realExecSync = childProcess.execSync.bind(childProcess);
|
||||
@@ -103,6 +112,18 @@ function registerChildProcessMock(): void {
|
||||
| Record<string, unknown>
|
||||
| undefined;
|
||||
|
||||
if (command === 'tmux') {
|
||||
spawnSyncCalls.push({ command, args, options });
|
||||
return {
|
||||
pid: process.pid,
|
||||
output: ['', '', ''],
|
||||
stdout: '',
|
||||
stderr: '',
|
||||
status: 0,
|
||||
signal: null,
|
||||
};
|
||||
}
|
||||
|
||||
return realSpawnSync(command, args, options as Parameters<typeof childProcess.spawnSync>[2]);
|
||||
},
|
||||
execSync: (...execArgs: unknown[]) =>
|
||||
@@ -140,6 +161,7 @@ ${yamlBody}
|
||||
}
|
||||
|
||||
let execClaude: typeof import('../../../src/utils/shell-executor').execClaude;
|
||||
let stripAnthropicRoutingEnv: typeof import('../../../src/utils/shell-executor').stripAnthropicRoutingEnv;
|
||||
let stripClaudeCodeEnv: typeof import('../../../src/utils/shell-executor').stripClaudeCodeEnv;
|
||||
let HeadlessExecutor: typeof import('../../../src/delegation/headless-executor').HeadlessExecutor;
|
||||
let SharedManager: typeof import('../../../src/management/shared-manager').default;
|
||||
@@ -149,6 +171,7 @@ beforeAll(async () => {
|
||||
|
||||
const shellExecutor = await import('../../../src/utils/shell-executor');
|
||||
execClaude = shellExecutor.execClaude;
|
||||
stripAnthropicRoutingEnv = shellExecutor.stripAnthropicRoutingEnv;
|
||||
stripClaudeCodeEnv = shellExecutor.stripClaudeCodeEnv;
|
||||
|
||||
const sharedManagerModule = await import('../../../src/management/shared-manager');
|
||||
@@ -165,6 +188,7 @@ afterAll(() => {
|
||||
describe('CLAUDECODE environment stripping', () => {
|
||||
beforeEach(() => {
|
||||
spawnCalls.length = 0;
|
||||
spawnSyncCalls.length = 0;
|
||||
process.env.CCS_QUIET = '1';
|
||||
|
||||
// Save original env values for restoration in afterEach
|
||||
@@ -172,10 +196,12 @@ describe('CLAUDECODE environment stripping', () => {
|
||||
originalCcsClaudePath = process.env.CCS_CLAUDE_PATH;
|
||||
originalDisableAutoUpdater = process.env.DISABLE_AUTOUPDATER;
|
||||
originalClaudeConfigDir = process.env.CLAUDE_CONFIG_DIR;
|
||||
originalTmux = process.env.TMUX;
|
||||
|
||||
// Clear CCS-managed env vars that leak from host sessions
|
||||
delete process.env.DISABLE_AUTOUPDATER;
|
||||
delete process.env.CLAUDE_CONFIG_DIR;
|
||||
delete process.env.TMUX;
|
||||
|
||||
baselineSigintListeners = process.listeners('SIGINT');
|
||||
baselineSigtermListeners = process.listeners('SIGTERM');
|
||||
@@ -197,8 +223,20 @@ describe('CLAUDECODE environment stripping', () => {
|
||||
} else {
|
||||
delete process.env.DISABLE_AUTOUPDATER;
|
||||
}
|
||||
if (originalClaudeConfigDir !== undefined) process.env.CLAUDE_CONFIG_DIR = originalClaudeConfigDir;
|
||||
if (originalClaudeConfigDir !== undefined)
|
||||
process.env.CLAUDE_CONFIG_DIR = originalClaudeConfigDir;
|
||||
else delete process.env.CLAUDE_CONFIG_DIR;
|
||||
if (originalTmux !== undefined) process.env.TMUX = originalTmux;
|
||||
else delete process.env.TMUX;
|
||||
delete process.env.ANTHROPIC_BASE_URL;
|
||||
delete process.env.ANTHROPIC_AUTH_TOKEN;
|
||||
delete process.env.ANTHROPIC_API_KEY;
|
||||
delete process.env.ANTHROPIC_MODEL;
|
||||
delete process.env.ANTHROPIC_DEFAULT_OPUS_MODEL;
|
||||
delete process.env.ANTHROPIC_DEFAULT_SONNET_MODEL;
|
||||
delete process.env.ANTHROPIC_DEFAULT_HAIKU_MODEL;
|
||||
delete process.env.ANTHROPIC_SMALL_FAST_MODEL;
|
||||
delete process.env.CLAUDE_CODE_MAX_OUTPUT_TOKENS;
|
||||
|
||||
for (const listener of process.listeners('SIGINT')) {
|
||||
if (!baselineSigintListeners.includes(listener)) {
|
||||
@@ -230,6 +268,25 @@ describe('CLAUDECODE environment stripping', () => {
|
||||
expect(result.PATH).toBe('/usr/bin');
|
||||
});
|
||||
|
||||
it('stripAnthropicRoutingEnv removes routing/auth env case-insensitively while preserving model vars', () => {
|
||||
const input: NodeJS.ProcessEnv = {
|
||||
anthropic_base_url: 'http://127.0.0.1:8317/api/provider/codex',
|
||||
Anthropic_Auth_Token: 'parent-routing-token',
|
||||
ANTHROPIC_API_KEY: 'parent-api-key',
|
||||
ANTHROPIC_MODEL: 'gpt-5.4',
|
||||
ANTHROPIC_DEFAULT_SONNET_MODEL: 'gpt-5.4',
|
||||
PATH: '/usr/bin',
|
||||
};
|
||||
|
||||
const result = stripAnthropicRoutingEnv(input);
|
||||
expect(result.anthropic_base_url).toBeUndefined();
|
||||
expect(result.Anthropic_Auth_Token).toBeUndefined();
|
||||
expect(result.ANTHROPIC_API_KEY).toBeUndefined();
|
||||
expect(result.ANTHROPIC_MODEL).toBe('gpt-5.4');
|
||||
expect(result.ANTHROPIC_DEFAULT_SONNET_MODEL).toBe('gpt-5.4');
|
||||
expect(result.PATH).toBe('/usr/bin');
|
||||
});
|
||||
|
||||
it('execClaude strips CLAUDECODE from merged env (including overrides)', () => {
|
||||
process.env.CLAUDECODE = 'from-parent';
|
||||
process.env.claudecode = 'from-parent-lower';
|
||||
@@ -336,6 +393,97 @@ describe('CLAUDECODE environment stripping', () => {
|
||||
expect(normalizeSpy).toHaveBeenCalledWith(instancePath);
|
||||
});
|
||||
|
||||
it('execClaude strips inherited ANTHROPIC routing env but keeps model intent for settings-profile Claude launches', () => {
|
||||
process.env.ANTHROPIC_BASE_URL = 'http://127.0.0.1:8317/api/provider/codex';
|
||||
process.env.ANTHROPIC_AUTH_TOKEN = 'ccs-internal-managed';
|
||||
process.env.ANTHROPIC_API_KEY = 'stale-api-key';
|
||||
process.env.ANTHROPIC_MODEL = 'gpt-5.4';
|
||||
process.env.ANTHROPIC_DEFAULT_OPUS_MODEL = 'gpt-5.4';
|
||||
process.env.ANTHROPIC_DEFAULT_SONNET_MODEL = 'gpt-5.4';
|
||||
process.env.ANTHROPIC_DEFAULT_HAIKU_MODEL = 'gpt-5.4-mini';
|
||||
process.env.ANTHROPIC_SMALL_FAST_MODEL = 'gpt-5-codex-mini';
|
||||
|
||||
execClaude('claude', ['--help'], {
|
||||
CCS_PROFILE_TYPE: 'settings',
|
||||
CCS_STRIP_INHERITED_ANTHROPIC_ENV: '1',
|
||||
CLAUDE_CONFIG_DIR: path.join(os.tmpdir(), 'ccs-settings-profile-instance'),
|
||||
CCS_WEBSEARCH_SKIP: '1',
|
||||
});
|
||||
|
||||
expect(spawnCalls.length).toBeGreaterThan(0);
|
||||
const env = spawnCalls[0].options?.env as NodeJS.ProcessEnv;
|
||||
expect(env.CCS_PROFILE_TYPE).toBe('settings');
|
||||
expect(env.CLAUDE_CONFIG_DIR).toContain('ccs-settings-profile-instance');
|
||||
expect(env.ANTHROPIC_BASE_URL).toBeUndefined();
|
||||
expect(env.ANTHROPIC_AUTH_TOKEN).toBeUndefined();
|
||||
expect(env.ANTHROPIC_API_KEY).toBeUndefined();
|
||||
expect(env.ANTHROPIC_MODEL).toBe('gpt-5.4');
|
||||
expect(env.ANTHROPIC_DEFAULT_OPUS_MODEL).toBe('gpt-5.4');
|
||||
expect(env.ANTHROPIC_DEFAULT_SONNET_MODEL).toBe('gpt-5.4');
|
||||
expect(env.ANTHROPIC_DEFAULT_HAIKU_MODEL).toBe('gpt-5.4-mini');
|
||||
expect(env.ANTHROPIC_SMALL_FAST_MODEL).toBe('gpt-5-codex-mini');
|
||||
});
|
||||
|
||||
it('execClaude strips routing env reintroduced by explicit settings-profile overrides', () => {
|
||||
execClaude('claude', ['--help'], {
|
||||
CCS_PROFILE_TYPE: 'settings',
|
||||
CCS_STRIP_INHERITED_ANTHROPIC_ENV: '1',
|
||||
ANTHROPIC_BASE_URL: 'http://127.0.0.1:8317/api/provider/codex',
|
||||
ANTHROPIC_AUTH_TOKEN: 'reintroduced-routing-token',
|
||||
ANTHROPIC_API_KEY: 'reintroduced-api-key',
|
||||
ANTHROPIC_MODEL: 'gpt-5.4',
|
||||
ANTHROPIC_DEFAULT_SONNET_MODEL: 'gpt-5.4',
|
||||
});
|
||||
|
||||
expect(spawnCalls.length).toBeGreaterThan(0);
|
||||
const env = spawnCalls[0].options?.env as NodeJS.ProcessEnv;
|
||||
expect(env.ANTHROPIC_BASE_URL).toBeUndefined();
|
||||
expect(env.ANTHROPIC_AUTH_TOKEN).toBeUndefined();
|
||||
expect(env.ANTHROPIC_API_KEY).toBeUndefined();
|
||||
expect(env.ANTHROPIC_MODEL).toBe('gpt-5.4');
|
||||
expect(env.ANTHROPIC_DEFAULT_SONNET_MODEL).toBe('gpt-5.4');
|
||||
});
|
||||
|
||||
it('execClaude sanitizes tmux teammate env for bridge-backed settings launches while keeping the launched child on the runtime proxy', () => {
|
||||
process.env.TMUX = 'session-1';
|
||||
process.env.ANTHROPIC_BASE_URL = 'http://127.0.0.1:8317/api/provider/codex';
|
||||
process.env.ANTHROPIC_AUTH_TOKEN = 'parent-routing-token';
|
||||
process.env.ANTHROPIC_API_KEY = 'parent-api-key';
|
||||
process.env.ANTHROPIC_MODEL = 'gpt-5.4';
|
||||
process.env.ANTHROPIC_DEFAULT_SONNET_MODEL = 'gpt-5.4';
|
||||
|
||||
execClaude('claude', ['--help'], {
|
||||
CCS_PROFILE_TYPE: 'settings',
|
||||
CLAUDE_CONFIG_DIR: path.join(os.tmpdir(), 'ccs-settings-profile-instance'),
|
||||
ANTHROPIC_BASE_URL: 'http://127.0.0.1:3456',
|
||||
ANTHROPIC_AUTH_TOKEN: 'fresh-runtime-token',
|
||||
ANTHROPIC_MODEL: 'gpt-5.4',
|
||||
});
|
||||
|
||||
expect(spawnCalls.length).toBeGreaterThan(0);
|
||||
const childEnv = spawnCalls[0].options?.env as NodeJS.ProcessEnv;
|
||||
expect(childEnv.ANTHROPIC_BASE_URL).toBe('http://127.0.0.1:3456');
|
||||
expect(childEnv.ANTHROPIC_AUTH_TOKEN).toBe('fresh-runtime-token');
|
||||
|
||||
const unsetBaseUrlCall = spawnSyncCalls.find(
|
||||
(call) => call.command === 'tmux' && call.args.join(' ') === 'setenv -u ANTHROPIC_BASE_URL'
|
||||
);
|
||||
const unsetAuthTokenCall = spawnSyncCalls.find(
|
||||
(call) => call.command === 'tmux' && call.args.join(' ') === 'setenv -u ANTHROPIC_AUTH_TOKEN'
|
||||
);
|
||||
const modelCall = spawnSyncCalls.find(
|
||||
(call) =>
|
||||
call.command === 'tmux' &&
|
||||
call.args[0] === 'setenv' &&
|
||||
call.args[1] === 'ANTHROPIC_MODEL' &&
|
||||
call.args[2] === 'gpt-5.4'
|
||||
);
|
||||
|
||||
expect(unsetBaseUrlCall).toBeDefined();
|
||||
expect(unsetAuthTokenCall).toBeDefined();
|
||||
expect(modelCall).toBeDefined();
|
||||
});
|
||||
|
||||
it('headless executor spawn path strips CLAUDECODE before spawn', async () => {
|
||||
writeConfigWithAutoUpdatePreference(false);
|
||||
process.env.CLAUDECODE = 'nested';
|
||||
@@ -432,6 +580,92 @@ describe('CLAUDECODE environment stripping', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('headless executor strips inherited routing env for settings-profile delegation while preserving model intent', async () => {
|
||||
writeConfigWithAutoUpdatePreference(false);
|
||||
const ccsDir = path.join(process.env.CCS_HOME as string, '.ccs');
|
||||
fs.writeFileSync(
|
||||
path.join(ccsDir, 'glm.settings.json'),
|
||||
JSON.stringify(
|
||||
{
|
||||
env: {
|
||||
ANTHROPIC_MODEL: 'gpt-5.4',
|
||||
ANTHROPIC_DEFAULT_SONNET_MODEL: 'gpt-5.4',
|
||||
CLAUDE_CODE_MAX_OUTPUT_TOKENS: '12345',
|
||||
},
|
||||
},
|
||||
null,
|
||||
2
|
||||
) + '\n',
|
||||
'utf8'
|
||||
);
|
||||
const projectDir = path.join(ccsDir, 'project-headless-settings');
|
||||
fs.mkdirSync(projectDir, { recursive: true });
|
||||
process.env.CCS_CLAUDE_PATH = 'claude';
|
||||
process.env.ANTHROPIC_BASE_URL = 'http://127.0.0.1:8317/api/provider/codex';
|
||||
process.env.ANTHROPIC_AUTH_TOKEN = 'parent-routing-token';
|
||||
process.env.ANTHROPIC_API_KEY = 'parent-api-key';
|
||||
process.env.ANTHROPIC_MODEL = 'gpt-5.4';
|
||||
|
||||
const result = await HeadlessExecutor.execute('glm', 'latest AI chip news', {
|
||||
cwd: projectDir,
|
||||
permissionMode: 'default',
|
||||
timeout: 1000,
|
||||
});
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(spawnCalls.length).toBeGreaterThan(0);
|
||||
const env = spawnCalls[0].options?.env as NodeJS.ProcessEnv;
|
||||
expect(env.ANTHROPIC_BASE_URL).toBeUndefined();
|
||||
expect(env.ANTHROPIC_AUTH_TOKEN).toBeUndefined();
|
||||
expect(env.ANTHROPIC_API_KEY).toBeUndefined();
|
||||
expect(env.ANTHROPIC_MODEL).toBe('gpt-5.4');
|
||||
expect(env.ANTHROPIC_DEFAULT_SONNET_MODEL).toBe('gpt-5.4');
|
||||
expect(env.CLAUDE_CODE_MAX_OUTPUT_TOKENS).toBe('12345');
|
||||
});
|
||||
|
||||
it('headless executor rebuilds OpenAI-compatible bridge env from settings instead of inheriting stale parent routing', async () => {
|
||||
writeConfigWithAutoUpdatePreference(false);
|
||||
const ccsDir = path.join(process.env.CCS_HOME as string, '.ccs');
|
||||
fs.writeFileSync(
|
||||
path.join(ccsDir, 'bridge.settings.json'),
|
||||
JSON.stringify(
|
||||
{
|
||||
env: {
|
||||
ANTHROPIC_BASE_URL: 'https://api.openai.com/v1',
|
||||
ANTHROPIC_AUTH_TOKEN: 'settings-bridge-token',
|
||||
ANTHROPIC_MODEL: 'gpt-5.4',
|
||||
CLAUDE_CODE_MAX_OUTPUT_TOKENS: '12345',
|
||||
},
|
||||
},
|
||||
null,
|
||||
2
|
||||
) + '\n',
|
||||
'utf8'
|
||||
);
|
||||
const projectDir = path.join(ccsDir, 'project-headless-bridge');
|
||||
fs.mkdirSync(projectDir, { recursive: true });
|
||||
process.env.CCS_CLAUDE_PATH = 'claude';
|
||||
process.env.ANTHROPIC_BASE_URL = 'http://127.0.0.1:8317/api/provider/codex';
|
||||
process.env.ANTHROPIC_AUTH_TOKEN = 'parent-routing-token';
|
||||
process.env.ANTHROPIC_API_KEY = 'parent-api-key';
|
||||
|
||||
const result = await HeadlessExecutor.execute('bridge', 'latest AI chip news', {
|
||||
cwd: projectDir,
|
||||
permissionMode: 'default',
|
||||
timeout: 1000,
|
||||
});
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(spawnCalls.length).toBeGreaterThan(0);
|
||||
const env = spawnCalls[0].options?.env as NodeJS.ProcessEnv;
|
||||
expect(env.ANTHROPIC_BASE_URL).toMatch(/^http:\/\/127\.0\.0\.1:\d+$/);
|
||||
expect(env.ANTHROPIC_BASE_URL).not.toBe('http://127.0.0.1:8317/api/provider/codex');
|
||||
expect(env.ANTHROPIC_AUTH_TOKEN).toBeDefined();
|
||||
expect(env.ANTHROPIC_AUTH_TOKEN).not.toBe('parent-routing-token');
|
||||
expect(env.ANTHROPIC_MODEL).toBe('gpt-5.4');
|
||||
expect(env.CLAUDE_CODE_MAX_OUTPUT_TOKENS).toBe('12345');
|
||||
});
|
||||
|
||||
it('headless executor prepares image-analysis MCP and suppresses the legacy hook on healthy launches', async () => {
|
||||
writeConfigWithAutoUpdatePreference(false);
|
||||
const ccsDir = path.join(process.env.CCS_HOME as string, '.ccs');
|
||||
@@ -468,9 +702,7 @@ describe('CLAUDECODE environment stripping', () => {
|
||||
args: [path.join(ccsDir, 'mcp', 'ccs-image-analysis-server.cjs')],
|
||||
env: {},
|
||||
});
|
||||
expect(fs.existsSync(path.join(ccsDir, 'hooks', 'image-analyzer-transformer.cjs'))).toBe(
|
||||
false
|
||||
);
|
||||
expect(fs.existsSync(path.join(ccsDir, 'hooks', 'image-analyzer-transformer.cjs'))).toBe(false);
|
||||
expect(fs.existsSync(path.join(ccsDir, 'hooks', 'image-analysis-runtime.cjs'))).toBe(false);
|
||||
});
|
||||
|
||||
|
||||
@@ -56,7 +56,7 @@ describe('installDashboardCliproxyVersion', () => {
|
||||
success: true,
|
||||
restarted: true,
|
||||
port: 8317,
|
||||
message: 'Successfully installed CLIProxy Plus v6.7.1 and restarted it on port 8317',
|
||||
message: 'Successfully installed CLIProxy v6.7.1 and restarted it on port 8317',
|
||||
});
|
||||
expect(calls.isCliproxyRunning).toBe(0);
|
||||
expect(calls.installCliproxyVersion).toBe(1);
|
||||
@@ -71,7 +71,7 @@ describe('installDashboardCliproxyVersion', () => {
|
||||
expect(result).toEqual<DashboardCliproxyInstallResult>({
|
||||
success: true,
|
||||
restarted: false,
|
||||
message: 'Successfully installed CLIProxy Plus v6.7.1',
|
||||
message: 'Successfully installed CLIProxy v6.7.1',
|
||||
});
|
||||
expect(calls.isCliproxyRunning).toBe(1);
|
||||
expect(calls.installCliproxyVersion).toBe(1);
|
||||
@@ -118,8 +118,8 @@ describe('installDashboardCliproxyVersion', () => {
|
||||
expect(result).toEqual<DashboardCliproxyInstallResult>({
|
||||
success: false,
|
||||
restarted: false,
|
||||
error: 'Installed CLIProxy Plus v6.7.1, but restart failed',
|
||||
message: 'Installed CLIProxy Plus v6.7.1, but failed to restart it',
|
||||
error: 'Installed CLIProxy v6.7.1, but restart failed',
|
||||
message: 'Installed CLIProxy v6.7.1, but failed to restart it',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -10,6 +10,7 @@ let createEmptyUnifiedConfig: typeof import('../../../src/config/unified-config-
|
||||
let saveUnifiedConfig: typeof import('../../../src/config/unified-config-loader').saveUnifiedConfig;
|
||||
let setGlobalConfigDir: typeof import('../../../src/utils/config-manager').setGlobalConfigDir;
|
||||
let writeInstalledVersion: typeof import('../../../src/cliproxy/binary/version-cache').writeInstalledVersion;
|
||||
let writeVersionCache: typeof import('../../../src/cliproxy/binary/version-cache').writeVersionCache;
|
||||
let writeVersionListCache: typeof import('../../../src/cliproxy/binary/version-cache').writeVersionListCache;
|
||||
|
||||
let server: Server;
|
||||
@@ -25,7 +26,7 @@ beforeAll(async () => {
|
||||
({ setGlobalConfigDir } = await import('../../../src/utils/config-manager'));
|
||||
({ createEmptyUnifiedConfig } = await import('../../../src/config/unified-config-types'));
|
||||
({ saveUnifiedConfig } = await import('../../../src/config/unified-config-loader'));
|
||||
({ writeInstalledVersion, writeVersionListCache } = await import(
|
||||
({ writeInstalledVersion, writeVersionCache, writeVersionListCache } = await import(
|
||||
'../../../src/cliproxy/binary/version-cache'
|
||||
));
|
||||
|
||||
@@ -39,6 +40,7 @@ beforeAll(async () => {
|
||||
saveUnifiedConfig(config);
|
||||
|
||||
writeInstalledVersion(plusBinDir, '6.6.80');
|
||||
writeVersionCache('6.6.89', 'plus');
|
||||
writeVersionListCache(
|
||||
{
|
||||
versions: ['6.6.89', '6.6.88', '6.6.81', '6.6.80'],
|
||||
@@ -94,6 +96,23 @@ afterAll(async () => {
|
||||
});
|
||||
|
||||
describe('cliproxy-stats-routes install contract', () => {
|
||||
it('routes saved plus configs through original backend for update checks', async () => {
|
||||
const response = await fetch(`${baseUrl}/api/cliproxy/update-check`);
|
||||
expect(response.status).toBe(200);
|
||||
|
||||
const body = (await response.json()) as {
|
||||
backend: string;
|
||||
backendLabel: string;
|
||||
currentVersion: string;
|
||||
latestVersion: string;
|
||||
};
|
||||
|
||||
expect(body.backend).toBe('original');
|
||||
expect(body.backendLabel).toBe('CLIProxy');
|
||||
expect(body.currentVersion).toBe('6.6.80');
|
||||
expect(body.latestVersion).toBe('6.6.89');
|
||||
});
|
||||
|
||||
it('returns faultyRange in the versions response', async () => {
|
||||
const response = await fetch(`${baseUrl}/api/cliproxy/versions`);
|
||||
expect(response.status).toBe(200);
|
||||
|
||||
@@ -129,7 +129,7 @@ describe('cliproxy-stats-routes model update canonicalization', () => {
|
||||
expect(persisted.env.ANTHROPIC_MODEL).toBe('claude-sonnet-4-6');
|
||||
});
|
||||
|
||||
it('canonicalizes Codex effort suffix and syncs linked core model env vars', async () => {
|
||||
it('preserves Codex effort suffixes while syncing linked core model env vars', async () => {
|
||||
const settingsPath = path.join(tempHome, '.ccs', 'cliproxy', 'codex.settings.json');
|
||||
writeSettings(settingsPath, {
|
||||
ANTHROPIC_BASE_URL: 'http://127.0.0.1:8317/api/provider/codex',
|
||||
@@ -148,14 +148,14 @@ describe('cliproxy-stats-routes model update canonicalization', () => {
|
||||
expect(response.status).toBe(200);
|
||||
|
||||
const body = (await response.json()) as { model: string };
|
||||
expect(body.model).toBe('gpt-5.3-codex');
|
||||
expect(body.model).toBe('gpt-5.3-codex-xhigh');
|
||||
|
||||
const persisted = JSON.parse(fs.readFileSync(settingsPath, 'utf8')) as {
|
||||
env: Record<string, string>;
|
||||
};
|
||||
expect(persisted.env.ANTHROPIC_MODEL).toBe('gpt-5.3-codex');
|
||||
expect(persisted.env.ANTHROPIC_DEFAULT_OPUS_MODEL).toBe('gpt-5.3-codex');
|
||||
expect(persisted.env.ANTHROPIC_DEFAULT_SONNET_MODEL).toBe('gpt-5.3-codex');
|
||||
expect(persisted.env.ANTHROPIC_MODEL).toBe('gpt-5.3-codex-xhigh');
|
||||
expect(persisted.env.ANTHROPIC_DEFAULT_OPUS_MODEL).toBe('gpt-5.3-codex-xhigh');
|
||||
expect(persisted.env.ANTHROPIC_DEFAULT_SONNET_MODEL).toBe('gpt-5.3-codex-xhigh');
|
||||
expect(persisted.env.ANTHROPIC_DEFAULT_HAIKU_MODEL).toBe('gpt-5.4-mini');
|
||||
});
|
||||
|
||||
|
||||
@@ -154,6 +154,83 @@ describe('settings-routes model canonicalization', () => {
|
||||
expect(persisted.presets[0]?.haiku).toBe('qwen3-coder-plus');
|
||||
});
|
||||
|
||||
it('preserves codex effort suffixes on PUT /:profile while still normalizing legacy aliases', async () => {
|
||||
const settingsPath = path.join(tempHome, '.ccs', 'codex.settings.json');
|
||||
writeSettings(settingsPath, { env: {} });
|
||||
|
||||
const response = await fetch(`${baseUrl}/api/settings/codex`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
settings: {
|
||||
env: {
|
||||
ANTHROPIC_BASE_URL: 'http://127.0.0.1:8317/api/provider/codex',
|
||||
ANTHROPIC_AUTH_TOKEN: 'ccs-internal-managed',
|
||||
ANTHROPIC_MODEL: 'gpt-5.3-codex-xhigh',
|
||||
ANTHROPIC_DEFAULT_OPUS_MODEL: 'gpt-5-codex-high',
|
||||
ANTHROPIC_DEFAULT_SONNET_MODEL: 'gpt-5.3-codex-high',
|
||||
ANTHROPIC_DEFAULT_HAIKU_MODEL: 'gpt-5-mini-medium',
|
||||
},
|
||||
presets: [
|
||||
{
|
||||
name: 'legacy-codex',
|
||||
default: 'gpt-5.3-codex-xhigh',
|
||||
opus: 'gpt-5-codex-high',
|
||||
sonnet: 'gpt-5.3-codex-high',
|
||||
haiku: 'gpt-5-mini-medium',
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
});
|
||||
expect(response.status).toBe(200);
|
||||
|
||||
const persisted = JSON.parse(fs.readFileSync(settingsPath, 'utf8')) as {
|
||||
env: Record<string, string>;
|
||||
presets: Array<Record<string, string>>;
|
||||
};
|
||||
|
||||
expect(persisted.env.ANTHROPIC_MODEL).toBe('gpt-5.3-codex-xhigh');
|
||||
expect(persisted.env.ANTHROPIC_DEFAULT_OPUS_MODEL).toBe('gpt-5.4-high');
|
||||
expect(persisted.env.ANTHROPIC_DEFAULT_SONNET_MODEL).toBe('gpt-5.3-codex-high');
|
||||
expect(persisted.env.ANTHROPIC_DEFAULT_HAIKU_MODEL).toBe('gpt-5.4-mini-medium');
|
||||
expect(persisted.presets[0]?.default).toBe('gpt-5.3-codex-xhigh');
|
||||
expect(persisted.presets[0]?.opus).toBe('gpt-5.4-high');
|
||||
expect(persisted.presets[0]?.sonnet).toBe('gpt-5.3-codex-high');
|
||||
expect(persisted.presets[0]?.haiku).toBe('gpt-5.4-mini-medium');
|
||||
});
|
||||
|
||||
it('preserves codex effort suffixes on GET /:profile/raw canonicalization', async () => {
|
||||
const settingsPath = path.join(tempHome, '.ccs', 'codex.settings.json');
|
||||
writeSettings(settingsPath, {
|
||||
env: {
|
||||
ANTHROPIC_BASE_URL: 'http://127.0.0.1:8317/api/provider/codex',
|
||||
ANTHROPIC_AUTH_TOKEN: 'ccs-internal-managed',
|
||||
ANTHROPIC_MODEL: 'gpt-5-codex-high',
|
||||
ANTHROPIC_DEFAULT_OPUS_MODEL: 'gpt-5.3-codex-xhigh',
|
||||
ANTHROPIC_DEFAULT_SONNET_MODEL: 'gpt-5.3-codex-high',
|
||||
ANTHROPIC_DEFAULT_HAIKU_MODEL: 'gpt-5-mini-medium',
|
||||
},
|
||||
});
|
||||
|
||||
const response = await fetch(`${baseUrl}/api/settings/codex/raw`);
|
||||
expect(response.status).toBe(200);
|
||||
|
||||
const body = (await response.json()) as { settings: { env: Record<string, string> } };
|
||||
expect(body.settings.env.ANTHROPIC_MODEL).toBe('gpt-5.4-high');
|
||||
expect(body.settings.env.ANTHROPIC_DEFAULT_OPUS_MODEL).toBe('gpt-5.3-codex-xhigh');
|
||||
expect(body.settings.env.ANTHROPIC_DEFAULT_SONNET_MODEL).toBe('gpt-5.3-codex-high');
|
||||
expect(body.settings.env.ANTHROPIC_DEFAULT_HAIKU_MODEL).toBe('gpt-5.4-mini-medium');
|
||||
|
||||
const persisted = JSON.parse(fs.readFileSync(settingsPath, 'utf8')) as {
|
||||
env: Record<string, string>;
|
||||
};
|
||||
expect(persisted.env.ANTHROPIC_MODEL).toBe('gpt-5.4-high');
|
||||
expect(persisted.env.ANTHROPIC_DEFAULT_OPUS_MODEL).toBe('gpt-5.3-codex-xhigh');
|
||||
expect(persisted.env.ANTHROPIC_DEFAULT_SONNET_MODEL).toBe('gpt-5.3-codex-high');
|
||||
expect(persisted.env.ANTHROPIC_DEFAULT_HAIKU_MODEL).toBe('gpt-5.4-mini-medium');
|
||||
});
|
||||
|
||||
it('canonicalizes AGY preset values on POST /:profile/presets', async () => {
|
||||
const settingsPath = path.join(tempHome, '.ccs', 'agy.settings.json');
|
||||
writeSettings(settingsPath, {
|
||||
|
||||
@@ -19,7 +19,13 @@ import { useTranslation } from 'react-i18next';
|
||||
import { useCreateVariant, useCliproxyAuth } from '@/hooks/use-cliproxy';
|
||||
import { usePrivacy } from '@/contexts/privacy-context';
|
||||
import { formatAccountDisplayName } from '@/lib/account-identity';
|
||||
import { CLIPROXY_PROVIDERS, getProviderDisplayName } from '@/lib/provider-config';
|
||||
import {
|
||||
CLIPROXY_PROVIDERS,
|
||||
CLIPROXY_PROVIDER_SECTIONS,
|
||||
getProviderDisplayName,
|
||||
getProviderSection,
|
||||
isPlusExtraProvider,
|
||||
} from '@/lib/provider-config';
|
||||
import { isDeniedAgyModelId } from '@/lib/utils';
|
||||
|
||||
const singleProviderSchema = z.object({
|
||||
@@ -104,6 +110,8 @@ export function CliproxyDialog({ open, onClose }: CliproxyDialogProps) {
|
||||
});
|
||||
|
||||
const selectedProvider = useWatch({ control: singleForm.control, name: 'provider' });
|
||||
const compositeTiers = useWatch({ control: compositeForm.control, name: 'tiers' });
|
||||
const selectedProviderSection = getProviderSection(selectedProvider);
|
||||
const providerAuth = authData?.authStatus.find((s) => s.provider === selectedProvider);
|
||||
const providerAccounts = providerAuth?.accounts || [];
|
||||
|
||||
@@ -197,10 +205,16 @@ export function CliproxyDialog({ open, onClose }: CliproxyDialogProps) {
|
||||
className="flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
|
||||
>
|
||||
<option value="">{t('cliproxyDialog.selectProvider')}</option>
|
||||
{providerOptions.map((opt) => (
|
||||
<option key={opt.value} value={opt.value}>
|
||||
{opt.label}
|
||||
</option>
|
||||
{CLIPROXY_PROVIDER_SECTIONS.map((section) => (
|
||||
<optgroup key={section.id} label={t(section.labelKey)}>
|
||||
{providerOptions
|
||||
.filter((opt) => section.providers.includes(opt.value))
|
||||
.map((opt) => (
|
||||
<option key={opt.value} value={opt.value}>
|
||||
{opt.label}
|
||||
</option>
|
||||
))}
|
||||
</optgroup>
|
||||
))}
|
||||
</select>
|
||||
{singleForm.formState.errors.provider && (
|
||||
@@ -208,6 +222,14 @@ export function CliproxyDialog({ open, onClose }: CliproxyDialogProps) {
|
||||
{singleForm.formState.errors.provider.message}
|
||||
</span>
|
||||
)}
|
||||
{selectedProviderSection && (
|
||||
<p className="mt-2 text-xs text-muted-foreground">
|
||||
{t(selectedProviderSection.hintKey)}
|
||||
{isPlusExtraProvider(selectedProvider)
|
||||
? ` ${t('providerConfig.plusTrackNote')}`
|
||||
: ''}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{selectedProvider && providerAccounts.length > 0 && (
|
||||
@@ -297,12 +319,26 @@ export function CliproxyDialog({ open, onClose }: CliproxyDialogProps) {
|
||||
{...compositeForm.register(`tiers.${tier}.provider`)}
|
||||
className="flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
|
||||
>
|
||||
{providerOptions.map((opt) => (
|
||||
<option key={opt.value} value={opt.value}>
|
||||
{opt.label}
|
||||
</option>
|
||||
{CLIPROXY_PROVIDER_SECTIONS.map((section) => (
|
||||
<optgroup key={section.id} label={t(section.labelKey)}>
|
||||
{providerOptions
|
||||
.filter((opt) => section.providers.includes(opt.value))
|
||||
.map((opt) => (
|
||||
<option key={opt.value} value={opt.value}>
|
||||
{opt.label}
|
||||
</option>
|
||||
))}
|
||||
</optgroup>
|
||||
))}
|
||||
</select>
|
||||
{compositeTiers?.[tier]?.provider && (
|
||||
<p className="mt-2 text-xs text-muted-foreground">
|
||||
{t(getProviderSection(compositeTiers[tier].provider)?.hintKey || '')}
|
||||
{isPlusExtraProvider(compositeTiers[tier].provider)
|
||||
? ` ${t('providerConfig.plusTrackNote')}`
|
||||
: ''}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor={`${tier}-model`}>{t('cliproxyDialog.model')}</Label>
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
* Phase 05: Dashboard UI full CRUD for composite variants
|
||||
*/
|
||||
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { useForm, useWatch } from 'react-hook-form';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import * as z from 'zod';
|
||||
import { useEffect } from 'react';
|
||||
@@ -15,7 +15,13 @@ import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs';
|
||||
import { toast } from 'sonner';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useUpdateVariant } from '@/hooks/use-cliproxy';
|
||||
import { CLIPROXY_PROVIDERS, getProviderDisplayName } from '@/lib/provider-config';
|
||||
import {
|
||||
CLIPROXY_PROVIDERS,
|
||||
CLIPROXY_PROVIDER_SECTIONS,
|
||||
getProviderDisplayName,
|
||||
getProviderSection,
|
||||
isPlusExtraProvider,
|
||||
} from '@/lib/provider-config';
|
||||
import type { UpdateVariant, Variant } from '@/lib/api-client';
|
||||
import { isDeniedAgyModelId } from '@/lib/utils';
|
||||
|
||||
@@ -137,6 +143,8 @@ export function CliproxyEditDialog({ variant, open, onOpenChange }: CliproxyEdit
|
||||
const compositeForm = useForm<CompositeFormData>({
|
||||
resolver: zodResolver(compositeSchema),
|
||||
});
|
||||
const selectedProvider = useWatch({ control: singleForm.control, name: 'provider' });
|
||||
const compositeTiers = useWatch({ control: compositeForm.control, name: 'tiers' });
|
||||
|
||||
// Pre-populate form when variant changes
|
||||
useEffect(() => {
|
||||
@@ -318,12 +326,26 @@ export function CliproxyEditDialog({ variant, open, onOpenChange }: CliproxyEdit
|
||||
{...compositeForm.register(`tiers.${tier}.provider`)}
|
||||
className="flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
|
||||
>
|
||||
{providerOptions.map((opt) => (
|
||||
<option key={opt.value} value={opt.value}>
|
||||
{opt.label}
|
||||
</option>
|
||||
{CLIPROXY_PROVIDER_SECTIONS.map((section) => (
|
||||
<optgroup key={section.id} label={t(section.labelKey)}>
|
||||
{providerOptions
|
||||
.filter((opt) => section.providers.includes(opt.value))
|
||||
.map((opt) => (
|
||||
<option key={opt.value} value={opt.value}>
|
||||
{opt.label}
|
||||
</option>
|
||||
))}
|
||||
</optgroup>
|
||||
))}
|
||||
</select>
|
||||
{compositeTiers?.[tier]?.provider && (
|
||||
<p className="mt-2 text-xs text-muted-foreground">
|
||||
{t(getProviderSection(compositeTiers[tier].provider)?.hintKey || '')}
|
||||
{isPlusExtraProvider(compositeTiers[tier].provider)
|
||||
? ` ${t('providerConfig.plusTrackNote')}`
|
||||
: ''}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor={`edit-${tier}-model`}>{t('cliproxyDialog.model')}</Label>
|
||||
@@ -399,12 +421,26 @@ export function CliproxyEditDialog({ variant, open, onOpenChange }: CliproxyEdit
|
||||
{...singleForm.register('provider')}
|
||||
className="flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
|
||||
>
|
||||
{providerOptions.map((opt) => (
|
||||
<option key={opt.value} value={opt.value}>
|
||||
{opt.label}
|
||||
</option>
|
||||
{CLIPROXY_PROVIDER_SECTIONS.map((section) => (
|
||||
<optgroup key={section.id} label={t(section.labelKey)}>
|
||||
{providerOptions
|
||||
.filter((opt) => section.providers.includes(opt.value))
|
||||
.map((opt) => (
|
||||
<option key={opt.value} value={opt.value}>
|
||||
{opt.label}
|
||||
</option>
|
||||
))}
|
||||
</optgroup>
|
||||
))}
|
||||
</select>
|
||||
{selectedProvider && (
|
||||
<p className="mt-2 text-xs text-muted-foreground">
|
||||
{t(getProviderSection(selectedProvider)?.hintKey || '')}
|
||||
{isPlusExtraProvider(selectedProvider)
|
||||
? ` ${t('providerConfig.plusTrackNote')}`
|
||||
: ''}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
|
||||
@@ -304,6 +304,7 @@ export function ProviderEditor({
|
||||
<ProviderInfoTab
|
||||
provider={provider}
|
||||
displayName={displayName}
|
||||
baseProvider={baseProvider}
|
||||
defaultTarget={defaultTarget}
|
||||
data={data}
|
||||
authStatus={authStatus}
|
||||
|
||||
@@ -229,7 +229,8 @@ export function ModelConfigSection({
|
||||
{provider === 'codex' && (
|
||||
<p className="text-[11px] text-muted-foreground mb-3 rounded-md border bg-muted/30 px-2.5 py-2">
|
||||
Codex tip: suffixes <code>-medium</code>, <code>-high</code>, and <code>-xhigh</code>{' '}
|
||||
pin reasoning effort. Unsuffixed models use Thinking settings.
|
||||
pin reasoning effort. Select a suffixed model to pin effort; unsuffixed models use
|
||||
Thinking settings.
|
||||
</p>
|
||||
)}
|
||||
<div className="space-y-4">
|
||||
|
||||
@@ -10,11 +10,13 @@ import { Info, Shield } from 'lucide-react';
|
||||
import { UsageCommand } from './usage-command';
|
||||
import type { SettingsResponse } from './types';
|
||||
import type { AuthStatus, CliTarget } from '@/lib/api-client';
|
||||
import { getProviderSection, isPlusExtraProvider } from '@/lib/provider-config';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
interface ProviderInfoTabProps {
|
||||
provider: string;
|
||||
displayName: string;
|
||||
baseProvider?: string;
|
||||
defaultTarget?: CliTarget;
|
||||
data?: SettingsResponse;
|
||||
authStatus: AuthStatus;
|
||||
@@ -24,6 +26,7 @@ interface ProviderInfoTabProps {
|
||||
export function ProviderInfoTab({
|
||||
provider,
|
||||
displayName,
|
||||
baseProvider,
|
||||
defaultTarget,
|
||||
data,
|
||||
authStatus,
|
||||
@@ -33,6 +36,8 @@ export function ProviderInfoTab({
|
||||
const resolvedTarget = defaultTarget || 'claude';
|
||||
const isDroidTarget = resolvedTarget === 'droid';
|
||||
const isCodexProvider = provider === 'codex';
|
||||
const sectionProvider = baseProvider || authStatus.provider || provider;
|
||||
const providerSection = getProviderSection(sectionProvider);
|
||||
const managementPrefix =
|
||||
resolvedTarget === 'claude' ? `ccs ${provider}` : `ccs ${provider} --target claude`;
|
||||
const changeModelCommand = `${managementPrefix} --config`;
|
||||
@@ -101,6 +106,22 @@ export function ProviderInfoTab({
|
||||
</span>
|
||||
<span className="font-mono">{resolvedTarget}</span>
|
||||
</div>
|
||||
{providerSection && (
|
||||
<div className="grid grid-cols-[100px_1fr] gap-2 text-sm items-start">
|
||||
<span className="font-medium text-muted-foreground">
|
||||
{t('providerConfig.trackLabel')}
|
||||
</span>
|
||||
<div className="space-y-1">
|
||||
<span className="font-mono">{t(providerSection.labelKey)}</span>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t(providerSection.hintKey)}
|
||||
{isPlusExtraProvider(sectionProvider)
|
||||
? ` ${t('providerConfig.plusTrackNote')}`
|
||||
: ''}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -12,7 +12,8 @@ import { Badge } from '@/components/ui/badge';
|
||||
import { SearchableSelect } from '@/components/ui/searchable-select';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import type { CliproxyProviderRoutingHints } from '@/lib/api-client';
|
||||
import { getCodexEffortDisplay } from '@/lib/codex-effort';
|
||||
import type { CodexEffort } from '@/lib/codex-effort';
|
||||
import { getCodexEffortDisplay, getCodexEffortVariants } from '@/lib/codex-effort';
|
||||
import { getResolvedCatalogModels, getSupplementalCatalogModels } from '@/lib/model-catalogs';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
@@ -35,6 +36,8 @@ export interface ModelEntry {
|
||||
sonnet: string;
|
||||
haiku: string;
|
||||
};
|
||||
/** Highest codex reasoning-effort suffix this model supports in the dashboard UI. */
|
||||
codexMaxEffort?: CodexEffort;
|
||||
}
|
||||
|
||||
/** Provider catalog */
|
||||
@@ -313,6 +316,14 @@ function getPreferredOptionValue(
|
||||
return routingHint?.recommendedModelId ?? modelId;
|
||||
}
|
||||
|
||||
function getModelOptionValues(
|
||||
codexMaxEffort: CodexEffort | undefined,
|
||||
optionValue: string,
|
||||
isCodexProvider: boolean
|
||||
): string[] {
|
||||
return isCodexProvider ? getCodexEffortVariants(optionValue, codexMaxEffort) : [optionValue];
|
||||
}
|
||||
|
||||
export function FlexibleModelSelector({
|
||||
label,
|
||||
description,
|
||||
@@ -342,55 +353,64 @@ export function FlexibleModelSelector({
|
||||
const recommendedOptionValues = useMemo(
|
||||
() =>
|
||||
new Set(
|
||||
resolvedCatalogModels.map((model) =>
|
||||
getPreferredOptionValue(model.id, routingHints.get(model.id.toLowerCase()))
|
||||
resolvedCatalogModels.flatMap((model) =>
|
||||
getModelOptionValues(
|
||||
model.codexMaxEffort,
|
||||
getPreferredOptionValue(model.id, routingHints.get(model.id.toLowerCase())),
|
||||
isCodexProvider
|
||||
)
|
||||
)
|
||||
),
|
||||
[resolvedCatalogModels, routingHints]
|
||||
[isCodexProvider, resolvedCatalogModels, routingHints]
|
||||
);
|
||||
const selectedRoutingHint = useMemo(
|
||||
() => routingHints.get(normalizeModelValue(value, routing).toLowerCase()),
|
||||
[routing, routingHints, value]
|
||||
);
|
||||
|
||||
const recommendedOptions = resolvedCatalogModels.map((model) => ({
|
||||
value: getPreferredOptionValue(model.id, routingHints.get(model.id.toLowerCase())),
|
||||
groupKey: 'recommended',
|
||||
searchText: `${model.id} ${model.name} ${routingHints.get(model.id.toLowerCase())?.recommendedModelId ?? ''}`,
|
||||
keywords: [model.tier ?? '', catalog?.provider ?? ''],
|
||||
triggerContent: (
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<span className="truncate font-mono text-xs">
|
||||
{getPreferredOptionValue(model.id, routingHints.get(model.id.toLowerCase()))}
|
||||
</span>
|
||||
{routingHints.get(model.id.toLowerCase())?.pinnedAvailable ? (
|
||||
<Badge variant="secondary" className="text-[9px] h-4 px-1 uppercase">
|
||||
{routingHints.get(model.id.toLowerCase())?.prefix}
|
||||
</Badge>
|
||||
) : null}
|
||||
{isCodexProvider && <CodexEffortBadge modelId={model.id} />}
|
||||
</div>
|
||||
),
|
||||
itemContent: (
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<span className="truncate font-mono text-xs">
|
||||
{getPreferredOptionValue(model.id, routingHints.get(model.id.toLowerCase()))}
|
||||
</span>
|
||||
{model.tier === 'paid' && <PaidBadge label={t('providerModelSelector.paid')} />}
|
||||
{routingHints.get(model.id.toLowerCase())?.unprefixedStatus === 'shadowed' ? (
|
||||
<Badge variant="outline" className="text-[9px] h-4 px-1">
|
||||
{t('providerModelSelector.shadowed')}
|
||||
</Badge>
|
||||
) : null}
|
||||
{routingHints.get(model.id.toLowerCase())?.unprefixedStatus === 'prefix-only' ? (
|
||||
<Badge variant="outline" className="text-[9px] h-4 px-1">
|
||||
{t('providerModelSelector.prefixOnly')}
|
||||
</Badge>
|
||||
) : null}
|
||||
{isCodexProvider && <CodexEffortBadge modelId={model.id} />}
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
const recommendedOptions = resolvedCatalogModels.flatMap((model) => {
|
||||
const routingHint = routingHints.get(model.id.toLowerCase());
|
||||
const optionValues = getModelOptionValues(
|
||||
model.codexMaxEffort,
|
||||
getPreferredOptionValue(model.id, routingHint),
|
||||
isCodexProvider
|
||||
);
|
||||
|
||||
return optionValues.map((optionValue) => ({
|
||||
value: optionValue,
|
||||
groupKey: 'recommended',
|
||||
searchText: `${optionValue} ${model.id} ${model.name} ${routingHint?.recommendedModelId ?? ''}`,
|
||||
keywords: [model.tier ?? '', catalog?.provider ?? ''],
|
||||
triggerContent: (
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<span className="truncate font-mono text-xs">{optionValue}</span>
|
||||
{routingHint?.pinnedAvailable ? (
|
||||
<Badge variant="secondary" className="text-[9px] h-4 px-1 uppercase">
|
||||
{routingHint.prefix}
|
||||
</Badge>
|
||||
) : null}
|
||||
{isCodexProvider && <CodexEffortBadge modelId={optionValue} />}
|
||||
</div>
|
||||
),
|
||||
itemContent: (
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<span className="truncate font-mono text-xs">{optionValue}</span>
|
||||
{model.tier === 'paid' && <PaidBadge label={t('providerModelSelector.paid')} />}
|
||||
{routingHint?.unprefixedStatus === 'shadowed' ? (
|
||||
<Badge variant="outline" className="text-[9px] h-4 px-1">
|
||||
{t('providerModelSelector.shadowed')}
|
||||
</Badge>
|
||||
) : null}
|
||||
{routingHint?.unprefixedStatus === 'prefix-only' ? (
|
||||
<Badge variant="outline" className="text-[9px] h-4 px-1">
|
||||
{t('providerModelSelector.prefixOnly')}
|
||||
</Badge>
|
||||
) : null}
|
||||
{isCodexProvider && <CodexEffortBadge modelId={optionValue} />}
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
});
|
||||
|
||||
const allModelOptions = supplementalModels
|
||||
.filter((model) => !catalogModelIds.has(model.id))
|
||||
@@ -400,43 +420,48 @@ export function FlexibleModelSelector({
|
||||
getPreferredOptionValue(model.id, routingHints.get(model.id.toLowerCase()))
|
||||
)
|
||||
)
|
||||
.map((model) => ({
|
||||
value: getPreferredOptionValue(model.id, routingHints.get(model.id.toLowerCase())),
|
||||
groupKey: 'all',
|
||||
searchText: `${model.id} ${routingHints.get(model.id.toLowerCase())?.recommendedModelId ?? ''}`,
|
||||
keywords: [model.owned_by],
|
||||
triggerContent: (
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<span className="truncate font-mono text-xs">
|
||||
{getPreferredOptionValue(model.id, routingHints.get(model.id.toLowerCase()))}
|
||||
</span>
|
||||
{routingHints.get(model.id.toLowerCase())?.pinnedAvailable ? (
|
||||
<Badge variant="secondary" className="text-[9px] h-4 px-1 uppercase">
|
||||
{routingHints.get(model.id.toLowerCase())?.prefix}
|
||||
</Badge>
|
||||
) : null}
|
||||
{isCodexProvider && <CodexEffortBadge modelId={model.id} />}
|
||||
</div>
|
||||
),
|
||||
itemContent: (
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<span className="truncate font-mono text-xs">
|
||||
{getPreferredOptionValue(model.id, routingHints.get(model.id.toLowerCase()))}
|
||||
</span>
|
||||
{routingHints.get(model.id.toLowerCase())?.unprefixedStatus === 'shadowed' ? (
|
||||
<Badge variant="outline" className="text-[9px] h-4 px-1">
|
||||
{t('providerModelSelector.shadowed')}
|
||||
</Badge>
|
||||
) : null}
|
||||
{routingHints.get(model.id.toLowerCase())?.unprefixedStatus === 'prefix-only' ? (
|
||||
<Badge variant="outline" className="text-[9px] h-4 px-1">
|
||||
{t('providerModelSelector.prefixOnly')}
|
||||
</Badge>
|
||||
) : null}
|
||||
{isCodexProvider && <CodexEffortBadge modelId={model.id} />}
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
.flatMap((model) => {
|
||||
const routingHint = routingHints.get(model.id.toLowerCase());
|
||||
const optionValues = getModelOptionValues(
|
||||
undefined,
|
||||
getPreferredOptionValue(model.id, routingHint),
|
||||
isCodexProvider
|
||||
);
|
||||
|
||||
return optionValues.map((optionValue) => ({
|
||||
value: optionValue,
|
||||
groupKey: 'all',
|
||||
searchText: `${optionValue} ${model.id} ${routingHint?.recommendedModelId ?? ''}`,
|
||||
keywords: [model.owned_by],
|
||||
triggerContent: (
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<span className="truncate font-mono text-xs">{optionValue}</span>
|
||||
{routingHint?.pinnedAvailable ? (
|
||||
<Badge variant="secondary" className="text-[9px] h-4 px-1 uppercase">
|
||||
{routingHint.prefix}
|
||||
</Badge>
|
||||
) : null}
|
||||
{isCodexProvider && <CodexEffortBadge modelId={optionValue} />}
|
||||
</div>
|
||||
),
|
||||
itemContent: (
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<span className="truncate font-mono text-xs">{optionValue}</span>
|
||||
{routingHint?.unprefixedStatus === 'shadowed' ? (
|
||||
<Badge variant="outline" className="text-[9px] h-4 px-1">
|
||||
{t('providerModelSelector.shadowed')}
|
||||
</Badge>
|
||||
) : null}
|
||||
{routingHint?.unprefixedStatus === 'prefix-only' ? (
|
||||
<Badge variant="outline" className="text-[9px] h-4 px-1">
|
||||
{t('providerModelSelector.prefixOnly')}
|
||||
</Badge>
|
||||
) : null}
|
||||
{isCodexProvider && <CodexEffortBadge modelId={optionValue} />}
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
});
|
||||
const selectedValueMissing =
|
||||
Boolean(value) &&
|
||||
!recommendedOptions.some((option) => option.value === value) &&
|
||||
|
||||
@@ -1,14 +1,56 @@
|
||||
export type CodexEffort = 'medium' | 'high' | 'xhigh';
|
||||
|
||||
const CODEX_EFFORT_SUFFIX_REGEX = /-(medium|high|xhigh)$/i;
|
||||
const CODEX_EFFORTS_IN_ORDER: readonly CodexEffort[] = ['medium', 'high', 'xhigh'];
|
||||
|
||||
function trimModelId(modelId: string | undefined): string {
|
||||
return modelId?.trim() ?? '';
|
||||
}
|
||||
|
||||
export function parseCodexEffort(modelId: string | undefined): CodexEffort | undefined {
|
||||
if (!modelId) return undefined;
|
||||
const match = modelId.trim().match(CODEX_EFFORT_SUFFIX_REGEX);
|
||||
const match = trimModelId(modelId).match(CODEX_EFFORT_SUFFIX_REGEX);
|
||||
if (!match?.[1]) return undefined;
|
||||
return match[1].toLowerCase() as CodexEffort;
|
||||
}
|
||||
|
||||
export function stripCodexEffortSuffix(modelId: string | undefined): string {
|
||||
return trimModelId(modelId).replace(CODEX_EFFORT_SUFFIX_REGEX, '');
|
||||
}
|
||||
|
||||
export function applyCodexEffortSuffix(
|
||||
modelId: string | undefined,
|
||||
effort: CodexEffort | undefined
|
||||
): string {
|
||||
const normalizedModelId = stripCodexEffortSuffix(modelId);
|
||||
if (!normalizedModelId || !effort) {
|
||||
return normalizedModelId;
|
||||
}
|
||||
return `${normalizedModelId}-${effort}`;
|
||||
}
|
||||
|
||||
export function getCodexEffortVariants(
|
||||
modelId: string,
|
||||
maxEffort: CodexEffort | undefined
|
||||
): string[] {
|
||||
if (!maxEffort) {
|
||||
const explicitEffort = parseCodexEffort(modelId);
|
||||
return [applyCodexEffortSuffix(modelId, explicitEffort)];
|
||||
}
|
||||
|
||||
const normalizedModelId = stripCodexEffortSuffix(modelId);
|
||||
const variantIds = [normalizedModelId];
|
||||
|
||||
for (const effort of CODEX_EFFORTS_IN_ORDER) {
|
||||
variantIds.push(applyCodexEffortSuffix(normalizedModelId, effort));
|
||||
if (effort === maxEffort) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return variantIds;
|
||||
}
|
||||
|
||||
export function getCodexEffortDisplay(
|
||||
modelId: string | undefined,
|
||||
effortLabels?: { pinned: (effort: string) => string; auto: string }
|
||||
|
||||
+50
-12
@@ -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: '更新センターを開く',
|
||||
|
||||
@@ -263,6 +263,7 @@ export const MODEL_CATALOGS: Record<string, ProviderCatalog> = {
|
||||
id: 'gpt-5-codex',
|
||||
name: 'GPT-5 Codex',
|
||||
description: 'Cross-plan safe Codex default',
|
||||
codexMaxEffort: 'xhigh',
|
||||
presetMapping: {
|
||||
default: 'gpt-5-codex',
|
||||
opus: 'gpt-5-codex',
|
||||
@@ -274,6 +275,7 @@ export const MODEL_CATALOGS: Record<string, ProviderCatalog> = {
|
||||
id: 'gpt-5-codex-mini',
|
||||
name: 'GPT-5 Codex Mini',
|
||||
description: 'Faster and cheaper Codex option',
|
||||
codexMaxEffort: 'high',
|
||||
presetMapping: {
|
||||
default: 'gpt-5-codex-mini',
|
||||
opus: 'gpt-5-codex',
|
||||
@@ -285,6 +287,7 @@ export const MODEL_CATALOGS: Record<string, ProviderCatalog> = {
|
||||
id: 'gpt-5-mini',
|
||||
name: 'GPT-5 Mini',
|
||||
description: 'Legacy mini model ID kept for backwards compatibility',
|
||||
codexMaxEffort: 'high',
|
||||
presetMapping: {
|
||||
default: 'gpt-5-mini',
|
||||
opus: 'gpt-5-codex',
|
||||
@@ -296,6 +299,7 @@ export const MODEL_CATALOGS: Record<string, ProviderCatalog> = {
|
||||
id: 'gpt-5.1-codex-mini',
|
||||
name: 'GPT-5.1 Codex Mini',
|
||||
description: 'Legacy fast Codex mini model',
|
||||
codexMaxEffort: 'high',
|
||||
presetMapping: {
|
||||
default: 'gpt-5.1-codex-mini',
|
||||
opus: 'gpt-5.1-codex-max',
|
||||
@@ -307,6 +311,7 @@ export const MODEL_CATALOGS: Record<string, ProviderCatalog> = {
|
||||
id: 'gpt-5.1-codex-max',
|
||||
name: 'GPT-5.1 Codex Max',
|
||||
description: 'Higher-effort Codex model with xhigh support',
|
||||
codexMaxEffort: 'xhigh',
|
||||
presetMapping: {
|
||||
default: 'gpt-5.1-codex-max',
|
||||
opus: 'gpt-5.1-codex-max',
|
||||
@@ -318,6 +323,7 @@ export const MODEL_CATALOGS: Record<string, ProviderCatalog> = {
|
||||
id: 'gpt-5.2-codex',
|
||||
name: 'GPT-5.2 Codex',
|
||||
description: 'Cross-plan Codex model with xhigh support',
|
||||
codexMaxEffort: 'xhigh',
|
||||
presetMapping: {
|
||||
default: 'gpt-5.2-codex',
|
||||
opus: 'gpt-5.2-codex',
|
||||
@@ -330,6 +336,7 @@ export const MODEL_CATALOGS: Record<string, ProviderCatalog> = {
|
||||
name: 'GPT-5.3 Codex',
|
||||
tier: 'paid',
|
||||
description: 'Paid Codex plans only',
|
||||
codexMaxEffort: 'xhigh',
|
||||
presetMapping: {
|
||||
default: 'gpt-5.3-codex',
|
||||
opus: 'gpt-5.3-codex',
|
||||
@@ -342,6 +349,7 @@ export const MODEL_CATALOGS: Record<string, ProviderCatalog> = {
|
||||
name: 'GPT-5.3 Codex Spark',
|
||||
tier: 'paid',
|
||||
description: 'Paid Codex plans only, ultra-fast coding model',
|
||||
codexMaxEffort: 'xhigh',
|
||||
presetMapping: {
|
||||
default: 'gpt-5.3-codex-spark',
|
||||
opus: 'gpt-5.3-codex',
|
||||
@@ -354,6 +362,7 @@ export const MODEL_CATALOGS: Record<string, ProviderCatalog> = {
|
||||
name: 'GPT-5.4',
|
||||
tier: 'paid',
|
||||
description: 'Paid Codex plans only, latest GPT-5 family model',
|
||||
codexMaxEffort: 'xhigh',
|
||||
presetMapping: {
|
||||
default: 'gpt-5.4',
|
||||
opus: 'gpt-5.4',
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
getProvidersByOAuthFlow,
|
||||
} from '../../../src/cliproxy/provider-capabilities';
|
||||
import type { AiProviderFamilyId, AiProviderModelAlias } from '../../../src/cliproxy/ai-providers';
|
||||
import { PLUS_ONLY_PROVIDERS } from '../../../src/cliproxy/types';
|
||||
import i18n from './i18n';
|
||||
|
||||
// Monorepo contract: UI consumes provider capability constants directly from backend
|
||||
@@ -19,6 +20,39 @@ import i18n from './i18n';
|
||||
export const CLIPROXY_PROVIDERS = CLIPROXY_PROVIDER_IDS;
|
||||
export type CLIProxyProvider = (typeof CLIPROXY_PROVIDERS)[number];
|
||||
export type ProviderVisualId = CLIProxyProvider | 'openai' | 'vertex';
|
||||
export type CLIProxyProviderSectionId = 'core' | 'plus-extra';
|
||||
|
||||
export interface CLIProxyProviderSection {
|
||||
id: CLIProxyProviderSectionId;
|
||||
labelKey: string;
|
||||
hintKey: string;
|
||||
providers: readonly CLIProxyProvider[];
|
||||
}
|
||||
|
||||
const PLUS_ONLY_PROVIDER_SET = new Set<CLIProxyProvider>(PLUS_ONLY_PROVIDERS);
|
||||
|
||||
export const CORE_CLIPROXY_PROVIDERS: readonly CLIProxyProvider[] = Object.freeze(
|
||||
CLIPROXY_PROVIDERS.filter((provider) => !PLUS_ONLY_PROVIDER_SET.has(provider))
|
||||
);
|
||||
|
||||
export const PLUS_EXTRA_CLIPROXY_PROVIDERS: readonly CLIProxyProvider[] = Object.freeze(
|
||||
CLIPROXY_PROVIDERS.filter((provider) => PLUS_ONLY_PROVIDER_SET.has(provider))
|
||||
);
|
||||
|
||||
export const CLIPROXY_PROVIDER_SECTIONS: readonly CLIProxyProviderSection[] = Object.freeze([
|
||||
{
|
||||
id: 'core',
|
||||
labelKey: 'providerConfig.sectionCoreLabel',
|
||||
hintKey: 'providerConfig.sectionCoreHint',
|
||||
providers: CORE_CLIPROXY_PROVIDERS,
|
||||
},
|
||||
{
|
||||
id: 'plus-extra',
|
||||
labelKey: 'providerConfig.sectionPlusLabel',
|
||||
hintKey: 'providerConfig.sectionPlusHint',
|
||||
providers: PLUS_EXTRA_CLIPROXY_PROVIDERS,
|
||||
},
|
||||
]);
|
||||
|
||||
/** Check if a string is a backend-supported CLIProxy provider. */
|
||||
export function isValidProvider(provider: string): provider is CLIProxyProvider {
|
||||
@@ -259,6 +293,52 @@ export function getProviderDisplayName(provider: unknown): string {
|
||||
return PROVIDER_NAMES[normalized] || i18n.t('toasts.providerUnknown', { provider: normalized });
|
||||
}
|
||||
|
||||
export function isPlusExtraProvider(provider: unknown): boolean {
|
||||
const normalized = normalizeProviderInput(provider);
|
||||
return isValidProvider(normalized) && PLUS_ONLY_PROVIDER_SET.has(normalized);
|
||||
}
|
||||
|
||||
export function getProviderSection(provider: unknown): CLIProxyProviderSection | null {
|
||||
const normalized = normalizeProviderInput(provider);
|
||||
if (!isValidProvider(normalized)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
CLIPROXY_PROVIDER_SECTIONS.find((section) => section.providers.includes(normalized)) || null
|
||||
);
|
||||
}
|
||||
|
||||
interface VariantLike {
|
||||
provider?: unknown;
|
||||
tiers?: Record<string, { provider?: unknown } | undefined> | null;
|
||||
}
|
||||
|
||||
export function variantUsesPlusExtraProvider(variant: VariantLike | null | undefined): boolean {
|
||||
if (!variant) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (variant.tiers) {
|
||||
return Object.values(variant.tiers).some((tier) => isPlusExtraProvider(tier?.provider));
|
||||
}
|
||||
|
||||
return isPlusExtraProvider(variant.provider);
|
||||
}
|
||||
|
||||
export function groupProvidersBySection<T>(
|
||||
items: readonly T[],
|
||||
getProvider: (item: T) => unknown
|
||||
): Array<CLIProxyProviderSection & { items: T[] }> {
|
||||
return CLIPROXY_PROVIDER_SECTIONS.map((section) => ({
|
||||
...section,
|
||||
items: items.filter((item) => {
|
||||
const normalized = normalizeProviderInput(getProvider(item));
|
||||
return isValidProvider(normalized) && section.providers.includes(normalized);
|
||||
}),
|
||||
})).filter((section) => section.items.length > 0);
|
||||
}
|
||||
|
||||
/** Map provider to user-facing short description */
|
||||
export function getProviderDescription(provider: unknown): string {
|
||||
const normalized = normalizeProviderInput(provider);
|
||||
|
||||
@@ -33,7 +33,11 @@ import {
|
||||
} from '@/hooks/use-cliproxy';
|
||||
import type { AuthStatus, Variant } from '@/lib/api-client';
|
||||
import { buildUiCatalogs } from '@/lib/model-catalogs';
|
||||
import { getProviderDisplayName, isValidProvider } from '@/lib/provider-config';
|
||||
import {
|
||||
getProviderDisplayName,
|
||||
groupProvidersBySection,
|
||||
isValidProvider,
|
||||
} from '@/lib/provider-config';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
@@ -261,6 +265,10 @@ export function CliproxyPage() {
|
||||
});
|
||||
|
||||
const providers = useMemo(() => authData?.authStatus || [], [authData?.authStatus]);
|
||||
const providerSections = useMemo(
|
||||
() => groupProvidersBySection(providers, (status) => status.provider),
|
||||
[providers]
|
||||
);
|
||||
const isRemoteMode = authData?.source === 'remote';
|
||||
const variants = useMemo(() => variantsData?.variants || [], [variantsData?.variants]);
|
||||
const catalogs = useMemo(() => buildUiCatalogs(catalogData?.catalogs), [catalogData?.catalogs]);
|
||||
@@ -393,14 +401,28 @@ export function CliproxyPage() {
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-1">
|
||||
{providers.map((status) => (
|
||||
<ProviderSidebarItem
|
||||
key={status.provider}
|
||||
status={status}
|
||||
isSelected={effectiveProvider === status.provider}
|
||||
onSelect={() => handleSelectProvider(status.provider)}
|
||||
/>
|
||||
<div className="space-y-4">
|
||||
{providerSections.map((section) => (
|
||||
<div key={section.id} className="space-y-1">
|
||||
<div className="px-3">
|
||||
<div className="text-[11px] font-medium uppercase tracking-wide text-muted-foreground">
|
||||
{t(section.labelKey)}
|
||||
</div>
|
||||
<p className="mt-1 text-[11px] leading-relaxed text-muted-foreground">
|
||||
{t(section.hintKey)}
|
||||
</p>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
{section.items.map((status) => (
|
||||
<ProviderSidebarItem
|
||||
key={status.provider}
|
||||
status={status}
|
||||
isSelected={effectiveProvider === status.provider}
|
||||
onSelect={() => handleSelectProvider(status.provider)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -29,15 +29,18 @@ import { ProxyStatusWidget } from '@/components/monitoring/proxy-status-widget';
|
||||
import { api } from '@/lib/api-client';
|
||||
import { CLIPROXY_DEFAULT_PORT } from '@/lib/preset-utils';
|
||||
import { RISK_ACK_PHRASE } from '@/components/account/antigravity-responsibility-constants';
|
||||
import {
|
||||
CORE_CLIPROXY_PROVIDERS,
|
||||
PLUS_EXTRA_CLIPROXY_PROVIDERS,
|
||||
getProviderDisplayName,
|
||||
variantUsesPlusExtraProvider,
|
||||
} from '@/lib/provider-config';
|
||||
import { toast } from 'sonner';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
/** LocalStorage key for debug mode preference */
|
||||
const DEBUG_MODE_KEY = 'ccs_debug_mode';
|
||||
|
||||
/** Providers only available on CLIProxyAPIPlus */
|
||||
const PLUS_ONLY_PROVIDERS = ['kiro', 'ghcp'];
|
||||
|
||||
function normalizeRiskAckPhrase(value: string): string {
|
||||
return value.trim().replace(/\s+/g, ' ').toUpperCase();
|
||||
}
|
||||
@@ -191,11 +194,13 @@ export default function ProxySection() {
|
||||
}, [isAgyConfirmPhraseValid, persistAgyAckBypass, t]);
|
||||
|
||||
// Backend state (loaded from API) + mutation hook for proper query invalidation
|
||||
const [backend, setBackend] = useState<'original' | 'plus'>('plus');
|
||||
const [backend, setBackend] = useState<'original' | 'plus'>('original');
|
||||
const [hasKiroGhcpVariants, setHasKiroGhcpVariants] = useState(false);
|
||||
const updateBackendMutation = useUpdateBackend();
|
||||
const { data: proxyStatus } = useProxyStatus();
|
||||
const isProxyRunning = proxyStatus?.running ?? false;
|
||||
const coreProviderNames = CORE_CLIPROXY_PROVIDERS.map(getProviderDisplayName).join(', ');
|
||||
const plusProviderNames = PLUS_EXTRA_CLIPROXY_PROVIDERS.map(getProviderDisplayName).join(', ');
|
||||
|
||||
// Fetch backend setting
|
||||
const fetchBackend = useCallback(async () => {
|
||||
@@ -211,7 +216,9 @@ export default function ProxySection() {
|
||||
const checkPlusOnlyVariants = useCallback(async () => {
|
||||
try {
|
||||
const result = await api.cliproxy.list();
|
||||
const hasIncompatible = result.variants.some((v) => PLUS_ONLY_PROVIDERS.includes(v.provider));
|
||||
const hasIncompatible = result.variants.some((variant) =>
|
||||
variantUsesPlusExtraProvider(variant)
|
||||
);
|
||||
setHasKiroGhcpVariants(hasIncompatible);
|
||||
} catch (err) {
|
||||
console.error('[Proxy] Failed to check variants:', err);
|
||||
@@ -490,11 +497,11 @@ export default function ProxySection() {
|
||||
>
|
||||
<div className="flex items-center gap-3 mb-2">
|
||||
<span className="font-medium">{t('settingsProxy.backendPlusApi')}</span>
|
||||
<span className="text-xs px-1.5 py-0.5 rounded bg-green-100 text-green-700 dark:bg-green-900/50 dark:text-green-400">
|
||||
{t('settingsProxy.default')}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">{t('settingsProxy.plusDesc')}</p>
|
||||
<p className="mt-2 text-[11px] leading-relaxed text-muted-foreground">
|
||||
{plusProviderNames}
|
||||
</p>
|
||||
</button>
|
||||
|
||||
{/* Original Backend Card */}
|
||||
@@ -509,15 +516,31 @@ export default function ProxySection() {
|
||||
>
|
||||
<div className="flex items-center gap-3 mb-2">
|
||||
<span className="font-medium">{t('settingsProxy.backendApi')}</span>
|
||||
<span className="text-xs px-1.5 py-0.5 rounded bg-green-100 text-green-700 dark:bg-green-900/50 dark:text-green-400">
|
||||
{t('settingsProxy.default')}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">{t('settingsProxy.originalDesc')}</p>
|
||||
<p className="mt-2 text-[11px] leading-relaxed text-muted-foreground">
|
||||
{coreProviderNames}
|
||||
</p>
|
||||
</button>
|
||||
</div>
|
||||
{backend === 'plus' && (
|
||||
<Alert className="py-2 border-amber-200 bg-amber-50 dark:border-amber-900/50 dark:bg-amber-900/20 [&>svg]:top-2.5">
|
||||
<AlertTriangle className="h-4 w-4 text-amber-600" />
|
||||
<AlertDescription className="text-amber-700 dark:text-amber-400">
|
||||
{t('settingsProxy.plusFallbackNotice')}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
{/* Warning when original backend selected with Kiro/ghcp variants */}
|
||||
{backend === 'original' && hasKiroGhcpVariants && (
|
||||
<Alert variant="destructive" className="py-2">
|
||||
<AlertTriangle className="h-4 w-4" />
|
||||
<AlertDescription>{t('settingsProxy.variantsIncompatible')}</AlertDescription>
|
||||
<AlertDescription>
|
||||
{t('settingsProxy.variantsIncompatible', { providers: plusProviderNames })}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -32,6 +32,7 @@ const THINKING_LEVELS = [
|
||||
{ value: 'medium', label: 'Medium (8K tokens)' },
|
||||
{ value: 'high', label: 'High (24K tokens)' },
|
||||
{ value: 'xhigh', label: 'Extra High (32K tokens)' },
|
||||
{ value: 'max', label: 'Max (adaptive ceiling)' },
|
||||
{ value: 'auto', label: 'Auto (dynamic)' },
|
||||
];
|
||||
|
||||
|
||||
@@ -51,4 +51,48 @@ describe('ProviderInfoTab', () => {
|
||||
expect(screen.queryByText('Change model')).not.toBeInTheDocument();
|
||||
expect(screen.getByText('ccs custom-provider --auth --add')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows the plus-extra track note for community-maintained providers', () => {
|
||||
render(
|
||||
<ProviderInfoTab
|
||||
provider="cursor"
|
||||
displayName="Cursor"
|
||||
defaultTarget="claude"
|
||||
authStatus={{
|
||||
...authenticatedStatus,
|
||||
provider: 'cursor',
|
||||
displayName: 'Cursor',
|
||||
}}
|
||||
supportsModelConfig
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByText('Track')).toBeInTheDocument();
|
||||
expect(screen.getByText('Plus extras / community-maintained')).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText(
|
||||
/Requires the optional Plus backend while that track remains community-maintained\./
|
||||
)
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('uses the base provider when rendering variant track metadata', () => {
|
||||
render(
|
||||
<ProviderInfoTab
|
||||
provider="my-cursor"
|
||||
baseProvider="cursor"
|
||||
displayName="My Cursor Variant"
|
||||
defaultTarget="claude"
|
||||
authStatus={{
|
||||
...authenticatedStatus,
|
||||
provider: 'cursor',
|
||||
displayName: 'Cursor',
|
||||
}}
|
||||
supportsModelConfig
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByText('Track')).toBeInTheDocument();
|
||||
expect(screen.getByText('Plus extras / community-maintained')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -151,4 +151,49 @@ describe('useProviderEditor', () => {
|
||||
ANTHROPIC_DEFAULT_HAIKU_MODEL: 'claude-haiku-4-5-20251001',
|
||||
});
|
||||
});
|
||||
|
||||
it('preserves explicit codex effort suffixes in editor state updates', async () => {
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn((input: RequestInfo | URL) => {
|
||||
const url = String(input);
|
||||
|
||||
if (url.includes('/api/settings/codex/raw')) {
|
||||
return Promise.resolve(
|
||||
createJsonResponse({
|
||||
profile: 'codex',
|
||||
settings: {
|
||||
env: {
|
||||
ANTHROPIC_MODEL: 'gpt-5.3-codex-high',
|
||||
ANTHROPIC_DEFAULT_OPUS_MODEL: 'gpt-5.3-codex-xhigh',
|
||||
ANTHROPIC_DEFAULT_SONNET_MODEL: 'gpt-5.3-codex-high',
|
||||
ANTHROPIC_DEFAULT_HAIKU_MODEL: 'gpt-5.4-mini-medium',
|
||||
},
|
||||
},
|
||||
mtime: 1,
|
||||
path: '~/.ccs/codex.settings.json',
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
return Promise.reject(new Error(`Unexpected fetch: ${url}`));
|
||||
})
|
||||
);
|
||||
|
||||
const { result } = renderHook(() => useProviderEditor('codex'), { wrapper });
|
||||
|
||||
await waitFor(() => expect(result.current.currentModel).toBe('gpt-5.3-codex-high'));
|
||||
|
||||
act(() => {
|
||||
result.current.updateEnvValue('ANTHROPIC_MODEL', 'gpt-5.3-codex-xhigh');
|
||||
});
|
||||
|
||||
const nextSettings = JSON.parse(result.current.rawJsonContent);
|
||||
expect(nextSettings.env).toMatchObject({
|
||||
ANTHROPIC_MODEL: 'gpt-5.3-codex-xhigh',
|
||||
ANTHROPIC_DEFAULT_OPUS_MODEL: 'gpt-5.3-codex-xhigh',
|
||||
ANTHROPIC_DEFAULT_SONNET_MODEL: 'gpt-5.3-codex-high',
|
||||
ANTHROPIC_DEFAULT_HAIKU_MODEL: 'gpt-5.4-mini-medium',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { FlexibleModelSelector } from '@/components/cliproxy/provider-model-selector';
|
||||
import { buildUiCatalogs } from '@/lib/model-catalogs';
|
||||
import { MODEL_CATALOGS, buildUiCatalogs } from '@/lib/model-catalogs';
|
||||
import { render, screen, userEvent } from '@tests/setup/test-utils';
|
||||
|
||||
const noisyAgyModels = [
|
||||
@@ -87,4 +87,68 @@ describe('FlexibleModelSelector', () => {
|
||||
expect(screen.getAllByText('gemini-3.1-pro-preview').length).toBeGreaterThan(0);
|
||||
expect(screen.getByText('gemini-3.1-pro-high')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('offers codex effort-suffixed variants as first-class selectable options', async () => {
|
||||
const onChange = vi.fn();
|
||||
|
||||
render(
|
||||
<FlexibleModelSelector
|
||||
label="Primary model"
|
||||
value={undefined}
|
||||
onChange={onChange}
|
||||
catalog={MODEL_CATALOGS.codex}
|
||||
allModels={[]}
|
||||
/>
|
||||
);
|
||||
|
||||
await userEvent.click(screen.getByRole('button', { name: /select model/i }));
|
||||
|
||||
expect(screen.getByText('gpt-5.3-codex-high')).toBeInTheDocument();
|
||||
expect(screen.getByText('gpt-5.3-codex-xhigh')).toBeInTheDocument();
|
||||
|
||||
await userEvent.click(screen.getByText('gpt-5.3-codex-high'));
|
||||
expect(onChange).toHaveBeenCalledWith('gpt-5.3-codex-high');
|
||||
});
|
||||
|
||||
it('does not relegate saved codex effort variants to the legacy current-value fallback', async () => {
|
||||
render(
|
||||
<FlexibleModelSelector
|
||||
label="Primary model"
|
||||
value="gpt-5.3-codex-high"
|
||||
onChange={vi.fn()}
|
||||
catalog={MODEL_CATALOGS.codex}
|
||||
allModels={[]}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByRole('button', { name: /gpt-5\.3-codex-high/i })).toBeInTheDocument();
|
||||
|
||||
await userEvent.click(screen.getByRole('button', { name: /gpt-5\.3-codex-high/i }));
|
||||
|
||||
expect(screen.queryByText('Current value')).not.toBeInTheDocument();
|
||||
expect(screen.getByText('gpt-5.3-codex')).toBeInTheDocument();
|
||||
expect(screen.getAllByText('gpt-5.3-codex-high').length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('preserves explicit suffixes on supplemental codex models outside the static catalog', async () => {
|
||||
const onChange = vi.fn();
|
||||
|
||||
render(
|
||||
<FlexibleModelSelector
|
||||
label="Primary model"
|
||||
value={undefined}
|
||||
onChange={onChange}
|
||||
catalog={MODEL_CATALOGS.codex}
|
||||
allModels={[{ id: 'gpt-5.5-codex-high', owned_by: 'openai' }]}
|
||||
/>
|
||||
);
|
||||
|
||||
await userEvent.click(screen.getByRole('button', { name: /select model/i }));
|
||||
|
||||
expect(screen.getByText(/All Models \(1\)/i)).toBeInTheDocument();
|
||||
expect(screen.getByText('gpt-5.5-codex-high')).toBeInTheDocument();
|
||||
|
||||
await userEvent.click(screen.getByText('gpt-5.5-codex-high'));
|
||||
expect(onChange).toHaveBeenCalledWith('gpt-5.5-codex-high');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,6 +2,23 @@ import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { render, screen, userEvent } from '@tests/setup/test-utils';
|
||||
|
||||
const hookState = vi.hoisted(() => ({
|
||||
authData: {
|
||||
authStatus: [
|
||||
{
|
||||
provider: 'gemini',
|
||||
displayName: 'Gemini',
|
||||
authenticated: true,
|
||||
accounts: [{ id: 'acct-1', provider: 'gemini' }],
|
||||
},
|
||||
{
|
||||
provider: 'ghcp',
|
||||
displayName: 'GitHub Copilot (OAuth)',
|
||||
authenticated: false,
|
||||
accounts: [],
|
||||
},
|
||||
],
|
||||
source: 'local' as const,
|
||||
},
|
||||
catalogData: undefined as
|
||||
| {
|
||||
catalogs: Record<
|
||||
@@ -23,17 +40,7 @@ vi.mock('@/hooks/use-cliproxy', () => ({
|
||||
isFetching: false,
|
||||
}),
|
||||
useCliproxyAuth: () => ({
|
||||
data: {
|
||||
authStatus: [
|
||||
{
|
||||
provider: 'gemini',
|
||||
displayName: 'Gemini',
|
||||
authenticated: true,
|
||||
accounts: [{ id: 'acct-1', provider: 'gemini' }],
|
||||
},
|
||||
],
|
||||
source: 'local',
|
||||
},
|
||||
data: hookState.authData,
|
||||
isLoading: false,
|
||||
}),
|
||||
useCliproxyCatalog: () => ({
|
||||
@@ -85,9 +92,39 @@ import { CliproxyPage } from '@/pages/cliproxy';
|
||||
|
||||
describe('CliproxyPage add-account catalog gating', () => {
|
||||
beforeEach(() => {
|
||||
hookState.authData = {
|
||||
authStatus: [
|
||||
{
|
||||
provider: 'gemini',
|
||||
displayName: 'Gemini',
|
||||
authenticated: true,
|
||||
accounts: [{ id: 'acct-1', provider: 'gemini' }],
|
||||
},
|
||||
{
|
||||
provider: 'ghcp',
|
||||
displayName: 'GitHub Copilot (OAuth)',
|
||||
authenticated: false,
|
||||
accounts: [],
|
||||
},
|
||||
],
|
||||
source: 'local',
|
||||
};
|
||||
hookState.catalogData = undefined;
|
||||
});
|
||||
|
||||
it('separates core providers from plus extras in the sidebar', () => {
|
||||
render(<CliproxyPage />);
|
||||
|
||||
expect(screen.getByText('Core / original backend')).toBeInTheDocument();
|
||||
expect(screen.getByText('Plus extras / community-maintained')).toBeInTheDocument();
|
||||
expect(screen.getByText('Default, always-available provider track')).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText('Still supported, but separated from the default backend for now')
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByText('Gemini')).toBeInTheDocument();
|
||||
expect(screen.getByText('GitHub Copilot (OAuth)')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('does not pass a static fallback catalog before the catalog query resolves', async () => {
|
||||
render(<CliproxyPage />);
|
||||
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { getCodexEffortDisplay, parseCodexEffort } from '@/lib/codex-effort';
|
||||
import {
|
||||
applyCodexEffortSuffix,
|
||||
getCodexEffortDisplay,
|
||||
getCodexEffortVariants,
|
||||
parseCodexEffort,
|
||||
stripCodexEffortSuffix,
|
||||
} from '@/lib/codex-effort';
|
||||
|
||||
describe('parseCodexEffort', () => {
|
||||
it('parses lowercase suffixes', () => {
|
||||
@@ -38,3 +44,25 @@ describe('getCodexEffortDisplay', () => {
|
||||
expect(getCodexEffortDisplay(undefined)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('codex effort helpers', () => {
|
||||
it('strips and reapplies codex effort suffixes', () => {
|
||||
expect(stripCodexEffortSuffix('gpt-5.3-codex-high')).toBe('gpt-5.3-codex');
|
||||
expect(applyCodexEffortSuffix('gpt-5.3-codex-high', 'xhigh')).toBe('gpt-5.3-codex-xhigh');
|
||||
expect(applyCodexEffortSuffix('gpt-5.3-codex', undefined)).toBe('gpt-5.3-codex');
|
||||
});
|
||||
|
||||
it('builds ordered codex effort variants up to the supported max level', () => {
|
||||
expect(getCodexEffortVariants('gpt-5.3-codex', 'xhigh')).toEqual([
|
||||
'gpt-5.3-codex',
|
||||
'gpt-5.3-codex-medium',
|
||||
'gpt-5.3-codex-high',
|
||||
'gpt-5.3-codex-xhigh',
|
||||
]);
|
||||
expect(getCodexEffortVariants('gpt-5.4-mini', 'high')).toEqual([
|
||||
'gpt-5.4-mini',
|
||||
'gpt-5.4-mini-medium',
|
||||
'gpt-5.4-mini-high',
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -6,8 +6,11 @@ describe('codex model catalog defaults', () => {
|
||||
const codexCatalog = MODEL_CATALOGS.codex;
|
||||
const codex53 = codexCatalog.models.find((model) => model.id === 'gpt-5.3-codex');
|
||||
const codex52 = codexCatalog.models.find((model) => model.id === 'gpt-5.2-codex');
|
||||
const codexMini = codexCatalog.models.find((model) => model.id === 'gpt-5-codex-mini');
|
||||
|
||||
expect(codex53?.presetMapping?.haiku).toBe('gpt-5-codex-mini');
|
||||
expect(codex52?.presetMapping?.haiku).toBe('gpt-5-codex-mini');
|
||||
expect(codex53?.codexMaxEffort).toBe('xhigh');
|
||||
expect(codexMini?.codexMaxEffort).toBe('high');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,15 +1,22 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
CLIPROXY_PROVIDER_SECTIONS,
|
||||
CORE_CLIPROXY_PROVIDERS,
|
||||
formatRequestedUpstreamModelRules,
|
||||
getProviderDescription,
|
||||
getProviderDisplayName,
|
||||
getProviderFallbackVisual,
|
||||
getProviderLogoAsset,
|
||||
getProviderSection,
|
||||
getRequestedUpstreamModelRuleErrors,
|
||||
getRequestedModelId,
|
||||
groupProvidersBySection,
|
||||
isPlusExtraProvider,
|
||||
parseRequestedUpstreamModelRules,
|
||||
PLUS_EXTRA_CLIPROXY_PROVIDERS,
|
||||
PROVIDER_COLORS,
|
||||
variantUsesPlusExtraProvider,
|
||||
} from '@/lib/provider-config';
|
||||
|
||||
describe('provider model mapping helpers', () => {
|
||||
@@ -49,6 +56,61 @@ describe('provider model mapping helpers', () => {
|
||||
});
|
||||
|
||||
describe('provider presentation metadata', () => {
|
||||
it('splits providers into core and plus-extra sections', () => {
|
||||
expect(CLIPROXY_PROVIDER_SECTIONS.map((section) => section.id)).toEqual(['core', 'plus-extra']);
|
||||
expect(CORE_CLIPROXY_PROVIDERS).toContain('gemini');
|
||||
expect(CORE_CLIPROXY_PROVIDERS).toContain('kimi');
|
||||
expect(PLUS_EXTRA_CLIPROXY_PROVIDERS).toEqual([
|
||||
'kiro',
|
||||
'ghcp',
|
||||
'cursor',
|
||||
'gitlab',
|
||||
'codebuddy',
|
||||
'kilo',
|
||||
]);
|
||||
expect(getProviderSection('gitlab')?.id).toBe('plus-extra');
|
||||
expect(getProviderSection('gemini')?.id).toBe('core');
|
||||
expect(isPlusExtraProvider('cursor')).toBe(true);
|
||||
expect(isPlusExtraProvider('gemini')).toBe(false);
|
||||
});
|
||||
|
||||
it('groups provider-backed data by shared section metadata', () => {
|
||||
const grouped = groupProvidersBySection(
|
||||
[
|
||||
{ provider: 'cursor', value: 'plus' },
|
||||
{ provider: 'gemini', value: 'core' },
|
||||
],
|
||||
(entry) => entry.provider
|
||||
);
|
||||
|
||||
expect(grouped).toHaveLength(2);
|
||||
expect(grouped[0]?.id).toBe('core');
|
||||
expect(grouped[0]?.items.map((entry) => entry.provider)).toEqual(['gemini']);
|
||||
expect(grouped[1]?.id).toBe('plus-extra');
|
||||
expect(grouped[1]?.items.map((entry) => entry.provider)).toEqual(['cursor']);
|
||||
});
|
||||
|
||||
it('detects plus-extra providers inside composite variants', () => {
|
||||
expect(
|
||||
variantUsesPlusExtraProvider({
|
||||
provider: 'gemini',
|
||||
tiers: {
|
||||
opus: { provider: 'gemini' },
|
||||
sonnet: { provider: 'cursor' },
|
||||
},
|
||||
})
|
||||
).toBe(true);
|
||||
expect(
|
||||
variantUsesPlusExtraProvider({
|
||||
provider: 'gemini',
|
||||
tiers: {
|
||||
opus: { provider: 'gemini' },
|
||||
sonnet: { provider: 'kimi' },
|
||||
},
|
||||
})
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it.each([
|
||||
['cursor', 'Cursor', 'Cursor browser-authenticated provider', '/assets/sidebar/cursor.svg'],
|
||||
['gitlab', 'GitLab Duo', 'GitLab Duo with OAuth or PAT auth', '/assets/providers/gitlab.svg'],
|
||||
|
||||
Reference in New Issue
Block a user