From 478d64a50ab7d6aef8507efe92971641ad523b09 Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Tue, 21 Apr 2026 12:34:07 -0400 Subject: [PATCH 01/39] fix(cliproxy): quarantine exhausted quota accounts --- docs/project-roadmap.md | 3 +- docs/system-architecture/provider-flows.md | 6 + src/cliproxy/account-safety.ts | 146 ++++++++++++++++++ src/cliproxy/quota-manager.ts | 36 ++++- .../account-safety-quota-exhaustion.test.ts | 139 +++++++++++++++++ 5 files changed, 326 insertions(+), 4 deletions(-) diff --git a/docs/project-roadmap.md b/docs/project-roadmap.md index a08866ec..0cb616b3 100644 --- a/docs/project-roadmap.md +++ b/docs/project-roadmap.md @@ -1,6 +1,6 @@ # CCS Project Roadmap -Last Updated: 2026-04-19 +Last Updated: 2026-04-21 Forward-looking roadmap documenting current priorities, GitHub issues, and future feature plans. @@ -41,6 +41,7 @@ All major modularization work is complete. The codebase evolved from monolithic ### Recent Fixes +- **2026-04-21**: CLIProxy quota failover now quarantines exhausted Claude and Antigravity accounts out of live rotation when a healthy fallback exists. CCS persists those quota-triggered pauses across launches, automatically resumes them after the configured cooldown window, and deliberately avoids auto-pausing the last available account so single-account setups still degrade gracefully instead of hard-locking themselves. - **2026-04-20**: **#1051** Browser automation now defaults safe-off for new installs and upgrades that do not already carry explicit browser settings. CCS changes both Claude Browser Attach and Codex Browser Tools to start with `enabled: false` and `policy: manual`, normalizes missing browser policies on upgrade back to `manual`, preserves explicit existing enablement, and updates status/help/docs so browser tooling is never implied to auto-expose unless users opt in. - **2026-04-19**: **#1051** Browser tooling now has an explicit exposure policy instead of only coarse enablement toggles. CCS adds `browser..policy` (`auto` or `manual`) for both Claude Browser Attach and Codex Browser Tools, exposes CLI-first policy controls through `ccs browser policy`, `ccs browser enable`, and `ccs browser disable`, and adds one-run launch overrides `--browser` and `--no-browser` so users can force browser tooling on or off without editing saved config. - **2026-04-19**: **#1049** Browser setup now has a real remediation path instead of status/doctor-only guidance. CCS adds `ccs browser setup` as the primary one-command flow for Claude Browser Attach, shortens managed browser-path output to home-relative display paths where appropriate, and updates browser readiness guidance to point users at setup first while keeping browser doctor read-only by default. diff --git a/docs/system-architecture/provider-flows.md b/docs/system-architecture/provider-flows.md index eb19fd6f..a1853c49 100644 --- a/docs/system-architecture/provider-flows.md +++ b/docs/system-architecture/provider-flows.md @@ -257,6 +257,7 @@ async function checkRemoteProxyHealth(config: ResolvedProxyConfig): Promise Select best account (not paused, not exhausted) | +---> Auto-failover to next account if current exhausted + | + +---> Temporarily pause exhausted account when fallback exists + | - move token out of live auth discovery + | - persist cooldown expiry across launches + | - auto-resume only CCS-created quota pauses CLI Commands: ccs cliproxy pause --> Set isPaused=true in account-manager diff --git a/src/cliproxy/account-safety.ts b/src/cliproxy/account-safety.ts index f8f61e0c..d7b26607 100644 --- a/src/cliproxy/account-safety.ts +++ b/src/cliproxy/account-safety.ts @@ -37,10 +37,26 @@ interface AutoPausedFile { sessions: AutoPausedSession[]; } +interface QuotaPausedEntry { + provider: CLIProxyProvider; + accountId: string; + pausedAt: string; + until: number; + reason: 'quota_exhausted'; +} + +interface QuotaPausedFile { + entries: QuotaPausedEntry[]; +} + function getAutoPausedPath(): string { return path.join(getCcsDir(), 'cliproxy', 'auto-paused.json'); } +function getQuotaPausedPath(): string { + return path.join(getCcsDir(), 'cliproxy', 'quota-paused.json'); +} + function loadAutoPaused(): AutoPausedFile { try { const filePath = getAutoPausedPath(); @@ -69,6 +85,48 @@ function saveAutoPaused(data: AutoPausedFile): void { fs.writeFileSync(filePath, JSON.stringify(data, null, 2) + '\n', { mode: 0o600 }); } +function loadQuotaPaused(): QuotaPausedFile { + try { + const filePath = getQuotaPausedPath(); + if (fs.existsSync(filePath)) { + const data = JSON.parse(fs.readFileSync(filePath, 'utf-8')) as { + entries?: unknown; + }; + if (Array.isArray(data.entries)) { + return { + entries: data.entries.filter( + (entry): entry is QuotaPausedEntry => + typeof entry === 'object' && + entry !== null && + typeof (entry as QuotaPausedEntry).provider === 'string' && + typeof (entry as QuotaPausedEntry).accountId === 'string' && + typeof (entry as QuotaPausedEntry).pausedAt === 'string' && + Number.isFinite((entry as QuotaPausedEntry).until) + ), + }; + } + } + } catch { + // Corrupted or malformed file — start fresh + } + return { entries: [] }; +} + +function saveQuotaPaused(data: QuotaPausedFile): void { + const filePath = getQuotaPausedPath(); + if (data.entries.length === 0) { + try { + fs.unlinkSync(filePath); + } catch { + /* already gone */ + } + return; + } + const dir = path.dirname(filePath); + if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true, mode: 0o700 }); + fs.writeFileSync(filePath, JSON.stringify(data, null, 2) + '\n', { mode: 0o600 }); +} + /** * Check if a process is alive. NOTE: PIDs can be recycled by the OS. * If a stale PID is reused by an unrelated process, cleanup is deferred until that process exits. @@ -325,6 +383,93 @@ export function cleanupStaleAutoPauses(): void { } } +/** + * Resume quota-paused accounts whose cooldown windows have expired. + * Auto-resume only applies to pauses created by CCS quota handling. + */ +export function restoreExpiredQuotaPauses(now = Date.now()): number { + const data = loadQuotaPaused(); + if (data.entries.length === 0) return 0; + + const keep: QuotaPausedEntry[] = []; + let resumed = 0; + const registry = loadAccountsRegistry(); + + for (const entry of data.entries) { + if (!Number.isFinite(entry.until) || entry.until > now) { + keep.push(entry); + continue; + } + + const account = registry.providers[entry.provider]?.accounts[entry.accountId]; + if (!account?.paused) { + continue; + } + + // Only auto-resume the exact pause CCS created for quota cooldown. + // Missing or changed pausedAt metadata is treated as a mismatch so we do + // not accidentally resume a manually paused account. + if (account.pausedAt !== entry.pausedAt) { + continue; + } + + if (resumeAccount(entry.provider, entry.accountId)) { + resumed += 1; + continue; + } + + // Resume failures are treated as transient I/O/state issues. Keep the + // quota-pause record so the next restore pass can retry instead of leaving + // the account paused forever without any cooldown metadata. + keep.push(entry); + } + + saveQuotaPaused({ entries: keep }); + return resumed; +} + +/** + * Temporarily remove an exhausted account from CLIProxy rotation for the + * configured cooldown window. Returns false when the account was already paused + * or could not be paused, so callers can fall back to in-memory cooldown only. + */ +export function pauseAccountForQuotaCooldown( + provider: CLIProxyProvider, + accountId: string, + cooldownMinutes: number, + now = Date.now() +): boolean { + const registryBefore = loadAccountsRegistry(); + const accountBefore = registryBefore.providers[provider]?.accounts[accountId]; + if (!accountBefore || accountBefore.paused) { + return false; + } + + if (!pauseAccount(provider, accountId)) { + return false; + } + + const registryAfter = loadAccountsRegistry(); + const pausedAt = registryAfter.providers[provider]?.accounts[accountId]?.pausedAt; + if (!pausedAt) { + return false; + } + + const data = loadQuotaPaused(); + data.entries = data.entries.filter( + (entry) => !(entry.provider === provider && entry.accountId === accountId) + ); + data.entries.push({ + provider, + accountId, + pausedAt, + until: now + cooldownMinutes * 60 * 1000, + reason: 'quota_exhausted', + }); + saveQuotaPaused(data); + return true; +} + /** * Enforce provider isolation by auto-pausing conflicting accounts in other providers. * Records paused accounts for crash recovery and session exit restore. @@ -553,6 +698,7 @@ export async function handleQuotaExhaustion( const alternative = await findHealthyAccount(provider, [accountId]); if (alternative) { + pauseAccountForQuotaCooldown(provider, accountId, cooldownMinutes); setDefaultAccount(provider, alternative.id); touchAccount(provider, alternative.id); writeQuotaExhausted(accountId, alternative.id, cooldownMinutes); diff --git a/src/cliproxy/quota-manager.ts b/src/cliproxy/quota-manager.ts index 31c46780..ce3b59a8 100644 --- a/src/cliproxy/quota-manager.ts +++ b/src/cliproxy/quota-manager.ts @@ -286,6 +286,9 @@ export async function findHealthyAccount( return null; } + const { restoreExpiredQuotaPauses } = await import('./account-safety'); + restoreExpiredQuotaPauses(); + const config = loadOrCreateUnifiedConfig(); const tierPriority = config.quota_management?.auto?.tier_priority ?? ['ultra', 'pro', 'free']; const threshold = config.quota_management?.auto?.exhaustion_threshold ?? 5; @@ -388,6 +391,11 @@ export async function preflightCheck(provider: CLIProxyProvider): Promise; }> { + const { restoreExpiredQuotaPauses } = await import('./account-safety'); + restoreExpiredQuotaPauses(); + const accounts = getProviderAccounts(provider); const defaultAccount = getDefaultAccount(provider); diff --git a/tests/unit/cliproxy/account-safety-quota-exhaustion.test.ts b/tests/unit/cliproxy/account-safety-quota-exhaustion.test.ts index 4ad5145f..e688e009 100644 --- a/tests/unit/cliproxy/account-safety-quota-exhaustion.test.ts +++ b/tests/unit/cliproxy/account-safety-quota-exhaustion.test.ts @@ -17,6 +17,8 @@ import { handleQuotaExhaustion, writeQuotaWarning, maskEmail, + pauseAccountForQuotaCooldown, + restoreExpiredQuotaPauses, } from '../../../src/cliproxy/account-safety'; import { sanitizeEmail } from '../../../src/cliproxy/auth-utils'; @@ -84,6 +86,12 @@ function writeClaudeAuth(accountId: string, accessToken: string): void { ); } +function writeAuthToken(tokenFile: string, payload: Record): void { + const authDir = path.join(tmpDir, '.ccs', 'cliproxy', 'auth'); + fs.mkdirSync(authDir, { recursive: true }); + fs.writeFileSync(path.join(authDir, tokenFile), JSON.stringify(payload, null, 2)); +} + describe('Quota Exhaustion Handlers', () => { describe('writeQuotaWarning', () => { it('should write to stderr with box format', async () => { @@ -247,10 +255,13 @@ describe('Quota Exhaustion Handlers', () => { }); const result = await handleQuotaExhaustion('agy', 'only@gmail.com', 10); + const { getAccount } = await import('../../../src/cliproxy/account-manager'); // Should return gracefully with null switched expect(result.switchedTo).toBeNull(); expect(result.reason).toContain('no alternatives'); + expect(getAccount('agy', 'only@gmail.com')?.paused).not.toBe(true); + expect(fs.existsSync(path.join(tmpDir, '.ccs', 'cliproxy', 'quota-paused.json'))).toBe(false); }); it('should switch Claude accounts when fallback quota endpoint returns 404', async () => { @@ -305,6 +316,18 @@ describe('Quota Exhaustion Handlers', () => { expect(result.switchedTo).toBe('fallback@example.com'); expect(getDefaultAccount('claude')?.id).toBe('fallback@example.com'); + expect(fs.existsSync(path.join(tmpDir, '.ccs', 'cliproxy', 'quota-paused.json'))).toBe(true); + expect( + fs.existsSync( + path.join( + tmpDir, + '.ccs', + 'cliproxy', + 'auth-paused', + `claude-${sanitizeEmail('exhausted@example.com')}.json` + ) + ) + ).toBe(true); }); it('should write warning to stderr', async () => { @@ -389,5 +412,121 @@ describe('Quota Exhaustion Handlers', () => { expect(result).toBeDefined(); expect(result.switchedTo).toBeNull(); }); + + it('auto-resumes quota-paused accounts after cooldown expiry', async () => { + writeRegistry({ + agy: { + default: 'cooldown@gmail.com', + accounts: { + 'cooldown@gmail.com': { + email: 'cooldown@gmail.com', + tokenFile: 'agy-cooldown.json', + }, + }, + }, + }); + writeAuthToken('agy-cooldown.json', { + type: 'agy', + email: 'cooldown@gmail.com', + access_token: 'token', + }); + + const now = Date.now(); + expect(pauseAccountForQuotaCooldown('agy', 'cooldown@gmail.com', 5, now)).toBe(true); + + const { getAccount } = await import('../../../src/cliproxy/account-manager'); + expect(getAccount('agy', 'cooldown@gmail.com')?.paused).toBe(true); + + const resumed = restoreExpiredQuotaPauses(now + 6 * 60 * 1000); + + expect(resumed).toBe(1); + expect(getAccount('agy', 'cooldown@gmail.com')?.paused).not.toBe(true); + expect(fs.existsSync(path.join(tmpDir, '.ccs', 'cliproxy', 'quota-paused.json'))).toBe(false); + }); + + it('does not auto-resume quota-paused accounts when pausedAt metadata is missing', async () => { + writeRegistry({ + agy: { + default: 'cooldown@gmail.com', + accounts: { + 'cooldown@gmail.com': { + email: 'cooldown@gmail.com', + tokenFile: 'agy-cooldown.json', + }, + }, + }, + }); + writeAuthToken('agy-cooldown.json', { + type: 'agy', + email: 'cooldown@gmail.com', + access_token: 'token', + }); + + const now = Date.now(); + expect(pauseAccountForQuotaCooldown('agy', 'cooldown@gmail.com', 5, now)).toBe(true); + + const registryPath = path.join(tmpDir, '.ccs', 'cliproxy', 'accounts.json'); + const registry = JSON.parse(fs.readFileSync(registryPath, 'utf8')) as { + providers: { + agy: { + default: string; + accounts: Record; + }; + }; + }; + delete registry.providers.agy.accounts['cooldown@gmail.com']?.pausedAt; + fs.writeFileSync(registryPath, JSON.stringify(registry, null, 2)); + + const resumed = restoreExpiredQuotaPauses(now + 6 * 60 * 1000); + const { getAccount } = await import('../../../src/cliproxy/account-manager'); + + expect(resumed).toBe(0); + expect(getAccount('agy', 'cooldown@gmail.com')?.paused).toBe(true); + expect( + fs.existsSync(path.join(tmpDir, '.ccs', 'cliproxy', 'auth-paused', 'agy-cooldown.json')) + ).toBe(true); + expect(fs.existsSync(path.join(tmpDir, '.ccs', 'cliproxy', 'quota-paused.json'))).toBe(false); + }); + + it('keeps quota-paused entries when auto-resume fails and retries later', async () => { + writeRegistry({ + agy: { + default: 'cooldown@gmail.com', + accounts: { + 'cooldown@gmail.com': { + email: 'cooldown@gmail.com', + tokenFile: 'agy-cooldown.json', + }, + }, + }, + }); + writeAuthToken('agy-cooldown.json', { + type: 'agy', + email: 'cooldown@gmail.com', + access_token: 'token', + }); + + const now = Date.now(); + expect(pauseAccountForQuotaCooldown('agy', 'cooldown@gmail.com', 5, now)).toBe(true); + + fs.rmSync(path.join(tmpDir, '.ccs', 'cliproxy', 'auth'), { + recursive: true, + force: true, + }); + + const resumed = restoreExpiredQuotaPauses(now + 6 * 60 * 1000); + const { getAccount } = await import('../../../src/cliproxy/account-manager'); + const quotaPausedPath = path.join(tmpDir, '.ccs', 'cliproxy', 'quota-paused.json'); + const quotaPaused = JSON.parse(fs.readFileSync(quotaPausedPath, 'utf8')) as { + entries?: Array<{ accountId?: string }>; + }; + + expect(resumed).toBe(0); + expect(getAccount('agy', 'cooldown@gmail.com')?.paused).toBe(true); + expect( + fs.existsSync(path.join(tmpDir, '.ccs', 'cliproxy', 'auth-paused', 'agy-cooldown.json')) + ).toBe(true); + expect(quotaPaused.entries?.map((entry) => entry.accountId)).toContain('cooldown@gmail.com'); + }); }); }); From 08edf1eef4459927ef72b794df0969b33460dd81 Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Tue, 21 Apr 2026 12:34:43 -0400 Subject: [PATCH 02/39] fix(ci): isolate bun cache per job --- .github/workflows/ci.yml | 18 +++++++++++++----- 1 file changed, 13 insertions(+), 5 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index eb7f95c7..0b5bf26a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -17,13 +17,17 @@ concurrency: jobs: validate: runs-on: [self-hosted, linux, x64] + env: + # Keep Bun cache isolated per job workspace so parallel self-hosted runs + # do not race on ~/.bun/install/cache and corrupt restore/install state. + BUN_INSTALL_CACHE_DIR: ${{ github.workspace }}/.bun/install/cache strategy: fail-fast: false matrix: check: - { name: typecheck, cmd: 'bun run typecheck' } - - { name: lint, cmd: 'bun run lint' } - - { name: format, cmd: 'bun run format:check' } + - { name: lint, cmd: 'bun run lint' } + - { name: format, cmd: 'bun run format:check' } name: ${{ matrix.check.name }} steps: @@ -44,7 +48,7 @@ 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- @@ -58,6 +62,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,7 +82,7 @@ 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- @@ -99,6 +105,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,7 +125,7 @@ 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- From c03f2ea791ce6da6eea50fa3fdcb3d17cc5b3ba3 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Tue, 21 Apr 2026 16:52:30 +0000 Subject: [PATCH 03/39] chore(release): 7.73.0-dev.3 [skip ci] --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index bc3dd025..aac4a8ab 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@kaitranntt/ccs", - "version": "7.73.0-dev.2", + "version": "7.73.0-dev.3", "description": "Claude Code Switch - Instant profile switching between Claude, GLM, Kimi, and more", "keywords": [ "cli", From 9ae85b82c12c45e73f0819ca33ad5ffd2f3ac323 Mon Sep 17 00:00:00 2001 From: Wooseong Kim Date: Tue, 21 Apr 2026 16:14:10 +0900 Subject: [PATCH 04/39] fix(runtime): strip inherited Anthropic routing env selectively --- src/utils/shell-executor.ts | 40 +++++++++++++++---- .../utils/claudecode-env-stripping.test.ts | 31 ++++++++++++++ 2 files changed, 64 insertions(+), 7 deletions(-) diff --git a/src/utils/shell-executor.ts b/src/utils/shell-executor.ts index b6f26ab2..3764f484 100644 --- a/src/utils/shell-executor.ts +++ b/src/utils/shell-executor.ts @@ -26,6 +26,28 @@ export function stripAnthropicEnv(env: NodeJS.ProcessEnv): NodeJS.ProcessEnv { return result; } +const ANTHROPIC_ROUTING_ENV_KEYS = new Set([ + 'ANTHROPIC_BASE_URL', + 'ANTHROPIC_AUTH_TOKEN', + 'ANTHROPIC_API_KEY', +]); + +/** + * 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_KEYS.has(key)) { + result[key] = env[key]; + } + } + return result; +} + /** * Strip Claude Code nested-session guard env var from a process environment. * @@ -137,14 +159,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 baseEnv = - profileType === 'account' || profileType === 'default' - ? stripAnthropicEnv(process.env) + const stripInheritedAnthropicEnv = profileType === 'account' || profileType === 'default'; + const stripInheritedAnthropicRoutingEnv = envVars?.CCS_STRIP_INHERITED_ANTHROPIC_ENV === '1'; + const baseEnv = stripInheritedAnthropicEnv + ? stripAnthropicEnv(process.env) + : stripInheritedAnthropicRoutingEnv + ? stripAnthropicRoutingEnv(process.env) : process.env; // Prepare environment (merge with base env if envVars provided) diff --git a/tests/unit/utils/claudecode-env-stripping.test.ts b/tests/unit/utils/claudecode-env-stripping.test.ts index 5fb99ded..96ca54df 100644 --- a/tests/unit/utils/claudecode-env-stripping.test.ts +++ b/tests/unit/utils/claudecode-env-stripping.test.ts @@ -336,6 +336,37 @@ 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('headless executor spawn path strips CLAUDECODE before spawn', async () => { writeConfigWithAutoUpdatePreference(false); process.env.CLAUDECODE = 'nested'; From 5881a505d9b824aa9c7f8a9fb06d7122a94325d5 Mon Sep 17 00:00:00 2001 From: Wooseong Kim Date: Tue, 21 Apr 2026 16:14:23 +0900 Subject: [PATCH 05/39] fix(settings-profile): pass selective Anthropic env stripping to Claude launches --- src/ccs.ts | 18 ++++++-- .../settings-profile-browser-launch.test.ts | 44 +++++++++++++++++++ 2 files changed, 59 insertions(+), 3 deletions(-) diff --git a/src/ccs.ts b/src/ccs.ts index 1d8c5ad7..8832e6d2 100644 --- a/src/ccs.ts +++ b/src/ccs.ts @@ -1359,8 +1359,20 @@ async function main(): Promise { console.error(info(`Global env: ${envNames}`)); } - // Explicitly inject effective settings env vars so stale ANTHROPIC_* - // values from prior sessions cannot leak into the active profile. + // For Claude target launches that already pass `--settings`, keep runtime + // env limited to CCS-managed flags so nested Team/subagent sessions do not + // inherit profile-specific ANTHROPIC_* routing from the parent process. + const claudeRuntimeEnvVars: NodeJS.ProcessEnv = { + ...globalEnv, + ...(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, @@ -1472,7 +1484,7 @@ async function main(): Promise { settingsPath: expandedSettingsPath, }); - execClaude(claudeCli, launchArgs, { ...envVars, ...traceEnv }); + execClaude(claudeCli, launchArgs, { ...claudeRuntimeEnvVars, ...traceEnv }); } else if (profileInfo.type === 'account') { // NEW FLOW: Account-based profile (work, personal) // All platforms: Use instance isolation with CLAUDE_CONFIG_DIR diff --git a/tests/unit/targets/settings-profile-browser-launch.test.ts b/tests/unit/targets/settings-profile-browser-launch.test.ts index 1a343f62..df9a2f22 100644 --- a/tests/unit/targets/settings-profile-browser-launch.test.ts +++ b/tests/unit/targets/settings-profile-browser-launch.test.ts @@ -135,6 +135,12 @@ 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" } > "${claudeEnvLogPath}" exit 0 `, @@ -179,6 +185,44 @@ 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', + }, + }, + null, + 2 + ) + '\n' + ); + + 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'); + }); + it('does not auto-enable browser reuse for settings-profile launches from env overrides alone', async () => { if (process.platform === 'win32') return; From 1fd2fb83fbf08ef9ef9a03fb69d9c6cea5f8eb37 Mon Sep 17 00:00:00 2001 From: Wooseong Kim Date: Tue, 21 Apr 2026 16:52:19 +0900 Subject: [PATCH 06/39] fix(runtime): strip reintroduced Anthropic routing env Prevent settings-profile Claude launches from reintroducing routing/auth env after the final env merge. - re-strip ANTHROPIC_BASE_URL, ANTHROPIC_AUTH_TOKEN, and ANTHROPIC_API_KEY after merging envVars - preserve model-selection env such as ANTHROPIC_MODEL and ANTHROPIC_DEFAULT_SONNET_MODEL - add a regression test covering explicit settings-profile overrides that try to reintroduce routing env Verification: - bun test tests/unit/utils/claudecode-env-stripping.test.ts - bun run validate --- src/utils/shell-executor.ts | 5 ++++- .../utils/claudecode-env-stripping.test.ts | 20 +++++++++++++++++++ 2 files changed, 24 insertions(+), 1 deletion(-) diff --git a/src/utils/shell-executor.ts b/src/utils/shell-executor.ts index 3764f484..e6183349 100644 --- a/src/utils/shell-executor.ts +++ b/src/utils/shell-executor.ts @@ -177,10 +177,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 { diff --git a/tests/unit/utils/claudecode-env-stripping.test.ts b/tests/unit/utils/claudecode-env-stripping.test.ts index 96ca54df..afed6472 100644 --- a/tests/unit/utils/claudecode-env-stripping.test.ts +++ b/tests/unit/utils/claudecode-env-stripping.test.ts @@ -367,6 +367,26 @@ describe('CLAUDECODE environment stripping', () => { 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('headless executor spawn path strips CLAUDECODE before spawn', async () => { writeConfigWithAutoUpdatePreference(false); process.env.CLAUDECODE = 'nested'; From 2191f0a6c7ee9121bd28da6b56d58d9f50e119cc Mon Sep 17 00:00:00 2001 From: Wooseong Kim Date: Tue, 21 Apr 2026 16:56:54 +0900 Subject: [PATCH 07/39] fix(settings-profile): preserve non-routing env for Claude launches Keep settings-profile Claude launches aligned with profile-scoped non-routing env while still stripping routing/auth values. - build claudeRuntimeEnvVars from filtered global + settings env - preserve CLAUDE_CODE_MAX_OUTPUT_TOKENS and model defaults for nested settings-profile launches - extend browser launch regression coverage to prove global_env cannot reintroduce routing/auth values --- src/ccs.ts | 9 +-- .../settings-profile-browser-launch.test.ts | 58 ++++++++++++++----- 2 files changed, 47 insertions(+), 20 deletions(-) diff --git a/src/ccs.ts b/src/ccs.ts index 8832e6d2..a908fce3 100644 --- a/src/ccs.ts +++ b/src/ccs.ts @@ -80,7 +80,7 @@ import { handleError, runCleanup } from './errors'; import { tryHandleRootCommand } from './commands/root-command-router'; // Import extracted utility functions -import { execClaude } from './utils/shell-executor'; +import { execClaude, stripAnthropicRoutingEnv } from './utils/shell-executor'; import { isDeprecatedGlmtProfileName, normalizeDeprecatedGlmtEnv } from './utils/glmt-deprecation'; import { maybeWarnAboutResumeLaneMismatch } from './auth/resume-lane-warning'; import { createLogger } from './services/logging'; @@ -1360,10 +1360,11 @@ async function main(): Promise { } // For Claude target launches that already pass `--settings`, keep runtime - // env limited to CCS-managed flags so nested Team/subagent sessions do not - // inherit profile-specific ANTHROPIC_* routing from the parent process. + // 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 = { - ...globalEnv, + ...stripAnthropicRoutingEnv({ ...globalEnv, ...settingsEnv }), ...(inheritedClaudeConfigDir ? { CLAUDE_CONFIG_DIR: inheritedClaudeConfigDir } : {}), ...webSearchEnv, ...imageAnalysisEnv, diff --git a/tests/unit/targets/settings-profile-browser-launch.test.ts b/tests/unit/targets/settings-profile-browser-launch.test.ts index df9a2f22..fa63cbc6 100644 --- a/tests/unit/targets/settings-profile-browser-launch.test.ts +++ b/tests/unit/targets/settings-profile-browser-launch.test.ts @@ -141,6 +141,7 @@ printf "%s\n" "$@" > "${claudeArgsLogPath}" 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 `, @@ -197,6 +198,7 @@ exit 0 ANTHROPIC_AUTH_TOKEN: 'profile-token', ANTHROPIC_MODEL: 'gpt-5.4', ANTHROPIC_DEFAULT_SONNET_MODEL: 'gpt-5.4', + CLAUDE_CODE_MAX_OUTPUT_TOKENS: '12345', }, }, null, @@ -204,23 +206,47 @@ exit 0 ) + '\n' ); - 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', - }); + const originalCcsHome = process.env.CCS_HOME; + process.env.CCS_HOME = tmpHome; - 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'); + 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 () => { From b8b48a99ba8d9e17398886a005c7c64f011ad6f3 Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Wed, 22 Apr 2026 07:51:46 -0400 Subject: [PATCH 08/39] hotfix(cliproxy): fallback from deleted CLIProxyAPIPlus to original backend The upstream router-for-me/CLIProxyAPIPlus repo was deleted, breaking any install/update path that resolved backend: plus. Existing users with `backend: plus` saved in config.yaml hit 404s on every bootstrap. Changes: - Flip DEFAULT_BACKEND from 'plus' to 'original' (platform-detector.ts) - Flip default cliproxy.backend in createEmptyUnifiedConfig() to 'original' - Runtime 404 fallback: getConfiguredBackend() degrades 'plus' -> 'original' with a one-time warning, so existing installations keep working without a manual reconfig step - Update CLIProxyBackend docblock to document the fallback behavior - Update tests to match new default Retains the 'plus' type and BACKEND_CONFIG entry for forward compatibility once CCS self-maintains its own Plus fork. Closes #1062 --- src/cliproxy/binary-manager.ts | 34 +++++++++++++++++-- src/cliproxy/platform-detector.ts | 11 ++++-- src/cliproxy/types.ts | 6 ++-- src/config/unified-config-types.ts | 2 +- tests/unit/cliproxy/backend-selection.test.js | 19 ++++++----- .../cliproxy/binary-manager-install.test.ts | 2 +- 6 files changed, 57 insertions(+), 17 deletions(-) diff --git a/src/cliproxy/binary-manager.ts b/src/cliproxy/binary-manager.ts index 452bd162..1a4237aa 100644 --- a/src/cliproxy/binary-manager.ts +++ b/src/cliproxy/binary-manager.ts @@ -32,15 +32,43 @@ import { import type { CLIProxyBackend } from './types'; /** - * 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. + */ +let plusFallbackWarned = false; + +/** + * 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). */ function getConfiguredBackend(): CLIProxyBackend { + let configured: CLIProxyBackend; try { const config = loadOrCreateUnifiedConfig(); - return config.cliproxy?.backend || DEFAULT_BACKEND; + configured = config.cliproxy?.backend || DEFAULT_BACKEND; } catch { return DEFAULT_BACKEND; } + + if (configured === 'plus') { + if (!plusFallbackWarned) { + plusFallbackWarned = true; + warn( + 'CLIProxyAPIPlus upstream repo is currently unavailable; falling back to ' + + '`backend: original`. Run `ccs config` to update your saved config. ' + + 'Tracking: https://github.com/kaitranntt/ccs/issues/1062' + ); + } + return 'original'; + } + + return configured; } /** @@ -52,7 +80,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 { diff --git a/src/cliproxy/platform-detector.ts b/src/cliproxy/platform-detector.ts index f5493f50..6c73c533 100644 --- a/src/cliproxy/platform-detector.ts +++ b/src/cliproxy/platform-detector.ts @@ -29,8 +29,15 @@ export const BACKEND_CONFIG = { }, } as const; -/** Default backend */ -export const DEFAULT_BACKEND: CLIProxyBackend = 'plus'; +/** + * Default backend + * + * Set to 'original' because upstream `router-for-me/CLIProxyAPIPlus` was + * deleted (issue #1062). The original `router-for-me/CLIProxyAPI` repo is + * still maintained. Users with existing `backend: plus` configs are migrated + * at runtime via a 404 fallback in the installer (see binary/installer.ts). + */ +export const DEFAULT_BACKEND: CLIProxyBackend = 'original'; /** * CLIProxyAPIPlus fallback version (used when GitHub API unavailable) diff --git a/src/cliproxy/types.ts b/src/cliproxy/types.ts index ea723480..37686a50 100644 --- a/src/cliproxy/types.ts +++ b/src/cliproxy/types.ts @@ -148,8 +148,10 @@ export type CLIProxyProvider = /** * CLIProxy backend selection - * - original: CLIProxyAPI (legacy provider subset) - * - plus: CLIProxyAPIPlus (expanded provider support, default) + * - original: CLIProxyAPI (current default; upstream still maintained) + * - plus: CLIProxyAPIPlus (expanded provider support; upstream deleted as of + * issue #1062 — runtime falls back to `original`. Retained for forward + * compatibility once CCS self-maintains its own Plus build.) */ export type CLIProxyBackend = 'original' | 'plus'; diff --git a/src/config/unified-config-types.ts b/src/config/unified-config-types.ts index 6b294857..472bd168 100644 --- a/src/config/unified-config-types.ts +++ b/src/config/unified-config-types.ts @@ -1016,7 +1016,7 @@ export function createEmptyUnifiedConfig(): UnifiedConfig { accounts: {}, profiles: {}, cliproxy: { - backend: 'plus', + backend: 'original', oauth_accounts: {}, providers: [...CLIPROXY_SUPPORTED_PROVIDERS], variants: {}, diff --git a/tests/unit/cliproxy/backend-selection.test.js b/tests/unit/cliproxy/backend-selection.test.js index ff2ead96..2a36c93e 100644 --- a/tests/unit/cliproxy/backend-selection.test.js +++ b/tests/unit/cliproxy/backend-selection.test.js @@ -28,8 +28,8 @@ describe('Backend Selection', () => { }); describe('DEFAULT_BACKEND', () => { - it('defaults to plus backend for backward compatibility', () => { - assert.strictEqual(platformDetector.DEFAULT_BACKEND, 'plus'); + it('defaults to original backend (Plus upstream deleted, issue #1062)', () => { + assert.strictEqual(platformDetector.DEFAULT_BACKEND, 'original'); }); }); @@ -45,9 +45,10 @@ describe('Backend Selection', () => { assert(info.binaryName.startsWith('CLIProxyAPIPlus_6.6.51-0_')); }); - it('uses plus backend by default', () => { + it('uses original backend by default (Plus upstream deleted, issue #1062)', () => { const info = platformDetector.detectPlatform(); - assert(info.binaryName.includes('CLIProxyAPIPlus')); + assert(info.binaryName.startsWith('CLIProxyAPI_')); + assert(!info.binaryName.includes('CLIProxyAPIPlus')); }); it('uses fallback version when version not specified', () => { @@ -76,9 +77,10 @@ describe('Backend Selection', () => { assert.strictEqual(name, expected); }); - it('defaults to plus backend', () => { + it('defaults to original backend (Plus upstream deleted, issue #1062)', () => { const name = platformDetector.getExecutableName(); - assert(name.includes('cli-proxy-api-plus')); + const expected = isWindows ? 'cli-proxy-api.exe' : 'cli-proxy-api'; + assert.strictEqual(name, expected); }); }); @@ -94,9 +96,10 @@ describe('Backend Selection', () => { assert(url.includes('router-for-me/CLIProxyAPIPlus/releases')); }); - it('defaults to plus backend', () => { + it('defaults to original backend (Plus upstream deleted, issue #1062)', () => { const url = platformDetector.getDownloadUrl(); - assert(url.includes('CLIProxyAPIPlus')); + assert(url.includes('router-for-me/CLIProxyAPI/releases')); + assert(!url.includes('CLIProxyAPIPlus')); }); }); diff --git a/tests/unit/cliproxy/binary-manager-install.test.ts b/tests/unit/cliproxy/binary-manager-install.test.ts index 92140f1d..4b7d686d 100644 --- a/tests/unit/cliproxy/binary-manager-install.test.ts +++ b/tests/unit/cliproxy/binary-manager-install.test.ts @@ -78,7 +78,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.' ); }); }); From dd90ea7e2fa4dcac6227009caacc1f76711c79b5 Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Wed, 22 Apr 2026 08:35:26 -0400 Subject: [PATCH 09/39] hotfix: close cliproxy plus fallback gaps --- src/cliproxy/binary-manager.ts | 81 ++++++++++++++----- src/cliproxy/executor/index.ts | 40 ++++----- src/cliproxy/services/binary-service.ts | 43 ++++++---- src/cliproxy/services/variant-service.ts | 14 ++-- src/commands/cliproxy/index.ts | 10 +-- src/commands/cliproxy/install-subcommand.ts | 2 +- src/config/unified-config-loader.ts | 2 +- src/config/unified-config-types.ts | 2 +- .../routes/cliproxy-stats-routes.ts | 24 ++---- .../cliproxy-dashboard-install-service.ts | 7 +- .../cliproxy/binary-manager-install.test.ts | 48 +++++++++++ .../cliproxy/variant-update-service.test.ts | 12 ++- ...cliproxy-dashboard-install-service.test.ts | 8 +- .../cliproxy-stats-routes-install.test.ts | 29 +++++-- .../pages/settings/sections/proxy/index.tsx | 17 +++- 15 files changed, 235 insertions(+), 104 deletions(-) diff --git a/src/cliproxy/binary-manager.ts b/src/cliproxy/binary-manager.ts index 1a4237aa..5f507e53 100644 --- a/src/cliproxy/binary-manager.ts +++ b/src/cliproxy/binary-manager.ts @@ -31,12 +31,47 @@ import { import type { CLIProxyBackend } from './types'; +export const CLIPROXY_PLUS_TRACKING_URL = 'https://github.com/kaitranntt/ccs/issues/1062'; + /** * Track whether we've already warned the user about the Plus fallback this * process lifetime. Prevents spamming the warning on every command. */ 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'; +} + /** * Get backend from config, with runtime fallback to 'original' when the user * still has `backend: plus` saved. @@ -47,7 +82,7 @@ let plusFallbackWarned = false; * runtime and warn once. This keeps existing installations working without a * reconfig step, while CCS self-maintains its own Plus fork (future work). */ -function getConfiguredBackend(): CLIProxyBackend { +export function getConfiguredBackend(options: { warnOnFallback?: boolean } = {}): CLIProxyBackend { let configured: CLIProxyBackend; try { const config = loadOrCreateUnifiedConfig(); @@ -56,19 +91,7 @@ function getConfiguredBackend(): CLIProxyBackend { return DEFAULT_BACKEND; } - if (configured === 'plus') { - if (!plusFallbackWarned) { - plusFallbackWarned = true; - warn( - 'CLIProxyAPIPlus upstream repo is currently unavailable; falling back to ' + - '`backend: original`. Run `ccs config` to update your saved config. ' + - 'Tracking: https://github.com/kaitranntt/ccs/issues/1062' - ); - } - return 'original'; - } - - return configured; + return resolveLocalBackend(configured, options); } /** @@ -104,7 +127,9 @@ export class BinaryManager { private backend: CLIProxyBackend; constructor(config: Partial = {}, backend?: CLIProxyBackend) { - this.backend = backend ?? getConfiguredBackend(); + this.backend = backend + ? resolveLocalBackend(backend, { warnOnFallback: true }) + : getConfiguredBackend({ warnOnFallback: true }); const defaultConfig = createDefaultConfig(this.backend); this.config = { ...defaultConfig, ...config }; } @@ -155,7 +180,7 @@ export async function ensureCLIProxyBinary( verbose = false, options: EnsureCLIProxyBinaryOptions = {} ): Promise { - const backend = getConfiguredBackend(); + const backend = getConfiguredBackend({ warnOnFallback: true }); // Migrate old shared pin to backend-specific location (one-time migration) migrateVersionPin(backend); @@ -186,19 +211,25 @@ export async function ensureCLIProxyBinary( /** Check if CLIProxyAPI binary is installed */ export function isCLIProxyInstalled(backend?: CLIProxyBackend): boolean { - const effectiveBackend = backend ?? getConfiguredBackend(); + const effectiveBackend = backend + ? resolveLocalBackend(backend, { warnOnFallback: true }) + : getConfiguredBackend({ 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 effectiveBackend = backend + ? resolveLocalBackend(backend, { warnOnFallback: true }) + : getConfiguredBackend({ 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 effectiveBackend = backend + ? resolveLocalBackend(backend, { warnOnFallback: true }) + : getConfiguredBackend({ warnOnFallback: true }); return readInstalledVersion( getBackendBinDir(effectiveBackend), BACKEND_CONFIG[effectiveBackend].fallbackVersion @@ -224,7 +255,9 @@ export async function installCliproxyVersion( backend?: CLIProxyBackend, deps: InstallCliproxyVersionDeps = {} ): Promise { - const effectiveBackend = backend ?? getConfiguredBackend(); + const effectiveBackend = backend + ? resolveLocalBackend(backend, { warnOnFallback: true }) + : getConfiguredBackend({ warnOnFallback: true }); const manager = deps.createManager?.({ version, verbose, forceVersion: true }, effectiveBackend) ?? new BinaryManager({ version, verbose, forceVersion: true }, effectiveBackend); @@ -265,7 +298,9 @@ export async function installCliproxyVersion( /** Fetch the latest CLIProxyAPI version from GitHub API */ export async function fetchLatestCliproxyVersion(backend?: CLIProxyBackend): Promise { - const effectiveBackend = backend ?? getConfiguredBackend(); + const effectiveBackend = backend + ? resolveLocalBackend(backend, { warnOnFallback: true }) + : getConfiguredBackend({ warnOnFallback: true }); const result = await new BinaryManager({}, effectiveBackend).checkForUpdates(); return result.latestVersion; } @@ -290,7 +325,9 @@ export interface CliproxyUpdateCheckResult { export async function checkCliproxyUpdate( backend?: CLIProxyBackend ): Promise { - const effectiveBackend = backend ?? getConfiguredBackend(); + const effectiveBackend = backend + ? resolveLocalBackend(backend, { warnOnFallback: true }) + : getConfiguredBackend({ warnOnFallback: true }); const result = await new BinaryManager({}, effectiveBackend).checkForUpdates(); // Import isNewerVersion for stability check diff --git a/src/cliproxy/executor/index.ts b/src/cliproxy/executor/index.ts index 2f534ff2..004ba277 100644 --- a/src/cliproxy/executor/index.ts +++ b/src/cliproxy/executor/index.ts @@ -18,7 +18,11 @@ import { ProgressIndicator } from '../../utils/progress-indicator'; import { ok, fail, info, warn } from '../../utils/ui'; import { getCcsDir } from '../../utils/config-manager'; import { escapeShellArg, getWindowsEscapedCommandShell } from '../../utils/shell-executor'; -import { ensureCLIProxyBinary } from '../binary-manager'; +import { + ensureCLIProxyBinary, + getConfiguredBackend, + getPlusBackendUnavailableMessage, +} from '../binary-manager'; import { generateConfig, getProviderConfig, @@ -30,7 +34,6 @@ import { import { checkRemoteProxy } from '../remote-proxy-client'; import { isAuthenticated } from '../auth-handler'; import { CLIProxyProvider, CLIProxyBackend, PLUS_ONLY_PROVIDERS, ExecutorConfig } from '../types'; -import { DEFAULT_BACKEND } from '../platform-detector'; import { configureProviderModel, getCurrentModel } from '../model-config'; import { reconcileCodexModelForActivePlan } from '../codex-plan-compatibility'; import { resolveProxyConfig, PROXY_CLI_FLAGS } from '../proxy-config-resolver'; @@ -211,23 +214,8 @@ export async function execClaudeWithCLIProxy( // 0. Resolve proxy configuration (CLI > ENV > config.yaml > defaults) const unifiedConfig = loadOrCreateUnifiedConfig(); - // 0a. Runtime backend/provider validation - const backend: CLIProxyBackend = unifiedConfig.cliproxy?.backend ?? DEFAULT_BACKEND; - // Collect all providers to validate (default + composite tiers) const allProviders = [provider, ...compositeProviders]; - for (const p of allProviders) { - if (backend === 'original' && PLUS_ONLY_PROVIDERS.includes(p as CLIProxyProvider)) { - console.error(''); - console.error(fail(`${p} requires CLIProxyAPIPlus backend`)); - console.error(''); - console.error('To use this provider, either:'); - console.error(' 1. Set `cliproxy.backend: plus` in ~/.ccs/config.yaml'); - console.error(' 2. Use --backend=plus flag: ccs ' + p + ' --backend=plus'); - console.error(''); - throw new Error(`Provider ${p} requires Plus backend`); - } - } const cliproxyServerConfig = unifiedConfig.cliproxy_server; const { config: proxyConfig, remainingArgs: argsWithoutProxy } = resolveProxyConfig(args, { @@ -314,6 +302,7 @@ export async function execClaudeWithCLIProxy( // Check remote proxy if configured let useRemoteProxy = false; + let localBackend: CLIProxyBackend = 'original'; if (proxyConfig.mode === 'remote' && proxyConfig.host) { const status = await checkRemoteProxy({ host: proxyConfig.host, @@ -361,6 +350,19 @@ export async function execClaudeWithCLIProxy( } } + if (!useRemoteProxy) { + localBackend = getConfiguredBackend({ warnOnFallback: true }); + + for (const p of allProviders) { + if (localBackend === 'original' && PLUS_ONLY_PROVIDERS.includes(p as CLIProxyProvider)) { + console.error(''); + console.error(fail(getPlusBackendUnavailableMessage(p))); + console.error(''); + throw new Error(`Provider ${p} is temporarily unavailable on local CLIProxy`); + } + } + } + // Variables for local proxy mode let binaryPath: string | undefined; let sessionId: string | undefined; @@ -1000,13 +1002,13 @@ export async function execClaudeWithCLIProxy( cfg.port, cfg.timeout, cfg.pollInterval, - backend, + localBackend, configPath ); // Register session if (proxy.pid) { - sessionId = registerProxySession(cfg.port, proxy.pid, backend, verbose); + sessionId = registerProxySession(cfg.port, proxy.pid, localBackend, verbose); } } } diff --git a/src/cliproxy/services/binary-service.ts b/src/cliproxy/services/binary-service.ts index 3826e581..798511ad 100644 --- a/src/cliproxy/services/binary-service.ts +++ b/src/cliproxy/services/binary-service.ts @@ -18,6 +18,7 @@ import { savePinnedVersion, clearPinnedVersion, isVersionPinned, + resolveLocalBackend, } from '../binary-manager'; import { BACKEND_CONFIG, DEFAULT_BACKEND } from '../platform-detector'; import { CLIProxyBackend } from '../types'; @@ -54,8 +55,10 @@ export interface LatestVersionResult { * Get current binary status for a specific backend */ export function getBinaryStatus(backend?: CLIProxyBackend): BinaryStatusResult { - const effectiveBackend = - backend ?? loadOrCreateUnifiedConfig().cliproxy?.backend ?? DEFAULT_BACKEND; + const effectiveBackend = resolveLocalBackend( + backend ?? loadOrCreateUnifiedConfig().cliproxy?.backend ?? DEFAULT_BACKEND, + { warnOnFallback: true } + ); const backendConfig = BACKEND_CONFIG[effectiveBackend]; return { installed: isCLIProxyInstalled(effectiveBackend), @@ -71,8 +74,10 @@ export function getBinaryStatus(backend?: CLIProxyBackend): BinaryStatusResult { * Check for latest version */ export async function checkLatestVersion(backend?: CLIProxyBackend): Promise { - const effectiveBackend = - backend ?? loadOrCreateUnifiedConfig().cliproxy?.backend ?? DEFAULT_BACKEND; + const effectiveBackend = resolveLocalBackend( + backend ?? loadOrCreateUnifiedConfig().cliproxy?.backend ?? DEFAULT_BACKEND, + { warnOnFallback: true } + ); try { // Use checkCliproxyUpdate which is backend-aware (uses correct GitHub repo) @@ -119,8 +124,10 @@ export async function installVersion( }; } - const effectiveBackend = - backend ?? loadOrCreateUnifiedConfig().cliproxy?.backend ?? DEFAULT_BACKEND; + const effectiveBackend = resolveLocalBackend( + backend ?? loadOrCreateUnifiedConfig().cliproxy?.backend ?? DEFAULT_BACKEND, + { warnOnFallback: true } + ); try { await installCliproxyVersion(version, verbose, effectiveBackend); @@ -147,8 +154,10 @@ export async function installLatest( verbose = false, backend?: CLIProxyBackend ): Promise { - const effectiveBackend = - backend ?? loadOrCreateUnifiedConfig().cliproxy?.backend ?? DEFAULT_BACKEND; + const effectiveBackend = resolveLocalBackend( + backend ?? loadOrCreateUnifiedConfig().cliproxy?.backend ?? DEFAULT_BACKEND, + { warnOnFallback: true } + ); try { const latestVersion = await fetchLatestCliproxyVersion(effectiveBackend); @@ -184,8 +193,10 @@ export async function installLatest( * Check if a version is pinned */ export function isPinned(backend?: CLIProxyBackend): boolean { - const effectiveBackend = - backend ?? loadOrCreateUnifiedConfig().cliproxy?.backend ?? DEFAULT_BACKEND; + const effectiveBackend = resolveLocalBackend( + backend ?? loadOrCreateUnifiedConfig().cliproxy?.backend ?? DEFAULT_BACKEND, + { warnOnFallback: true } + ); return isVersionPinned(effectiveBackend); } @@ -193,8 +204,10 @@ export function isPinned(backend?: CLIProxyBackend): boolean { * Get pinned version if any */ export function getPinned(backend?: CLIProxyBackend): string | null { - const effectiveBackend = - backend ?? loadOrCreateUnifiedConfig().cliproxy?.backend ?? DEFAULT_BACKEND; + const effectiveBackend = resolveLocalBackend( + backend ?? loadOrCreateUnifiedConfig().cliproxy?.backend ?? DEFAULT_BACKEND, + { warnOnFallback: true } + ); return getPinnedVersion(effectiveBackend); } @@ -202,7 +215,9 @@ export function getPinned(backend?: CLIProxyBackend): string | null { * Clear version pin */ export function clearPin(backend?: CLIProxyBackend): void { - const effectiveBackend = - backend ?? loadOrCreateUnifiedConfig().cliproxy?.backend ?? DEFAULT_BACKEND; + const effectiveBackend = resolveLocalBackend( + backend ?? loadOrCreateUnifiedConfig().cliproxy?.backend ?? DEFAULT_BACKEND, + { warnOnFallback: true } + ); clearPinnedVersion(effectiveBackend); } diff --git a/src/cliproxy/services/variant-service.ts b/src/cliproxy/services/variant-service.ts index f8970270..f15ae110 100644 --- a/src/cliproxy/services/variant-service.ts +++ b/src/cliproxy/services/variant-service.ts @@ -8,12 +8,10 @@ import * as os from 'os'; import * as path from 'path'; import { CLIProxyProfileName } from '../../auth/profile-detector'; -import { CLIProxyProvider, CLIProxyBackend, PLUS_ONLY_PROVIDERS } from '../types'; +import { CLIProxyProvider, PLUS_ONLY_PROVIDERS } from '../types'; import { CompositeTierConfig, CompositeVariantConfig } from '../../config/unified-config-types'; import type { TargetType } from '../../targets/target-adapter'; import { isReservedName, isWindowsReservedName } from '../../config/reserved-names'; -import { loadOrCreateUnifiedConfig } from '../../config/unified-config-loader'; -import { DEFAULT_BACKEND } from '../platform-detector'; import { isUnifiedMode } from '../../config/unified-config-loader'; import { deleteConfigForPort } from '../config-generator'; import { hasActiveSessions, deleteSessionLockForPort } from '../session-tracker'; @@ -44,6 +42,7 @@ import { removeVariantFromLegacyConfig, getNextAvailablePort, } from './variant-config-adapter'; +import { getConfiguredBackend, getPlusBackendUnavailableMessage } from '../binary-manager'; // Re-export VariantConfig from adapter export type { VariantConfig } from './variant-config-adapter'; @@ -80,16 +79,15 @@ export function validateProfileName(name: string): string | null { /** * Validate provider/backend compatibility - * Returns error message if provider requires Plus backend but original is configured + * Returns error message if a provider requires Plus while local CLIProxy is + * running with the fallbacked original backend. */ export function validateProviderBackend(provider: CLIProxyProfileName): string | null { - const config = loadOrCreateUnifiedConfig(); - const backend: CLIProxyBackend = config.cliproxy?.backend ?? DEFAULT_BACKEND; - // Normalize provider to lowercase for case-insensitive comparison const normalizedProvider = provider.toLowerCase() as CLIProxyProvider; + const backend = getConfiguredBackend(); if (backend === 'original' && PLUS_ONLY_PROVIDERS.includes(normalizedProvider)) { - return `${provider} requires CLIProxyAPIPlus. Set \`cliproxy.backend: plus\` in config.yaml or use --backend=plus`; + return getPlusBackendUnavailableMessage(provider); } return null; } diff --git a/src/commands/cliproxy/index.ts b/src/commands/cliproxy/index.ts index 52c4cb02..7456a5ba 100644 --- a/src/commands/cliproxy/index.ts +++ b/src/commands/cliproxy/index.ts @@ -6,14 +6,13 @@ */ import { CLIProxyBackend } from '../../cliproxy/types'; -import { DEFAULT_BACKEND } from '../../cliproxy/platform-detector'; +import { getConfiguredBackend, resolveLocalBackend } 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'; @@ -74,9 +73,10 @@ function parseBackendArg(args: string[]): { * Get effective backend (CLI flag > config.yaml > default) */ function getEffectiveBackend(cliBackend?: CLIProxyBackend): CLIProxyBackend { - if (cliBackend) return cliBackend; - const config = loadOrCreateUnifiedConfig(); - return config.cliproxy?.backend ?? DEFAULT_BACKEND; + if (cliBackend) { + return resolveLocalBackend(cliBackend, { warnOnFallback: true }); + } + return getConfiguredBackend({ warnOnFallback: true }); } /** diff --git a/src/commands/cliproxy/install-subcommand.ts b/src/commands/cliproxy/install-subcommand.ts index f5f59036..2e898063 100644 --- a/src/commands/cliproxy/install-subcommand.ts +++ b/src/commands/cliproxy/install-subcommand.ts @@ -47,7 +47,7 @@ export async function showStatus(verbose: boolean, backend: CLIProxyBackend): Pr console.log(` ${dim('Run "ccs gemini" or any provider to auto-install')}`); } - const latestCheck = await checkLatestVersion(); + const latestCheck = await checkLatestVersion(backend); if (latestCheck.success && latestCheck.latestVersion) { console.log(''); if (latestCheck.updateAvailable) { diff --git a/src/config/unified-config-loader.ts b/src/config/unified-config-loader.ts index a3548fc6..39b3a8aa 100644 --- a/src/config/unified-config-loader.ts +++ b/src/config/unified-config-loader.ts @@ -410,7 +410,7 @@ function mergeWithDefaults(partial: Partial): UnifiedConfig { backend: partial.cliproxy?.backend === 'original' || partial.cliproxy?.backend === 'plus' ? partial.cliproxy.backend - : undefined, // Invalid values become undefined (defaults to 'plus' at runtime) + : undefined, // Invalid values become undefined (defaults to 'original' at runtime) // Auto-sync - default to true auto_sync: partial.cliproxy?.auto_sync ?? defaults.cliproxy.auto_sync ?? true, routing: { diff --git a/src/config/unified-config-types.ts b/src/config/unified-config-types.ts index 472bd168..de23e6ef 100644 --- a/src/config/unified-config-types.ts +++ b/src/config/unified-config-types.ts @@ -211,7 +211,7 @@ export interface CLIProxyRoutingConfig { * CLIProxy configuration section. */ export interface CLIProxyConfig { - /** Backend selection: 'original' or 'plus' (default: 'plus') */ + /** Backend selection: 'original' or 'plus' (default: 'original') */ backend?: 'original' | 'plus'; /** Nickname to email mapping for OAuth accounts */ oauth_accounts: OAuthAccounts; diff --git a/src/web-server/routes/cliproxy-stats-routes.ts b/src/web-server/routes/cliproxy-stats-routes.ts index e580d993..7e57dac2 100644 --- a/src/web-server/routes/cliproxy-stats-routes.ts +++ b/src/web-server/routes/cliproxy-stats-routes.ts @@ -38,7 +38,11 @@ import { } from '../../cliproxy/config-generator'; import { getProxyStatus as getProxyProcessStatus, stopProxy } from '../../cliproxy/session-tracker'; import { ensureCliproxyService } from '../../cliproxy/service-manager'; -import { checkCliproxyUpdate, getInstalledCliproxyVersion } from '../../cliproxy/binary-manager'; +import { + checkCliproxyUpdate, + getConfiguredBackend, + getInstalledCliproxyVersion, +} 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,16 +145,6 @@ 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, getInstalledVersionFn: typeof getInstalledCliproxyVersion = getInstalledCliproxyVersion @@ -409,7 +401,7 @@ router.post('/proxy-stop', async (_req: Request, res: Response): Promise = */ router.get('/update-check', async (_req: Request, res: Response): Promise => { try { - const backend = getConfiguredBackend(); + const backend = getConfiguredBackend({ warnOnFallback: true }); const result = await resolveCliproxyUpdateCheckPayload(backend); res.json(result); @@ -1019,7 +1011,7 @@ router.get('/quota/:provider/:accountId', async (req: Request, res: Response): P */ router.get('/versions', async (_req: Request, res: Response): Promise => { try { - const backend = getConfiguredBackend(); + const backend = getConfiguredBackend({ warnOnFallback: true }); res.json(await resolveCliproxyVersionsPayload(backend)); } catch (error) { console.error(`[cliproxy-stats] ${(error as Error).message}`); @@ -1073,7 +1065,7 @@ router.post('/install', async (req: Request, res: Response): Promise => { return; } - const backend = getConfiguredBackend(); + const backend = getConfiguredBackend({ warnOnFallback: true }); const installResult = await installDashboardCliproxyVersion(version, backend); res.json({ diff --git a/src/web-server/services/cliproxy-dashboard-install-service.ts b/src/web-server/services/cliproxy-dashboard-install-service.ts index 8ea78bb5..3fc32388 100644 --- a/src/web-server/services/cliproxy-dashboard-install-service.ts +++ b/src/web-server/services/cliproxy-dashboard-install-service.ts @@ -1,4 +1,4 @@ -import { installCliproxyVersion } from '../../cliproxy/binary-manager'; +import { installCliproxyVersion, resolveLocalBackend } 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 +52,13 @@ export async function installDashboardCliproxyVersion( backend: CLIProxyBackend, deps: InstallDashboardCliproxyVersionDeps = defaultDeps ): Promise { - const backendLabel = backend === 'plus' ? 'CLIProxy Plus' : 'CLIProxy'; + const effectiveBackend = resolveLocalBackend(backend, { warnOnFallback: true }); + const backendLabel = effectiveBackend === 'plus' ? 'CLIProxy Plus' : 'CLIProxy'; const shouldRestoreService = await wasProxyRunning(deps); // The installer owns the stop-and-replace lifecycle, including best-effort // shutdown for tracked and untracked proxies before swapping the binary. - await deps.installCliproxyVersion(version, true, backend); + await deps.installCliproxyVersion(version, true, effectiveBackend); if (!shouldRestoreService) { return { diff --git a/tests/unit/cliproxy/binary-manager-install.test.ts b/tests/unit/cliproxy/binary-manager-install.test.ts index 4b7d686d..f11cee5c 100644 --- a/tests/unit/cliproxy/binary-manager-install.test.ts +++ b/tests/unit/cliproxy/binary-manager-install.test.ts @@ -25,6 +25,54 @@ 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('attempts to stop the proxy even when there is no tracked running session', async () => { const calls = { stopProxy: 0, diff --git a/tests/unit/cliproxy/variant-update-service.test.ts b/tests/unit/cliproxy/variant-update-service.test.ts index 7e98a4ff..25ef9a21 100644 --- a/tests/unit/cliproxy/variant-update-service.test.ts +++ b/tests/unit/cliproxy/variant-update-service.test.ts @@ -6,7 +6,10 @@ import * as fs from 'fs'; import * as os from 'os'; import * as path from 'path'; import { afterEach, beforeEach, describe, expect, it } from 'bun:test'; -import { updateVariant } from '../../../src/cliproxy/services/variant-service'; +import { + updateVariant, + validateProviderBackend, +} from '../../../src/cliproxy/services/variant-service'; import { loadOrCreateUnifiedConfig } from '../../../src/config/unified-config-loader'; describe('updateVariant - provider/model consistency', () => { @@ -50,6 +53,7 @@ preferences: telemetry: false auto_update: true cliproxy: + backend: plus oauth_accounts: {} providers: - gemini @@ -92,6 +96,12 @@ 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('updates provider and regenerates provider-specific core env in same settings file', () => { const result = updateVariant('demo', { provider: 'codex', diff --git a/tests/unit/web-server/cliproxy-dashboard-install-service.test.ts b/tests/unit/web-server/cliproxy-dashboard-install-service.test.ts index 6522dd58..8c08b01e 100644 --- a/tests/unit/web-server/cliproxy-dashboard-install-service.test.ts +++ b/tests/unit/web-server/cliproxy-dashboard-install-service.test.ts @@ -56,7 +56,7 @@ describe('installDashboardCliproxyVersion', () => { success: true, restarted: true, port: 8317, - message: 'Successfully installed CLIProxy Plus v6.7.1 and restarted it on port 8317', + message: 'Successfully installed CLIProxy v6.7.1 and restarted it on port 8317', }); expect(calls.isCliproxyRunning).toBe(0); expect(calls.installCliproxyVersion).toBe(1); @@ -71,7 +71,7 @@ describe('installDashboardCliproxyVersion', () => { expect(result).toEqual({ success: true, restarted: false, - message: 'Successfully installed CLIProxy Plus v6.7.1', + message: 'Successfully installed CLIProxy v6.7.1', }); expect(calls.isCliproxyRunning).toBe(1); expect(calls.installCliproxyVersion).toBe(1); @@ -118,8 +118,8 @@ describe('installDashboardCliproxyVersion', () => { expect(result).toEqual({ success: false, restarted: false, - error: 'Installed CLIProxy Plus v6.7.1, but restart failed', - message: 'Installed CLIProxy Plus v6.7.1, but failed to restart it', + error: 'Installed CLIProxy v6.7.1, but restart failed', + message: 'Installed CLIProxy v6.7.1, but failed to restart it', }); }); }); diff --git a/tests/unit/web-server/cliproxy-stats-routes-install.test.ts b/tests/unit/web-server/cliproxy-stats-routes-install.test.ts index 40578506..60470a3c 100644 --- a/tests/unit/web-server/cliproxy-stats-routes-install.test.ts +++ b/tests/unit/web-server/cliproxy-stats-routes-install.test.ts @@ -10,6 +10,7 @@ let createEmptyUnifiedConfig: typeof import('../../../src/config/unified-config- let saveUnifiedConfig: typeof import('../../../src/config/unified-config-loader').saveUnifiedConfig; let setGlobalConfigDir: typeof import('../../../src/utils/config-manager').setGlobalConfigDir; let writeInstalledVersion: typeof import('../../../src/cliproxy/binary/version-cache').writeInstalledVersion; +let writeVersionCache: typeof import('../../../src/cliproxy/binary/version-cache').writeVersionCache; let writeVersionListCache: typeof import('../../../src/cliproxy/binary/version-cache').writeVersionListCache; let server: Server; @@ -25,20 +26,21 @@ 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' )); const ccsDir = path.join(tempHome, '.ccs'); - const plusBinDir = path.join(ccsDir, 'cliproxy', 'bin', 'plus'); - fs.mkdirSync(plusBinDir, { recursive: true }); + const originalBinDir = path.join(ccsDir, 'cliproxy', 'bin', 'original'); + fs.mkdirSync(originalBinDir, { recursive: true }); setGlobalConfigDir(ccsDir); const config = createEmptyUnifiedConfig(); config.cliproxy = { backend: 'plus' }; saveUnifiedConfig(config); - writeInstalledVersion(plusBinDir, '6.6.80'); + writeInstalledVersion(originalBinDir, '6.6.80'); + writeVersionCache('6.6.89', 'original'); writeVersionListCache( { versions: ['6.6.89', '6.6.88', '6.6.81', '6.6.80'], @@ -46,7 +48,7 @@ beforeAll(async () => { latest: '6.6.89', checkedAt: Date.now(), }, - 'plus' + 'original' ); ({ default: cliproxyStatsRoutes } = await import( @@ -94,6 +96,23 @@ afterAll(async () => { }); describe('cliproxy-stats-routes install contract', () => { + it('routes saved plus configs through original backend for update checks', async () => { + const response = await fetch(`${baseUrl}/api/cliproxy/update-check`); + expect(response.status).toBe(200); + + const body = (await response.json()) as { + backend: string; + backendLabel: string; + currentVersion: string; + latestVersion: string; + }; + + expect(body.backend).toBe('original'); + expect(body.backendLabel).toBe('CLIProxy'); + expect(body.currentVersion).toBe('6.6.80'); + expect(body.latestVersion).toBe('6.6.89'); + }); + it('returns faultyRange in the versions response', async () => { const response = await fetch(`${baseUrl}/api/cliproxy/versions`); expect(response.status).toBe(200); diff --git a/ui/src/pages/settings/sections/proxy/index.tsx b/ui/src/pages/settings/sections/proxy/index.tsx index ce90a1d1..218d4326 100644 --- a/ui/src/pages/settings/sections/proxy/index.tsx +++ b/ui/src/pages/settings/sections/proxy/index.tsx @@ -191,7 +191,7 @@ 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(); @@ -490,9 +490,6 @@ export default function ProxySection() { >
{t('settingsProxy.backendPlusApi')} - - {t('settingsProxy.default')} -

{t('settingsProxy.plusDesc')}

@@ -509,10 +506,22 @@ export default function ProxySection() { >
{t('settingsProxy.backendApi')} + + {t('settingsProxy.default')} +

{t('settingsProxy.originalDesc')}

+ {backend === 'plus' && ( + + + + CLIProxyAPIPlus upstream is currently unavailable. Local CLIProxy will use the + original backend until issue #1062 is resolved. + + + )} {/* Warning when original backend selected with Kiro/ghcp variants */} {backend === 'original' && hasKiroGhcpVariants && ( From 3e7ce1743b491a7698b210df05bdc815cc02191b Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Wed, 22 Apr 2026 08:45:05 -0400 Subject: [PATCH 10/39] hotfix: preserve plus fallback state and guard variant updates --- src/cliproxy/binary-manager.ts | 102 ++++++++++++------ src/cliproxy/services/binary-service.ts | 57 +++++----- src/cliproxy/services/variant-service.ts | 15 ++- src/commands/cliproxy/help-subcommand.ts | 10 +- src/commands/cliproxy/index.ts | 9 +- .../routes/cliproxy-stats-routes.ts | 16 +-- .../cliproxy-dashboard-install-service.ts | 7 +- .../cliproxy/binary-manager-install.test.ts | 26 +++++ .../cliproxy/variant-update-service.test.ts | 13 +++ .../cliproxy-stats-routes-install.test.ts | 10 +- .../pages/settings/sections/proxy/index.tsx | 2 +- 11 files changed, 177 insertions(+), 90 deletions(-) diff --git a/src/cliproxy/binary-manager.ts b/src/cliproxy/binary-manager.ts index 5f507e53..081c7b5c 100644 --- a/src/cliproxy/binary-manager.ts +++ b/src/cliproxy/binary-manager.ts @@ -5,10 +5,17 @@ * Pattern: Mirrors npm install behavior (fast check, download only when needed) */ +import * as fs from 'fs'; +import * as path from 'path'; import { info, warn } from '../utils/ui'; import { getBinDir, CLIPROXY_DEFAULT_PORT } from './config-generator'; import { BinaryInfo, BinaryManagerConfig } from './types'; -import { BACKEND_CONFIG, DEFAULT_BACKEND, CLIPROXY_MAX_STABLE_VERSION } from './platform-detector'; +import { + BACKEND_CONFIG, + DEFAULT_BACKEND, + CLIPROXY_MAX_STABLE_VERSION, + getExecutableName, +} from './platform-detector'; import { stopProxy } from './services/proxy-lifecycle-service'; import { waitForPortFree } from '../utils/port-utils'; import { loadOrCreateUnifiedConfig } from '../config/unified-config-loader'; @@ -16,6 +23,7 @@ import { UpdateCheckResult, checkForUpdates, deleteBinary, + getVersionCachePath, getBinaryPath, isBinaryInstalled, getBinaryInfo, @@ -30,6 +38,7 @@ 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'; @@ -72,6 +81,41 @@ export function resolveLocalBackend( 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; + } catch { + return DEFAULT_BACKEND; + } +} + +export function getStoredConfiguredBackend(): CLIProxyBackend { + return getConfiguredOrDefaultBackend(); +} + /** * Get backend from config, with runtime fallback to 'original' when the user * still has `backend: plus` saved. @@ -83,15 +127,7 @@ export function resolveLocalBackend( * reconfig step, while CCS self-maintains its own Plus fork (future work). */ export function getConfiguredBackend(options: { warnOnFallback?: boolean } = {}): CLIProxyBackend { - let configured: CLIProxyBackend; - try { - const config = loadOrCreateUnifiedConfig(); - configured = config.cliproxy?.backend || DEFAULT_BACKEND; - } catch { - return DEFAULT_BACKEND; - } - - return resolveLocalBackend(configured, options); + return resolveLocalBackend(getConfiguredOrDefaultBackend(), options); } /** @@ -127,9 +163,9 @@ export class BinaryManager { private backend: CLIProxyBackend; constructor(config: Partial = {}, backend?: CLIProxyBackend) { - this.backend = backend - ? resolveLocalBackend(backend, { warnOnFallback: true }) - : getConfiguredBackend({ warnOnFallback: true }); + const configuredBackend = backend ?? getConfiguredOrDefaultBackend(); + syncPlusFallbackStateIfNeeded(configuredBackend); + this.backend = resolveLocalBackend(configuredBackend, { warnOnFallback: true }); const defaultConfig = createDefaultConfig(this.backend); this.config = { ...defaultConfig, ...config }; } @@ -180,7 +216,9 @@ export async function ensureCLIProxyBinary( verbose = false, options: EnsureCLIProxyBinaryOptions = {} ): Promise { - const backend = getConfiguredBackend({ warnOnFallback: true }); + const configuredBackend = getConfiguredOrDefaultBackend(); + syncPlusFallbackStateIfNeeded(configuredBackend); + const backend = resolveLocalBackend(configuredBackend, { warnOnFallback: true }); // Migrate old shared pin to backend-specific location (one-time migration) migrateVersionPin(backend); @@ -211,25 +249,25 @@ export async function ensureCLIProxyBinary( /** Check if CLIProxyAPI binary is installed */ export function isCLIProxyInstalled(backend?: CLIProxyBackend): boolean { - const effectiveBackend = backend - ? resolveLocalBackend(backend, { warnOnFallback: true }) - : getConfiguredBackend({ warnOnFallback: true }); + 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 - ? resolveLocalBackend(backend, { warnOnFallback: true }) - : getConfiguredBackend({ warnOnFallback: true }); + 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 - ? resolveLocalBackend(backend, { warnOnFallback: true }) - : getConfiguredBackend({ warnOnFallback: true }); + const configuredBackend = backend ?? getConfiguredOrDefaultBackend(); + syncPlusFallbackStateIfNeeded(configuredBackend); + const effectiveBackend = resolveLocalBackend(configuredBackend, { warnOnFallback: true }); return readInstalledVersion( getBackendBinDir(effectiveBackend), BACKEND_CONFIG[effectiveBackend].fallbackVersion @@ -255,9 +293,9 @@ export async function installCliproxyVersion( backend?: CLIProxyBackend, deps: InstallCliproxyVersionDeps = {} ): Promise { - const effectiveBackend = backend - ? resolveLocalBackend(backend, { warnOnFallback: true }) - : getConfiguredBackend({ warnOnFallback: true }); + 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); @@ -298,9 +336,9 @@ export async function installCliproxyVersion( /** Fetch the latest CLIProxyAPI version from GitHub API */ export async function fetchLatestCliproxyVersion(backend?: CLIProxyBackend): Promise { - const effectiveBackend = backend - ? resolveLocalBackend(backend, { warnOnFallback: true }) - : getConfiguredBackend({ warnOnFallback: true }); + const configuredBackend = backend ?? getConfiguredOrDefaultBackend(); + syncPlusFallbackStateIfNeeded(configuredBackend); + const effectiveBackend = resolveLocalBackend(configuredBackend, { warnOnFallback: true }); const result = await new BinaryManager({}, effectiveBackend).checkForUpdates(); return result.latestVersion; } @@ -325,9 +363,9 @@ export interface CliproxyUpdateCheckResult { export async function checkCliproxyUpdate( backend?: CLIProxyBackend ): Promise { - const effectiveBackend = backend - ? resolveLocalBackend(backend, { warnOnFallback: true }) - : getConfiguredBackend({ warnOnFallback: true }); + const configuredBackend = backend ?? getConfiguredOrDefaultBackend(); + syncPlusFallbackStateIfNeeded(configuredBackend); + const effectiveBackend = resolveLocalBackend(configuredBackend, { warnOnFallback: true }); const result = await new BinaryManager({}, effectiveBackend).checkForUpdates(); // Import isNewerVersion for stability check diff --git a/src/cliproxy/services/binary-service.ts b/src/cliproxy/services/binary-service.ts index 798511ad..834df41c 100644 --- a/src/cliproxy/services/binary-service.ts +++ b/src/cliproxy/services/binary-service.ts @@ -19,6 +19,7 @@ import { clearPinnedVersion, isVersionPinned, resolveLocalBackend, + syncPlusFallbackStateIfNeeded, } from '../binary-manager'; import { BACKEND_CONFIG, DEFAULT_BACKEND } from '../platform-detector'; import { CLIProxyBackend } from '../types'; @@ -55,10 +56,10 @@ export interface LatestVersionResult { * Get current binary status for a specific backend */ export function getBinaryStatus(backend?: CLIProxyBackend): BinaryStatusResult { - const effectiveBackend = resolveLocalBackend( - backend ?? loadOrCreateUnifiedConfig().cliproxy?.backend ?? DEFAULT_BACKEND, - { warnOnFallback: true } - ); + 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), @@ -74,10 +75,10 @@ export function getBinaryStatus(backend?: CLIProxyBackend): BinaryStatusResult { * Check for latest version */ export async function checkLatestVersion(backend?: CLIProxyBackend): Promise { - const effectiveBackend = resolveLocalBackend( - backend ?? loadOrCreateUnifiedConfig().cliproxy?.backend ?? DEFAULT_BACKEND, - { warnOnFallback: true } - ); + 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) @@ -124,10 +125,10 @@ export async function installVersion( }; } - const effectiveBackend = resolveLocalBackend( - backend ?? loadOrCreateUnifiedConfig().cliproxy?.backend ?? DEFAULT_BACKEND, - { warnOnFallback: true } - ); + const configuredBackend = + backend ?? loadOrCreateUnifiedConfig().cliproxy?.backend ?? DEFAULT_BACKEND; + syncPlusFallbackStateIfNeeded(configuredBackend); + const effectiveBackend = resolveLocalBackend(configuredBackend, { warnOnFallback: true }); try { await installCliproxyVersion(version, verbose, effectiveBackend); @@ -154,10 +155,10 @@ export async function installLatest( verbose = false, backend?: CLIProxyBackend ): Promise { - const effectiveBackend = resolveLocalBackend( - backend ?? loadOrCreateUnifiedConfig().cliproxy?.backend ?? DEFAULT_BACKEND, - { warnOnFallback: true } - ); + const configuredBackend = + backend ?? loadOrCreateUnifiedConfig().cliproxy?.backend ?? DEFAULT_BACKEND; + syncPlusFallbackStateIfNeeded(configuredBackend); + const effectiveBackend = resolveLocalBackend(configuredBackend, { warnOnFallback: true }); try { const latestVersion = await fetchLatestCliproxyVersion(effectiveBackend); @@ -193,10 +194,10 @@ export async function installLatest( * Check if a version is pinned */ export function isPinned(backend?: CLIProxyBackend): boolean { - const effectiveBackend = resolveLocalBackend( - backend ?? loadOrCreateUnifiedConfig().cliproxy?.backend ?? DEFAULT_BACKEND, - { warnOnFallback: true } - ); + const configuredBackend = + backend ?? loadOrCreateUnifiedConfig().cliproxy?.backend ?? DEFAULT_BACKEND; + syncPlusFallbackStateIfNeeded(configuredBackend); + const effectiveBackend = resolveLocalBackend(configuredBackend, { warnOnFallback: true }); return isVersionPinned(effectiveBackend); } @@ -204,10 +205,10 @@ export function isPinned(backend?: CLIProxyBackend): boolean { * Get pinned version if any */ export function getPinned(backend?: CLIProxyBackend): string | null { - const effectiveBackend = resolveLocalBackend( - backend ?? loadOrCreateUnifiedConfig().cliproxy?.backend ?? DEFAULT_BACKEND, - { warnOnFallback: true } - ); + const configuredBackend = + backend ?? loadOrCreateUnifiedConfig().cliproxy?.backend ?? DEFAULT_BACKEND; + syncPlusFallbackStateIfNeeded(configuredBackend); + const effectiveBackend = resolveLocalBackend(configuredBackend, { warnOnFallback: true }); return getPinnedVersion(effectiveBackend); } @@ -215,9 +216,9 @@ export function getPinned(backend?: CLIProxyBackend): string | null { * Clear version pin */ export function clearPin(backend?: CLIProxyBackend): void { - const effectiveBackend = resolveLocalBackend( - backend ?? loadOrCreateUnifiedConfig().cliproxy?.backend ?? DEFAULT_BACKEND, - { warnOnFallback: true } - ); + const configuredBackend = + backend ?? loadOrCreateUnifiedConfig().cliproxy?.backend ?? DEFAULT_BACKEND; + syncPlusFallbackStateIfNeeded(configuredBackend); + const effectiveBackend = resolveLocalBackend(configuredBackend, { warnOnFallback: true }); clearPinnedVersion(effectiveBackend); } diff --git a/src/cliproxy/services/variant-service.ts b/src/cliproxy/services/variant-service.ts index f15ae110..90361c78 100644 --- a/src/cliproxy/services/variant-service.ts +++ b/src/cliproxy/services/variant-service.ts @@ -278,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()); @@ -300,14 +307,6 @@ export function updateVariant(name: string, updates: UpdateVariantOptions): Vari // Update config entry if provider/account/target changed if (updates.provider !== undefined || updates.account !== undefined || targetChanged) { const newProvider = updates.provider ?? existing.provider; - - // Validate provider/backend compatibility on provider change - if (updates.provider !== undefined) { - const backendError = validateProviderBackend(updates.provider); - if (backendError) { - return { success: false, error: backendError }; - } - } const newAccount = updates.account !== undefined ? updates.account : existing.account; const newTarget = updates.target ?? existingTarget; diff --git a/src/commands/cliproxy/help-subcommand.ts b/src/commands/cliproxy/help-subcommand.ts index 823b1317..f3f81797 100644 --- a/src/commands/cliproxy/help-subcommand.ts +++ b/src/commands/cliproxy/help-subcommand.ts @@ -84,7 +84,10 @@ export async function showHelp(): Promise { [ 'Options:', [ - ['--backend ', 'Use specific backend: original | plus (default: from config)'], + [ + '--backend ', + 'Use specific backend: original | plus (local default: original; plus currently falls back locally)', + ], ['--target ', 'Default target for created/edited variants: claude | droid'], ['--verbose, -v', 'Show detailed diagnostics including routing hints and quota fetches'], ], @@ -102,6 +105,11 @@ export async function showHelp(): Promise { console.log(dim(' Note: CLIProxy now persists by default. Use "stop" to terminate.')); console.log(dim(' Routing: use gcli/ or agy/ to keep overlapping models pinned.')); + console.log( + dim( + ' Backend: local CLIProxy currently uses original by default; saved plus configs fall back locally.' + ) + ); console.log(''); console.log(subheader('Notes:')); console.log(` Default fallback version: ${color(getFallbackVersion(), 'info')}`); diff --git a/src/commands/cliproxy/index.ts b/src/commands/cliproxy/index.ts index 7456a5ba..b7de5ebb 100644 --- a/src/commands/cliproxy/index.ts +++ b/src/commands/cliproxy/index.ts @@ -6,7 +6,7 @@ */ import { CLIProxyBackend } from '../../cliproxy/types'; -import { getConfiguredBackend, resolveLocalBackend } from '../../cliproxy/binary-manager'; +import { getStoredConfiguredBackend } from '../../cliproxy/binary-manager'; import { type QuotaSupportedProvider, QUOTA_PROVIDER_HELP_TEXT, @@ -70,13 +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 resolveLocalBackend(cliBackend, { warnOnFallback: true }); - } - return getConfiguredBackend({ warnOnFallback: true }); + return cliBackend ?? getStoredConfiguredBackend(); } /** diff --git a/src/web-server/routes/cliproxy-stats-routes.ts b/src/web-server/routes/cliproxy-stats-routes.ts index 7e57dac2..2bf87cc6 100644 --- a/src/web-server/routes/cliproxy-stats-routes.ts +++ b/src/web-server/routes/cliproxy-stats-routes.ts @@ -40,8 +40,8 @@ import { getProxyStatus as getProxyProcessStatus, stopProxy } from '../../clipro import { ensureCliproxyService } from '../../cliproxy/service-manager'; import { checkCliproxyUpdate, - getConfiguredBackend, getInstalledCliproxyVersion, + getStoredConfiguredBackend, } from '../../cliproxy/binary-manager'; import { fetchAllVersions, @@ -146,7 +146,7 @@ export function shouldCacheQuotaResult(result: { } function buildUpdateCheckFallback( - backend: ReturnType, + backend: ReturnType, getInstalledVersionFn: typeof getInstalledCliproxyVersion = getInstalledCliproxyVersion ) { const currentVersion = getInstalledVersionFn(backend); @@ -170,7 +170,7 @@ function buildUpdateCheckFallback( } function buildVersionsFallback( - backend: ReturnType, + backend: ReturnType, getInstalledVersionFn: typeof getInstalledCliproxyVersion = getInstalledCliproxyVersion ) { const currentVersion = getInstalledVersionFn(backend); @@ -198,7 +198,7 @@ interface ResolveVersionsDeps { } export async function resolveCliproxyUpdateCheckPayload( - backend: ReturnType, + backend: ReturnType, deps: ResolveUpdateCheckDeps = {} ) { const checkCliproxyUpdateFn = deps.checkCliproxyUpdateFn ?? checkCliproxyUpdate; @@ -210,7 +210,7 @@ export async function resolveCliproxyUpdateCheckPayload( } export async function resolveCliproxyVersionsPayload( - backend: ReturnType, + backend: ReturnType, deps: ResolveVersionsDeps = {} ) { const fetchAllVersionsFn = deps.fetchAllVersionsFn ?? fetchAllVersions; @@ -401,7 +401,7 @@ router.post('/proxy-stop', async (_req: Request, res: Response): Promise = */ router.get('/update-check', async (_req: Request, res: Response): Promise => { try { - const backend = getConfiguredBackend({ warnOnFallback: true }); + const backend = getStoredConfiguredBackend(); const result = await resolveCliproxyUpdateCheckPayload(backend); res.json(result); @@ -1011,7 +1011,7 @@ router.get('/quota/:provider/:accountId', async (req: Request, res: Response): P */ router.get('/versions', async (_req: Request, res: Response): Promise => { try { - const backend = getConfiguredBackend({ warnOnFallback: true }); + const backend = getStoredConfiguredBackend(); res.json(await resolveCliproxyVersionsPayload(backend)); } catch (error) { console.error(`[cliproxy-stats] ${(error as Error).message}`); @@ -1065,7 +1065,7 @@ router.post('/install', async (req: Request, res: Response): Promise => { return; } - const backend = getConfiguredBackend({ warnOnFallback: true }); + const backend = getStoredConfiguredBackend(); const installResult = await installDashboardCliproxyVersion(version, backend); res.json({ diff --git a/src/web-server/services/cliproxy-dashboard-install-service.ts b/src/web-server/services/cliproxy-dashboard-install-service.ts index 3fc32388..305d3315 100644 --- a/src/web-server/services/cliproxy-dashboard-install-service.ts +++ b/src/web-server/services/cliproxy-dashboard-install-service.ts @@ -1,4 +1,8 @@ -import { installCliproxyVersion, resolveLocalBackend } 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,6 +56,7 @@ export async function installDashboardCliproxyVersion( backend: CLIProxyBackend, deps: InstallDashboardCliproxyVersionDeps = defaultDeps ): Promise { + syncPlusFallbackStateIfNeeded(backend); const effectiveBackend = resolveLocalBackend(backend, { warnOnFallback: true }); const backendLabel = effectiveBackend === 'plus' ? 'CLIProxy Plus' : 'CLIProxy'; const shouldRestoreService = await wasProxyRunning(deps); diff --git a/tests/unit/cliproxy/binary-manager-install.test.ts b/tests/unit/cliproxy/binary-manager-install.test.ts index f11cee5c..d76f1fdd 100644 --- a/tests/unit/cliproxy/binary-manager-install.test.ts +++ b/tests/unit/cliproxy/binary-manager-install.test.ts @@ -73,6 +73,32 @@ describe('installCliproxyVersion', () => { 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, diff --git a/tests/unit/cliproxy/variant-update-service.test.ts b/tests/unit/cliproxy/variant-update-service.test.ts index 25ef9a21..abd2039c 100644 --- a/tests/unit/cliproxy/variant-update-service.test.ts +++ b/tests/unit/cliproxy/variant-update-service.test.ts @@ -102,6 +102,19 @@ cliproxy: expect(error).toContain('issues/1062'); }); + it('leaves the settings file unchanged when a plus-only provider update is rejected', () => { + const settingsPath = path.join(tmpDir, 'gemini-demo.settings.json'); + const before = fs.readFileSync(settingsPath, 'utf-8'); + + const result = updateVariant('demo', { + provider: 'ghcp', + model: 'gpt-5.4-mini', + }); + + expect(result.success).toBe(false); + expect(fs.readFileSync(settingsPath, 'utf-8')).toBe(before); + }); + it('updates provider and regenerates provider-specific core env in same settings file', () => { const result = updateVariant('demo', { provider: 'codex', diff --git a/tests/unit/web-server/cliproxy-stats-routes-install.test.ts b/tests/unit/web-server/cliproxy-stats-routes-install.test.ts index 60470a3c..296851dc 100644 --- a/tests/unit/web-server/cliproxy-stats-routes-install.test.ts +++ b/tests/unit/web-server/cliproxy-stats-routes-install.test.ts @@ -31,16 +31,16 @@ beforeAll(async () => { )); const ccsDir = path.join(tempHome, '.ccs'); - const originalBinDir = path.join(ccsDir, 'cliproxy', 'bin', 'original'); - fs.mkdirSync(originalBinDir, { recursive: true }); + const plusBinDir = path.join(ccsDir, 'cliproxy', 'bin', 'plus'); + fs.mkdirSync(plusBinDir, { recursive: true }); setGlobalConfigDir(ccsDir); const config = createEmptyUnifiedConfig(); config.cliproxy = { backend: 'plus' }; saveUnifiedConfig(config); - writeInstalledVersion(originalBinDir, '6.6.80'); - writeVersionCache('6.6.89', 'original'); + writeInstalledVersion(plusBinDir, '6.6.80'); + writeVersionCache('6.6.89', 'plus'); writeVersionListCache( { versions: ['6.6.89', '6.6.88', '6.6.81', '6.6.80'], @@ -48,7 +48,7 @@ beforeAll(async () => { latest: '6.6.89', checkedAt: Date.now(), }, - 'original' + 'plus' ); ({ default: cliproxyStatsRoutes } = await import( diff --git a/ui/src/pages/settings/sections/proxy/index.tsx b/ui/src/pages/settings/sections/proxy/index.tsx index 218d4326..76ec80e1 100644 --- a/ui/src/pages/settings/sections/proxy/index.tsx +++ b/ui/src/pages/settings/sections/proxy/index.tsx @@ -36,7 +36,7 @@ import { useTranslation } from 'react-i18next'; const DEBUG_MODE_KEY = 'ccs_debug_mode'; /** Providers only available on CLIProxyAPIPlus */ -const PLUS_ONLY_PROVIDERS = ['kiro', 'ghcp']; +const PLUS_ONLY_PROVIDERS = ['kiro', 'ghcp', 'cursor', 'gitlab', 'codebuddy', 'kilo']; function normalizeRiskAckPhrase(value: string): string { return value.trim().replace(/\s+/g, ' ').toUpperCase(); From e6ac91b9c716d21ba2a0493b4023efaa5e126975 Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Wed, 22 Apr 2026 09:16:15 -0400 Subject: [PATCH 11/39] fix(test): stabilize proxy integration server ports --- .../proxy/messages-edge-cases.test.ts | 34 ++++- .../proxy/messages-endpoint.test.ts | 141 ++++++++++-------- .../integration/proxy/request-routing.test.ts | 109 ++++++++------ 3 files changed, 172 insertions(+), 112 deletions(-) diff --git a/tests/integration/proxy/messages-edge-cases.test.ts b/tests/integration/proxy/messages-edge-cases.test.ts index fea8ca87..faefb833 100644 --- a/tests/integration/proxy/messages-edge-cases.test.ts +++ b/tests/integration/proxy/messages-edge-cases.test.ts @@ -1,5 +1,4 @@ import { afterEach, beforeEach, describe, expect, it } from 'bun:test'; -import getPort from 'get-port'; import * as fs from 'fs'; import * as http from 'http'; import * as os from 'os'; @@ -16,6 +15,29 @@ let tempDir: string; let originalTimeoutEnv: string | undefined; let originalCcsHome: string | undefined; +function resolveListeningPort(server: http.Server): number { + const address = server.address(); + if (!address || typeof address === 'string') { + throw new Error('Failed to resolve server port'); + } + return address.port; +} + +async function waitForServerListening(server: http.Server): Promise { + if (server.listening) { + return resolveListeningPort(server); + } + + return new Promise((resolve, reject) => { + const onError = (error: Error) => reject(error); + server.once('error', onError); + server.once('listening', () => { + server.off('error', onError); + resolve(resolveListeningPort(server)); + }); + }); +} + async function startUpstream( handler: (req: http.IncomingMessage, res: http.ServerResponse) => Promise | void ): Promise { @@ -28,9 +50,8 @@ async function startUpstream( upstreamSockets.delete(socket); }); }); - await new Promise((resolve) => - upstreamServer.listen(upstreamPort, '127.0.0.1', () => resolve()) - ); + upstreamServer.listen(0, '127.0.0.1'); + upstreamPort = await waitForServerListening(upstreamServer); } async function requestProxy(payload: unknown, signal?: AbortSignal): Promise { @@ -46,8 +67,6 @@ async function requestProxy(payload: unknown, signal?: AbortSignal): Promise { - upstreamPort = await getPort(); - proxyPort = await getPort(); tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-proxy-edge-')); originalTimeoutEnv = process.env.CCS_OPENAI_PROXY_REQUEST_TIMEOUT_MS; originalCcsHome = process.env.CCS_HOME; @@ -94,9 +113,10 @@ describe('openai proxy message edge cases', () => { }; proxyServer = startOpenAICompatProxyServer({ profile, - port: proxyPort, + port: 0, authToken: 'test-proxy-token', }); + proxyPort = await waitForServerListening(proxyServer); } it('preserves rate-limit errors from the upstream provider', async () => { diff --git a/tests/integration/proxy/messages-endpoint.test.ts b/tests/integration/proxy/messages-endpoint.test.ts index 90c819eb..d8fdf275 100644 --- a/tests/integration/proxy/messages-endpoint.test.ts +++ b/tests/integration/proxy/messages-endpoint.test.ts @@ -1,5 +1,4 @@ import { afterEach, beforeEach, describe, expect, it } from 'bun:test'; -import getPort from 'get-port'; import * as http from 'http'; import { startOpenAICompatProxyServer } from '../../../src/proxy/server/proxy-server'; import type { OpenAICompatProfileConfig } from '../../../src/proxy/profile-router'; @@ -10,52 +9,64 @@ let upstreamBody: unknown; let upstreamPort: number; let proxyPort: number; -function startMockUpstream(): Promise { - return new Promise((resolve) => { - upstreamServer = http.createServer(async (req, res) => { - if (req.method !== 'POST' || req.url !== '/v1/chat/completions') { - res.writeHead(404).end(); - return; - } +function resolveListeningPort(server: http.Server): number { + const address = server.address(); + if (!address || typeof address === 'string') { + throw new Error('Failed to resolve server port'); + } + return address.port; +} - let body = ''; - for await (const chunk of req) { - body += chunk.toString(); - } - upstreamBody = JSON.parse(body); - const parsed = upstreamBody as { - stream?: boolean; - messages?: Array<{ role?: string; content?: string | Array<{ type?: string; text?: string }> }>; - }; +async function waitForServerListening(server: http.Server): Promise { + if (server.listening) { + return resolveListeningPort(server); + } - if (parsed.stream) { - res.writeHead(200, { 'Content-Type': 'text/event-stream' }); + return new Promise((resolve, reject) => { + const onError = (error: Error) => reject(error); + server.once('error', onError); + server.once('listening', () => { + server.off('error', onError); + resolve(resolveListeningPort(server)); + }); + }); +} - if (parsed.messages?.[0]?.content === 'interleaved tool fragments') { - res.write( - 'data: {"id":"chatcmpl_1","model":"hf-model","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"id":"call_1","type":"function","function":{"name":"search"}}]}}]}\n\n' - ); - res.write( - 'data: {"id":"chatcmpl_1","model":"hf-model","choices":[{"index":0,"delta":{"tool_calls":[{"index":1,"id":"call_2","type":"function","function":{"name":"open"}}]}}]}\n\n' - ); - res.write( - 'data: {"id":"chatcmpl_1","model":"hf-model","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"function":{"arguments":"{\\"path\\":\\"a.ts\\"}"}}]}}]}\n\n' - ); - res.write( - 'data: {"id":"chatcmpl_1","model":"hf-model","choices":[{"index":0,"delta":{"tool_calls":[{"index":1,"function":{"arguments":"{\\"path\\":\\"b.ts\\"}"}}]}}]}\n\n' - ); - res.write( - 'data: {"id":"chatcmpl_1","model":"hf-model","choices":[{"index":0,"delta":{},"finish_reason":"tool_calls"}],"usage":{"prompt_tokens":9,"completion_tokens":4}}\n\n' - ); - res.end('data: [DONE]\n\n'); - return; - } +async function startMockUpstream(): Promise { + upstreamServer = http.createServer(async (req, res) => { + if (req.method !== 'POST' || req.url !== '/v1/chat/completions') { + res.writeHead(404).end(); + return; + } + let body = ''; + for await (const chunk of req) { + body += chunk.toString(); + } + upstreamBody = JSON.parse(body); + const parsed = upstreamBody as { + stream?: boolean; + messages?: Array<{ + role?: string; + content?: string | Array<{ type?: string; text?: string }>; + }>; + }; + + if (parsed.stream) { + res.writeHead(200, { 'Content-Type': 'text/event-stream' }); + + if (parsed.messages?.[0]?.content === 'interleaved tool fragments') { res.write( - 'data: {"id":"chatcmpl_1","model":"hf-model","choices":[{"index":0,"delta":{"role":"assistant","content":"Hello"}}]}\n\n' + 'data: {"id":"chatcmpl_1","model":"hf-model","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"id":"call_1","type":"function","function":{"name":"search"}}]}}]}\n\n' ); res.write( - 'data: {"id":"chatcmpl_1","model":"hf-model","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"id":"call_1","type":"function","function":{"name":"search","arguments":"{\\"q\\":\\"docs\\"}"}}]}}]}\n\n' + 'data: {"id":"chatcmpl_1","model":"hf-model","choices":[{"index":0,"delta":{"tool_calls":[{"index":1,"id":"call_2","type":"function","function":{"name":"open"}}]}}]}\n\n' + ); + res.write( + 'data: {"id":"chatcmpl_1","model":"hf-model","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"function":{"arguments":"{\\"path\\":\\"a.ts\\"}"}}]}}]}\n\n' + ); + res.write( + 'data: {"id":"chatcmpl_1","model":"hf-model","choices":[{"index":0,"delta":{"tool_calls":[{"index":1,"function":{"arguments":"{\\"path\\":\\"b.ts\\"}"}}]}}]}\n\n' ); res.write( 'data: {"id":"chatcmpl_1","model":"hf-model","choices":[{"index":0,"delta":{},"finish_reason":"tool_calls"}],"usage":{"prompt_tokens":9,"completion_tokens":4}}\n\n' @@ -64,25 +75,38 @@ function startMockUpstream(): Promise { return; } - res.writeHead(200, { 'Content-Type': 'application/json' }); - res.end( - JSON.stringify({ - id: 'chatcmpl_1', - model: 'hf-model', - choices: [ - { - index: 0, - message: { role: 'assistant', content: 'Plain answer' }, - finish_reason: 'stop', - }, - ], - usage: { prompt_tokens: 2, completion_tokens: 3 }, - }) + res.write( + 'data: {"id":"chatcmpl_1","model":"hf-model","choices":[{"index":0,"delta":{"role":"assistant","content":"Hello"}}]}\n\n' ); - }); + res.write( + 'data: {"id":"chatcmpl_1","model":"hf-model","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"id":"call_1","type":"function","function":{"name":"search","arguments":"{\\"q\\":\\"docs\\"}"}}]}}]}\n\n' + ); + res.write( + 'data: {"id":"chatcmpl_1","model":"hf-model","choices":[{"index":0,"delta":{},"finish_reason":"tool_calls"}],"usage":{"prompt_tokens":9,"completion_tokens":4}}\n\n' + ); + res.end('data: [DONE]\n\n'); + return; + } - upstreamServer.listen(upstreamPort, '127.0.0.1', () => resolve()); + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end( + JSON.stringify({ + id: 'chatcmpl_1', + model: 'hf-model', + choices: [ + { + index: 0, + message: { role: 'assistant', content: 'Plain answer' }, + finish_reason: 'stop', + }, + ], + usage: { prompt_tokens: 2, completion_tokens: 3 }, + }) + ); }); + + upstreamServer.listen(0, '127.0.0.1'); + upstreamPort = await waitForServerListening(upstreamServer); } async function requestProxy(payload: unknown): Promise { @@ -98,8 +122,6 @@ async function requestProxy(payload: unknown): Promise { } beforeEach(async () => { - upstreamPort = await getPort(); - proxyPort = await getPort(); upstreamBody = undefined; await startMockUpstream(); const profile: OpenAICompatProfileConfig = { @@ -112,9 +134,10 @@ beforeEach(async () => { }; proxyServer = startOpenAICompatProxyServer({ profile, - port: proxyPort, + port: 0, authToken: 'test-proxy-token', }); + proxyPort = await waitForServerListening(proxyServer); }); afterEach(async () => { diff --git a/tests/integration/proxy/request-routing.test.ts b/tests/integration/proxy/request-routing.test.ts index b9f7afe8..080405b2 100644 --- a/tests/integration/proxy/request-routing.test.ts +++ b/tests/integration/proxy/request-routing.test.ts @@ -1,5 +1,4 @@ import { afterEach, beforeEach, describe, expect, it } from 'bun:test'; -import getPort from 'get-port'; import * as fs from 'fs'; import * as http from 'http'; import * as os from 'os'; @@ -13,40 +12,61 @@ let proxyServer: http.Server; let upstreamServers: http.Server[] = []; let proxyPort: number; -function startMockUpstream( - port: number, +function resolveListeningPort(server: http.Server): number { + const address = server.address(); + if (!address || typeof address === 'string') { + throw new Error('Failed to resolve server port'); + } + return address.port; +} + +async function waitForServerListening(server: http.Server): Promise { + if (server.listening) { + return resolveListeningPort(server); + } + + return new Promise((resolve, reject) => { + const onError = (error: Error) => reject(error); + server.once('error', onError); + server.once('listening', () => { + server.off('error', onError); + resolve(resolveListeningPort(server)); + }); + }); +} + +async function startMockUpstream( hitLabel: string, hits: string[], bodies: Array<{ label: string; body: unknown }> -): Promise { - return new Promise((resolve) => { - const server = http.createServer(async (req, res) => { - let body = ''; - for await (const chunk of req) { - body += chunk.toString(); - } - hits.push(hitLabel); - bodies.push({ label: hitLabel, body: JSON.parse(body) }); +): Promise { + const server = http.createServer(async (req, res) => { + let body = ''; + for await (const chunk of req) { + body += chunk.toString(); + } + hits.push(hitLabel); + bodies.push({ label: hitLabel, body: JSON.parse(body) }); - res.writeHead(200, { 'Content-Type': 'application/json' }); - res.end( - JSON.stringify({ - id: `chatcmpl_${hitLabel}`, - model: hitLabel, - choices: [ - { - index: 0, - message: { role: 'assistant', content: `Reply from ${hitLabel}` }, - finish_reason: 'stop', - }, - ], - usage: { prompt_tokens: 2, completion_tokens: 3 }, - }) - ); - }); - upstreamServers.push(server); - server.listen(port, '127.0.0.1', () => resolve()); + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end( + JSON.stringify({ + id: `chatcmpl_${hitLabel}`, + model: hitLabel, + choices: [ + { + index: 0, + message: { role: 'assistant', content: `Reply from ${hitLabel}` }, + finish_reason: 'stop', + }, + ], + usage: { prompt_tokens: 2, completion_tokens: 3 }, + }) + ); }); + upstreamServers.push(server); + server.listen(0, '127.0.0.1'); + return waitForServerListening(server); } function writeSettings(profileName: string, env: Record): string { @@ -71,7 +91,7 @@ beforeEach(async () => { tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-proxy-routing-')); fs.mkdirSync(path.join(tempDir, '.ccs'), { recursive: true }); process.env.CCS_HOME = tempDir; - proxyPort = await getPort(); + proxyPort = 0; }); afterEach(async () => { @@ -97,12 +117,10 @@ afterEach(async () => { describe('openai proxy request routing', () => { it('routes explicit profile:model selectors to the matching upstream profile', async () => { - const primaryPort = await getPort(); - const secondaryPort = await getPort(); const hits: string[] = []; const bodies: Array<{ label: string; body: unknown }> = []; - await startMockUpstream(primaryPort, 'primary', hits, bodies); - await startMockUpstream(secondaryPort, 'secondary', hits, bodies); + const primaryPort = await startMockUpstream('primary', hits, bodies); + const secondaryPort = await startMockUpstream('secondary', hits, bodies); const primarySettings = writeSettings('hf', { ANTHROPIC_BASE_URL: `http://127.0.0.1:${primaryPort}`, @@ -133,9 +151,10 @@ describe('openai proxy request routing', () => { }; proxyServer = startOpenAICompatProxyServer({ profile, - port: proxyPort, + port: 0, authToken: 'test-proxy-token', }); + proxyPort = await waitForServerListening(proxyServer); const response = await requestProxy({ model: 'deepseek:deepseek-reasoner', @@ -150,12 +169,10 @@ describe('openai proxy request routing', () => { }); it('routes thinking requests through the configured think scenario', async () => { - const primaryPort = await getPort(); - const thinkPort = await getPort(); const hits: string[] = []; const bodies: Array<{ label: string; body: unknown }> = []; - await startMockUpstream(primaryPort, 'primary', hits, bodies); - await startMockUpstream(thinkPort, 'thinker', hits, bodies); + const primaryPort = await startMockUpstream('primary', hits, bodies); + const thinkPort = await startMockUpstream('thinker', hits, bodies); const primarySettings = writeSettings('hf', { ANTHROPIC_BASE_URL: `http://127.0.0.1:${primaryPort}`, @@ -197,9 +214,10 @@ describe('openai proxy request routing', () => { }; proxyServer = startOpenAICompatProxyServer({ profile, - port: proxyPort, + port: 0, authToken: 'test-proxy-token', }); + proxyPort = await waitForServerListening(proxyServer); const response = await requestProxy({ model: 'hf-default', @@ -216,12 +234,10 @@ describe('openai proxy request routing', () => { }); it('routes adaptive thinking requests through the configured think scenario', async () => { - const primaryPort = await getPort(); - const thinkPort = await getPort(); const hits: string[] = []; const bodies: Array<{ label: string; body: unknown }> = []; - await startMockUpstream(primaryPort, 'primary', hits, bodies); - await startMockUpstream(thinkPort, 'thinker', hits, bodies); + const primaryPort = await startMockUpstream('primary', hits, bodies); + const thinkPort = await startMockUpstream('thinker', hits, bodies); const primarySettings = writeSettings('hf', { ANTHROPIC_BASE_URL: `http://127.0.0.1:${primaryPort}`, @@ -263,9 +279,10 @@ describe('openai proxy request routing', () => { }; proxyServer = startOpenAICompatProxyServer({ profile, - port: proxyPort, + port: 0, authToken: 'test-proxy-token', }); + proxyPort = await waitForServerListening(proxyServer); const response = await requestProxy({ model: 'hf-default', From 89a32c2ecd269d0d4b1da4455bac2fa942a5585d Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Wed, 22 Apr 2026 09:24:03 -0400 Subject: [PATCH 12/39] fix(test): stabilize suite isolation for dev pushes --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index aac4a8ab..4b684b6c 100644 --- a/package.json +++ b/package.json @@ -75,7 +75,7 @@ "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:all": "bun test --max-concurrency=1 tests/unit tests/integration tests/npm", "test:unit": "bun test tests/unit", "test:npm": "bun test tests/npm/", "test:native": "bash tests/native/unix/edge-cases.sh", From b376a485e0475336befa49852b27ae6f1ff41504 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Wed, 22 Apr 2026 13:30:21 +0000 Subject: [PATCH 13/39] chore(release): 7.73.1-dev.1 [skip ci] --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 4b684b6c..634e9d20 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@kaitranntt/ccs", - "version": "7.73.0-dev.3", + "version": "7.73.1-dev.1", "description": "Claude Code Switch - Instant profile switching between Claude, GLM, Kimi, and more", "keywords": [ "cli", From e2ca19739784d7ad864928af32abed314a48899c Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Wed, 22 Apr 2026 14:24:00 -0400 Subject: [PATCH 14/39] fix: preserve codex effort suffixes in dashboard profiles --- .../issue-1058/codex-selector-variants.png | Bin 0 -> 214854 bytes .github/pr-assets/issue-1058/index.html | 138 +++++++++++++ src/cliproxy/config/env-builder.ts | 63 ------ src/cliproxy/model-config.ts | 10 +- src/cliproxy/model-id-normalizer.ts | 31 ++- src/cliproxy/services/variant-settings.ts | 8 +- src/web-server/middleware/auth-middleware.ts | 6 +- .../cliproxy/env-builder-provider-url.test.ts | 46 ++--- .../unit/cliproxy/model-id-normalizer.test.ts | 10 +- ...cliproxy-stats-routes-model-update.test.ts | 10 +- ...ings-routes-model-canonicalization.test.ts | 77 ++++++++ .../provider-editor/model-config-section.tsx | 3 +- .../cliproxy/provider-model-selector.tsx | 183 ++++++++++-------- ui/src/lib/codex-effort.ts | 43 +++- ui/src/lib/model-catalogs.ts | 9 + .../use-provider-editor.test.tsx | 45 +++++ .../cliproxy/provider-model-selector.test.tsx | 44 ++++- ui/tests/unit/ui/lib/codex-effort.test.ts | 30 ++- .../unit/ui/lib/model-catalogs-codex.test.ts | 3 + 19 files changed, 557 insertions(+), 202 deletions(-) create mode 100644 .github/pr-assets/issue-1058/codex-selector-variants.png create mode 100644 .github/pr-assets/issue-1058/index.html diff --git a/.github/pr-assets/issue-1058/codex-selector-variants.png b/.github/pr-assets/issue-1058/codex-selector-variants.png new file mode 100644 index 0000000000000000000000000000000000000000..0775cae0b96f97375bf87cc075b18e9895f0a05c GIT binary patch literal 214854 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μu^zo0~kY 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⁡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 KYE`P{A^!nJ`(h&i literal 0 HcmV?d00001 diff --git a/.github/pr-assets/issue-1058/index.html b/.github/pr-assets/issue-1058/index.html new file mode 100644 index 00000000..77fcccae --- /dev/null +++ b/.github/pr-assets/issue-1058/index.html @@ -0,0 +1,138 @@ + + + + + + Issue 1058 UI Evidence + + + +

+
+

Issue 1058

+

Codex selector now exposes pinned effort variants

+

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

+
+
+

What changed

+

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

+
+
+

Why it matters

+

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

+
+
+

Review cue

+

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

+
+
+
+ +
+
+

Targeted evidence

+

Codex provider editor model picker

+

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

+
+ Codex provider editor with the model selector opened and suffixed effort variants visible. +
+
+ + diff --git a/src/cliproxy/config/env-builder.ts b/src/cliproxy/config/env-builder.ts index dafd0c95..417d5f04 100644 --- a/src/cliproxy/config/env-builder.ts +++ b/src/cliproxy/config/env-builder.ts @@ -42,7 +42,6 @@ interface ProviderSettings { const DEPRECATED_MODEL_PREFIX = 'gemini-claude-'; /** Replacement prefix matching actual upstream model names */ const UPSTREAM_MODEL_PREFIX = 'claude-'; -const CODEX_EFFORT_SUFFIX_REGEX = /-(xhigh|high|medium)$/i; const PRESET_MODEL_KEYS = ['default', 'opus', 'sonnet', 'haiku'] as const; const REQUIRED_PROVIDER_ENV_KEYS = [ 'ANTHROPIC_BASE_URL', @@ -62,10 +61,6 @@ function isObjectRecord(value: unknown): value is Record { return typeof value === 'object' && value !== null && !Array.isArray(value); } -function stripCodexEffortSuffix(modelId: string): string { - return modelId.replace(CODEX_EFFORT_SUFFIX_REGEX, ''); -} - /** * Migrate deprecated gemini-claude-* model names to upstream claude-* names in a settings file. * CLIProxyAPI registry no longer recognizes the gemini-claude-* prefix convention. @@ -130,58 +125,6 @@ function migrateDeprecatedModelNames( return migrated; } -/** - * Migrate codex effort-suffixed model IDs in settings to canonical IDs. - * Example: gpt-5.3-codex-xhigh -> gpt-5.3-codex - */ -function migrateCodexEffortSuffixes( - settingsPath: string, - provider: CLIProxyProvider, - settings: ProviderSettings -): boolean { - if (provider !== 'codex') return false; - if (!settings.env || typeof settings.env !== 'object') return false; - - let migrated = false; - - for (const key of MODEL_ENV_VAR_KEYS) { - const value = settings.env[key]; - if (typeof value !== 'string') continue; - const canonical = stripCodexEffortSuffix(value); - if (canonical !== value) { - settings.env[key] = canonical; - migrated = true; - } - } - - if (Array.isArray(settings.presets)) { - for (const preset of settings.presets) { - if (!preset || typeof preset !== 'object') continue; - const presetRecord = preset as Record; - - for (const key of PRESET_MODEL_KEYS) { - const value = presetRecord[key]; - if (typeof value !== 'string') continue; - const canonical = stripCodexEffortSuffix(value); - if (canonical !== value) { - presetRecord[key] = canonical; - migrated = true; - } - } - } - } - - if (migrated) { - try { - fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + '\n', { mode: 0o600 }); - } catch { - // Best-effort migration — don't block startup if write fails - } - } - - return migrated; -} - /** * Migrate legacy iFlow model IDs to current upstream model IDs. * Example: iflow-default -> qwen3-coder-plus, kimi-k2.5 -> kimi-k2 @@ -510,8 +453,6 @@ export function getEffectiveEnvVars( if (settings.env && typeof settings.env === 'object') { // Migrate deprecated gemini-claude-* model names if present migrateDeprecatedModelNames(expandedPath, provider, settings); - // Migrate codex effort suffixes to canonical IDs if present - migrateCodexEffortSuffixes(expandedPath, provider, settings); // Migrate legacy iFlow placeholders to supported model IDs migrateIFlowPlaceholderModel(expandedPath, provider, settings); // Custom variant settings found - merge with global env @@ -545,8 +486,6 @@ export function getEffectiveEnvVars( if (settings.env && typeof settings.env === 'object') { // Migrate deprecated gemini-claude-* model names if present migrateDeprecatedModelNames(settingsPath, provider, settings); - // Migrate codex effort suffixes to canonical IDs if present - migrateCodexEffortSuffixes(settingsPath, provider, settings); // Migrate legacy iFlow placeholders to supported model IDs migrateIFlowPlaceholderModel(settingsPath, provider, settings); // User override found - merge with global env @@ -719,7 +658,6 @@ export function getRemoteEnvVars( const settings: ProviderSettings = JSON.parse(content); if (settings.env && typeof settings.env === 'object') { migrateDeprecatedModelNames(expandedPath, provider, settings); - migrateCodexEffortSuffixes(expandedPath, provider, settings); migrateIFlowPlaceholderModel(expandedPath, provider, settings); userEnvVars = settings.env as Record; } @@ -739,7 +677,6 @@ export function getRemoteEnvVars( const settings: ProviderSettings = JSON.parse(content); if (settings.env && typeof settings.env === 'object') { migrateDeprecatedModelNames(settingsPath, provider, settings); - migrateCodexEffortSuffixes(settingsPath, provider, settings); migrateIFlowPlaceholderModel(settingsPath, provider, settings); userEnvVars = settings.env as Record; } diff --git a/src/cliproxy/model-config.ts b/src/cliproxy/model-config.ts index 8b99316d..843bfa1b 100644 --- a/src/cliproxy/model-config.ts +++ b/src/cliproxy/model-config.ts @@ -15,16 +15,8 @@ import { initUI, color, bold, dim, ok, info, header } from '../utils/ui'; import { getCcsDir } from '../utils/config-manager'; import { normalizeModelIdForProvider } from './model-id-normalizer'; -const CODEX_EFFORT_SUFFIX_REGEX = /-(xhigh|high|medium)$/i; - -function stripCodexEffortSuffix(model: string, provider: CLIProxyProvider): string { - if (provider !== 'codex') return model; - return model.replace(CODEX_EFFORT_SUFFIX_REGEX, ''); -} - function canonicalizeModelForProvider(provider: CLIProxyProvider, model: string): string { - const withoutCodexSuffix = stripCodexEffortSuffix(model, provider); - return normalizeModelIdForProvider(withoutCodexSuffix, provider); + return normalizeModelIdForProvider(model, provider); } /** diff --git a/src/cliproxy/model-id-normalizer.ts b/src/cliproxy/model-id-normalizer.ts index 192a369f..7bdaf503 100644 --- a/src/cliproxy/model-id-normalizer.ts +++ b/src/cliproxy/model-id-normalizer.ts @@ -65,6 +65,18 @@ function splitBaseModelAndSuffix(model: string): { baseModel: string; suffix: st }; } +function splitCodexEffortSuffix(model: string): { baseModel: string; suffix: string } { + const match = model.match(CODEX_EFFORT_SUFFIX_REGEX); + if (!match?.[0]) { + return { baseModel: model, suffix: '' }; + } + + return { + baseModel: model.slice(0, -match[0].length), + suffix: match[0], + }; +} + /** * Extract provider segment from `/api/provider/{provider}` request paths. * @@ -97,20 +109,24 @@ export function isIFlowProvider(provider: ProviderLike): boolean { return provider.trim().toLowerCase() === 'iflow'; } -/** Normalize Codex effort-suffixed IDs to canonical IDs. */ +/** Strip Codex effort suffixes while preserving trailing config suffixes. */ export function stripCodexEffortSuffix(model: string): string { - return model.replace(CODEX_EFFORT_SUFFIX_REGEX, ''); + const trimmed = trimModelId(model); + const { baseModel, suffix } = splitBaseModelAndSuffix(trimmed); + const { baseModel: withoutEffort } = splitCodexEffortSuffix(baseModel); + return `${withoutEffort}${suffix}`; } /** Normalize legacy Codex aliases to the current public Codex model IDs. */ export function normalizeCodexLegacyModelAliases(model: string): string { const trimmed = trimModelId(model); const { baseModel, suffix } = splitBaseModelAndSuffix(trimmed); - const replacement = CODEX_LEGACY_MODEL_ALIASES[baseModel.trim().toLowerCase()]; + const { baseModel: baseWithoutEffort, suffix: effortSuffix } = splitCodexEffortSuffix(baseModel); + const replacement = CODEX_LEGACY_MODEL_ALIASES[baseWithoutEffort.trim().toLowerCase()]; if (!replacement) { return trimmed; } - return `${replacement}${suffix}`; + return `${replacement}${effortSuffix}${suffix}`; } /** @@ -217,15 +233,12 @@ export function normalizeModelIdForProvider(model: string, provider: ProviderLik /** * Canonicalize model ID for provider-specific compatibility. - * - Codex: strip effort suffixes. + * - Codex: preserve valid effort suffixes while normalizing legacy aliases. * - Antigravity: normalize dotted/historical aliases. */ export function canonicalizeModelIdForProvider(model: string, provider: ProviderLike): string { const trimmedModel = trimModelId(model); - const withoutCodexSuffix = isCodexProvider(provider) - ? stripCodexEffortSuffix(trimmedModel) - : trimmedModel; - return normalizeModelIdForProvider(withoutCodexSuffix, provider); + return normalizeModelIdForProvider(trimmedModel, provider); } /** diff --git a/src/cliproxy/services/variant-settings.ts b/src/cliproxy/services/variant-settings.ts index f0aa20eb..3b22998d 100644 --- a/src/cliproxy/services/variant-settings.ts +++ b/src/cliproxy/services/variant-settings.ts @@ -41,16 +41,12 @@ interface SettingsFile { [key: string]: unknown; } -const CODEX_EFFORT_SUFFIX_REGEX = /-(xhigh|high|medium)$/i; - function canonicalizeModelForProvider( provider: CLIProxyProfileName | undefined, model: string ): string { - const withoutCodexSuffix = - provider === 'codex' ? model.replace(CODEX_EFFORT_SUFFIX_REGEX, '') : model; - if (!provider) return withoutCodexSuffix; - return normalizeModelIdForProvider(withoutCodexSuffix, provider); + if (!provider) return model; + return normalizeModelIdForProvider(model, provider); } /** diff --git a/src/web-server/middleware/auth-middleware.ts b/src/web-server/middleware/auth-middleware.ts index c42272a2..7ea23145 100644 --- a/src/web-server/middleware/auth-middleware.ts +++ b/src/web-server/middleware/auth-middleware.ts @@ -84,7 +84,11 @@ export const loginRateLimiter = rateLimit({ /** * Create session middleware configured for CCS dashboard. */ -export function createSessionMiddleware() { +export function createSessionMiddleware(): ( + req: Request, + res: Response, + next: NextFunction +) => void { const authConfig = getDashboardAuthConfig(); const maxAge = (authConfig.session_timeout_hours ?? 24) * 60 * 60 * 1000; diff --git a/tests/unit/cliproxy/env-builder-provider-url.test.ts b/tests/unit/cliproxy/env-builder-provider-url.test.ts index 7f96cde9..acb41cca 100644 --- a/tests/unit/cliproxy/env-builder-provider-url.test.ts +++ b/tests/unit/cliproxy/env-builder-provider-url.test.ts @@ -40,7 +40,7 @@ describe('getEffectiveEnvVars local provider URL normalization', () => { fs.rmSync(tempHome, { recursive: true, force: true }); }); - it('rewrites local root URL to provider endpoint', () => { + it('rewrites local root URL to provider endpoint without stripping codex effort suffixes', () => { writeSettings(settingsPath, { ANTHROPIC_BASE_URL: 'http://127.0.0.1:8317', ANTHROPIC_AUTH_TOKEN: 'ccs-internal-managed', @@ -52,18 +52,18 @@ describe('getEffectiveEnvVars local provider URL normalization', () => { const env = getEffectiveEnvVars('codex', 8317, settingsPath); expect(env.ANTHROPIC_BASE_URL).toBe('http://127.0.0.1:8317/api/provider/codex'); - expect(env.ANTHROPIC_MODEL).toBe('gpt-5.3-codex'); - expect(env.ANTHROPIC_DEFAULT_OPUS_MODEL).toBe('gpt-5.3-codex'); - expect(env.ANTHROPIC_DEFAULT_SONNET_MODEL).toBe('gpt-5.3-codex'); - expect(env.ANTHROPIC_DEFAULT_HAIKU_MODEL).toBe('gpt-5.4-mini'); + expect(env.ANTHROPIC_MODEL).toBe('gpt-5.3-codex-xhigh'); + expect(env.ANTHROPIC_DEFAULT_OPUS_MODEL).toBe('gpt-5.3-codex-xhigh'); + expect(env.ANTHROPIC_DEFAULT_SONNET_MODEL).toBe('gpt-5.3-codex-high'); + expect(env.ANTHROPIC_DEFAULT_HAIKU_MODEL).toBe('gpt-5.4-mini-medium'); const persisted = JSON.parse(fs.readFileSync(settingsPath, 'utf-8')) as { env: Record; }; - expect(persisted.env.ANTHROPIC_MODEL).toBe('gpt-5.3-codex'); - expect(persisted.env.ANTHROPIC_DEFAULT_OPUS_MODEL).toBe('gpt-5.3-codex'); - expect(persisted.env.ANTHROPIC_DEFAULT_SONNET_MODEL).toBe('gpt-5.3-codex'); - expect(persisted.env.ANTHROPIC_DEFAULT_HAIKU_MODEL).toBe('gpt-5.4-mini'); + expect(persisted.env.ANTHROPIC_MODEL).toBe('gpt-5.3-codex-xhigh'); + expect(persisted.env.ANTHROPIC_DEFAULT_OPUS_MODEL).toBe('gpt-5.3-codex-xhigh'); + expect(persisted.env.ANTHROPIC_DEFAULT_SONNET_MODEL).toBe('gpt-5.3-codex-high'); + expect(persisted.env.ANTHROPIC_DEFAULT_HAIKU_MODEL).toBe('gpt-5.4-mini-medium'); }); it('rewrites wrong local provider path to the requested provider', () => { @@ -162,7 +162,7 @@ describe('getEffectiveEnvVars local provider URL normalization', () => { expect(persisted.presets?.[0]?.haiku).toBe('claude-sonnet-4-6'); }); - it('migrates codex preset model mappings to canonical IDs', () => { + it('preserves codex preset effort suffixes while loading provider env vars', () => { writeSettings( settingsPath, { @@ -191,10 +191,10 @@ describe('getEffectiveEnvVars local provider URL normalization', () => { const persisted = JSON.parse(fs.readFileSync(settingsPath, 'utf-8')) as { presets: Array>; }; - expect(persisted.presets[0]?.default).toBe('gpt-5.3-codex'); - expect(persisted.presets[0]?.opus).toBe('gpt-5.3-codex'); - expect(persisted.presets[0]?.sonnet).toBe('gpt-5.3-codex'); - expect(persisted.presets[0]?.haiku).toBe('gpt-5-mini'); + expect(persisted.presets[0]?.default).toBe('gpt-5.3-codex-xhigh'); + expect(persisted.presets[0]?.opus).toBe('gpt-5.3-codex-xhigh'); + expect(persisted.presets[0]?.sonnet).toBe('gpt-5.3-codex-high'); + expect(persisted.presets[0]?.haiku).toBe('gpt-5-mini-medium'); }); it('migrates iflow placeholder model IDs to a supported default', () => { @@ -424,7 +424,7 @@ describe('getEffectiveEnvVars local provider URL normalization', () => { expect(repaired.presets?.[0]?.haiku).toBe('claude-sonnet-4-6'); }); - it('migrates codex effort-suffixed preset IDs during ensureProviderSettings', () => { + it('preserves codex effort-suffixed IDs during ensureProviderSettings', () => { process.env.CCS_HOME = tempHome; const codexSettingsPath = path.join(tempHome, '.ccs', 'codex.settings.json'); fs.mkdirSync(path.dirname(codexSettingsPath), { recursive: true }); @@ -461,14 +461,14 @@ describe('getEffectiveEnvVars local provider URL normalization', () => { env?: Record; presets?: Array>; }; - expect(repaired.env?.ANTHROPIC_MODEL).toBe('gpt-5.3-codex'); - expect(repaired.env?.ANTHROPIC_DEFAULT_OPUS_MODEL).toBe('gpt-5.3-codex'); - expect(repaired.env?.ANTHROPIC_DEFAULT_SONNET_MODEL).toBe('gpt-5.3-codex'); - expect(repaired.env?.ANTHROPIC_DEFAULT_HAIKU_MODEL).toBe('gpt-5.4-mini'); - expect(repaired.presets?.[0]?.default).toBe('gpt-5.3-codex'); - expect(repaired.presets?.[0]?.opus).toBe('gpt-5.3-codex'); - expect(repaired.presets?.[0]?.sonnet).toBe('gpt-5.3-codex'); - expect(repaired.presets?.[0]?.haiku).toBe('gpt-5.4-mini'); + expect(repaired.env?.ANTHROPIC_MODEL).toBe('gpt-5.3-codex-xhigh'); + expect(repaired.env?.ANTHROPIC_DEFAULT_OPUS_MODEL).toBe('gpt-5.3-codex-xhigh'); + expect(repaired.env?.ANTHROPIC_DEFAULT_SONNET_MODEL).toBe('gpt-5.3-codex-high'); + expect(repaired.env?.ANTHROPIC_DEFAULT_HAIKU_MODEL).toBe('gpt-5.4-mini-medium'); + expect(repaired.presets?.[0]?.default).toBe('gpt-5.3-codex-xhigh'); + expect(repaired.presets?.[0]?.opus).toBe('gpt-5.3-codex-xhigh'); + expect(repaired.presets?.[0]?.sonnet).toBe('gpt-5.3-codex-high'); + expect(repaired.presets?.[0]?.haiku).toBe('gpt-5.4-mini-medium'); }); it('recovers malformed provider settings files by writing defaults and backup copy', () => { diff --git a/tests/unit/cliproxy/model-id-normalizer.test.ts b/tests/unit/cliproxy/model-id-normalizer.test.ts index d7e969cf..638ad419 100644 --- a/tests/unit/cliproxy/model-id-normalizer.test.ts +++ b/tests/unit/cliproxy/model-id-normalizer.test.ts @@ -83,7 +83,9 @@ describe('model-id-normalizer', () => { }); it('applies provider canonicalization for codex and antigravity', () => { - expect(canonicalizeModelIdForProvider('gpt-5.3-codex-xhigh', 'codex')).toBe('gpt-5.3-codex'); + expect(canonicalizeModelIdForProvider('gpt-5.3-codex-xhigh', 'codex')).toBe( + 'gpt-5.3-codex-xhigh' + ); expect(canonicalizeModelIdForProvider('claude-sonnet-4.6-thinking', 'agy')).toBe( 'claude-sonnet-4-6' ); @@ -94,7 +96,7 @@ describe('model-id-normalizer', () => { it('trims and canonicalizes provider model IDs with surrounding whitespace', () => { expect(canonicalizeModelIdForProvider(' gpt-5.3-codex-high ', 'codex')).toBe( - 'gpt-5.3-codex' + 'gpt-5.3-codex-high' ); expect(canonicalizeModelIdForProvider(' claude-sonnet-4.6-thinking ', 'agy')).toBe( 'claude-sonnet-4-6' @@ -120,9 +122,11 @@ describe('model-id-normalizer', () => { it('normalizes legacy codex aliases to the current supported model IDs', () => { expect(normalizeCodexLegacyModelAliases('gpt-5-codex')).toBe('gpt-5.4'); expect(normalizeCodexLegacyModelAliases('gpt-5-codex-mini[1m]')).toBe('gpt-5.4-mini[1m]'); + expect(normalizeCodexLegacyModelAliases('gpt-5-codex-high')).toBe('gpt-5.4-high'); + expect(normalizeCodexLegacyModelAliases('gpt-5-codex-high[1m]')).toBe('gpt-5.4-high[1m]'); expect(normalizeModelIdForProvider('gpt-5.2-codex', 'codex')).toBe('gpt-5.2'); expect(normalizeModelIdForProvider('gpt-5.1-codex-mini', 'codex')).toBe('gpt-5.4-mini'); - expect(canonicalizeModelIdForProvider('gpt-5-codex-high', 'codex')).toBe('gpt-5.4'); + expect(canonicalizeModelIdForProvider('gpt-5-codex-high', 'codex')).toBe('gpt-5.4-high'); }); }); diff --git a/tests/unit/web-server/cliproxy-stats-routes-model-update.test.ts b/tests/unit/web-server/cliproxy-stats-routes-model-update.test.ts index 538152a4..3a22363c 100644 --- a/tests/unit/web-server/cliproxy-stats-routes-model-update.test.ts +++ b/tests/unit/web-server/cliproxy-stats-routes-model-update.test.ts @@ -129,7 +129,7 @@ describe('cliproxy-stats-routes model update canonicalization', () => { expect(persisted.env.ANTHROPIC_MODEL).toBe('claude-sonnet-4-6'); }); - it('canonicalizes Codex effort suffix and syncs linked core model env vars', async () => { + it('preserves Codex effort suffixes while syncing linked core model env vars', async () => { const settingsPath = path.join(tempHome, '.ccs', 'cliproxy', 'codex.settings.json'); writeSettings(settingsPath, { ANTHROPIC_BASE_URL: 'http://127.0.0.1:8317/api/provider/codex', @@ -148,14 +148,14 @@ describe('cliproxy-stats-routes model update canonicalization', () => { expect(response.status).toBe(200); const body = (await response.json()) as { model: string }; - expect(body.model).toBe('gpt-5.3-codex'); + expect(body.model).toBe('gpt-5.3-codex-xhigh'); const persisted = JSON.parse(fs.readFileSync(settingsPath, 'utf8')) as { env: Record; }; - expect(persisted.env.ANTHROPIC_MODEL).toBe('gpt-5.3-codex'); - expect(persisted.env.ANTHROPIC_DEFAULT_OPUS_MODEL).toBe('gpt-5.3-codex'); - expect(persisted.env.ANTHROPIC_DEFAULT_SONNET_MODEL).toBe('gpt-5.3-codex'); + expect(persisted.env.ANTHROPIC_MODEL).toBe('gpt-5.3-codex-xhigh'); + expect(persisted.env.ANTHROPIC_DEFAULT_OPUS_MODEL).toBe('gpt-5.3-codex-xhigh'); + expect(persisted.env.ANTHROPIC_DEFAULT_SONNET_MODEL).toBe('gpt-5.3-codex-xhigh'); expect(persisted.env.ANTHROPIC_DEFAULT_HAIKU_MODEL).toBe('gpt-5.4-mini'); }); diff --git a/tests/unit/web-server/settings-routes-model-canonicalization.test.ts b/tests/unit/web-server/settings-routes-model-canonicalization.test.ts index bf70d518..ec3d9b06 100644 --- a/tests/unit/web-server/settings-routes-model-canonicalization.test.ts +++ b/tests/unit/web-server/settings-routes-model-canonicalization.test.ts @@ -154,6 +154,83 @@ describe('settings-routes model canonicalization', () => { expect(persisted.presets[0]?.haiku).toBe('qwen3-coder-plus'); }); + it('preserves codex effort suffixes on PUT /:profile while still normalizing legacy aliases', async () => { + const settingsPath = path.join(tempHome, '.ccs', 'codex.settings.json'); + writeSettings(settingsPath, { env: {} }); + + const response = await fetch(`${baseUrl}/api/settings/codex`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + settings: { + env: { + ANTHROPIC_BASE_URL: 'http://127.0.0.1:8317/api/provider/codex', + ANTHROPIC_AUTH_TOKEN: 'ccs-internal-managed', + ANTHROPIC_MODEL: 'gpt-5.3-codex-xhigh', + ANTHROPIC_DEFAULT_OPUS_MODEL: 'gpt-5-codex-high', + ANTHROPIC_DEFAULT_SONNET_MODEL: 'gpt-5.3-codex-high', + ANTHROPIC_DEFAULT_HAIKU_MODEL: 'gpt-5-mini-medium', + }, + presets: [ + { + name: 'legacy-codex', + default: 'gpt-5.3-codex-xhigh', + opus: 'gpt-5-codex-high', + sonnet: 'gpt-5.3-codex-high', + haiku: 'gpt-5-mini-medium', + }, + ], + }, + }), + }); + expect(response.status).toBe(200); + + const persisted = JSON.parse(fs.readFileSync(settingsPath, 'utf8')) as { + env: Record; + presets: Array>; + }; + + expect(persisted.env.ANTHROPIC_MODEL).toBe('gpt-5.3-codex-xhigh'); + expect(persisted.env.ANTHROPIC_DEFAULT_OPUS_MODEL).toBe('gpt-5.4-high'); + expect(persisted.env.ANTHROPIC_DEFAULT_SONNET_MODEL).toBe('gpt-5.3-codex-high'); + expect(persisted.env.ANTHROPIC_DEFAULT_HAIKU_MODEL).toBe('gpt-5.4-mini-medium'); + expect(persisted.presets[0]?.default).toBe('gpt-5.3-codex-xhigh'); + expect(persisted.presets[0]?.opus).toBe('gpt-5.4-high'); + expect(persisted.presets[0]?.sonnet).toBe('gpt-5.3-codex-high'); + expect(persisted.presets[0]?.haiku).toBe('gpt-5.4-mini-medium'); + }); + + it('preserves codex effort suffixes on GET /:profile/raw canonicalization', async () => { + const settingsPath = path.join(tempHome, '.ccs', 'codex.settings.json'); + writeSettings(settingsPath, { + env: { + ANTHROPIC_BASE_URL: 'http://127.0.0.1:8317/api/provider/codex', + ANTHROPIC_AUTH_TOKEN: 'ccs-internal-managed', + ANTHROPIC_MODEL: 'gpt-5-codex-high', + ANTHROPIC_DEFAULT_OPUS_MODEL: 'gpt-5.3-codex-xhigh', + ANTHROPIC_DEFAULT_SONNET_MODEL: 'gpt-5.3-codex-high', + ANTHROPIC_DEFAULT_HAIKU_MODEL: 'gpt-5-mini-medium', + }, + }); + + const response = await fetch(`${baseUrl}/api/settings/codex/raw`); + expect(response.status).toBe(200); + + const body = (await response.json()) as { settings: { env: Record } }; + expect(body.settings.env.ANTHROPIC_MODEL).toBe('gpt-5.4-high'); + expect(body.settings.env.ANTHROPIC_DEFAULT_OPUS_MODEL).toBe('gpt-5.3-codex-xhigh'); + expect(body.settings.env.ANTHROPIC_DEFAULT_SONNET_MODEL).toBe('gpt-5.3-codex-high'); + expect(body.settings.env.ANTHROPIC_DEFAULT_HAIKU_MODEL).toBe('gpt-5.4-mini-medium'); + + const persisted = JSON.parse(fs.readFileSync(settingsPath, 'utf8')) as { + env: Record; + }; + expect(persisted.env.ANTHROPIC_MODEL).toBe('gpt-5.4-high'); + expect(persisted.env.ANTHROPIC_DEFAULT_OPUS_MODEL).toBe('gpt-5.3-codex-xhigh'); + expect(persisted.env.ANTHROPIC_DEFAULT_SONNET_MODEL).toBe('gpt-5.3-codex-high'); + expect(persisted.env.ANTHROPIC_DEFAULT_HAIKU_MODEL).toBe('gpt-5.4-mini-medium'); + }); + it('canonicalizes AGY preset values on POST /:profile/presets', async () => { const settingsPath = path.join(tempHome, '.ccs', 'agy.settings.json'); writeSettings(settingsPath, { diff --git a/ui/src/components/cliproxy/provider-editor/model-config-section.tsx b/ui/src/components/cliproxy/provider-editor/model-config-section.tsx index 027a05ba..966f964e 100644 --- a/ui/src/components/cliproxy/provider-editor/model-config-section.tsx +++ b/ui/src/components/cliproxy/provider-editor/model-config-section.tsx @@ -229,7 +229,8 @@ export function ModelConfigSection({ {provider === 'codex' && (

Codex tip: suffixes -medium, -high, and -xhigh{' '} - pin reasoning effort. Unsuffixed models use Thinking settings. + pin reasoning effort. Select a suffixed model to pin effort; unsuffixed models use + Thinking settings.

)}
diff --git a/ui/src/components/cliproxy/provider-model-selector.tsx b/ui/src/components/cliproxy/provider-model-selector.tsx index 75a2977e..471bd71a 100644 --- a/ui/src/components/cliproxy/provider-model-selector.tsx +++ b/ui/src/components/cliproxy/provider-model-selector.tsx @@ -12,7 +12,8 @@ import { Badge } from '@/components/ui/badge'; import { SearchableSelect } from '@/components/ui/searchable-select'; import { Skeleton } from '@/components/ui/skeleton'; import type { CliproxyProviderRoutingHints } from '@/lib/api-client'; -import { getCodexEffortDisplay } from '@/lib/codex-effort'; +import type { CodexEffort } from '@/lib/codex-effort'; +import { getCodexEffortDisplay, getCodexEffortVariants } from '@/lib/codex-effort'; import { getResolvedCatalogModels, getSupplementalCatalogModels } from '@/lib/model-catalogs'; import { cn } from '@/lib/utils'; @@ -35,6 +36,8 @@ export interface ModelEntry { sonnet: string; haiku: string; }; + /** Highest codex reasoning-effort suffix this model supports in the dashboard UI. */ + codexMaxEffort?: CodexEffort; } /** Provider catalog */ @@ -313,6 +316,14 @@ function getPreferredOptionValue( return routingHint?.recommendedModelId ?? modelId; } +function getModelOptionValues( + codexMaxEffort: CodexEffort | undefined, + optionValue: string, + isCodexProvider: boolean +): string[] { + return isCodexProvider ? getCodexEffortVariants(optionValue, codexMaxEffort) : [optionValue]; +} + export function FlexibleModelSelector({ label, description, @@ -342,55 +353,64 @@ export function FlexibleModelSelector({ const recommendedOptionValues = useMemo( () => new Set( - resolvedCatalogModels.map((model) => - getPreferredOptionValue(model.id, routingHints.get(model.id.toLowerCase())) + resolvedCatalogModels.flatMap((model) => + getModelOptionValues( + model.codexMaxEffort, + getPreferredOptionValue(model.id, routingHints.get(model.id.toLowerCase())), + isCodexProvider + ) ) ), - [resolvedCatalogModels, routingHints] + [isCodexProvider, resolvedCatalogModels, routingHints] ); const selectedRoutingHint = useMemo( () => routingHints.get(normalizeModelValue(value, routing).toLowerCase()), [routing, routingHints, value] ); - const recommendedOptions = resolvedCatalogModels.map((model) => ({ - value: getPreferredOptionValue(model.id, routingHints.get(model.id.toLowerCase())), - groupKey: 'recommended', - searchText: `${model.id} ${model.name} ${routingHints.get(model.id.toLowerCase())?.recommendedModelId ?? ''}`, - keywords: [model.tier ?? '', catalog?.provider ?? ''], - triggerContent: ( -
- - {getPreferredOptionValue(model.id, routingHints.get(model.id.toLowerCase()))} - - {routingHints.get(model.id.toLowerCase())?.pinnedAvailable ? ( - - {routingHints.get(model.id.toLowerCase())?.prefix} - - ) : null} - {isCodexProvider && } -
- ), - itemContent: ( -
- - {getPreferredOptionValue(model.id, routingHints.get(model.id.toLowerCase()))} - - {model.tier === 'paid' && } - {routingHints.get(model.id.toLowerCase())?.unprefixedStatus === 'shadowed' ? ( - - {t('providerModelSelector.shadowed')} - - ) : null} - {routingHints.get(model.id.toLowerCase())?.unprefixedStatus === 'prefix-only' ? ( - - {t('providerModelSelector.prefixOnly')} - - ) : null} - {isCodexProvider && } -
- ), - })); + const recommendedOptions = resolvedCatalogModels.flatMap((model) => { + const routingHint = routingHints.get(model.id.toLowerCase()); + const optionValues = getModelOptionValues( + model.codexMaxEffort, + getPreferredOptionValue(model.id, routingHint), + isCodexProvider + ); + + return optionValues.map((optionValue) => ({ + value: optionValue, + groupKey: 'recommended', + searchText: `${optionValue} ${model.id} ${model.name} ${routingHint?.recommendedModelId ?? ''}`, + keywords: [model.tier ?? '', catalog?.provider ?? ''], + triggerContent: ( +
+ {optionValue} + {routingHint?.pinnedAvailable ? ( + + {routingHint.prefix} + + ) : null} + {isCodexProvider && } +
+ ), + itemContent: ( +
+ {optionValue} + {model.tier === 'paid' && } + {routingHint?.unprefixedStatus === 'shadowed' ? ( + + {t('providerModelSelector.shadowed')} + + ) : null} + {routingHint?.unprefixedStatus === 'prefix-only' ? ( + + {t('providerModelSelector.prefixOnly')} + + ) : null} + {isCodexProvider && } +
+ ), + })); + }); const allModelOptions = supplementalModels .filter((model) => !catalogModelIds.has(model.id)) @@ -400,43 +420,48 @@ export function FlexibleModelSelector({ getPreferredOptionValue(model.id, routingHints.get(model.id.toLowerCase())) ) ) - .map((model) => ({ - value: getPreferredOptionValue(model.id, routingHints.get(model.id.toLowerCase())), - groupKey: 'all', - searchText: `${model.id} ${routingHints.get(model.id.toLowerCase())?.recommendedModelId ?? ''}`, - keywords: [model.owned_by], - triggerContent: ( -
- - {getPreferredOptionValue(model.id, routingHints.get(model.id.toLowerCase()))} - - {routingHints.get(model.id.toLowerCase())?.pinnedAvailable ? ( - - {routingHints.get(model.id.toLowerCase())?.prefix} - - ) : null} - {isCodexProvider && } -
- ), - itemContent: ( -
- - {getPreferredOptionValue(model.id, routingHints.get(model.id.toLowerCase()))} - - {routingHints.get(model.id.toLowerCase())?.unprefixedStatus === 'shadowed' ? ( - - {t('providerModelSelector.shadowed')} - - ) : null} - {routingHints.get(model.id.toLowerCase())?.unprefixedStatus === 'prefix-only' ? ( - - {t('providerModelSelector.prefixOnly')} - - ) : null} - {isCodexProvider && } -
- ), - })); + .flatMap((model) => { + const routingHint = routingHints.get(model.id.toLowerCase()); + const optionValues = getModelOptionValues( + undefined, + getPreferredOptionValue(model.id, routingHint), + isCodexProvider + ); + + return optionValues.map((optionValue) => ({ + value: optionValue, + groupKey: 'all', + searchText: `${optionValue} ${model.id} ${routingHint?.recommendedModelId ?? ''}`, + keywords: [model.owned_by], + triggerContent: ( +
+ {optionValue} + {routingHint?.pinnedAvailable ? ( + + {routingHint.prefix} + + ) : null} + {isCodexProvider && } +
+ ), + itemContent: ( +
+ {optionValue} + {routingHint?.unprefixedStatus === 'shadowed' ? ( + + {t('providerModelSelector.shadowed')} + + ) : null} + {routingHint?.unprefixedStatus === 'prefix-only' ? ( + + {t('providerModelSelector.prefixOnly')} + + ) : null} + {isCodexProvider && } +
+ ), + })); + }); const selectedValueMissing = Boolean(value) && !recommendedOptions.some((option) => option.value === value) && diff --git a/ui/src/lib/codex-effort.ts b/ui/src/lib/codex-effort.ts index 9f3b26f2..5bc13019 100644 --- a/ui/src/lib/codex-effort.ts +++ b/ui/src/lib/codex-effort.ts @@ -1,14 +1,55 @@ 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) { + return [stripCodexEffortSuffix(modelId)]; + } + + const normalizedModelId = stripCodexEffortSuffix(modelId); + const variantIds = [normalizedModelId]; + + for (const effort of CODEX_EFFORTS_IN_ORDER) { + variantIds.push(applyCodexEffortSuffix(normalizedModelId, effort)); + if (effort === maxEffort) { + break; + } + } + + return variantIds; +} + export function getCodexEffortDisplay( modelId: string | undefined, effortLabels?: { pinned: (effort: string) => string; auto: string } diff --git a/ui/src/lib/model-catalogs.ts b/ui/src/lib/model-catalogs.ts index 025ddc01..5398645e 100644 --- a/ui/src/lib/model-catalogs.ts +++ b/ui/src/lib/model-catalogs.ts @@ -263,6 +263,7 @@ export const MODEL_CATALOGS: Record = { id: 'gpt-5-codex', name: 'GPT-5 Codex', description: 'Cross-plan safe Codex default', + codexMaxEffort: 'xhigh', presetMapping: { default: 'gpt-5-codex', opus: 'gpt-5-codex', @@ -274,6 +275,7 @@ export const MODEL_CATALOGS: Record = { id: 'gpt-5-codex-mini', name: 'GPT-5 Codex Mini', description: 'Faster and cheaper Codex option', + codexMaxEffort: 'high', presetMapping: { default: 'gpt-5-codex-mini', opus: 'gpt-5-codex', @@ -285,6 +287,7 @@ export const MODEL_CATALOGS: Record = { id: 'gpt-5-mini', name: 'GPT-5 Mini', description: 'Legacy mini model ID kept for backwards compatibility', + codexMaxEffort: 'high', presetMapping: { default: 'gpt-5-mini', opus: 'gpt-5-codex', @@ -296,6 +299,7 @@ export const MODEL_CATALOGS: Record = { id: 'gpt-5.1-codex-mini', name: 'GPT-5.1 Codex Mini', description: 'Legacy fast Codex mini model', + codexMaxEffort: 'high', presetMapping: { default: 'gpt-5.1-codex-mini', opus: 'gpt-5.1-codex-max', @@ -307,6 +311,7 @@ export const MODEL_CATALOGS: Record = { id: 'gpt-5.1-codex-max', name: 'GPT-5.1 Codex Max', description: 'Higher-effort Codex model with xhigh support', + codexMaxEffort: 'xhigh', presetMapping: { default: 'gpt-5.1-codex-max', opus: 'gpt-5.1-codex-max', @@ -318,6 +323,7 @@ export const MODEL_CATALOGS: Record = { id: 'gpt-5.2-codex', name: 'GPT-5.2 Codex', description: 'Cross-plan Codex model with xhigh support', + codexMaxEffort: 'xhigh', presetMapping: { default: 'gpt-5.2-codex', opus: 'gpt-5.2-codex', @@ -330,6 +336,7 @@ export const MODEL_CATALOGS: Record = { name: 'GPT-5.3 Codex', tier: 'paid', description: 'Paid Codex plans only', + codexMaxEffort: 'xhigh', presetMapping: { default: 'gpt-5.3-codex', opus: 'gpt-5.3-codex', @@ -342,6 +349,7 @@ export const MODEL_CATALOGS: Record = { name: 'GPT-5.3 Codex Spark', tier: 'paid', description: 'Paid Codex plans only, ultra-fast coding model', + codexMaxEffort: 'xhigh', presetMapping: { default: 'gpt-5.3-codex-spark', opus: 'gpt-5.3-codex', @@ -354,6 +362,7 @@ export const MODEL_CATALOGS: Record = { name: 'GPT-5.4', tier: 'paid', description: 'Paid Codex plans only, latest GPT-5 family model', + codexMaxEffort: 'xhigh', presetMapping: { default: 'gpt-5.4', opus: 'gpt-5.4', diff --git a/ui/tests/unit/components/cliproxy/provider-editor/use-provider-editor.test.tsx b/ui/tests/unit/components/cliproxy/provider-editor/use-provider-editor.test.tsx index c1546fb2..25c3e7ab 100644 --- a/ui/tests/unit/components/cliproxy/provider-editor/use-provider-editor.test.tsx +++ b/ui/tests/unit/components/cliproxy/provider-editor/use-provider-editor.test.tsx @@ -151,4 +151,49 @@ describe('useProviderEditor', () => { ANTHROPIC_DEFAULT_HAIKU_MODEL: 'claude-haiku-4-5-20251001', }); }); + + it('preserves explicit codex effort suffixes in editor state updates', async () => { + vi.stubGlobal( + 'fetch', + vi.fn((input: RequestInfo | URL) => { + const url = String(input); + + if (url.includes('/api/settings/codex/raw')) { + return Promise.resolve( + createJsonResponse({ + profile: 'codex', + settings: { + env: { + ANTHROPIC_MODEL: 'gpt-5.3-codex-high', + ANTHROPIC_DEFAULT_OPUS_MODEL: 'gpt-5.3-codex-xhigh', + ANTHROPIC_DEFAULT_SONNET_MODEL: 'gpt-5.3-codex-high', + ANTHROPIC_DEFAULT_HAIKU_MODEL: 'gpt-5.4-mini-medium', + }, + }, + mtime: 1, + path: '~/.ccs/codex.settings.json', + }) + ); + } + + return Promise.reject(new Error(`Unexpected fetch: ${url}`)); + }) + ); + + const { result } = renderHook(() => useProviderEditor('codex'), { wrapper }); + + await waitFor(() => expect(result.current.currentModel).toBe('gpt-5.3-codex-high')); + + act(() => { + result.current.updateEnvValue('ANTHROPIC_MODEL', 'gpt-5.3-codex-xhigh'); + }); + + const nextSettings = JSON.parse(result.current.rawJsonContent); + expect(nextSettings.env).toMatchObject({ + ANTHROPIC_MODEL: 'gpt-5.3-codex-xhigh', + ANTHROPIC_DEFAULT_OPUS_MODEL: 'gpt-5.3-codex-xhigh', + ANTHROPIC_DEFAULT_SONNET_MODEL: 'gpt-5.3-codex-high', + ANTHROPIC_DEFAULT_HAIKU_MODEL: 'gpt-5.4-mini-medium', + }); + }); }); diff --git a/ui/tests/unit/components/cliproxy/provider-model-selector.test.tsx b/ui/tests/unit/components/cliproxy/provider-model-selector.test.tsx index 3b8b94b1..198a839a 100644 --- a/ui/tests/unit/components/cliproxy/provider-model-selector.test.tsx +++ b/ui/tests/unit/components/cliproxy/provider-model-selector.test.tsx @@ -1,7 +1,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; import { FlexibleModelSelector } from '@/components/cliproxy/provider-model-selector'; -import { buildUiCatalogs } from '@/lib/model-catalogs'; +import { MODEL_CATALOGS, buildUiCatalogs } from '@/lib/model-catalogs'; import { render, screen, userEvent } from '@tests/setup/test-utils'; const noisyAgyModels = [ @@ -87,4 +87,46 @@ describe('FlexibleModelSelector', () => { expect(screen.getAllByText('gemini-3.1-pro-preview').length).toBeGreaterThan(0); expect(screen.getByText('gemini-3.1-pro-high')).toBeInTheDocument(); }); + + it('offers codex effort-suffixed variants as first-class selectable options', async () => { + const onChange = vi.fn(); + + render( + + ); + + await userEvent.click(screen.getByRole('button', { name: /select model/i })); + + expect(screen.getByText('gpt-5.3-codex-high')).toBeInTheDocument(); + expect(screen.getByText('gpt-5.3-codex-xhigh')).toBeInTheDocument(); + + await userEvent.click(screen.getByText('gpt-5.3-codex-high')); + expect(onChange).toHaveBeenCalledWith('gpt-5.3-codex-high'); + }); + + it('does not relegate saved codex effort variants to the legacy current-value fallback', async () => { + render( + + ); + + expect(screen.getByRole('button', { name: /gpt-5\.3-codex-high/i })).toBeInTheDocument(); + + await userEvent.click(screen.getByRole('button', { name: /gpt-5\.3-codex-high/i })); + + expect(screen.queryByText('Current value')).not.toBeInTheDocument(); + expect(screen.getByText('gpt-5.3-codex')).toBeInTheDocument(); + expect(screen.getAllByText('gpt-5.3-codex-high').length).toBeGreaterThan(0); + }); }); diff --git a/ui/tests/unit/ui/lib/codex-effort.test.ts b/ui/tests/unit/ui/lib/codex-effort.test.ts index 965d1963..90b30e3e 100644 --- a/ui/tests/unit/ui/lib/codex-effort.test.ts +++ b/ui/tests/unit/ui/lib/codex-effort.test.ts @@ -1,5 +1,11 @@ import { describe, expect, it } from 'vitest'; -import { getCodexEffortDisplay, parseCodexEffort } from '@/lib/codex-effort'; +import { + applyCodexEffortSuffix, + getCodexEffortDisplay, + getCodexEffortVariants, + parseCodexEffort, + stripCodexEffortSuffix, +} from '@/lib/codex-effort'; describe('parseCodexEffort', () => { it('parses lowercase suffixes', () => { @@ -38,3 +44,25 @@ describe('getCodexEffortDisplay', () => { expect(getCodexEffortDisplay(undefined)).toBeNull(); }); }); + +describe('codex effort helpers', () => { + it('strips and reapplies codex effort suffixes', () => { + expect(stripCodexEffortSuffix('gpt-5.3-codex-high')).toBe('gpt-5.3-codex'); + expect(applyCodexEffortSuffix('gpt-5.3-codex-high', 'xhigh')).toBe('gpt-5.3-codex-xhigh'); + expect(applyCodexEffortSuffix('gpt-5.3-codex', undefined)).toBe('gpt-5.3-codex'); + }); + + it('builds ordered codex effort variants up to the supported max level', () => { + expect(getCodexEffortVariants('gpt-5.3-codex', 'xhigh')).toEqual([ + 'gpt-5.3-codex', + 'gpt-5.3-codex-medium', + 'gpt-5.3-codex-high', + 'gpt-5.3-codex-xhigh', + ]); + expect(getCodexEffortVariants('gpt-5.4-mini', 'high')).toEqual([ + 'gpt-5.4-mini', + 'gpt-5.4-mini-medium', + 'gpt-5.4-mini-high', + ]); + }); +}); diff --git a/ui/tests/unit/ui/lib/model-catalogs-codex.test.ts b/ui/tests/unit/ui/lib/model-catalogs-codex.test.ts index 8e302458..b8ceaf9a 100644 --- a/ui/tests/unit/ui/lib/model-catalogs-codex.test.ts +++ b/ui/tests/unit/ui/lib/model-catalogs-codex.test.ts @@ -6,8 +6,11 @@ describe('codex model catalog defaults', () => { const codexCatalog = MODEL_CATALOGS.codex; const codex53 = codexCatalog.models.find((model) => model.id === 'gpt-5.3-codex'); const codex52 = codexCatalog.models.find((model) => model.id === 'gpt-5.2-codex'); + const codexMini = codexCatalog.models.find((model) => model.id === 'gpt-5-codex-mini'); expect(codex53?.presetMapping?.haiku).toBe('gpt-5-codex-mini'); expect(codex52?.presetMapping?.haiku).toBe('gpt-5-codex-mini'); + expect(codex53?.codexMaxEffort).toBe('xhigh'); + expect(codexMini?.codexMaxEffort).toBe('high'); }); }); From 3a63cc04312b127cbaa31a1a2c2f58c1c3047515 Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Wed, 22 Apr 2026 14:31:40 -0400 Subject: [PATCH 15/39] fix: preserve supplemental codex effort variants --- ui/src/lib/codex-effort.ts | 3 ++- .../cliproxy/provider-model-selector.test.tsx | 22 +++++++++++++++++++ 2 files changed, 24 insertions(+), 1 deletion(-) diff --git a/ui/src/lib/codex-effort.ts b/ui/src/lib/codex-effort.ts index 5bc13019..ff2d311a 100644 --- a/ui/src/lib/codex-effort.ts +++ b/ui/src/lib/codex-effort.ts @@ -34,7 +34,8 @@ export function getCodexEffortVariants( maxEffort: CodexEffort | undefined ): string[] { if (!maxEffort) { - return [stripCodexEffortSuffix(modelId)]; + const explicitEffort = parseCodexEffort(modelId); + return [applyCodexEffortSuffix(modelId, explicitEffort)]; } const normalizedModelId = stripCodexEffortSuffix(modelId); diff --git a/ui/tests/unit/components/cliproxy/provider-model-selector.test.tsx b/ui/tests/unit/components/cliproxy/provider-model-selector.test.tsx index 198a839a..d7c15e75 100644 --- a/ui/tests/unit/components/cliproxy/provider-model-selector.test.tsx +++ b/ui/tests/unit/components/cliproxy/provider-model-selector.test.tsx @@ -129,4 +129,26 @@ describe('FlexibleModelSelector', () => { expect(screen.getByText('gpt-5.3-codex')).toBeInTheDocument(); expect(screen.getAllByText('gpt-5.3-codex-high').length).toBeGreaterThan(0); }); + + it('preserves explicit suffixes on supplemental codex models outside the static catalog', async () => { + const onChange = vi.fn(); + + render( + + ); + + await userEvent.click(screen.getByRole('button', { name: /select model/i })); + + expect(screen.getByText(/All Models \(1\)/i)).toBeInTheDocument(); + expect(screen.getByText('gpt-5.5-codex-high')).toBeInTheDocument(); + + await userEvent.click(screen.getByText('gpt-5.5-codex-high')); + expect(onChange).toHaveBeenCalledWith('gpt-5.5-codex-high'); + }); }); From fd0e9b7c3affbdb40e946e8653e82d579290fa39 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Wed, 22 Apr 2026 18:45:09 +0000 Subject: [PATCH 16/39] chore(release): 7.73.1-dev.2 [skip ci] --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 634e9d20..d758342e 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@kaitranntt/ccs", - "version": "7.73.1-dev.1", + "version": "7.73.1-dev.2", "description": "Claude Code Switch - Instant profile switching between Claude, GLM, Kimi, and more", "keywords": [ "cli", From 051f78aee75f73efdf0435bbf6458e68a8818a45 Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Wed, 22 Apr 2026 15:05:44 -0400 Subject: [PATCH 17/39] chore(ci): split dev quality checks from release automation --- .github/workflows/ci.yml | 6 -- .github/workflows/push-ci.yml | 134 ++++++++++++++++++++++++++++++++++ .husky/pre-push | 4 +- package.json | 6 +- scripts/ci-parity-gate.sh | 8 +- scripts/run-test-bucket.js | 101 +++++++++++++++++++++++++ 6 files changed, 247 insertions(+), 12 deletions(-) create mode 100644 .github/workflows/push-ci.yml create mode 100644 scripts/run-test-bucket.js diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0b5bf26a..3dba111b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -50,8 +50,6 @@ jobs: path: | ${{ 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 @@ -84,8 +82,6 @@ jobs: path: | ${{ 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 @@ -127,8 +123,6 @@ jobs: path: | ${{ env.BUN_INSTALL_CACHE_DIR }} key: ${{ runner.os }}-bun-cache-v2-${{ hashFiles('bun.lock', 'ui/bun.lock') }} - restore-keys: | - ${{ runner.os }}-bun-cache-v2- - name: Ensure dependencies run: bash scripts/ensure-deps.sh diff --git a/.github/workflows/push-ci.yml b/.github/workflows/push-ci.yml new file mode 100644 index 00000000..2aef1166 --- /dev/null +++ b/.github/workflows/push-ci.yml @@ -0,0 +1,134 @@ +name: Push CI + +on: + push: + branches: [dev] + +concurrency: + group: push-ci-${{ github.ref }} + cancel-in-progress: true + +jobs: + validate: + runs-on: [self-hosted, linux, x64] + env: + BUN_INSTALL_CACHE_DIR: ${{ github.workspace }}/.bun/install/cache + strategy: + fail-fast: false + matrix: + check: + - { name: typecheck, cmd: 'bun run typecheck' } + - { name: lint, cmd: 'bun run lint' } + - { name: format, cmd: 'bun run format:check' } + name: ${{ matrix.check.name }} + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Setup Bun + uses: oven-sh/setup-bun@v2 + with: + bun-version: '1.3.9' + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: '22' + + - name: Restore bun package cache + uses: actions/cache@v4 + with: + path: | + ${{ env.BUN_INSTALL_CACHE_DIR }} + key: ${{ runner.os }}-bun-cache-v2-${{ hashFiles('bun.lock', 'ui/bun.lock') }} + + - name: Ensure dependencies + run: bash scripts/ensure-deps.sh + + - name: Run ${{ matrix.check.name }} + run: ${{ matrix.check.cmd }} + + build: + runs-on: [self-hosted, linux, x64] + name: build + env: + BUN_INSTALL_CACHE_DIR: ${{ github.workspace }}/.bun/install/cache + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Setup Bun + uses: oven-sh/setup-bun@v2 + with: + bun-version: '1.3.9' + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: '22' + + - name: Restore bun package cache + uses: actions/cache@v4 + with: + path: | + ${{ env.BUN_INSTALL_CACHE_DIR }} + key: ${{ runner.os }}-bun-cache-v2-${{ hashFiles('bun.lock', 'ui/bun.lock') }} + + - name: Ensure dependencies + run: bash scripts/ensure-deps.sh + + - name: Build + run: bun run build:all + + - name: Upload dist artifact + uses: actions/upload-artifact@v4 + with: + name: dist + path: dist/ + retention-days: 1 + if-no-files-found: error + + test: + runs-on: [self-hosted, linux, x64] + name: test + needs: [build] + env: + BUN_INSTALL_CACHE_DIR: ${{ github.workspace }}/.bun/install/cache + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Setup Bun + uses: oven-sh/setup-bun@v2 + with: + bun-version: '1.3.9' + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: '22' + + - name: Restore bun package cache + uses: actions/cache@v4 + with: + path: | + ${{ env.BUN_INSTALL_CACHE_DIR }} + key: ${{ runner.os }}-bun-cache-v2-${{ hashFiles('bun.lock', 'ui/bun.lock') }} + + - name: Ensure dependencies + run: bash scripts/ensure-deps.sh + + - name: Download dist artifact + uses: actions/download-artifact@v4 + with: + name: dist + path: dist/ + + - name: Test + run: bun run test:all + + - name: Test CLI e2e + env: + CCS_E2E_SKIP_BUILD: '1' + run: bun run test:e2e diff --git a/.husky/pre-push b/.husky/pre-push index 07a426a0..b47b1dd4 100755 --- a/.husky/pre-push +++ b/.husky/pre-push @@ -45,9 +45,9 @@ echo " branch: $CURRENT_BRANCH" echo " base: $BASE_BRANCH" bun run typecheck -bun run lint:fix +bun run lint bun run format:check -bun run build:all +bun run test:fast git fetch origin "$BASE_BRANCH" --quiet || true DIFF_RANGE="HEAD" diff --git a/package.json b/package.json index d758342e..9ef64f27 100644 --- a/package.json +++ b/package.json @@ -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 --max-concurrency=1 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": "bun run test:fast && bun run test:slow", "test:unit": "bun test tests/unit", "test:npm": "bun test tests/npm/", "test:native": "bash tests/native/unix/edge-cases.sh", diff --git a/scripts/ci-parity-gate.sh b/scripts/ci-parity-gate.sh index 5e1c7a1f..c9b31a53 100755 --- a/scripts/ci-parity-gate.sh +++ b/scripts/ci-parity-gate.sh @@ -55,8 +55,12 @@ 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..." +bun run typecheck +bun run lint +bun run format:check bun run build:all -bun run validate +bun run test:all +CCS_E2E_SKIP_BUILD=1 bun run test:e2e echo "[OK] CI parity gate passed." diff --git a/scripts/run-test-bucket.js b/scripts/run-test-bucket.js new file mode 100644 index 00000000..f17ed2f7 --- /dev/null +++ b/scripts/run-test-bucket.js @@ -0,0 +1,101 @@ +#!/usr/bin/env node + +const fs = require('node:fs'); +const path = require('node:path'); +const { spawnSync } = require('node:child_process'); + +const bucket = process.argv[2]; +const rootDir = path.resolve(__dirname, '..'); +const candidateRoots = ['tests/unit', 'tests/integration', 'tests/npm']; +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', +]; + +if (bucket !== 'fast' && bucket !== 'slow') { + console.error('[X] Usage: node scripts/run-test-bucket.js '); + process.exit(1); +} + +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/'); +} + +const discovered = candidateRoots + .flatMap((relativeDir) => collectFiles(path.join(rootDir, relativeDir))) + .sort(); +const forceSlow = discovered.filter((file) => { + if (file.startsWith('tests/npm/')) { + return true; + } + + if (/\.(c|m)?js$/.test(file)) { + return true; + } + + return readsBuiltDist(file); +}); +const slowSet = new Set([...slowTests, ...forceSlow]); +const selected = + bucket === 'slow' + ? [...slowSet].sort() + : discovered.filter((file) => !slowSet.has(file)); + +if (selected.length === 0) { + console.error(`[X] No tests matched the '${bucket}' bucket.`); + process.exit(1); +} + +if (bucket === 'slow' && !fs.existsSync(path.join(rootDir, 'dist', 'ccs.js'))) { + const build = spawnSync('bun', ['run', 'build'], { + cwd: rootDir, + stdio: 'inherit', + shell: process.platform === 'win32', + }); + + if (build.status !== 0) { + process.exit(build.status ?? 1); + } +} + +const result = spawnSync( + 'bun', + ['test', '--max-concurrency=1', ...selected], + { + cwd: rootDir, + stdio: 'inherit', + shell: process.platform === 'win32', + }, +); + +process.exit(result.status ?? 1); From b9f5783facddb2decb702e378e558c5faac2fc80 Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Wed, 22 Apr 2026 15:06:55 -0400 Subject: [PATCH 18/39] test(ci): cover push quality workflow --- .../scripts/github/push-ci-workflow.test.ts | 34 +++++++++++++++++++ 1 file changed, 34 insertions(+) create mode 100644 tests/unit/scripts/github/push-ci-workflow.test.ts diff --git a/tests/unit/scripts/github/push-ci-workflow.test.ts b/tests/unit/scripts/github/push-ci-workflow.test.ts new file mode 100644 index 00000000..f498e014 --- /dev/null +++ b/tests/unit/scripts/github/push-ci-workflow.test.ts @@ -0,0 +1,34 @@ +import { describe, expect, test } from 'bun:test'; +import * as fs from 'node:fs'; +import * as path from 'node:path'; + +function resolvePath(relativePath: string) { + return path.resolve(import.meta.dir, relativePath); +} + +describe('push ci workflow', () => { + test('keeps dev push quality checks separate from release automation', () => { + const workflowPath = resolvePath('../../../../.github/workflows/push-ci.yml'); + + expect(fs.existsSync(workflowPath)).toBe(true); + + const workflow = fs.readFileSync(workflowPath, 'utf8'); + + expect(workflow).toContain('name: Push CI'); + expect(workflow).toContain('push:'); + expect(workflow).toContain('branches: [dev]'); + expect(workflow).toContain('group: push-ci-${{ github.ref }}'); + expect(workflow).toContain('cancel-in-progress: true'); + expect(workflow).toContain('runs-on: [self-hosted, linux, x64]'); + expect(workflow).toContain("key: ${{ runner.os }}-bun-cache-v2-${{ hashFiles('bun.lock', 'ui/bun.lock') }}"); + expect(workflow).not.toContain('restore-keys:'); + expect(workflow).toContain("name: ${{ matrix.check.name }}"); + expect(workflow).toContain("cmd: 'bun run typecheck'"); + expect(workflow).toContain("cmd: 'bun run lint'"); + expect(workflow).toContain("cmd: 'bun run format:check'"); + expect(workflow).toContain('run: bun run build:all'); + expect(workflow).toContain('run: bun run test:all'); + expect(workflow).toContain("CCS_E2E_SKIP_BUILD: '1'"); + expect(workflow).toContain('run: bun run test:e2e'); + }); +}); From c9eaae17c184a91addc2df85733db3d6339bb720 Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Wed, 22 Apr 2026 15:07:12 -0400 Subject: [PATCH 19/39] docs(contrib): clarify CI lanes and parity gates --- .github/pull_request_template.md | 6 ++- CLAUDE.md | 67 ++++++++++++++------------------ CONTRIBUTING.md | 51 ++++++++++++++++++++++-- README.md | 3 ++ 4 files changed, 85 insertions(+), 42 deletions(-) diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index 3ed3a780..9002ba3f 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -6,8 +6,9 @@ Use what applies. If you skipped something, add a short note instead of forcing it. -- [ ] `bun run validate` -- [ ] `bun run validate:ci-parity` +- [ ] `bun run format && bun run validate` +- [ ] `bun run validate:ci-parity` before requesting review +- [ ] `bun run test:e2e` if this PR touches command routing, proxy flows, or workflow/release logic - [ ] `cd ui && bun run validate` if UI changed - [ ] Not run @@ -20,6 +21,7 @@ Check what applies. Not every item is relevant for every PR. - [ ] Relevant `--help` output updated if CLI behavior changed - [ ] Tests added or updated if behavior changed - [ ] README or local docs updated if user-facing behavior changed +- [ ] If a check failed, the PR body explains what failed and what changed to fix it - [ ] No secrets, tokens, or private config data are included ## Docs Impact diff --git a/CLAUDE.md b/CLAUDE.md index 11175260..ad01f074 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -41,6 +41,20 @@ gh pr checks ### Absolute rule AI MUST NOT declare a task done, close a session, or move to the next task while CI is red or still running. Leaving a PR red and moving on is the primary failure mode this protocol prevents. +### Self-Hosted Runner Awareness + +- If `gh pr checks` or `gh run watch` stays queued for more than 10 minutes, assume the self-hosted runner is offline. +- Confirm on the maintainer host with `ssh docker "systemctl status actions-runner"`. +- Treat runner outages as infrastructure issues, not code failures. Do not blindly rerun local commands and hope the queue clears. + +### 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. +- `dev-release.yml` currently pushes with `PAT_TOKEN` because `dev` is protected by required status checks (`typecheck`, `lint`, `format`, `build`, `test`). 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 +101,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 +222,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 +235,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 +269,13 @@ 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 + validate + tests 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 - -### Maintainability Baseline Gate - -- 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. +- husky `pre-push` runs a faster feature-branch gate (`typecheck` + `lint` + `format:check` + `test:fast`) plus targeted checks based on changed files ## Critical Constraints (NEVER VIOLATE) @@ -532,17 +526,16 @@ rm -rf ~/.ccs # Clean environment Optimized for iterative push-then-review workflow. Do NOT run the full gate on every push — CI is the safety net. Run the full gate once before asking for review / merge. ### Tier 1 — Iterative push (feature branch) -Husky `pre-push` auto-runs: `typecheck + lint:fix + format:check + build:all` plus targeted tests based on changed files. AI does **nothing extra** at push time. +Husky `pre-push` auto-runs: `typecheck + lint + format:check + test:fast` plus targeted checks based on changed files. AI does **nothing extra** at push time. **After push (MANDATORY):** follow the [CI-First Protocol](#ci-first-protocol-mandatory) — watch CI until green. Do not move on while CI is red. ### Tier 2 — Before requesting review / merge Run ONCE, not per push: -- [ ] `bun run validate:ci-parity` — full build + validate matches CI +- [ ] `bun run validate:ci-parity` — branch freshness + build + full non-e2e tests + e2e - [ ] `gh pr checks ` — all checks green -- [ ] If touching debt-sensitive code: `bun run maintainability:check:strict` -- [ ] If strict mode fails and increase is intentional: `bun run maintainability:baseline` and commit `docs/metrics/maintainability-baseline.json` - [ ] If UI changed: `cd ui && bun run format && bun run validate` +- [ ] If touching command routing, proxy flows, workflows, or release logic: `bun run test:e2e` ### Code / Docs / Standards (verify before merge) - [ ] Conventional commit format (`feat:`, `fix:`, etc.) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index c283c060..a40efd91 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -76,6 +76,22 @@ 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 more than 10 minutes, the self-hosted runner is probably offline. That is a maintainer issue, not a contributor mistake. + +## 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: @@ -129,16 +145,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 +190,17 @@ bun run test:native bun run test:e2e ``` -`bun run validate` is the main gate. It covers typechecking, linting, format checks, maintainability checks, and automated tests for the main project. +Use `bun run test:e2e` locally before review if you touch command routing, proxy flows, release automation, or workflow wiring and want to reproduce the same CLI e2e lane that PR CI runs. + +### Why Did CI Fail? + +| Symptom | Likely cause | Fix | +| --- | --- | --- | +| `format` fails in PR CI | `bun run format` was skipped locally | Run `bun run format`, recommit, push again | +| `lint` fails in PR CI | `validate` now uses read-only `lint` | Run `bun run lint:fix`, then rerun `bun run validate` | +| `test` fails in PR CI but `validate` passed | The failure is in `test:slow` or `test:e2e` | Run `bun run validate:ci-parity` locally | +| Checks stay queued for >10 min | Self-hosted runner is offline | Wait for maintainer intervention; rerunning usually does not help | +| `Dev Release` is red on `dev` after merge | Release-only failure or publish problem | Check `Push CI` first to confirm code quality | If you cannot run the full suite, that is still fine for early or docs-only PRs. Just say what you did run, or what blocked you, in the PR. diff --git a/README.md b/README.md index 4aab06f4..ea2c2617 100644 --- a/README.md +++ b/README.md @@ -177,6 +177,9 @@ ccs ollama "summarize these logs" ## Contribute And Report Safely - Contributing guide: [CONTRIBUTING.md](./CONTRIBUTING.md) +- Daily local gate: `bun run format && bun run validate` +- 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) From 46920dbc08a18c4c853547fe1e1ec32981e765de Mon Sep 17 00:00:00 2001 From: Sergey Galuza Date: Wed, 22 Apr 2026 21:09:21 +0200 Subject: [PATCH 20/39] fix(cliproxy): use adaptive thinking for Claude Opus 4.7 Per Anthropic docs, Claude Opus 4.7 only supports adaptive thinking; manual `thinking.type: "enabled"` with `budget_tokens` is rejected with HTTP 400. Switch the claude provider's `claude-opus-4-7` entry from `type: 'budget'` to `type: 'levels'` with the effort tiers exposed by the API: `low | medium | high | xhigh`. The proxy is expected to translate these into `thinking.type: "adaptive"` with the effort parameter. Opus 4.6 and Sonnet 4.6 keep `type: 'budget'` for now since the deprecated mode is still functional on those models. Built [OnSteroids](https://onsteroids.ai) Co-Authored-By: OnSteroids --- src/cliproxy/model-catalog.ts | 10 ++++++---- tests/unit/cliproxy/model-catalog.test.js | 8 +++++--- 2 files changed, 11 insertions(+), 7 deletions(-) diff --git a/src/cliproxy/model-catalog.ts b/src/cliproxy/model-catalog.ts index 6c19ab7c..52ec7e62 100644 --- a/src/cliproxy/model-catalog.ts +++ b/src/cliproxy/model-catalog.ts @@ -295,11 +295,13 @@ export const MODEL_CATALOG: Partial> = name: 'Claude Opus 4.7', description: 'Latest flagship model', nativeImageInput: true, + // Opus 4.7 only supports adaptive thinking on the Anthropic API; manual + // thinking.type: "enabled" with budget_tokens is rejected with 400. + // Expose effort levels; the proxy translates these into adaptive effort. thinking: { - type: 'budget', - min: 1024, - max: 128000, - zeroAllowed: false, + type: 'levels', + levels: ['low', 'medium', 'high', 'xhigh'], + maxLevel: 'xhigh', dynamicAllowed: true, }, extendedContext: true, diff --git a/tests/unit/cliproxy/model-catalog.test.js b/tests/unit/cliproxy/model-catalog.test.js index 5023bf32..4f5649b1 100644 --- a/tests/unit/cliproxy/model-catalog.test.js +++ b/tests/unit/cliproxy/model-catalog.test.js @@ -147,9 +147,11 @@ describe('Model Catalog', () => { const opus47 = MODEL_CATALOG.claude.models.find((m) => m.id === 'claude-opus-4-7'); assert(opus47, 'Should include Claude Opus 4.7'); assert.strictEqual(opus47.name, 'Claude Opus 4.7'); - // Claude provider differs from AGY: zero thinking budget is disallowed, - // and extended (1M) context is available on the Anthropic API. - assert.strictEqual(opus47.thinking.zeroAllowed, false); + // Opus 4.7 requires adaptive thinking (type: 'levels'); manual budget_tokens + // is rejected by the Anthropic API with 400. Extended (1M) context is available. + assert.strictEqual(opus47.thinking.type, 'levels'); + assert.deepStrictEqual(opus47.thinking.levels, ['low', 'medium', 'high', 'xhigh']); + assert.strictEqual(opus47.thinking.maxLevel, 'xhigh'); assert.strictEqual(opus47.extendedContext, true); }); From b314cf353ed38ea16919ba4fa0029dfc5cf66c0b Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Wed, 22 Apr 2026 15:12:07 -0400 Subject: [PATCH 21/39] fix(ci): keep release workflows on full test coverage --- .github/workflows/dev-release.yml | 5 ++- .github/workflows/release.yml | 5 ++- package.json | 2 +- scripts/run-test-bucket.js | 74 ++++++++++++++++++++++--------- 4 files changed, 61 insertions(+), 25 deletions(-) diff --git a/.github/workflows/dev-release.yml b/.github/workflows/dev-release.yml index 55e23f25..d3fb654c 100644 --- a/.github/workflows/dev-release.yml +++ b/.github/workflows/dev-release.yml @@ -47,9 +47,12 @@ jobs: - name: Build run: bun run build:all - - name: Validate (typecheck + lint + format + tests) + - name: Validate fast gate run: bun run validate + - name: Test slow bucket + run: bun run test:slow + - name: Test CLI e2e env: CCS_E2E_SKIP_BUILD: '1' diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 466394ec..db140f7b 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -42,9 +42,12 @@ jobs: - name: Build package run: bun run build:all - - name: Validate (typecheck + lint + format + tests) + - name: Validate fast gate run: bun run validate + - name: Test slow bucket + run: bun run test:slow + - name: Test CLI e2e env: CCS_E2E_SKIP_BUILD: '1' diff --git a/package.json b/package.json index 9ef64f27..c0a95eab 100644 --- a/package.json +++ b/package.json @@ -77,7 +77,7 @@ "test:ci": "bun run test:all", "test:fast": "node scripts/run-test-bucket.js fast", "test:slow": "node scripts/run-test-bucket.js slow", - "test:all": "bun run test:fast && bun run test:slow", + "test:all": "node scripts/run-test-bucket.js all", "test:unit": "bun test tests/unit", "test:npm": "bun test tests/npm/", "test:native": "bash tests/native/unix/edge-cases.sh", diff --git a/scripts/run-test-bucket.js b/scripts/run-test-bucket.js index f17ed2f7..5c02ec75 100644 --- a/scripts/run-test-bucket.js +++ b/scripts/run-test-bucket.js @@ -23,8 +23,8 @@ const slowTests = [ 'tests/unit/web-server/websearch-routes.test.ts', ]; -if (bucket !== 'fast' && bucket !== 'slow') { - console.error('[X] Usage: node scripts/run-test-bucket.js '); +if (!['fast', 'slow', 'all'].includes(bucket)) { + console.error('[X] Usage: node scripts/run-test-bucket.js '); process.exit(1); } @@ -66,36 +66,66 @@ const forceSlow = discovered.filter((file) => { return readsBuiltDist(file); }); const slowSet = new Set([...slowTests, ...forceSlow]); -const selected = - bucket === 'slow' + +function selectBucket(name) { + return name === 'slow' ? [...slowSet].sort() : discovered.filter((file) => !slowSet.has(file)); - -if (selected.length === 0) { - console.error(`[X] No tests matched the '${bucket}' bucket.`); - process.exit(1); } -if (bucket === 'slow' && !fs.existsSync(path.join(rootDir, 'dist', 'ccs.js'))) { +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', }); - if (build.status !== 0) { - process.exit(build.status ?? 1); - } + return build.status ?? 1; } -const result = spawnSync( - 'bun', - ['test', '--max-concurrency=1', ...selected], - { - cwd: rootDir, - stdio: 'inherit', - shell: process.platform === 'win32', - }, -); +function runBucket(name) { + const selected = selectBucket(name); -process.exit(result.status ?? 1); + 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; + } + } + + const result = spawnSync( + 'bun', + ['test', '--max-concurrency=1', ...selected], + { + cwd: rootDir, + stdio: 'inherit', + shell: process.platform === 'win32', + }, + ); + + return result.status ?? 1; +} + +if (bucket === 'all') { + let exitCode = 0; + + for (const name of ['fast', 'slow']) { + const status = runBucket(name); + if (status !== 0) { + exitCode = status; + } + } + + process.exit(exitCode); +} + +process.exit(runBucket(bucket)); From ddfbcb63f44eb6f379765bf5e44f42189d9012f3 Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Wed, 22 Apr 2026 15:12:24 -0400 Subject: [PATCH 22/39] docs(contrib): align local gate shortcuts --- .github/pull_request_template.md | 2 +- CLAUDE.md | 2 +- README.md | 2 +- docs/code-standards.md | 3 ++- 4 files changed, 5 insertions(+), 4 deletions(-) diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index 9002ba3f..c3c6ce9d 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -6,7 +6,7 @@ Use what applies. If you skipped something, add a short note instead of forcing it. -- [ ] `bun run format && bun run validate` +- [ ] `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 diff --git a/CLAUDE.md b/CLAUDE.md index ad01f074..f55cc489 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -272,7 +272,7 @@ bun run validate # Step 3: Final check (must pass) - `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 + validate + tests before publishing and still requires `PAT_TOKEN` to push back to protected `dev` +- `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` + `format:check` + `test:fast`) plus targeted checks based on changed files diff --git a/README.md b/README.md index ea2c2617..5a131860 100644 --- a/README.md +++ b/README.md @@ -177,7 +177,7 @@ ccs ollama "summarize these logs" ## Contribute And Report Safely - Contributing guide: [CONTRIBUTING.md](./CONTRIBUTING.md) -- Daily local gate: `bun run format && bun run validate` +- Daily local gate: `bun run format && bun run lint:fix && bun run validate` - 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: diff --git a/docs/code-standards.md b/docs/code-standards.md index 3c3b35cb..de84eebe 100644 --- a/docs/code-standards.md +++ b/docs/code-standards.md @@ -599,6 +599,7 @@ return ( bun run format bun run lint:fix bun run validate +bun run validate:ci-parity # UI project (if changed) cd ui @@ -611,7 +612,7 @@ bun run validate | Project | Command | Checks | |---------|---------|--------| -| Main | `bun run validate` | typecheck + lint + format:check + test | +| Main | `bun run validate` | typecheck + lint + format:check + test:fast | | UI | `bun run validate` | typecheck + lint + format:check | --- From cf5fc96b7d25b55100045f3b3595aa2eb982445b Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Wed, 22 Apr 2026 15:23:00 -0400 Subject: [PATCH 23/39] test(ci): cover pr workflow coverage --- tests/unit/scripts/github/ci-workflow.test.ts | 36 +++++++++++++++++++ 1 file changed, 36 insertions(+) create mode 100644 tests/unit/scripts/github/ci-workflow.test.ts diff --git a/tests/unit/scripts/github/ci-workflow.test.ts b/tests/unit/scripts/github/ci-workflow.test.ts new file mode 100644 index 00000000..a5319626 --- /dev/null +++ b/tests/unit/scripts/github/ci-workflow.test.ts @@ -0,0 +1,36 @@ +import { describe, expect, test } from 'bun:test'; +import * as fs from 'node:fs'; +import * as path from 'node:path'; + +function resolvePath(relativePath: string) { + return path.resolve(import.meta.dir, relativePath); +} + +describe('pr ci workflow', () => { + test('keeps full coverage on pull requests', () => { + const workflowPath = resolvePath('../../../../.github/workflows/ci.yml'); + + expect(fs.existsSync(workflowPath)).toBe(true); + + const workflow = fs.readFileSync(workflowPath, 'utf8'); + + expect(workflow).toContain('name: CI'); + expect(workflow).toContain('pull_request:'); + expect(workflow).toContain('branches: [main, dev]'); + expect(workflow).toContain('group: ci-${{ github.ref }}'); + expect(workflow).toContain('cancel-in-progress: true'); + expect(workflow).toContain('fail-fast: false'); + expect(workflow).toContain('runs-on: [self-hosted, linux, x64]'); + expect(workflow).toContain("cmd: 'bun run typecheck'"); + expect(workflow).toContain("cmd: 'bun run lint'"); + expect(workflow).toContain("cmd: 'bun run format:check'"); + expect(workflow).toContain("key: ${{ runner.os }}-bun-cache-v2-${{ hashFiles('bun.lock', 'ui/bun.lock') }}"); + expect(workflow).not.toContain('restore-keys:'); + expect(workflow).toContain('name: dist'); + expect(workflow).toContain('path: dist/'); + expect(workflow).toContain('needs: [build]'); + expect(workflow).toContain('run: bun run test:all'); + expect(workflow).toContain("CCS_E2E_SKIP_BUILD: '1'"); + expect(workflow).toContain('run: bun run test:e2e'); + }); +}); From 45fe7ab08666addb0937cc46bc02ceb1fb91588c Mon Sep 17 00:00:00 2001 From: Sergey Galuza Date: Wed, 22 Apr 2026 21:32:14 +0200 Subject: [PATCH 24/39] feat(cliproxy): add 'max' thinking level for Claude Opus 4.7 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Anthropic exposes `max` as a distinct adaptive-thinking effort above `xhigh` on Opus 4.7. Previously the validator aliased user input `max` down to `xhigh`, so users couldn't reach the real top-tier effort through CCS. Extend the validator: - Add `max` to VALID_THINKING_LEVELS and THINKING_LEVEL_RANK (rank 6, above xhigh) - Add `max` to THINKING_LEVEL_BUDGETS (65536, CCS-internal numeric mapping for closest-level lookups) - Widen ThinkingSupport.maxLevel union to include 'max' The existing `max: 'xhigh'` alias in findClosestLevel is kept as a graceful fallback for models whose levels list does not include `max` (e.g. Codex `gpt-5.4`), because exact-match on validLevels takes priority — so Opus 4.7 returns `max` directly while Codex still maps `max` -> `xhigh`. Update the claude.claude-opus-4-7 catalog entry to expose `['low', 'medium', 'high', 'xhigh', 'max']` with `maxLevel: 'max'`. Built [OnSteroids](https://onsteroids.ai) Co-Authored-By: OnSteroids --- src/cliproxy/model-catalog.ts | 7 +++--- src/cliproxy/thinking-validator.ts | 24 +++++++++++++++++-- tests/unit/cliproxy/model-catalog.test.js | 4 ++-- .../unit/cliproxy/thinking-validator.test.ts | 19 +++++++++++++++ 4 files changed, 47 insertions(+), 7 deletions(-) diff --git a/src/cliproxy/model-catalog.ts b/src/cliproxy/model-catalog.ts index 52ec7e62..ee1032f5 100644 --- a/src/cliproxy/model-catalog.ts +++ b/src/cliproxy/model-catalog.ts @@ -33,7 +33,7 @@ export interface ThinkingSupport { /** Valid level names (for levels type) */ levels?: string[]; /** Maximum reasoning effort level (caps effort at this level for levels type) */ - maxLevel?: 'minimal' | 'low' | 'medium' | 'high' | 'xhigh'; + maxLevel?: 'minimal' | 'low' | 'medium' | 'high' | 'xhigh' | 'max'; /** Whether zero/disabled thinking is allowed */ zeroAllowed?: boolean; /** Whether dynamic/auto thinking is allowed */ @@ -298,10 +298,11 @@ export const MODEL_CATALOG: Partial> = // 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: 'levels', - levels: ['low', 'medium', 'high', 'xhigh'], - maxLevel: 'xhigh', + levels: ['low', 'medium', 'high', 'xhigh', 'max'], + maxLevel: 'max', dynamicAllowed: true, }, extendedContext: true, diff --git a/src/cliproxy/thinking-validator.ts b/src/cliproxy/thinking-validator.ts index 5c58994f..55ff319a 100644 --- a/src/cliproxy/thinking-validator.ts +++ b/src/cliproxy/thinking-validator.ts @@ -32,6 +32,11 @@ export interface ThinkingValidationResult { /** * Named thinking level mappings to budget values (when converting level→budget) + * + * `max` sits above `xhigh` to represent unconstrained thinking (Claude Opus 4.7, + * Mythos). The numeric value is a CCS-internal mapping, not an Anthropic wire + * value: Opus 4.7 uses adaptive thinking with an effort string, and other + * max-capable models already treat the level as a qualitative cap. */ export const THINKING_LEVEL_BUDGETS: Record = { minimal: 512, @@ -39,6 +44,7 @@ export const THINKING_LEVEL_BUDGETS: Record = { medium: 8192, high: 24576, xhigh: 32768, + max: 65536, }; /** @@ -50,12 +56,21 @@ export const THINKING_LEVEL_RANK: Record = { medium: 3, high: 4, xhigh: 5, + max: 6, }; /** * Valid thinking level names */ -export const VALID_THINKING_LEVELS = ['minimal', 'low', 'medium', 'high', 'xhigh', 'auto'] as const; +export const VALID_THINKING_LEVELS = [ + 'minimal', + 'low', + 'medium', + 'high', + 'xhigh', + 'max', + 'auto', +] as const; export type ThinkingLevel = (typeof VALID_THINKING_LEVELS)[number]; /** @@ -121,7 +136,12 @@ function findClosestLevel(input: string, validLevels: string[]): string | undefi } } - // Common aliases + // Common aliases. + // + // `max: 'xhigh'` is a graceful fallback for models whose levels list does not + // include `max` (e.g. Codex `gpt-5.4`). Exact match above takes priority, so + // Opus 4.7 (which has `max` in its validLevels) returns `max` directly while + // Codex still maps `max` → `xhigh`. const aliases: Record = { min: 'minimal', lo: 'low', diff --git a/tests/unit/cliproxy/model-catalog.test.js b/tests/unit/cliproxy/model-catalog.test.js index 4f5649b1..704c5764 100644 --- a/tests/unit/cliproxy/model-catalog.test.js +++ b/tests/unit/cliproxy/model-catalog.test.js @@ -150,8 +150,8 @@ describe('Model Catalog', () => { // 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']); - assert.strictEqual(opus47.thinking.maxLevel, 'xhigh'); + assert.deepStrictEqual(opus47.thinking.levels, ['low', 'medium', 'high', 'xhigh', 'max']); + assert.strictEqual(opus47.thinking.maxLevel, 'max'); assert.strictEqual(opus47.extendedContext, true); }); diff --git a/tests/unit/cliproxy/thinking-validator.test.ts b/tests/unit/cliproxy/thinking-validator.test.ts index d48679be..59dfccbd 100644 --- a/tests/unit/cliproxy/thinking-validator.test.ts +++ b/tests/unit/cliproxy/thinking-validator.test.ts @@ -29,6 +29,7 @@ describe('Thinking Validator', () => { expect(VALID_THINKING_LEVELS).toContain('medium'); expect(VALID_THINKING_LEVELS).toContain('high'); expect(VALID_THINKING_LEVELS).toContain('xhigh'); + expect(VALID_THINKING_LEVELS).toContain('max'); expect(VALID_THINKING_LEVELS).toContain('auto'); }); @@ -38,6 +39,24 @@ describe('Thinking Validator', () => { expect(THINKING_LEVEL_BUDGETS.medium).toBe(8192); expect(THINKING_LEVEL_BUDGETS.high).toBe(24576); expect(THINKING_LEVEL_BUDGETS.xhigh).toBe(32768); + // `max` sits above xhigh — unconstrained thinking on Opus 4.7 / Mythos + expect(THINKING_LEVEL_BUDGETS.max).toBeGreaterThan(THINKING_LEVEL_BUDGETS.xhigh); + }); + + it('should treat max as a distinct top tier on models that list it (Opus 4.7)', () => { + // Opus 4.7 exposes both xhigh and max; max must not collapse into xhigh. + const result = validateThinking('claude', 'claude-opus-4-7', 'max'); + expect(result.valid).toBe(true); + expect(result.value).toBe('max'); + expect(result.warning).toBeUndefined(); + }); + + it('should still alias max -> xhigh for models without a max level (backcompat)', () => { + // Codex catalog uses ['low','medium','high','xhigh'] with maxLevel 'xhigh'. + // User input "max" should map down to xhigh rather than be rejected. + const result = validateThinking('codex', 'gpt-5.4', 'max'); + expect(result.valid).toBe(true); + expect(result.value).toBe('xhigh'); }); it('should export off values', () => { From 572b18422076f03d2d8ccbfce802740148a5fe36 Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Wed, 22 Apr 2026 15:41:35 -0400 Subject: [PATCH 25/39] fix(ci): address reviewer follow-up findings --- CLAUDE.md | 9 ++++++++- docs/project-roadmap.md | 25 +++++-------------------- scripts/run-test-bucket.js | 4 ---- 3 files changed, 13 insertions(+), 25 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index f55cc489..c7ce2d33 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -53,7 +53,8 @@ AI MUST NOT declare a task done, close a session, or move to the next task while - `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. -- `dev-release.yml` currently pushes with `PAT_TOKEN` because `dev` is protected by required status checks (`typecheck`, `lint`, `format`, `build`, `test`). Do not switch it back to `github.token` unless branch protection changes with it. +- 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 @@ -277,6 +278,12 @@ bun run validate # Step 3: Final check (must pass) - 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` + `format:check` + `test:fast`) plus targeted checks based on changed files +### Maintainability Gate Status + +- 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) 1. **NO EMOJIS in CLI output** - Terminal output uses ASCII only: [OK], [!], [X], [i] diff --git a/docs/project-roadmap.md b/docs/project-roadmap.md index 0cb616b3..6b76df05 100644 --- a/docs/project-roadmap.md +++ b/docs/project-roadmap.md @@ -242,28 +242,13 @@ All criteria achieved: - [x] Clear domain boundaries - [x] Consistent naming conventions -## Maintainability Gate (Issue #539 Foundation) +## Historical Maintainability Gate (Retired) -- Baseline metrics artifact: `docs/metrics/maintainability-baseline.json` -- Branch-aware gate wrapper: `scripts/maintainability-check.js` -- Generate or refresh baseline: - - `bun run maintainability:baseline` - - `npm run maintainability:baseline` -- Run regression check gate: - - `bun run maintainability:check` - - `npm run maintainability:check` - - `bun run maintainability:check:strict` (force strict locally) +This section is preserved as historical context from the original Issue `#539` work. -The baseline/check scripts enumerate git-tracked files under `src` for deterministic results and fail fast if git file listing is unavailable. - -Default gate behavior: -- strict mode on protected branches (`main`, `dev`, `hotfix/*`, `kai/hotfix-*`) -- warning mode on PR CI and non-protected branches (parallel PR friendly) - -The check mode supports a maintainability regression gate that blocks increases in: -- `process.exit` references -- synchronous fs API references -- TypeScript files over 350 LOC +- The maintainability baseline gate is no longer part of the active CCS workflow. +- Current contributor and CI gates are documented in `CLAUDE.md`, `CONTRIBUTING.md`, and the GitHub workflow files. +- Do not assume `maintainability:baseline` or `maintainability:check` exist unless they are reintroduced in a future follow-up. --- diff --git a/scripts/run-test-bucket.js b/scripts/run-test-bucket.js index 5c02ec75..0f3c0212 100644 --- a/scripts/run-test-bucket.js +++ b/scripts/run-test-bucket.js @@ -59,10 +59,6 @@ const forceSlow = discovered.filter((file) => { return true; } - if (/\.(c|m)?js$/.test(file)) { - return true; - } - return readsBuiltDist(file); }); const slowSet = new Set([...slowTests, ...forceSlow]); From 5af639ddf76ba7f84552d7b841c027c235cace1d Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Wed, 22 Apr 2026 15:54:07 -0400 Subject: [PATCH 26/39] fix(ci): tighten slow bucket guardrails --- README.md | 2 +- scripts/ci-parity-gate.sh | 2 + scripts/run-test-bucket.js | 73 +++++++++++++++------- tests/unit/scripts/run-test-bucket.test.js | 24 +++++++ 4 files changed, 79 insertions(+), 22 deletions(-) create mode 100644 tests/unit/scripts/run-test-bucket.test.js diff --git a/README.md b/README.md index 5a131860..a4b71e6a 100644 --- a/README.md +++ b/README.md @@ -177,7 +177,7 @@ 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` +- 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: diff --git a/scripts/ci-parity-gate.sh b/scripts/ci-parity-gate.sh index c9b31a53..36b66b28 100755 --- a/scripts/ci-parity-gate.sh +++ b/scripts/ci-parity-gate.sh @@ -56,6 +56,8 @@ if git show-ref --verify --quiet "refs/remotes/origin/$BASE_BRANCH"; then fi 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 diff --git a/scripts/run-test-bucket.js b/scripts/run-test-bucket.js index 0f3c0212..da2cdc68 100644 --- a/scripts/run-test-bucket.js +++ b/scripts/run-test-bucket.js @@ -4,9 +4,11 @@ const fs = require('node:fs'); const path = require('node:path'); const { spawnSync } = require('node:child_process'); -const bucket = process.argv[2]; const rootDir = path.resolve(__dirname, '..'); const candidateRoots = ['tests/unit', 'tests/integration', 'tests/npm']; +// Keep this list in sync with any newly added dist-dependent or long-running +// tests. `tests/unit/scripts/run-test-bucket.test.js` verifies every path here +// exists so bucket drift fails loudly instead of silently slowing `test:fast`. const slowTests = [ 'tests/integration/cursor-daemon-lifecycle.test.ts', 'tests/integration/proxy/daemon-lifecycle.test.ts', @@ -23,11 +25,6 @@ const slowTests = [ 'tests/unit/web-server/websearch-routes.test.ts', ]; -if (!['fast', 'slow', 'all'].includes(bucket)) { - console.error('[X] Usage: node scripts/run-test-bucket.js '); - process.exit(1); -} - const filePattern = /(\.test\.(c|m)?[jt]s|\.spec\.(c|m)?[jt]s|-test\.(c|m)?[jt]s)$/; function collectFiles(dir, files = []) { @@ -51,19 +48,30 @@ function readsBuiltDist(relativePath) { return source.includes('dist/'); } -const discovered = candidateRoots - .flatMap((relativeDir) => collectFiles(path.join(rootDir, relativeDir))) - .sort(); -const forceSlow = discovered.filter((file) => { +function getDiscoveredTests() { + return candidateRoots + .flatMap((relativeDir) => collectFiles(path.join(rootDir, relativeDir))) + .sort(); +} + +function shouldForceSlow(file) { if (file.startsWith('tests/npm/')) { return true; } return readsBuiltDist(file); -}); -const slowSet = new Set([...slowTests, ...forceSlow]); +} + +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)); @@ -111,17 +119,40 @@ function runBucket(name) { return result.status ?? 1; } -if (bucket === 'all') { - let exitCode = 0; +function main(args = process.argv.slice(2)) { + const bucket = args[0]; - for (const name of ['fast', 'slow']) { - const status = runBucket(name); - if (status !== 0) { - exitCode = status; - } + if (!['fast', 'slow', 'all'].includes(bucket)) { + console.error('[X] Usage: node scripts/run-test-bucket.js '); + return 1; } - process.exit(exitCode); + 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); } -process.exit(runBucket(bucket)); +if (require.main === module) { + process.exit(main()); +} + +module.exports = { + slowTests, + readsBuiltDist, + shouldForceSlow, + getDiscoveredTests, + getSlowSet, + selectBucket, + main, +}; diff --git a/tests/unit/scripts/run-test-bucket.test.js b/tests/unit/scripts/run-test-bucket.test.js new file mode 100644 index 00000000..b4a1213d --- /dev/null +++ b/tests/unit/scripts/run-test-bucket.test.js @@ -0,0 +1,24 @@ +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('still forces dist-dependent tests into the slow bucket', () => { + expect(bucket.shouldForceSlow('tests/unit/config-dir-override.test.js')).toBe(true); + }); +}); From 6d4dcc1174f4c6382233e12cfa9a211ddde367db Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Wed, 22 Apr 2026 16:00:39 -0400 Subject: [PATCH 27/39] fix(ci): keep fast bucket stable for commonjs tests --- scripts/run-test-bucket.js | 11 +++++++++++ tests/unit/scripts/run-test-bucket.test.js | 4 ++++ 2 files changed, 15 insertions(+) diff --git a/scripts/run-test-bucket.js b/scripts/run-test-bucket.js index da2cdc68..4cd682d6 100644 --- a/scripts/run-test-bucket.js +++ b/scripts/run-test-bucket.js @@ -24,6 +24,12 @@ const slowTests = [ '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)$/; @@ -59,6 +65,10 @@ function shouldForceSlow(file) { return true; } + if (/\.(c|m)?js$/.test(file) && !fastJsTests.has(file)) { + return true; + } + return readsBuiltDist(file); } @@ -149,6 +159,7 @@ if (require.main === module) { module.exports = { slowTests, + fastJsTests, readsBuiltDist, shouldForceSlow, getDiscoveredTests, diff --git a/tests/unit/scripts/run-test-bucket.test.js b/tests/unit/scripts/run-test-bucket.test.js index b4a1213d..d9c79ef2 100644 --- a/tests/unit/scripts/run-test-bucket.test.js +++ b/tests/unit/scripts/run-test-bucket.test.js @@ -18,6 +18,10 @@ describe('run-test-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); }); From a3f5aff45d06c3c200e568d92217a56525ec3dbf Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Wed, 22 Apr 2026 16:20:35 -0400 Subject: [PATCH 28/39] docs: remove maintainer-only runner info from contributor docs Self-hosted runner troubleshooting (`ssh docker`, `systemctl status actions-runner`, `cliproxy` runner label) is maintainer infra. External contributors have no access to that host and the guidance was noise. - CLAUDE.md: drop "Self-Hosted Runner Awareness" subsection. - CONTRIBUTING.md: rewrite queued-runner note as generic "maintainer infrastructure issue; leave a comment". Remove `cliproxy` runner label mentions from AI Review Lane. Equivalent maintainer runbook now lives in the monorepo root CLAUDE.md, where it's actually actionable. --- CLAUDE.md | 6 ------ CONTRIBUTING.md | 5 ++--- 2 files changed, 2 insertions(+), 9 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index c7ce2d33..05003c5f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -41,12 +41,6 @@ gh pr checks ### Absolute rule AI MUST NOT declare a task done, close a session, or move to the next task while CI is red or still running. Leaving a PR red and moving on is the primary failure mode this protocol prevents. -### Self-Hosted Runner Awareness - -- If `gh pr checks` or `gh run watch` stays queued for more than 10 minutes, assume the self-hosted runner is offline. -- Confirm on the maintainer host with `ssh docker "systemctl status actions-runner"`. -- Treat runner outages as infrastructure issues, not code failures. Do not blindly rerun local commands and hope the queue clears. - ### Dev Release vs Push CI - `CI` is the pull-request quality gate for contributor branches. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index a40efd91..b72b2b00 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -86,7 +86,7 @@ CCS now uses three separate automation lanes: 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 more than 10 minutes, the self-hosted runner is probably offline. That is a maintainer issue, not a contributor mistake. +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 @@ -97,9 +97,8 @@ If `CI` or `Push CI` stays queued for more than 10 minutes, the self-hosted runn 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. From eab2b48f2c9a536453411d9bde2c8c5867f57162 Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Wed, 22 Apr 2026 16:31:29 -0400 Subject: [PATCH 29/39] docs(test-bucket): document slowTests addition criteria Reviewer flagged that the manual `slowTests` array silently degrades `test:fast` if new subprocess/server/timer tests are added without being declared slow (no literal `dist/` means auto-detection misses them). Interim fix: explicit 5-rule criteria block in the script so contributors know when to add a test. Enforcement (automated perf-budget warning in push-ci.yml) tracked in issue #1071. --- scripts/run-test-bucket.js | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/scripts/run-test-bucket.js b/scripts/run-test-bucket.js index 4cd682d6..52981bac 100644 --- a/scripts/run-test-bucket.js +++ b/scripts/run-test-bucket.js @@ -6,9 +6,18 @@ const { spawnSync } = require('node:child_process'); const rootDir = path.resolve(__dirname, '..'); const candidateRoots = ['tests/unit', 'tests/integration', 'tests/npm']; -// Keep this list in sync with any newly added dist-dependent or long-running -// tests. `tests/unit/scripts/run-test-bucket.test.js` verifies every path here -// exists so bucket drift fails loudly instead of silently slowing `test:fast`. +// 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', From 6b5c74aa4baaac2b43da73250e9e8e30f68aafa5 Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Wed, 22 Apr 2026 16:42:45 -0400 Subject: [PATCH 30/39] perf(test-bucket): only serialize slow bucket, parallelize fast MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reviewer noted `--max-concurrency=1` was applied to both buckets. Slow bucket needs it (subprocesses, ports, shared state → flaky in parallel) but fast bucket was unnecessarily capped. Remove the flag for fast; keep for slow. Verified: test:fast still green (2510 pass, 14.4s locally). --- scripts/run-test-bucket.js | 21 ++++++++++++--------- 1 file changed, 12 insertions(+), 9 deletions(-) diff --git a/scripts/run-test-bucket.js b/scripts/run-test-bucket.js index 52981bac..adca8e60 100644 --- a/scripts/run-test-bucket.js +++ b/scripts/run-test-bucket.js @@ -125,15 +125,18 @@ function runBucket(name) { } } - const result = spawnSync( - 'bun', - ['test', '--max-concurrency=1', ...selected], - { - cwd: rootDir, - stdio: 'inherit', - shell: process.platform === 'win32', - }, - ); + // 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; } From 0357f4fee4240b7403367bfa143340a41c21b55a Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Wed, 22 Apr 2026 21:05:43 +0000 Subject: [PATCH 31/39] chore(release): 7.73.1-dev.3 [skip ci] --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index c0a95eab..20d4dbd4 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@kaitranntt/ccs", - "version": "7.73.1-dev.2", + "version": "7.73.1-dev.3", "description": "Claude Code Switch - Instant profile switching between Claude, GLM, Kimi, and more", "keywords": [ "cli", From f6c86b79fcbc041e6c2b586f62078739032aab40 Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Wed, 22 Apr 2026 19:51:20 -0400 Subject: [PATCH 32/39] feat(cliproxy): separate core and plus provider sections --- .../backend-ui-default-ports-sync.test.ts | 14 +++++ .../components/cliproxy/cliproxy-dialog.tsx | 54 +++++++++++++--- .../cliproxy/cliproxy-edit-dialog.tsx | 56 ++++++++++++++--- .../provider-editor/provider-info-tab.tsx | 16 +++++ ui/src/lib/provider-config.ts | 63 +++++++++++++++++++ ui/src/pages/cliproxy.tsx | 40 +++++++++--- .../pages/settings/sections/proxy/index.tsx | 37 ++++++++--- .../provider-info-tab.test.tsx | 24 +++++++ ui/tests/unit/pages/cliproxy-page.test.tsx | 59 +++++++++++++---- ui/tests/unit/ui/lib/provider-config.test.ts | 40 ++++++++++++ 10 files changed, 355 insertions(+), 48 deletions(-) diff --git a/tests/unit/cliproxy/backend-ui-default-ports-sync.test.ts b/tests/unit/cliproxy/backend-ui-default-ports-sync.test.ts index c8fe0d1d..5670eecf 100644 --- a/tests/unit/cliproxy/backend-ui-default-ports-sync.test.ts +++ b/tests/unit/cliproxy/backend-ui-default-ports-sync.test.ts @@ -19,9 +19,12 @@ import { } from '../../../ui/src/lib/default-ports'; import { CLIPROXY_PROVIDERS as UI_CLIPROXY_PROVIDERS, + CORE_CLIPROXY_PROVIDERS as UI_CORE_CLIPROXY_PROVIDERS, DEVICE_CODE_PROVIDERS as UI_DEVICE_CODE_PROVIDERS, + PLUS_EXTRA_CLIPROXY_PROVIDERS as UI_PLUS_EXTRA_CLIPROXY_PROVIDERS, PROVIDER_METADATA as UI_PROVIDER_METADATA, } from '../../../ui/src/lib/provider-config'; +import { PLUS_ONLY_PROVIDERS as BACKEND_PLUS_ONLY_PROVIDERS } from '../../../src/cliproxy/types'; function sorted(values: readonly string[]): string[] { return [...values].sort((a, b) => a.localeCompare(b)); @@ -44,6 +47,17 @@ describe('Default Port Sync', () => { expect(sorted(UI_DEVICE_CODE_PROVIDERS)).toEqual(sorted(getProvidersByOAuthFlow('device_code'))); }); + test('plus-extra providers are synced between backend and UI', () => { + expect(sorted(UI_PLUS_EXTRA_CLIPROXY_PROVIDERS)).toEqual(sorted(BACKEND_PLUS_ONLY_PROVIDERS)); + expect(sorted(UI_CORE_CLIPROXY_PROVIDERS)).toEqual( + sorted( + BACKEND_CLIPROXY_PROVIDER_IDS.filter( + (provider) => !BACKEND_PLUS_ONLY_PROVIDERS.includes(provider) + ) + ) + ); + }); + test('Provider display names are synced between backend and UI', () => { for (const provider of BACKEND_CLIPROXY_PROVIDER_IDS) { expect(UI_PROVIDER_METADATA[provider].displayName).toBe(getBackendProviderDisplayName(provider)); diff --git a/ui/src/components/cliproxy/cliproxy-dialog.tsx b/ui/src/components/cliproxy/cliproxy-dialog.tsx index b221b8a6..3256f236 100644 --- a/ui/src/components/cliproxy/cliproxy-dialog.tsx +++ b/ui/src/components/cliproxy/cliproxy-dialog.tsx @@ -19,7 +19,13 @@ import { useTranslation } from 'react-i18next'; import { useCreateVariant, useCliproxyAuth } from '@/hooks/use-cliproxy'; import { usePrivacy } from '@/contexts/privacy-context'; import { formatAccountDisplayName } from '@/lib/account-identity'; -import { CLIPROXY_PROVIDERS, getProviderDisplayName } from '@/lib/provider-config'; +import { + CLIPROXY_PROVIDERS, + CLIPROXY_PROVIDER_SECTIONS, + getProviderDisplayName, + getProviderSection, + isPlusExtraProvider, +} from '@/lib/provider-config'; import { isDeniedAgyModelId } from '@/lib/utils'; const singleProviderSchema = z.object({ @@ -104,6 +110,8 @@ export function CliproxyDialog({ open, onClose }: CliproxyDialogProps) { }); const selectedProvider = useWatch({ control: singleForm.control, name: 'provider' }); + const compositeTiers = useWatch({ control: compositeForm.control, name: 'tiers' }); + const selectedProviderSection = getProviderSection(selectedProvider); const providerAuth = authData?.authStatus.find((s) => s.provider === selectedProvider); const providerAccounts = providerAuth?.accounts || []; @@ -197,10 +205,16 @@ export function CliproxyDialog({ open, onClose }: CliproxyDialogProps) { className="flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring" > - {providerOptions.map((opt) => ( - + {CLIPROXY_PROVIDER_SECTIONS.map((section) => ( + + {providerOptions + .filter((opt) => section.providers.includes(opt.value)) + .map((opt) => ( + + ))} + ))} {singleForm.formState.errors.provider && ( @@ -208,6 +222,14 @@ export function CliproxyDialog({ open, onClose }: CliproxyDialogProps) { {singleForm.formState.errors.provider.message} )} + {selectedProviderSection && ( +

+ {selectedProviderSection.hint} + {isPlusExtraProvider(selectedProvider) + ? ' Requires the optional Plus backend while that track remains community-maintained.' + : ''} +

+ )}
{selectedProvider && providerAccounts.length > 0 && ( @@ -297,12 +319,26 @@ export function CliproxyDialog({ open, onClose }: CliproxyDialogProps) { {...compositeForm.register(`tiers.${tier}.provider`)} className="flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring" > - {providerOptions.map((opt) => ( - + {CLIPROXY_PROVIDER_SECTIONS.map((section) => ( + + {providerOptions + .filter((opt) => section.providers.includes(opt.value)) + .map((opt) => ( + + ))} + ))} + {compositeTiers?.[tier]?.provider && ( +

+ {getProviderSection(compositeTiers[tier].provider)?.hint} + {isPlusExtraProvider(compositeTiers[tier].provider) + ? ' Requires the optional Plus backend while that track remains community-maintained.' + : ''} +

+ )}
diff --git a/ui/src/components/cliproxy/cliproxy-edit-dialog.tsx b/ui/src/components/cliproxy/cliproxy-edit-dialog.tsx index 9274e975..16825e7f 100644 --- a/ui/src/components/cliproxy/cliproxy-edit-dialog.tsx +++ b/ui/src/components/cliproxy/cliproxy-edit-dialog.tsx @@ -3,7 +3,7 @@ * Phase 05: Dashboard UI full CRUD for composite variants */ -import { useForm } from 'react-hook-form'; +import { useForm, useWatch } from 'react-hook-form'; import { zodResolver } from '@hookform/resolvers/zod'; import * as z from 'zod'; import { useEffect } from 'react'; @@ -15,7 +15,13 @@ import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs'; import { toast } from 'sonner'; import { useTranslation } from 'react-i18next'; import { useUpdateVariant } from '@/hooks/use-cliproxy'; -import { CLIPROXY_PROVIDERS, getProviderDisplayName } from '@/lib/provider-config'; +import { + CLIPROXY_PROVIDERS, + CLIPROXY_PROVIDER_SECTIONS, + getProviderDisplayName, + getProviderSection, + isPlusExtraProvider, +} from '@/lib/provider-config'; import type { UpdateVariant, Variant } from '@/lib/api-client'; import { isDeniedAgyModelId } from '@/lib/utils'; @@ -137,6 +143,8 @@ export function CliproxyEditDialog({ variant, open, onOpenChange }: CliproxyEdit const compositeForm = useForm({ resolver: zodResolver(compositeSchema), }); + const selectedProvider = useWatch({ control: singleForm.control, name: 'provider' }); + const compositeTiers = useWatch({ control: compositeForm.control, name: 'tiers' }); // Pre-populate form when variant changes useEffect(() => { @@ -318,12 +326,26 @@ export function CliproxyEditDialog({ variant, open, onOpenChange }: CliproxyEdit {...compositeForm.register(`tiers.${tier}.provider`)} className="flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring" > - {providerOptions.map((opt) => ( - + {CLIPROXY_PROVIDER_SECTIONS.map((section) => ( + + {providerOptions + .filter((opt) => section.providers.includes(opt.value)) + .map((opt) => ( + + ))} + ))} + {compositeTiers?.[tier]?.provider && ( +

+ {getProviderSection(compositeTiers[tier].provider)?.hint} + {isPlusExtraProvider(compositeTiers[tier].provider) + ? ' Requires the optional Plus backend while that track remains community-maintained.' + : ''} +

+ )}
@@ -399,12 +421,26 @@ export function CliproxyEditDialog({ variant, open, onOpenChange }: CliproxyEdit {...singleForm.register('provider')} className="flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring" > - {providerOptions.map((opt) => ( - + {CLIPROXY_PROVIDER_SECTIONS.map((section) => ( + + {providerOptions + .filter((opt) => section.providers.includes(opt.value)) + .map((opt) => ( + + ))} + ))} + {selectedProvider && ( +

+ {getProviderSection(selectedProvider)?.hint} + {isPlusExtraProvider(selectedProvider) + ? ' Requires the optional Plus backend while that track remains community-maintained.' + : ''} +

+ )}
diff --git a/ui/src/components/cliproxy/provider-editor/provider-info-tab.tsx b/ui/src/components/cliproxy/provider-editor/provider-info-tab.tsx index 2ea26cdf..22edadaa 100644 --- a/ui/src/components/cliproxy/provider-editor/provider-info-tab.tsx +++ b/ui/src/components/cliproxy/provider-editor/provider-info-tab.tsx @@ -10,6 +10,7 @@ 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 { @@ -33,6 +34,7 @@ export function ProviderInfoTab({ const resolvedTarget = defaultTarget || 'claude'; const isDroidTarget = resolvedTarget === 'droid'; const isCodexProvider = provider === 'codex'; + const providerSection = getProviderSection(provider); const managementPrefix = resolvedTarget === 'claude' ? `ccs ${provider}` : `ccs ${provider} --target claude`; const changeModelCommand = `${managementPrefix} --config`; @@ -101,6 +103,20 @@ export function ProviderInfoTab({ {resolvedTarget}
+ {providerSection && ( +
+ Track +
+ {providerSection.label} +

+ {providerSection.hint} + {isPlusExtraProvider(provider) + ? ' Requires the optional Plus backend while that track remains community-maintained.' + : ''} +

+
+
+ )} diff --git a/ui/src/lib/provider-config.ts b/ui/src/lib/provider-config.ts index 3500e4a4..7f824ce0 100644 --- a/ui/src/lib/provider-config.ts +++ b/ui/src/lib/provider-config.ts @@ -10,6 +10,7 @@ import { getProvidersByOAuthFlow, } from '../../../src/cliproxy/provider-capabilities'; import type { AiProviderFamilyId, AiProviderModelAlias } from '../../../src/cliproxy/ai-providers'; +import { PLUS_ONLY_PROVIDERS } from '../../../src/cliproxy/types'; import i18n from './i18n'; // Monorepo contract: UI consumes provider capability constants directly from backend @@ -19,6 +20,39 @@ import i18n from './i18n'; export const CLIPROXY_PROVIDERS = CLIPROXY_PROVIDER_IDS; export type CLIProxyProvider = (typeof CLIPROXY_PROVIDERS)[number]; export type ProviderVisualId = CLIProxyProvider | 'openai' | 'vertex'; +export type CLIProxyProviderSectionId = 'core' | 'plus-extra'; + +export interface CLIProxyProviderSection { + id: CLIProxyProviderSectionId; + label: string; + hint: string; + providers: readonly CLIProxyProvider[]; +} + +const PLUS_ONLY_PROVIDER_SET = new Set(PLUS_ONLY_PROVIDERS); + +export const CORE_CLIPROXY_PROVIDERS: readonly CLIProxyProvider[] = Object.freeze( + CLIPROXY_PROVIDERS.filter((provider) => !PLUS_ONLY_PROVIDER_SET.has(provider)) +); + +export const PLUS_EXTRA_CLIPROXY_PROVIDERS: readonly CLIProxyProvider[] = Object.freeze( + CLIPROXY_PROVIDERS.filter((provider) => PLUS_ONLY_PROVIDER_SET.has(provider)) +); + +export const CLIPROXY_PROVIDER_SECTIONS: readonly CLIProxyProviderSection[] = Object.freeze([ + { + id: 'core', + label: 'Core / original backend', + hint: 'Default, always-available provider track', + providers: CORE_CLIPROXY_PROVIDERS, + }, + { + id: 'plus-extra', + label: 'Plus extras / community-maintained', + hint: 'Still supported, but separated from the default backend for now', + 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,35 @@ 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 + ); +} + +export function groupProvidersBySection( + items: readonly T[], + getProvider: (item: T) => unknown +): Array { + return CLIPROXY_PROVIDER_SECTIONS.map((section) => ({ + ...section, + items: items.filter((item) => { + const normalized = normalizeProviderInput(getProvider(item)); + return isValidProvider(normalized) && section.providers.includes(normalized); + }), + })).filter((section) => section.items.length > 0); +} + /** Map provider to user-facing short description */ export function getProviderDescription(provider: unknown): string { const normalized = normalizeProviderInput(provider); diff --git a/ui/src/pages/cliproxy.tsx b/ui/src/pages/cliproxy.tsx index 6aba8344..f48374e2 100644 --- a/ui/src/pages/cliproxy.tsx +++ b/ui/src/pages/cliproxy.tsx @@ -33,7 +33,11 @@ import { } from '@/hooks/use-cliproxy'; import type { AuthStatus, Variant } from '@/lib/api-client'; import { buildUiCatalogs } from '@/lib/model-catalogs'; -import { getProviderDisplayName, isValidProvider } from '@/lib/provider-config'; +import { + getProviderDisplayName, + groupProvidersBySection, + isValidProvider, +} from '@/lib/provider-config'; import { cn } from '@/lib/utils'; import { useTranslation } from 'react-i18next'; @@ -261,6 +265,10 @@ export function CliproxyPage() { }); const providers = useMemo(() => authData?.authStatus || [], [authData?.authStatus]); + const providerSections = useMemo( + () => groupProvidersBySection(providers, (status) => status.provider), + [providers] + ); const isRemoteMode = authData?.source === 'remote'; const variants = useMemo(() => variantsData?.variants || [], [variantsData?.variants]); const catalogs = useMemo(() => buildUiCatalogs(catalogData?.catalogs), [catalogData?.catalogs]); @@ -393,14 +401,28 @@ export function CliproxyPage() { ))} ) : ( -
- {providers.map((status) => ( - handleSelectProvider(status.provider)} - /> +
+ {providerSections.map((section) => ( +
+
+
+ {section.label} +
+

+ {section.hint} +

+
+
+ {section.items.map((status) => ( + handleSelectProvider(status.provider)} + /> + ))} +
+
))}
)} diff --git a/ui/src/pages/settings/sections/proxy/index.tsx b/ui/src/pages/settings/sections/proxy/index.tsx index 76ec80e1..f5b237ae 100644 --- a/ui/src/pages/settings/sections/proxy/index.tsx +++ b/ui/src/pages/settings/sections/proxy/index.tsx @@ -29,15 +29,18 @@ import { ProxyStatusWidget } from '@/components/monitoring/proxy-status-widget'; import { api } from '@/lib/api-client'; import { CLIPROXY_DEFAULT_PORT } from '@/lib/preset-utils'; import { RISK_ACK_PHRASE } from '@/components/account/antigravity-responsibility-constants'; +import { + CORE_CLIPROXY_PROVIDERS, + PLUS_EXTRA_CLIPROXY_PROVIDERS, + getProviderDisplayName, + isPlusExtraProvider, +} 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', 'cursor', 'gitlab', 'codebuddy', 'kilo']; - function normalizeRiskAckPhrase(value: string): string { return value.trim().replace(/\s+/g, ' ').toUpperCase(); } @@ -196,6 +199,8 @@ export default function ProxySection() { 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,7 @@ 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((v) => isPlusExtraProvider(v.provider)); setHasKiroGhcpVariants(hasIncompatible); } catch (err) { console.error('[Proxy] Failed to check variants:', err); @@ -491,7 +496,13 @@ export default function ProxySection() {
{t('settingsProxy.backendPlusApi')}
-

{t('settingsProxy.plusDesc')}

+

+ Optional track for extra providers. Still supported, but currently + community-maintained instead of upstream-maintained. +

+

+ {plusProviderNames} +

{/* Original Backend Card */} @@ -510,15 +521,20 @@ export default function ProxySection() { {t('settingsProxy.default')}
-

{t('settingsProxy.originalDesc')}

+

+ Default, always-available backend for the core provider track. +

+

+ {coreProviderNames} +

{backend === 'plus' && ( - CLIProxyAPIPlus upstream is currently unavailable. Local CLIProxy will use the - original backend until issue #1062 is resolved. + 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. )} @@ -526,7 +542,10 @@ export default function ProxySection() { {backend === 'original' && hasKiroGhcpVariants && ( - {t('settingsProxy.variantsIncompatible')} + + Existing plus-extra variants ({plusProviderNames}) will not run on the original + backend. Keep them visible for reference, but switch to Plus before using them. + )} diff --git a/ui/tests/unit/components/cliproxy/provider-editor/provider-info-tab.test.tsx b/ui/tests/unit/components/cliproxy/provider-editor/provider-info-tab.test.tsx index 9d71278d..0606a688 100644 --- a/ui/tests/unit/components/cliproxy/provider-editor/provider-info-tab.test.tsx +++ b/ui/tests/unit/components/cliproxy/provider-editor/provider-info-tab.test.tsx @@ -51,4 +51,28 @@ describe('ProviderInfoTab', () => { expect(screen.queryByText('Change model')).not.toBeInTheDocument(); expect(screen.getByText('ccs custom-provider --auth --add')).toBeInTheDocument(); }); + + it('shows the plus-extra track note for community-maintained providers', () => { + render( + + ); + + expect(screen.getByText('Track')).toBeInTheDocument(); + expect(screen.getByText('Plus extras / community-maintained')).toBeInTheDocument(); + expect( + screen.getByText( + /Requires the optional Plus backend while that track remains community-maintained\./ + ) + ).toBeInTheDocument(); + }); }); diff --git a/ui/tests/unit/pages/cliproxy-page.test.tsx b/ui/tests/unit/pages/cliproxy-page.test.tsx index bd651bfb..92b57035 100644 --- a/ui/tests/unit/pages/cliproxy-page.test.tsx +++ b/ui/tests/unit/pages/cliproxy-page.test.tsx @@ -2,6 +2,23 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; import { render, screen, userEvent } from '@tests/setup/test-utils'; const hookState = vi.hoisted(() => ({ + authData: { + authStatus: [ + { + provider: 'gemini', + displayName: 'Gemini', + authenticated: true, + accounts: [{ id: 'acct-1', provider: 'gemini' }], + }, + { + provider: 'ghcp', + displayName: 'GitHub Copilot (OAuth)', + authenticated: false, + accounts: [], + }, + ], + source: 'local' as const, + }, catalogData: undefined as | { catalogs: Record< @@ -23,17 +40,7 @@ vi.mock('@/hooks/use-cliproxy', () => ({ isFetching: false, }), useCliproxyAuth: () => ({ - data: { - authStatus: [ - { - provider: 'gemini', - displayName: 'Gemini', - authenticated: true, - accounts: [{ id: 'acct-1', provider: 'gemini' }], - }, - ], - source: 'local', - }, + data: hookState.authData, isLoading: false, }), useCliproxyCatalog: () => ({ @@ -85,9 +92,39 @@ import { CliproxyPage } from '@/pages/cliproxy'; describe('CliproxyPage add-account catalog gating', () => { beforeEach(() => { + hookState.authData = { + authStatus: [ + { + provider: 'gemini', + displayName: 'Gemini', + authenticated: true, + accounts: [{ id: 'acct-1', provider: 'gemini' }], + }, + { + provider: 'ghcp', + displayName: 'GitHub Copilot (OAuth)', + authenticated: false, + accounts: [], + }, + ], + source: 'local', + }; hookState.catalogData = undefined; }); + it('separates core providers from plus extras in the sidebar', () => { + render(); + + expect(screen.getByText('Core / original backend')).toBeInTheDocument(); + expect(screen.getByText('Plus extras / community-maintained')).toBeInTheDocument(); + expect(screen.getByText('Default, always-available provider track')).toBeInTheDocument(); + expect( + screen.getByText('Still supported, but separated from the default backend for now') + ).toBeInTheDocument(); + expect(screen.getByText('Gemini')).toBeInTheDocument(); + expect(screen.getByText('GitHub Copilot (OAuth)')).toBeInTheDocument(); + }); + it('does not pass a static fallback catalog before the catalog query resolves', async () => { render(); diff --git a/ui/tests/unit/ui/lib/provider-config.test.ts b/ui/tests/unit/ui/lib/provider-config.test.ts index b33f24b1..89905299 100644 --- a/ui/tests/unit/ui/lib/provider-config.test.ts +++ b/ui/tests/unit/ui/lib/provider-config.test.ts @@ -1,14 +1,20 @@ 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, } from '@/lib/provider-config'; @@ -49,6 +55,40 @@ 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.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'], From 254abd504f7e53a4b3b8b22d2ef20f18ae3e1b0a Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Wed, 22 Apr 2026 20:02:54 -0400 Subject: [PATCH 33/39] fix(cliproxy): keep provider sections consistent for variants --- .../components/cliproxy/cliproxy-dialog.tsx | 12 ++-- .../cliproxy/cliproxy-edit-dialog.tsx | 12 ++-- .../cliproxy/provider-editor/index.tsx | 1 + .../provider-editor/provider-info-tab.tsx | 17 +++-- ui/src/lib/i18n.ts | 62 +++++++++++++++---- ui/src/lib/provider-config.ts | 29 +++++++-- ui/src/pages/cliproxy.tsx | 4 +- .../pages/settings/sections/proxy/index.tsx | 21 +++---- .../provider-info-tab.test.tsx | 20 ++++++ ui/tests/unit/ui/lib/provider-config.test.ts | 22 +++++++ 10 files changed, 149 insertions(+), 51 deletions(-) diff --git a/ui/src/components/cliproxy/cliproxy-dialog.tsx b/ui/src/components/cliproxy/cliproxy-dialog.tsx index 3256f236..cb8ff68b 100644 --- a/ui/src/components/cliproxy/cliproxy-dialog.tsx +++ b/ui/src/components/cliproxy/cliproxy-dialog.tsx @@ -206,7 +206,7 @@ export function CliproxyDialog({ open, onClose }: CliproxyDialogProps) { > {CLIPROXY_PROVIDER_SECTIONS.map((section) => ( - + {providerOptions .filter((opt) => section.providers.includes(opt.value)) .map((opt) => ( @@ -224,9 +224,9 @@ export function CliproxyDialog({ open, onClose }: CliproxyDialogProps) { )} {selectedProviderSection && (

- {selectedProviderSection.hint} + {t(selectedProviderSection.hintKey)} {isPlusExtraProvider(selectedProvider) - ? ' Requires the optional Plus backend while that track remains community-maintained.' + ? ` ${t('providerConfig.plusTrackNote')}` : ''}

)} @@ -320,7 +320,7 @@ 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" > {CLIPROXY_PROVIDER_SECTIONS.map((section) => ( - + {providerOptions .filter((opt) => section.providers.includes(opt.value)) .map((opt) => ( @@ -333,9 +333,9 @@ export function CliproxyDialog({ open, onClose }: CliproxyDialogProps) { {compositeTiers?.[tier]?.provider && (

- {getProviderSection(compositeTiers[tier].provider)?.hint} + {t(getProviderSection(compositeTiers[tier].provider)?.hintKey || '')} {isPlusExtraProvider(compositeTiers[tier].provider) - ? ' Requires the optional Plus backend while that track remains community-maintained.' + ? ` ${t('providerConfig.plusTrackNote')}` : ''}

)} diff --git a/ui/src/components/cliproxy/cliproxy-edit-dialog.tsx b/ui/src/components/cliproxy/cliproxy-edit-dialog.tsx index 16825e7f..76540428 100644 --- a/ui/src/components/cliproxy/cliproxy-edit-dialog.tsx +++ b/ui/src/components/cliproxy/cliproxy-edit-dialog.tsx @@ -327,7 +327,7 @@ export function CliproxyEditDialog({ variant, open, onOpenChange }: CliproxyEdit 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" > {CLIPROXY_PROVIDER_SECTIONS.map((section) => ( - + {providerOptions .filter((opt) => section.providers.includes(opt.value)) .map((opt) => ( @@ -340,9 +340,9 @@ export function CliproxyEditDialog({ variant, open, onOpenChange }: CliproxyEdit {compositeTiers?.[tier]?.provider && (

- {getProviderSection(compositeTiers[tier].provider)?.hint} + {t(getProviderSection(compositeTiers[tier].provider)?.hintKey || '')} {isPlusExtraProvider(compositeTiers[tier].provider) - ? ' Requires the optional Plus backend while that track remains community-maintained.' + ? ` ${t('providerConfig.plusTrackNote')}` : ''}

)} @@ -422,7 +422,7 @@ export function CliproxyEditDialog({ variant, open, onOpenChange }: CliproxyEdit 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" > {CLIPROXY_PROVIDER_SECTIONS.map((section) => ( - + {providerOptions .filter((opt) => section.providers.includes(opt.value)) .map((opt) => ( @@ -435,9 +435,9 @@ export function CliproxyEditDialog({ variant, open, onOpenChange }: CliproxyEdit {selectedProvider && (

- {getProviderSection(selectedProvider)?.hint} + {t(getProviderSection(selectedProvider)?.hintKey || '')} {isPlusExtraProvider(selectedProvider) - ? ' Requires the optional Plus backend while that track remains community-maintained.' + ? ` ${t('providerConfig.plusTrackNote')}` : ''}

)} diff --git a/ui/src/components/cliproxy/provider-editor/index.tsx b/ui/src/components/cliproxy/provider-editor/index.tsx index 72b3d7ef..65be5b3b 100644 --- a/ui/src/components/cliproxy/provider-editor/index.tsx +++ b/ui/src/components/cliproxy/provider-editor/index.tsx @@ -304,6 +304,7 @@ export function ProviderEditor({ {providerSection && (
- Track + + {t('providerConfig.trackLabel')} +
- {providerSection.label} + {t(providerSection.labelKey)}

- {providerSection.hint} - {isPlusExtraProvider(provider) - ? ' Requires the optional Plus backend while that track remains community-maintained.' + {t(providerSection.hintKey)} + {isPlusExtraProvider(sectionProvider) + ? ` ${t('providerConfig.plusTrackNote')}` : ''}

diff --git a/ui/src/lib/i18n.ts b/ui/src/lib/i18n.ts index 5329df3a..b74a1194 100644 --- a/ui/src/lib/i18n.ts +++ b/ui/src/lib/i18n.ts @@ -977,10 +977,13 @@ const resources = { backendBinary: 'Backend Binary', stopProxyToSwitch: 'Stop the running proxy in Instance Status to switch backend.', default: 'Default', - plusDesc: 'Full provider support including Kiro and GitHub Copilot', - originalDesc: 'Original binary (Gemini, Codex, Antigravity only)', + plusDesc: + 'Optional track for extra providers. Still supported, but currently community-maintained instead of upstream-maintained.', + originalDesc: 'Default, always-available backend for the core provider track.', + plusFallbackNotice: + 'The Plus provider track is not deprecated, but local CLIProxy still falls back to the original backend while the maintained fork path is being brought back.', variantsIncompatible: - 'Existing Kiro/Copilot variants will not work with CLIProxyAPI. Switch to CLIProxyAPIPlus or remove those variants.', + 'Existing plus-extra variants ({{providers}}) will not run on the original backend. Keep them visible for reference, but switch to Plus before using them.', safety: 'Safety', agyModeTitle: 'Antigravity + Gemini Power User Mode', agyModeDesc: @@ -2234,6 +2237,13 @@ const resources = { }, providerConfig: { defaultDeviceCodeInstruction: 'Complete the authorization in your browser.', + trackLabel: 'Track', + sectionCoreLabel: 'Core / original backend', + sectionCoreHint: 'Default, always-available provider track', + sectionPlusLabel: 'Plus extras / community-maintained', + sectionPlusHint: 'Still supported, but separated from the default backend for now', + plusTrackNote: + 'Requires the optional Plus backend while that track remains community-maintained.', }, // ======================================== @@ -3443,10 +3453,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 中输入风险短语的步骤。', @@ -4656,6 +4668,12 @@ const resources = { }, providerConfig: { defaultDeviceCodeInstruction: '请在浏览器中完成授权。', + trackLabel: '分组', + sectionCoreLabel: '核心 / 原始后端', + sectionCoreHint: '默认且始终可用的提供商线路', + sectionPlusLabel: 'Plus 扩展 / 社区维护', + sectionPlusHint: '仍然受支持,但目前与默认后端分开显示', + plusTrackNote: '需要可选的 Plus 后端;当前这条线路由社区维护。', }, profileEditorSections: { imageAnalysis: '图片分析', @@ -5934,10 +5952,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: @@ -7170,6 +7191,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', @@ -8461,10 +8488,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: @@ -9984,6 +10014,14 @@ const resources = { }, providerConfig: { defaultDeviceCodeInstruction: 'ブラウザーで認証を完了してください。', + trackLabel: 'トラック', + sectionCoreLabel: 'コア / オリジナル backend', + sectionCoreHint: '既定で常に利用できるプロバイダートラック', + sectionPlusLabel: 'Plus 拡張 / コミュニティ保守', + sectionPlusHint: + '引き続きサポートされていますが、当面は既定の backend から分離されています', + plusTrackNote: + 'このトラックはオプションの Plus backend が必要で、現在はコミュニティ保守です。', }, updatesSpotlight: { openUpdatesCenter: '更新センターを開く', diff --git a/ui/src/lib/provider-config.ts b/ui/src/lib/provider-config.ts index 7f824ce0..00c34cc7 100644 --- a/ui/src/lib/provider-config.ts +++ b/ui/src/lib/provider-config.ts @@ -24,8 +24,8 @@ export type CLIProxyProviderSectionId = 'core' | 'plus-extra'; export interface CLIProxyProviderSection { id: CLIProxyProviderSectionId; - label: string; - hint: string; + labelKey: string; + hintKey: string; providers: readonly CLIProxyProvider[]; } @@ -42,14 +42,14 @@ export const PLUS_EXTRA_CLIPROXY_PROVIDERS: readonly CLIProxyProvider[] = Object export const CLIPROXY_PROVIDER_SECTIONS: readonly CLIProxyProviderSection[] = Object.freeze([ { id: 'core', - label: 'Core / original backend', - hint: 'Default, always-available provider track', + labelKey: 'providerConfig.sectionCoreLabel', + hintKey: 'providerConfig.sectionCoreHint', providers: CORE_CLIPROXY_PROVIDERS, }, { id: 'plus-extra', - label: 'Plus extras / community-maintained', - hint: 'Still supported, but separated from the default backend for now', + labelKey: 'providerConfig.sectionPlusLabel', + hintKey: 'providerConfig.sectionPlusHint', providers: PLUS_EXTRA_CLIPROXY_PROVIDERS, }, ]); @@ -309,6 +309,23 @@ export function getProviderSection(provider: unknown): CLIProxyProviderSection | ); } +interface VariantLike { + provider?: unknown; + tiers?: Record | null; +} + +export function variantUsesPlusExtraProvider(variant: VariantLike | null | undefined): boolean { + if (!variant) { + return false; + } + + if (variant.tiers) { + return Object.values(variant.tiers).some((tier) => isPlusExtraProvider(tier?.provider)); + } + + return isPlusExtraProvider(variant.provider); +} + export function groupProvidersBySection( items: readonly T[], getProvider: (item: T) => unknown diff --git a/ui/src/pages/cliproxy.tsx b/ui/src/pages/cliproxy.tsx index f48374e2..962591e2 100644 --- a/ui/src/pages/cliproxy.tsx +++ b/ui/src/pages/cliproxy.tsx @@ -406,10 +406,10 @@ export function CliproxyPage() {
- {section.label} + {t(section.labelKey)}

- {section.hint} + {t(section.hintKey)}

diff --git a/ui/src/pages/settings/sections/proxy/index.tsx b/ui/src/pages/settings/sections/proxy/index.tsx index f5b237ae..441de0f8 100644 --- a/ui/src/pages/settings/sections/proxy/index.tsx +++ b/ui/src/pages/settings/sections/proxy/index.tsx @@ -33,7 +33,7 @@ import { CORE_CLIPROXY_PROVIDERS, PLUS_EXTRA_CLIPROXY_PROVIDERS, getProviderDisplayName, - isPlusExtraProvider, + variantUsesPlusExtraProvider, } from '@/lib/provider-config'; import { toast } from 'sonner'; import { useTranslation } from 'react-i18next'; @@ -216,7 +216,9 @@ export default function ProxySection() { const checkPlusOnlyVariants = useCallback(async () => { try { const result = await api.cliproxy.list(); - const hasIncompatible = result.variants.some((v) => isPlusExtraProvider(v.provider)); + const hasIncompatible = result.variants.some((variant) => + variantUsesPlusExtraProvider(variant) + ); setHasKiroGhcpVariants(hasIncompatible); } catch (err) { console.error('[Proxy] Failed to check variants:', err); @@ -496,10 +498,7 @@ export default function ProxySection() {
{t('settingsProxy.backendPlusApi')}
-

- Optional track for extra providers. Still supported, but currently - community-maintained instead of upstream-maintained. -

+

{t('settingsProxy.plusDesc')}

{plusProviderNames}

@@ -521,9 +520,7 @@ export default function ProxySection() { {t('settingsProxy.default')}
-

- Default, always-available backend for the core provider track. -

+

{t('settingsProxy.originalDesc')}

{coreProviderNames}

@@ -533,8 +530,7 @@ export default function ProxySection() { - 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. + {t('settingsProxy.plusFallbackNotice')} )} @@ -543,8 +539,7 @@ export default function ProxySection() { - Existing plus-extra variants ({plusProviderNames}) will not run on the original - backend. Keep them visible for reference, but switch to Plus before using them. + {t('settingsProxy.variantsIncompatible', { providers: plusProviderNames })} )} diff --git a/ui/tests/unit/components/cliproxy/provider-editor/provider-info-tab.test.tsx b/ui/tests/unit/components/cliproxy/provider-editor/provider-info-tab.test.tsx index 0606a688..1c518ba6 100644 --- a/ui/tests/unit/components/cliproxy/provider-editor/provider-info-tab.test.tsx +++ b/ui/tests/unit/components/cliproxy/provider-editor/provider-info-tab.test.tsx @@ -75,4 +75,24 @@ describe('ProviderInfoTab', () => { ) ).toBeInTheDocument(); }); + + it('uses the base provider when rendering variant track metadata', () => { + render( + + ); + + expect(screen.getByText('Track')).toBeInTheDocument(); + expect(screen.getByText('Plus extras / community-maintained')).toBeInTheDocument(); + }); }); diff --git a/ui/tests/unit/ui/lib/provider-config.test.ts b/ui/tests/unit/ui/lib/provider-config.test.ts index 89905299..f83ede63 100644 --- a/ui/tests/unit/ui/lib/provider-config.test.ts +++ b/ui/tests/unit/ui/lib/provider-config.test.ts @@ -16,6 +16,7 @@ import { parseRequestedUpstreamModelRules, PLUS_EXTRA_CLIPROXY_PROVIDERS, PROVIDER_COLORS, + variantUsesPlusExtraProvider, } from '@/lib/provider-config'; describe('provider model mapping helpers', () => { @@ -89,6 +90,27 @@ describe('provider presentation metadata', () => { 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'], From d4e2ef549912a014ed089efb61304ccc75eba403 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Thu, 23 Apr 2026 01:11:23 +0000 Subject: [PATCH 34/39] chore(release): 7.73.1-dev.4 [skip ci] --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 20d4dbd4..4c8b2c93 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@kaitranntt/ccs", - "version": "7.73.1-dev.3", + "version": "7.73.1-dev.4", "description": "Claude Code Switch - Instant profile switching between Claude, GLM, Kimi, and more", "keywords": [ "cli", From 71deda553ad84defdf9dfcc8b90fb7fc1d5c6833 Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Wed, 22 Apr 2026 21:43:11 -0400 Subject: [PATCH 35/39] fix(cliproxy): preserve adaptive thinking on opus 4.7 paths --- src/cliproxy/catalog-cache.ts | 8 ++++ src/cliproxy/config/thinking-config.ts | 14 +++++- src/cursor/cursor-anthropic-translator.ts | 23 +++++++--- src/cursor/cursor-anthropic-types.ts | 3 ++ src/proxy/transformers/request-transformer.ts | 4 +- src/targets/droid-config-manager.ts | 44 +++++++++++++++++++ .../unit/cliproxy/composite-thinking.test.ts | 14 ++++++ .../cliproxy/model-catalog-compat.test.ts | 20 +++++++++ .../cursor-anthropic-translator.test.ts | 10 +++++ .../request-transformer-regressions.test.ts | 11 +++++ .../unit/targets/droid-config-manager.test.ts | 17 +++++++ 11 files changed, 159 insertions(+), 9 deletions(-) diff --git a/src/cliproxy/catalog-cache.ts b/src/cliproxy/catalog-cache.ts index 10436887..998c86c3 100644 --- a/src/cliproxy/catalog-cache.ts +++ b/src/cliproxy/catalog-cache.ts @@ -264,9 +264,17 @@ export function mergeCatalog( mergedIds.add(remote.id.toLowerCase()); if (staticEntry) { + const mergedThinking = remoteEntry.thinking + ? { + ...remoteEntry.thinking, + maxLevel: remoteEntry.thinking.maxLevel ?? staticEntry.thinking?.maxLevel, + } + : staticEntry.thinking; + // Merge: remote overrides, static fills gaps mergedModels.push({ ...remoteEntry, + thinking: mergedThinking, // Preserve static-only fields tier: staticEntry.tier, broken: staticEntry.broken, diff --git a/src/cliproxy/config/thinking-config.ts b/src/cliproxy/config/thinking-config.ts index d9f2b39f..ef98234e 100644 --- a/src/cliproxy/config/thinking-config.ts +++ b/src/cliproxy/config/thinking-config.ts @@ -7,7 +7,7 @@ import type { CLIProxyProvider } from '../types'; import { DEFAULT_THINKING_TIER_DEFAULTS } from '../../config/unified-config-types'; import type { ThinkingConfig } from '../../config/unified-config-types'; import { getThinkingConfig } from '../../config/unified-config-loader'; -import { supportsThinking } from '../model-catalog'; +import { getModelThinkingSupport, supportsThinking } from '../model-catalog'; import { isThinkingOffValue, validateThinking } from '../thinking-validator'; import { normalizeModelIdForProvider } from '../model-id-normalizer'; import { warn } from '../../utils/ui'; @@ -86,7 +86,8 @@ function applyThinkingSuffixForProvider( const parenthesizedSuffixMatch = model.match(/\(([^)]+)\)$/); // Existing parenthesized suffix: - // - keep as-is for non-codex providers + // - keep as-is for non-codex providers unless the target model now expects + // named levels and we need to rewrite an old numeric suffix // - for codex effort levels, normalize to codex model suffix style if (parenthesizedSuffixMatch) { if (provider === 'codex') { @@ -95,6 +96,15 @@ function applyThinkingSuffixForProvider( return model.replace(/\([^)]+\)$/, `-${normalizedParensValue}`); } } + + if (provider) { + const normalizedBaseModel = normalizeModelForThinkingLookup(model, provider); + const thinking = getModelThinkingSupport(provider, normalizedBaseModel); + if (thinking?.type === 'levels') { + return model.replace(/\([^)]+\)$/, `(${thinkingValue})`); + } + } + return model; } diff --git a/src/cursor/cursor-anthropic-translator.ts b/src/cursor/cursor-anthropic-translator.ts index 6dbe7d92..416b4246 100644 --- a/src/cursor/cursor-anthropic-translator.ts +++ b/src/cursor/cursor-anthropic-translator.ts @@ -68,17 +68,30 @@ function toToolResultContent(content: unknown, label: string): string { return safeJsonStringify(content, TOOL_RESULT_SERIALIZATION_FALLBACK); } -function mapThinkingToReasoningEffort( - thinking: CursorAnthropicRequest['thinking'] -): string | undefined { +function mapAdaptiveEffortToCursorReasoningEffort(effort: string | undefined): string { + const normalized = effort?.trim().toLowerCase(); + if (!normalized || normalized === 'auto') { + return 'high'; + } + if (normalized === 'minimal' || normalized === 'low' || normalized === 'medium') { + return 'medium'; + } + return 'high'; +} + +function mapThinkingToReasoningEffort(request: CursorAnthropicRequest): string | undefined { + const thinking = request.thinking; if (!thinking) { return undefined; } if (thinking.type === 'disabled') { return undefined; } + if (thinking.type === 'adaptive') { + return mapAdaptiveEffortToCursorReasoningEffort(request.output_config?.effort); + } if (thinking.type !== 'enabled') { - throw new Error('thinking.type must be "enabled" or "disabled"'); + throw new Error('thinking.type must be "enabled", "adaptive", or "disabled"'); } return typeof thinking.budget_tokens === 'number' && thinking.budget_tokens >= 8192 ? 'high' @@ -209,7 +222,7 @@ export function translateAnthropicRequest(raw: unknown): TranslatedAnthropicRequ ? request.model : undefined, stream: request.stream === true, - reasoning_effort: mapThinkingToReasoningEffort(request.thinking), + reasoning_effort: mapThinkingToReasoningEffort(request), tools: Array.isArray(request.tools) ? request.tools : undefined, messages: translatedMessages, }; diff --git a/src/cursor/cursor-anthropic-types.ts b/src/cursor/cursor-anthropic-types.ts index 336fb75f..9374de5b 100644 --- a/src/cursor/cursor-anthropic-types.ts +++ b/src/cursor/cursor-anthropic-types.ts @@ -41,6 +41,9 @@ export interface CursorAnthropicRequest { system?: string | AnthropicTextBlock[]; stream?: boolean; tools?: CursorTool[]; + output_config?: { + effort?: string; + }; thinking?: { type?: string; budget_tokens?: number; diff --git a/src/proxy/transformers/request-transformer.ts b/src/proxy/transformers/request-transformer.ts index a8ccb98c..58e7969e 100644 --- a/src/proxy/transformers/request-transformer.ts +++ b/src/proxy/transformers/request-transformer.ts @@ -396,7 +396,7 @@ function mapThinkingToReasoning( }; } -const VALID_EFFORT_LEVELS = new Set(['low', 'medium', 'high', 'max']); +const VALID_EFFORT_LEVELS = new Set(['low', 'medium', 'high', 'xhigh', 'max']); function resolveOutputConfigEffort( outputConfig: AnthropicOutputConfig | undefined @@ -416,7 +416,7 @@ function resolveOutputConfigEffort( * for Codex; for generic OpenAI-compat providers we clamp to high. */ function toOpenAIEffort(effort: string): string { - return effort === 'max' ? 'high' : effort; + return effort === 'max' || effort === 'xhigh' ? 'high' : effort; } function transformMessages(messagesValue: unknown): OpenAIMessage[] { diff --git a/src/targets/droid-config-manager.ts b/src/targets/droid-config-manager.ts index 29a3943e..29f3c92d 100644 --- a/src/targets/droid-config-manager.ts +++ b/src/targets/droid-config-manager.ts @@ -9,6 +9,9 @@ import * as fs from 'fs'; import * as path from 'path'; import * as os from 'os'; import * as lockfile from 'proper-lockfile'; +import { getModelThinkingSupport } from '../cliproxy/model-catalog'; +import { validateThinking } from '../cliproxy/thinking-validator'; +import { stripModelConfigurationSuffixes } from '../shared/extended-context-utils'; const CCS_MODEL_PREFIX = 'ccs-'; const CCS_DISPLAY_PREFIX = 'CCS '; @@ -144,6 +147,24 @@ function toAnthropicBudget(value: string | number): number { return DROID_ANTHROPIC_BUDGET_BY_EFFORT[normalized] ?? DROID_ANTHROPIC_BUDGET_BY_EFFORT.high; } +function resolveAnthropicModelId(model: string): string { + return stripModelConfigurationSuffixes(model); +} + +function usesAnthropicAdaptiveThinking(model: string): boolean { + return getModelThinkingSupport('claude', resolveAnthropicModelId(model))?.type === 'levels'; +} + +function toAnthropicAdaptiveEffort(model: string, value: string | number): string | undefined { + const validation = validateThinking('claude', resolveAnthropicModelId(model), value); + if (isReasoningOffValue(validation.value)) { + return undefined; + } + + const normalized = String(validation.value).trim().toLowerCase(); + return normalized === 'auto' ? undefined : normalized; +} + function toReasoningEffort(value: string | number): string { if (typeof value === 'number') { if (value <= 4000) return 'low'; @@ -181,12 +202,35 @@ function applyReasoningOverride( if (isReasoningOffValue(reasoningOverride)) { delete extraArgs.thinking; + delete extraArgs.output_config; + } else if (usesAnthropicAdaptiveThinking(entry.model)) { + const thinking = isObject(extraArgs.thinking) ? { ...extraArgs.thinking } : {}; + const outputConfig = isObject(extraArgs.output_config) ? { ...extraArgs.output_config } : {}; + const effort = toAnthropicAdaptiveEffort(entry.model, reasoningOverride); + + thinking.type = 'adaptive'; + delete thinking.budget_tokens; + delete thinking.budgetTokens; + + if (effort) { + outputConfig.effort = effort; + } else { + delete outputConfig.effort; + } + + extraArgs.thinking = thinking; + if (Object.keys(outputConfig).length > 0) { + extraArgs.output_config = outputConfig; + } else { + delete extraArgs.output_config; + } } else { const thinking = isObject(extraArgs.thinking) ? { ...extraArgs.thinking } : {}; thinking.type = 'enabled'; thinking.budget_tokens = toAnthropicBudget(reasoningOverride); delete thinking.budgetTokens; extraArgs.thinking = thinking; + delete extraArgs.output_config; } } else if (provider === 'openai') { delete extraArgs.reasoning_effort; diff --git a/tests/unit/cliproxy/composite-thinking.test.ts b/tests/unit/cliproxy/composite-thinking.test.ts index 6fb3758b..6a9de3fd 100644 --- a/tests/unit/cliproxy/composite-thinking.test.ts +++ b/tests/unit/cliproxy/composite-thinking.test.ts @@ -538,6 +538,20 @@ describe('applyThinkingConfig - composite variant integration', () => { expect(result.ANTHROPIC_DEFAULT_OPUS_MODEL).toBe('claude-opus-4-6-thinking(32768)'); }); + it('rewrites legacy budget suffixes for claude level-based models', () => { + const envVars: NodeJS.ProcessEnv = { + ANTHROPIC_MODEL: 'claude-opus-4-7(32768)', + ANTHROPIC_DEFAULT_OPUS_MODEL: 'claude-opus-4-7(32768)', + ANTHROPIC_DEFAULT_SONNET_MODEL: 'claude-sonnet-4-6', + ANTHROPIC_DEFAULT_HAIKU_MODEL: 'claude-haiku-4-5-20251001', + }; + + const result = applyThinkingConfig(envVars, 'claude' as CLIProxyProvider, 'max'); + + expect(result.ANTHROPIC_MODEL).toBe('claude-opus-4-7(max)'); + expect(result.ANTHROPIC_DEFAULT_OPUS_MODEL).toBe('claude-opus-4-7(max)'); + }); + it('should use codex effort suffix style when provider is codex', () => { const envVars: NodeJS.ProcessEnv = { ANTHROPIC_MODEL: 'gpt-5.3-codex', diff --git a/tests/unit/cliproxy/model-catalog-compat.test.ts b/tests/unit/cliproxy/model-catalog-compat.test.ts index b6fc21cc..0590a44b 100644 --- a/tests/unit/cliproxy/model-catalog-compat.test.ts +++ b/tests/unit/cliproxy/model-catalog-compat.test.ts @@ -58,4 +58,24 @@ describe('model-catalog compatibility lookups', () => { expect(catalog?.models.map((model) => model.id)).toEqual(['gemini-2.5-pro']); }); + + it('preserves static maxLevel when live thinking metadata omits it', () => { + const catalog = mergeCatalog('claude', [ + { + id: 'claude-opus-4-7', + display_name: 'Claude Opus 4.7', + thinking: { + levels: ['low', 'medium', 'high', 'xhigh', 'max'], + dynamic_allowed: true, + }, + }, + ]); + + expect(catalog?.models[0]?.thinking).toMatchObject({ + type: 'levels', + levels: ['low', 'medium', 'high', 'xhigh', 'max'], + maxLevel: 'max', + dynamicAllowed: true, + }); + }); }); diff --git a/tests/unit/cursor/cursor-anthropic-translator.test.ts b/tests/unit/cursor/cursor-anthropic-translator.test.ts index 9da161fe..2fce792c 100644 --- a/tests/unit/cursor/cursor-anthropic-translator.test.ts +++ b/tests/unit/cursor/cursor-anthropic-translator.test.ts @@ -90,6 +90,16 @@ describe('translateAnthropicRequest', () => { ]); }); + it('maps adaptive anthropic thinking into Cursor reasoning effort', () => { + const translated = translateAnthropicRequest({ + thinking: { type: 'adaptive' }, + output_config: { effort: 'xhigh' }, + messages: [{ role: 'user', content: 'hello' }], + }); + + expect(translated.reasoning_effort).toBe('high'); + }); + it('preserves mixed user text around tool_result blocks in order', () => { const translated = translateAnthropicRequest({ messages: [ diff --git a/tests/unit/proxy/transformers/request-transformer-regressions.test.ts b/tests/unit/proxy/transformers/request-transformer-regressions.test.ts index 77b56294..4bb3192f 100644 --- a/tests/unit/proxy/transformers/request-transformer-regressions.test.ts +++ b/tests/unit/proxy/transformers/request-transformer-regressions.test.ts @@ -30,6 +30,17 @@ describe('ProxyRequestTransformer regressions', () => { expect(result.reasoning).toEqual({ enabled: true, effort: 'high' }); }); + it('explicitly normalizes anthropic xhigh adaptive effort for OpenAI-compatible upstreams', () => { + const result = new ProxyRequestTransformer().transform({ + messages: [{ role: 'user', content: 'hello' }], + thinking: { type: 'adaptive' }, + output_config: { effort: 'xhigh' }, + }); + + expect(result.reasoning_effort).toBe('high'); + expect(result.reasoning).toEqual({ enabled: true, effort: 'high' }); + }); + it('rejects unsupported thinking types instead of silently dropping them', () => { expect(() => new ProxyRequestTransformer().transform({ diff --git a/tests/unit/targets/droid-config-manager.test.ts b/tests/unit/targets/droid-config-manager.test.ts index 8f0efaa1..21128d55 100644 --- a/tests/unit/targets/droid-config-manager.test.ts +++ b/tests/unit/targets/droid-config-manager.test.ts @@ -143,6 +143,23 @@ describe('droid-config-manager', () => { expect(settings.customModels[0].extraArgs?.thinking?.budget_tokens).toBe(40960); }); + it('writes adaptive anthropic thinking for claude-opus-4-7 overrides', async () => { + await upsertCcsModel('claude', { + model: 'claude-opus-4-7', + displayName: 'CCS claude', + baseUrl: 'https://api.anthropic.com', + apiKey: 'anthropic-key', + provider: 'anthropic', + reasoningOverride: 'max', + }); + + const settingsPath = path.join(tmpDir, '.factory', 'settings.json'); + const settings = JSON.parse(fs.readFileSync(settingsPath, 'utf8')); + expect(settings.customModels[0].extraArgs?.thinking?.type).toBe('adaptive'); + expect(settings.customModels[0].extraArgs?.thinking?.budget_tokens).toBeUndefined(); + expect(settings.customModels[0].extraArgs?.output_config?.effort).toBe('max'); + }); + it('should clear prior reasoning config when override disables thinking', async () => { await upsertCcsModel('glm', { model: 'glm-4.7', From b3bc17639ca632cf456f3c0e983c1bc24e9546bc Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Wed, 22 Apr 2026 21:43:32 -0400 Subject: [PATCH 36/39] fix(ui): surface the max thinking level in settings and help --- src/cliproxy/executor/index.ts | 2 +- src/commands/config-thinking-command.ts | 2 +- src/config/unified-config-loader.ts | 4 +++- tests/unit/commands/config-thinking-command.test.ts | 1 + ui/src/pages/settings/sections/thinking/index.tsx | 1 + 5 files changed, 7 insertions(+), 3 deletions(-) diff --git a/src/cliproxy/executor/index.ts b/src/cliproxy/executor/index.ts index 2f534ff2..b8dbcb7a 100644 --- a/src/cliproxy/executor/index.ts +++ b/src/cliproxy/executor/index.ts @@ -572,7 +572,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); diff --git a/src/commands/config-thinking-command.ts b/src/commands/config-thinking-command.ts index 36b55b41..646d4571 100644 --- a/src/commands/config-thinking-command.ts +++ b/src/commands/config-thinking-command.ts @@ -60,7 +60,7 @@ function showHelp(): void { console.log(subheader('Levels:')); console.log( - ` ${dim('minimal (512), low (1K), medium (8K), high (24K), xhigh (32K), auto, off')}` + ` ${dim('minimal (512), low (1K), medium (8K), high (24K), xhigh (32K), max (adaptive ceiling), auto, off')}` ); console.log(''); diff --git a/src/config/unified-config-loader.ts b/src/config/unified-config-loader.ts index a3548fc6..f18e39bc 100644 --- a/src/config/unified-config-loader.ts +++ b/src/config/unified-config-loader.ts @@ -904,7 +904,9 @@ function generateYamlWithComments(config: UnifiedConfig): string { lines.push( '# Modes: auto (use tier_defaults), off (disable), manual (--thinking/--effort flags)' ); - lines.push('# Levels: minimal (512), low (1K), medium (8K), high (24K), xhigh (32K), auto'); + lines.push( + '# Levels: minimal (512), low (1K), medium (8K), high (24K), xhigh (32K), max (adaptive ceiling), auto' + ); lines.push('# Override: Set global override value (number or level name)'); lines.push('# Provider overrides: Per-provider tier defaults'); lines.push('# ----------------------------------------------------------------------------'); diff --git a/tests/unit/commands/config-thinking-command.test.ts b/tests/unit/commands/config-thinking-command.test.ts index e98efedb..3c9f7f4e 100644 --- a/tests/unit/commands/config-thinking-command.test.ts +++ b/tests/unit/commands/config-thinking-command.test.ts @@ -35,6 +35,7 @@ describe('config thinking override normalization', () => { it('accepts valid levels', () => { expect(parseThinkingOverrideInput('High')).toEqual({ value: 'high' }); + expect(parseThinkingOverrideInput('Max')).toEqual({ value: 'max' }); }); it('validates numeric bounds', () => { diff --git a/ui/src/pages/settings/sections/thinking/index.tsx b/ui/src/pages/settings/sections/thinking/index.tsx index d4c57e74..1fd52591 100644 --- a/ui/src/pages/settings/sections/thinking/index.tsx +++ b/ui/src/pages/settings/sections/thinking/index.tsx @@ -32,6 +32,7 @@ const THINKING_LEVELS = [ { value: 'medium', label: 'Medium (8K tokens)' }, { value: 'high', label: 'High (24K tokens)' }, { value: 'xhigh', label: 'Extra High (32K tokens)' }, + { value: 'max', label: 'Max (adaptive ceiling)' }, { value: 'auto', label: 'Auto (dynamic)' }, ]; From c0e524925db907ac3ea5b403ba59692c9f7efa5a Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Thu, 23 Apr 2026 02:06:19 +0000 Subject: [PATCH 37/39] chore(release): 7.73.1-dev.5 [skip ci] --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 4c8b2c93..03b9ab01 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@kaitranntt/ccs", - "version": "7.73.1-dev.4", + "version": "7.73.1-dev.5", "description": "Claude Code Switch - Instant profile switching between Claude, GLM, Kimi, and more", "keywords": [ "cli", From f78c30ac50cb2351a77b840c483ecb94c6a9ab6d Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Wed, 22 Apr 2026 22:09:08 -0400 Subject: [PATCH 38/39] fix(runtime): isolate nested Anthropic env across delegation paths --- src/delegation/headless-executor.ts | 53 ++++- src/utils/shell-executor.ts | 66 ++++-- .../utils/claudecode-env-stripping.test.ts | 191 +++++++++++++++++- 3 files changed, 286 insertions(+), 24 deletions(-) diff --git a/src/delegation/headless-executor.ts b/src/delegation/headless-executor.ts index 569302f2..76e4bcdd 100644 --- a/src/delegation/headless-executor.ts +++ b/src/delegation/headless-executor.ts @@ -16,8 +16,13 @@ import { type ExecutionOptions, type ExecutionResult, type StreamMessage } from import { StreamBuffer, formatToolVerbose } from './executor/stream-parser'; import { buildExecutionResult } from './executor/result-aggregator'; import { getCcsDir, getModelDisplayName, loadSettings } from '../utils/config-manager'; +import { getGlobalEnvConfig } from '../config/unified-config-loader'; import { getProfileLookupCandidates } from '../utils/profile-compat'; -import { getClaudeLaunchEnvOverrides, stripClaudeCodeEnv } from '../utils/shell-executor'; +import { + getClaudeLaunchEnvOverrides, + stripAnthropicRoutingEnv, + stripClaudeCodeEnv, +} from '../utils/shell-executor'; import { resolveProfileContinuityInheritance } from '../auth/profile-continuity-inheritance'; import { appendThirdPartyImageAnalysisToolArgs, @@ -38,6 +43,11 @@ import { import { resolveCliproxyBridgeMetadata } from '../api/services'; import { ensureCliproxyService } from '../cliproxy'; import { CLIPROXY_DEFAULT_PORT } from '../cliproxy/config/port-manager'; +import { + buildOpenAICompatProxyEnv, + resolveOpenAICompatProfileConfig, + startOpenAICompatProxy, +} from '../proxy'; import { appendThirdPartyWebSearchToolArgs, appendWebSearchTrace, @@ -129,6 +139,14 @@ export class HeadlessExecutor { syncImageAnalysisMcpToConfigDir(inheritedClaudeConfigDir); const settings = loadSettings(settingsPath); + const globalEnvConfig = getGlobalEnvConfig(); + const globalEnv = globalEnvConfig.enabled ? globalEnvConfig.env : {}; + const settingsEnv = settings.env || {}; + const openAICompatProfile = resolveOpenAICompatProfileConfig( + profile, + settingsPath, + settingsEnv + ); const cliproxyBridge = resolveCliproxyBridgeMetadata(settings); let imageAnalysisFallbackHookReady: boolean | undefined; if (imageAnalysisMcpReady) { @@ -207,6 +225,33 @@ export class HeadlessExecutor { } } + let runtimeEnvVars: NodeJS.ProcessEnv = { + ...stripAnthropicRoutingEnv({ ...globalEnv, ...settingsEnv }), + ...(inheritedClaudeConfigDir ? { CLAUDE_CONFIG_DIR: inheritedClaudeConfigDir } : {}), + CCS_PROFILE_TYPE: 'settings', + CCS_STRIP_INHERITED_ANTHROPIC_ENV: '1', + }; + + if (openAICompatProfile) { + const proxyStart = await startOpenAICompatProxy(openAICompatProfile, { + insecure: openAICompatProfile.insecure, + }); + if (!proxyStart.success) { + throw new Error(proxyStart.error || 'Failed to start local OpenAI-compatible proxy'); + } + + runtimeEnvVars = { + ...runtimeEnvVars, + ...buildOpenAICompatProxyEnv( + openAICompatProfile, + proxyStart.port, + proxyStart.authToken || '', + inheritedClaudeConfigDir + ), + }; + delete runtimeEnvVars.ANTHROPIC_API_KEY; + } + // Smart slash command detection and preservation const processedPrompt = this._processSlashCommand(enhancedPrompt); @@ -321,6 +366,7 @@ export class HeadlessExecutor { sessionMgr, claudeConfigDir: inheritedClaudeConfigDir, imageAnalysisEnv, + runtimeEnvVars, traceEnv, }); } @@ -340,6 +386,7 @@ export class HeadlessExecutor { sessionMgr: SessionManager; claudeConfigDir?: string; imageAnalysisEnv?: Record; + runtimeEnvVars?: NodeJS.ProcessEnv; traceEnv?: Record; } ): Promise { @@ -352,6 +399,7 @@ export class HeadlessExecutor { sessionMgr, claudeConfigDir, imageAnalysisEnv = {}, + runtimeEnvVars = {}, traceEnv = {}, } = ctx; @@ -368,9 +416,10 @@ export class HeadlessExecutor { // Strip Claude Code nested session guard env var to allow CCS delegation // (Claude Code v2.1.39+ sets CLAUDECODE to detect nested sessions) const cleanEnv = stripClaudeCodeEnv({ - ...process.env, + ...stripAnthropicRoutingEnv(process.env), ...getClaudeLaunchEnvOverrides(), ...getWebSearchHookEnv(), + ...runtimeEnvVars, ...imageAnalysisEnv, ...traceEnv, ...(claudeConfigDir ? { CLAUDE_CONFIG_DIR: claudeConfigDir } : {}), diff --git a/src/utils/shell-executor.ts b/src/utils/shell-executor.ts index e6183349..10482f19 100644 --- a/src/utils/shell-executor.ts +++ b/src/utils/shell-executor.ts @@ -26,11 +26,28 @@ export function stripAnthropicEnv(env: NodeJS.ProcessEnv): NodeJS.ProcessEnv { return result; } -const ANTHROPIC_ROUTING_ENV_KEYS = new Set([ +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. @@ -41,13 +58,39 @@ const ANTHROPIC_ROUTING_ENV_KEYS = new Set([ export function stripAnthropicRoutingEnv(env: NodeJS.ProcessEnv): NodeJS.ProcessEnv { const result: NodeJS.ProcessEnv = {}; for (const key of Object.keys(env)) { - if (!ANTHROPIC_ROUTING_ENV_KEYS.has(key)) { + 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 Claude Code nested-session guard env var from a process environment. * @@ -193,20 +236,9 @@ export function execClaude( } } - // propagate key env vars to tmux session so agent team teammates - // (spawned via tmux split-window) inherit the correct config dir - if (process.env.TMUX && envVars) { - const tmuxPropagateVars = ['CLAUDE_CONFIG_DIR', 'CCS_PROFILE_TYPE', 'CCS_WEBSEARCH_SKIP']; - for (const key of tmuxPropagateVars) { - if (envVars[key]) { - try { - spawnSync('tmux', ['setenv', key, envVars[key] ?? ''], { stdio: 'ignore' }); - } catch { - // tmux setenv can fail if not in a tmux session; safe to ignore - } - } - } - } + // Keep tmux teammate panes aligned with the nested-safe Claude runtime env + // rather than the tmux server's original shell environment. + syncTmuxNestedSessionEnv(env, profileType); let child: ChildProcess; if (isPowerShellScript) { diff --git a/tests/unit/utils/claudecode-env-stripping.test.ts b/tests/unit/utils/claudecode-env-stripping.test.ts index afed6472..a4212c83 100644 --- a/tests/unit/utils/claudecode-env-stripping.test.ts +++ b/tests/unit/utils/claudecode-env-stripping.test.ts @@ -21,8 +21,16 @@ type SpawnCall = { options: Record | undefined; }; -const STEERING_PROMPT_SNIPPET = 'prefer the CCS MCP tool WebSearch instead of Bash/curl/http fetches'; +type SpawnSyncCall = { + command: string; + args: string[]; + options: Record | undefined; +}; + +const STEERING_PROMPT_SNIPPET = + 'prefer the CCS MCP tool WebSearch instead of Bash/curl/http fetches'; const spawnCalls: SpawnCall[] = []; +const spawnSyncCalls: SpawnSyncCall[] = []; const originalPlatform = process.platform; let baselineSigintListeners: Array<(...args: unknown[]) => void> = []; let baselineSigtermListeners: Array<(...args: unknown[]) => void> = []; @@ -31,6 +39,7 @@ let originalCcsHome: string | undefined; let originalCcsClaudePath: string | undefined; let originalDisableAutoUpdater: string | undefined; let originalClaudeConfigDir: string | undefined; +let originalTmux: string | undefined; const realSpawn = childProcess.spawn.bind(childProcess); const realSpawnSync = childProcess.spawnSync.bind(childProcess); const realExecSync = childProcess.execSync.bind(childProcess); @@ -103,6 +112,18 @@ function registerChildProcessMock(): void { | Record | undefined; + if (command === 'tmux') { + spawnSyncCalls.push({ command, args, options }); + return { + pid: process.pid, + output: ['', '', ''], + stdout: '', + stderr: '', + status: 0, + signal: null, + }; + } + return realSpawnSync(command, args, options as Parameters[2]); }, execSync: (...execArgs: unknown[]) => @@ -140,6 +161,7 @@ ${yamlBody} } let execClaude: typeof import('../../../src/utils/shell-executor').execClaude; +let stripAnthropicRoutingEnv: typeof import('../../../src/utils/shell-executor').stripAnthropicRoutingEnv; let stripClaudeCodeEnv: typeof import('../../../src/utils/shell-executor').stripClaudeCodeEnv; let HeadlessExecutor: typeof import('../../../src/delegation/headless-executor').HeadlessExecutor; let SharedManager: typeof import('../../../src/management/shared-manager').default; @@ -149,6 +171,7 @@ beforeAll(async () => { const shellExecutor = await import('../../../src/utils/shell-executor'); execClaude = shellExecutor.execClaude; + stripAnthropicRoutingEnv = shellExecutor.stripAnthropicRoutingEnv; stripClaudeCodeEnv = shellExecutor.stripClaudeCodeEnv; const sharedManagerModule = await import('../../../src/management/shared-manager'); @@ -165,6 +188,7 @@ afterAll(() => { describe('CLAUDECODE environment stripping', () => { beforeEach(() => { spawnCalls.length = 0; + spawnSyncCalls.length = 0; process.env.CCS_QUIET = '1'; // Save original env values for restoration in afterEach @@ -172,10 +196,12 @@ describe('CLAUDECODE environment stripping', () => { originalCcsClaudePath = process.env.CCS_CLAUDE_PATH; originalDisableAutoUpdater = process.env.DISABLE_AUTOUPDATER; originalClaudeConfigDir = process.env.CLAUDE_CONFIG_DIR; + originalTmux = process.env.TMUX; // Clear CCS-managed env vars that leak from host sessions delete process.env.DISABLE_AUTOUPDATER; delete process.env.CLAUDE_CONFIG_DIR; + delete process.env.TMUX; baselineSigintListeners = process.listeners('SIGINT'); baselineSigtermListeners = process.listeners('SIGTERM'); @@ -197,8 +223,20 @@ describe('CLAUDECODE environment stripping', () => { } else { delete process.env.DISABLE_AUTOUPDATER; } - if (originalClaudeConfigDir !== undefined) process.env.CLAUDE_CONFIG_DIR = originalClaudeConfigDir; + if (originalClaudeConfigDir !== undefined) + process.env.CLAUDE_CONFIG_DIR = originalClaudeConfigDir; else delete process.env.CLAUDE_CONFIG_DIR; + if (originalTmux !== undefined) process.env.TMUX = originalTmux; + else delete process.env.TMUX; + delete process.env.ANTHROPIC_BASE_URL; + delete process.env.ANTHROPIC_AUTH_TOKEN; + delete process.env.ANTHROPIC_API_KEY; + delete process.env.ANTHROPIC_MODEL; + delete process.env.ANTHROPIC_DEFAULT_OPUS_MODEL; + delete process.env.ANTHROPIC_DEFAULT_SONNET_MODEL; + delete process.env.ANTHROPIC_DEFAULT_HAIKU_MODEL; + delete process.env.ANTHROPIC_SMALL_FAST_MODEL; + delete process.env.CLAUDE_CODE_MAX_OUTPUT_TOKENS; for (const listener of process.listeners('SIGINT')) { if (!baselineSigintListeners.includes(listener)) { @@ -230,6 +268,25 @@ describe('CLAUDECODE environment stripping', () => { expect(result.PATH).toBe('/usr/bin'); }); + it('stripAnthropicRoutingEnv removes routing/auth env case-insensitively while preserving model vars', () => { + const input: NodeJS.ProcessEnv = { + anthropic_base_url: 'http://127.0.0.1:8317/api/provider/codex', + Anthropic_Auth_Token: 'parent-routing-token', + ANTHROPIC_API_KEY: 'parent-api-key', + ANTHROPIC_MODEL: 'gpt-5.4', + ANTHROPIC_DEFAULT_SONNET_MODEL: 'gpt-5.4', + PATH: '/usr/bin', + }; + + const result = stripAnthropicRoutingEnv(input); + expect(result.anthropic_base_url).toBeUndefined(); + expect(result.Anthropic_Auth_Token).toBeUndefined(); + expect(result.ANTHROPIC_API_KEY).toBeUndefined(); + expect(result.ANTHROPIC_MODEL).toBe('gpt-5.4'); + expect(result.ANTHROPIC_DEFAULT_SONNET_MODEL).toBe('gpt-5.4'); + expect(result.PATH).toBe('/usr/bin'); + }); + it('execClaude strips CLAUDECODE from merged env (including overrides)', () => { process.env.CLAUDECODE = 'from-parent'; process.env.claudecode = 'from-parent-lower'; @@ -387,6 +444,46 @@ describe('CLAUDECODE environment stripping', () => { 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'; @@ -483,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'); @@ -519,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); }); From 5ddf13022369c39e46b982654e9cf594053c24e5 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Thu, 23 Apr 2026 02:19:26 +0000 Subject: [PATCH 39/39] chore(release): 7.73.1-dev.6 [skip ci] --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 03b9ab01..c0d703e5 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@kaitranntt/ccs", - "version": "7.73.1-dev.5", + "version": "7.73.1-dev.6", "description": "Claude Code Switch - Instant profile switching between Claude, GLM, Kimi, and more", "keywords": [ "cli",