diff --git a/README.md b/README.md index bb57a1b4..537f711f 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@
-# CCS - Claude Code Switch +# CCS - Claude Codex Switch ![CCS Logo](assets/ccs-logo-medium.png) diff --git a/assets/screenshots/live-auth-monitor.webp b/assets/screenshots/live-auth-monitor.webp index 8bd4d02e..05bbf65a 100644 Binary files a/assets/screenshots/live-auth-monitor.webp and b/assets/screenshots/live-auth-monitor.webp differ diff --git a/docs/project-overview-pdr.md b/docs/project-overview-pdr.md index de9d4f86..a239fae2 100644 --- a/docs/project-overview-pdr.md +++ b/docs/project-overview-pdr.md @@ -4,7 +4,7 @@ Last Updated: 2026-05-07 ## Product Overview -**Product Name**: CCS (Claude Code Switch) +**Product Name**: CCS (Claude Codex Switch) **Tagline**: The multi-provider profile and runtime manager for Claude Code and compatible CLIs diff --git a/docs/reports/hardening-inventory.json b/docs/reports/hardening-inventory.json index e614b57a..2a6cb358 100644 --- a/docs/reports/hardening-inventory.json +++ b/docs/reports/hardening-inventory.json @@ -1,10 +1,10 @@ { "scope": "src/**/*.{ts,tsx,js,jsx,mjs,cjs}", "syncFs": { - "totalOccurrences": 2301, - "filesAffected": 247, - "hotpathOccurrences": 1946, - "hotpathFilesAffected": 197, + "totalOccurrences": 2358, + "filesAffected": 253, + "hotpathOccurrences": 1991, + "hotpathFilesAffected": 202, "topHotpathFiles": [ { "file": "src/cliproxy/__tests__/pool-routing-phase3.test.ts", @@ -285,8 +285,8 @@ ] }, "legacyShim": { - "totalMarkers": 427, - "filesAffected": 164, + "totalMarkers": 454, + "filesAffected": 170, "topFiles": [ { "file": "src/auth/profile-detector.ts", @@ -312,6 +312,28 @@ "// Priority 4: Check account-based profiles (work, personal) - LEGACY FALLBACK" ] }, + { + "file": "src/web-server/usage/native-quota-collector.ts", + "count": 15, + "calls": [], + "markers": [ + "* are used for backward compatibility.", + "* Kept for backward compatibility with existing tests.", + "* Legacy Codex collector — uses the old getDefaultCodexAccountId dep.", + "* List all Claude profiles from the profile registry (merged legacy + unified).", + "* profile name. Kept for backward compatibility with existing tests that stub", + "* When no enumeration deps are injected (legacy mode / old tests that stub only", + "/** Legacy Codex network row builder. */", + "/** Legacy Codex row builder (local quota). */", + "/** Legacy row builder — no surface/profile/is_subscription fields. */", + "// everything except the legacy harness takes the multi-profile path below.", + "// For the network fallback: the legacy getDefaultCodexAccountId is the", + "// Legacy path: backward-compatible with old tests that only inject", + "// Legacy single-profile collectors (unchanged; used by old tests + back-compat)", + "// Multi-profile enumeration is the DEFAULT (production) behavior. The legacy", + "// Use the legacy single-state approach via profile key '__legacy__' to avoid" + ] + }, { "file": "src/utils/config-manager.ts", "count": 13, @@ -373,14 +395,14 @@ "count": 10, "calls": [], "markers": [ - "* - Legacy CLI fallbacks: Gemini, Grok, OpenCode", "* Legacy AI CLI fallbacks remain available for compatibility only.", "* Uses deterministic search backends first, with optional legacy CLI fallback.", "/** Enable Gemini CLI legacy fallback (default: false) */", "/** Enable Grok CLI legacy fallback (default: false - requires GROK_API_KEY) */", "/** Enable OpenCode CLI legacy fallback (default: false) */", - "/** Gemini CLI - optional legacy LLM fallback */", + "/** Gemini CLI - deprecated legacy LLM fallback (retired upstream) */", "/** Grok CLI - optional legacy LLM fallback */", + "/** Model to use (default: gemini-2.5-flash; accepts legacy gemini ids) */", "/** OpenCode - optional legacy LLM fallback */", "// Legacy fields (deprecated, kept for backwards compatibility)" ] @@ -440,20 +462,6 @@ "it('legacy variant does not block port allocation', function () {", "legacy: {" ] - }, - { - "file": "src/cliproxy/config/__tests__/config-generator.test.js", - "count": 7, - "calls": [], - "markers": [ - "'Should preserve lone manual high-version aliases during legacy cleanup'", - "'Should preserve manual aliases after legacy migration has already run'", - "'Should preserve the manual 3.2 compatibility cluster during legacy cleanup'", - "'Should preserve the pruned legacy alias in the migration backup'", - "assert(fs.existsSync(backupPath), 'Should keep a backup of the legacy config before pruning');", - "it('prunes partially retained broad guessed ranges during legacy cleanup', () => {", - "it('writes a one-time backup before pruning legacy Gemini aliases during migration', () => {" - ] } ], "explicitShimFiles": [ @@ -468,11 +476,11 @@ }, "maintainability": { "typedErrors": { - "totalThrows": 431, - "typedThrows": 37, - "plainThrows": 336, - "otherThrows": 58, - "adoptionRatio": 0.0858, + "totalThrows": 440, + "typedThrows": 68, + "plainThrows": 312, + "otherThrows": 60, + "adoptionRatio": 0.1545, "topSubdomainsByThrows": [ { "subdomain": "web-server", @@ -488,9 +496,15 @@ }, { "subdomain": "commands", - "count": 33, - "typed": 0, - "plain": 32 + "count": 36, + "typed": 4, + "plain": 30 + }, + { + "subdomain": "codex-auth", + "count": 35, + "typed": 4, + "plain": 27 }, { "subdomain": "utils", @@ -498,23 +512,17 @@ "typed": 0, "plain": 33 }, - { - "subdomain": "codex-auth", - "count": 31, - "typed": 0, - "plain": 27 - }, { "subdomain": "targets", "count": 27, - "typed": 0, - "plain": 25 + "typed": 9, + "plain": 16 }, { "subdomain": "cursor", "count": 23, - "typed": 0, - "plain": 21 + "typed": 4, + "plain": 17 }, { "subdomain": "cliproxy/accounts", @@ -543,15 +551,15 @@ "web-server/routes", "auth" ], - "numerator": 21, - "denominator": 23, - "ratio": 0.913, + "numerator": 22, + "denominator": 24, + "ratio": 0.9167, "targetRatio": 0.4 }, "loggerCoverage": { - "filesWithCreateLogger": 64, - "totalSourceFiles": 745, - "coverageRatio": 0.0859, + "filesWithCreateLogger": 65, + "totalSourceFiles": 751, + "coverageRatio": 0.0866, "subdomainsWithZeroCreateLogger": [ "api", "bin", @@ -572,17 +580,17 @@ "topSubdomainsByFiles": [ { "subdomain": "web-server", - "count": 111, + "count": 112, "withLogger": 10 }, { "subdomain": "commands", - "count": 107, + "count": 108, "withLogger": 2 }, { "subdomain": "utils", - "count": 87, + "count": 88, "withLogger": 1 }, { @@ -605,16 +613,16 @@ "count": 27, "withLogger": 0 }, + { + "subdomain": "codex-auth", + "count": 24, + "withLogger": 9 + }, { "subdomain": "cursor", "count": 24, "withLogger": 2 }, - { - "subdomain": "codex-auth", - "count": 23, - "withLogger": 8 - }, { "subdomain": "auth", "count": 20, @@ -623,9 +631,9 @@ ] }, "hotpathConsoleErrors": { - "totalOccurrences": 569, - "exemptOccurrences": 302, - "hotpathOccurrences": 267, + "totalOccurrences": 571, + "exemptOccurrences": 305, + "hotpathOccurrences": 266, "filesAffected": 82, "topFiles": [ { @@ -691,16 +699,20 @@ ] }, "largeFiles": { - "countOver400": 89, - "countOver600": 39, + "countOver400": 91, + "countOver600": 42, "topOver400": [ + { + "file": "src/web-server/usage/native-quota-collector.ts", + "loc": 1662 + }, { "file": "src/web-server/routes/cliproxy-auth-routes.ts", - "loc": 1515 + "loc": 1531 }, { "file": "src/cliproxy/auth/oauth-handler.ts", - "loc": 1455 + "loc": 1467 }, { "file": "src/cursor/cursor-executor.ts", @@ -708,23 +720,23 @@ }, { "file": "src/web-server/model-pricing.ts", - "loc": 1070 + "loc": 1105 + }, + { + "file": "src/cliproxy/auth/oauth-process.ts", + "loc": 1048 }, { "file": "src/web-server/routes/settings-routes.ts", "loc": 1041 }, - { - "file": "src/cliproxy/config/env-builder.ts", - "loc": 1037 - }, { "file": "src/cliproxy/proxy/tool-sanitization-proxy.ts", "loc": 1020 }, { - "file": "src/cliproxy/auth/oauth-process.ts", - "loc": 1018 + "file": "src/cliproxy/config/env-builder.ts", + "loc": 1017 }, { "file": "src/cliproxy/config/generator.ts", @@ -732,7 +744,7 @@ }, { "file": "src/commands/cliproxy/variant-subcommand.ts", - "loc": 978 + "loc": 997 }, { "file": "src/cliproxy/quota/quota-manager.ts", @@ -749,10 +761,6 @@ { "file": "src/cliproxy/accounts/registry.ts", "loc": 871 - }, - { - "file": "src/channels/official-channels-runtime.ts", - "loc": 867 } ] } diff --git a/docs/reports/hardening-inventory.md b/docs/reports/hardening-inventory.md index e3f129b8..b708692d 100644 --- a/docs/reports/hardening-inventory.md +++ b/docs/reports/hardening-inventory.md @@ -6,12 +6,12 @@ Scope: `src/**/*.{ts,tsx,js,jsx,mjs,cjs}` | Metric | Value | |---|---:| -| Sync fs occurrences (all) | 2301 | -| Sync fs files affected (all) | 247 | -| Sync fs occurrences (runtime hotpaths) | 1946 | -| Sync fs files affected (runtime hotpaths) | 197 | -| Legacy shim markers | 427 | -| Legacy shim files affected | 164 | +| Sync fs occurrences (all) | 2358 | +| Sync fs files affected (all) | 253 | +| Sync fs occurrences (runtime hotpaths) | 1991 | +| Sync fs files affected (runtime hotpaths) | 202 | +| Legacy shim markers | 454 | +| Legacy shim files affected | 170 | ## Top Runtime Hotpath Sync fs Files @@ -33,6 +33,7 @@ Scope: `src/**/*.{ts,tsx,js,jsx,mjs,cjs}` | File | Marker Count | |---|---:| | `src/auth/profile-detector.ts` | 18 | +| `src/web-server/usage/native-quota-collector.ts` | 15 | | `src/utils/config-manager.ts` | 13 | | `src/cliproxy/__tests__/pool-onboarding-phase5.test.ts` | 12 | | `src/cliproxy/executor/__tests__/variant-port-allocation.test.js` | 12 | @@ -41,7 +42,6 @@ Scope: `src/**/*.{ts,tsx,js,jsx,mjs,cjs}` | `src/config/migration-manager.ts` | 9 | | `src/cliproxy/config/__tests__/env-builder-provider-url.test.ts` | 8 | | `src/cliproxy/executor/__tests__/variant-port-edge-cases.test.js` | 8 | -| `src/cliproxy/config/__tests__/config-generator.test.js` | 7 | ## Explicit Shim/Re-export Files @@ -56,14 +56,14 @@ Scope: `src/**/*.{ts,tsx,js,jsx,mjs,cjs}` | Metric | Value | |---|---:| -| typed-error adoption (typed/total throws) | 8.6% (37/431) | -| typed-error adoption (P4 locked subdomains) | 91.3% (21/23), target 40% | -| hotpath console.error/warn occurrences | 267 (569 total, 302 CLI-UX exempt) | +| typed-error adoption (typed/total throws) | 15.4% (68/440) | +| typed-error adoption (P4 locked subdomains) | 91.7% (22/24), target 40% | +| hotpath console.error/warn occurrences | 266 (571 total, 305 CLI-UX exempt) | | hotpath console.error/warn files | 82 | -| files with createLogger | 64/745 | +| files with createLogger | 65/751 | | subdomains with zero createLogger | 15 (api, bin, channels, cliproxy, cliproxy/accounts, cliproxy/ai-providers, cliproxy/binary, cliproxy/config, cliproxy/management, cliproxy/sync, cliproxy/types, config, dispatcher, shared, types) | -| files > 400 LOC | 89 | -| files > 600 LOC | 39 | +| files > 400 LOC | 91 | +| files > 600 LOC | 42 | ### Top Hotpath console.error/warn Files @@ -89,19 +89,19 @@ Scope: `src/**/*.{ts,tsx,js,jsx,mjs,cjs}` | File | LOC | |---|---:| -| `src/web-server/routes/cliproxy-auth-routes.ts` | 1515 | -| `src/cliproxy/auth/oauth-handler.ts` | 1455 | +| `src/web-server/usage/native-quota-collector.ts` | 1662 | +| `src/web-server/routes/cliproxy-auth-routes.ts` | 1531 | +| `src/cliproxy/auth/oauth-handler.ts` | 1467 | | `src/cursor/cursor-executor.ts` | 1234 | -| `src/web-server/model-pricing.ts` | 1070 | +| `src/web-server/model-pricing.ts` | 1105 | +| `src/cliproxy/auth/oauth-process.ts` | 1048 | | `src/web-server/routes/settings-routes.ts` | 1041 | -| `src/cliproxy/config/env-builder.ts` | 1037 | | `src/cliproxy/proxy/tool-sanitization-proxy.ts` | 1020 | -| `src/cliproxy/auth/oauth-process.ts` | 1018 | +| `src/cliproxy/config/env-builder.ts` | 1017 | | `src/cliproxy/config/generator.ts` | 1012 | -| `src/commands/cliproxy/variant-subcommand.ts` | 978 | +| `src/commands/cliproxy/variant-subcommand.ts` | 997 | | `src/cliproxy/quota/quota-manager.ts` | 954 | | `src/web-server/services/codex-dashboard-service.ts` | 940 | | `src/glmt/glmt-proxy.ts` | 939 | | `src/cliproxy/accounts/registry.ts` | 871 | -| `src/channels/official-channels-runtime.ts` | 867 | diff --git a/docs/system-architecture/index.md b/docs/system-architecture/index.md index 475718ba..280f9c94 100644 --- a/docs/system-architecture/index.md +++ b/docs/system-architecture/index.md @@ -2,7 +2,7 @@ Last Updated: 2026-04-14 -High-level architecture overview for the CCS (Claude Code Switch) system. +High-level architecture overview for the CCS (Claude Codex Switch) system. --- diff --git a/lib/ccs b/lib/ccs index b5c2351a..d4fe7c4b 100755 --- a/lib/ccs +++ b/lib/ccs @@ -1,5 +1,5 @@ #!/usr/bin/env bash -# CCS - Claude Code Switch (Bootstrap) +# CCS - Claude Codex Switch (Bootstrap) # Delegates to Node.js implementation via npx # https://github.com/kaitranntt/ccs set -euo pipefail diff --git a/lib/ccs.ps1 b/lib/ccs.ps1 index 8203a2d6..3195bf84 100644 --- a/lib/ccs.ps1 +++ b/lib/ccs.ps1 @@ -1,4 +1,4 @@ -# CCS - Claude Code Switch (Bootstrap) +# CCS - Claude Codex Switch (Bootstrap) # Delegates to Node.js implementation via npx # https://github.com/kaitranntt/ccs diff --git a/package.json b/package.json index 54ba48bd..d08899f1 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "@kaitranntt/ccs", - "version": "8.8.1", - "description": "Claude Code Switch - Instant profile switching between Claude, GLM, Kimi, and more", + "version": "8.8.1-dev.4", + "description": "Claude Codex Switch - Instant profile switching between Claude, GLM, Kimi, and more", "keywords": [ "cli", "claude", diff --git a/scripts/completion/ccs.bash b/scripts/completion/ccs.bash index a1b27984..48fb9541 100644 --- a/scripts/completion/ccs.bash +++ b/scripts/completion/ccs.bash @@ -1,4 +1,4 @@ -# Bash completion for CCS (Claude Code Switch) +# Bash completion for CCS (Claude Codex Switch) # Compatible with bash 3.2+ _ccs_completion() { diff --git a/scripts/completion/ccs.fish b/scripts/completion/ccs.fish index c06111e3..34f0c1fd 100644 --- a/scripts/completion/ccs.fish +++ b/scripts/completion/ccs.fish @@ -1,4 +1,4 @@ -# Fish completion for CCS (Claude Code Switch) +# Fish completion for CCS (Claude Codex Switch) function __fish_ccs_complete set -l tokens_before_current (commandline -opc) diff --git a/scripts/completion/ccs.ps1 b/scripts/completion/ccs.ps1 index 04051ead..d279764e 100644 --- a/scripts/completion/ccs.ps1 +++ b/scripts/completion/ccs.ps1 @@ -1,4 +1,4 @@ -# PowerShell completion for CCS (Claude Code Switch) +# PowerShell completion for CCS (Claude Codex Switch) function Invoke-CcsCompletionBackend { param( diff --git a/scripts/completion/ccs.zsh b/scripts/completion/ccs.zsh index 281da9e3..155aa391 100644 --- a/scripts/completion/ccs.zsh +++ b/scripts/completion/ccs.zsh @@ -1,6 +1,6 @@ #compdef ccs -# Zsh completion for CCS (Claude Code Switch) +# Zsh completion for CCS (Claude Codex Switch) _ccs() { local current diff --git a/src/api/services/profile-lifecycle-validation.ts b/src/api/services/profile-lifecycle-validation.ts index d7902584..6897d113 100644 --- a/src/api/services/profile-lifecycle-validation.ts +++ b/src/api/services/profile-lifecycle-validation.ts @@ -10,22 +10,16 @@ import { } from '../../cliproxy/ai-providers/model-id-normalizer'; import { mapExternalProviderName } from '../../cliproxy/provider-capabilities'; import type { CLIProxyProvider } from '../../cliproxy/types'; +import { ANTHROPIC_MODEL_ENV_KEYS } from '../../shared/extended-context-utils'; import type { ProfileValidationIssue, ProfileValidationSummary } from './profile-types'; -const MODEL_ENV_KEYS = [ - 'ANTHROPIC_MODEL', - 'ANTHROPIC_DEFAULT_OPUS_MODEL', - 'ANTHROPIC_DEFAULT_SONNET_MODEL', - 'ANTHROPIC_DEFAULT_HAIKU_MODEL', -] as const; - const ALLOWED_ANTHROPIC_ENV_KEYS = new Set([ 'ANTHROPIC_BASE_URL', 'ANTHROPIC_AUTH_TOKEN', 'ANTHROPIC_API_KEY', // Written by profile-writer when --extra-models is supplied 'ANTHROPIC_EXTRA_MODELS', - ...MODEL_ENV_KEYS, + ...ANTHROPIC_MODEL_ENV_KEYS, ]); function resolveProviderFromBaseUrl(baseUrl: string): CLIProxyProvider | null { @@ -118,7 +112,7 @@ export function validateApiProfileSettingsPayload(settings: unknown): ProfileVal } const provider = resolveProviderFromBaseUrl(baseUrl); - for (const modelKey of MODEL_ENV_KEYS) { + for (const modelKey of ANTHROPIC_MODEL_ENV_KEYS) { const value = envObj[modelKey]; if (typeof value !== 'string' || value.trim().length === 0) continue; const denyReason = getDeniedModelIdReasonForProvider(value, provider); diff --git a/src/cliproxy/executor/__tests__/launch-settings.test.ts b/src/cliproxy/executor/__tests__/launch-settings.test.ts index b08d27bc..6080ab27 100644 --- a/src/cliproxy/executor/__tests__/launch-settings.test.ts +++ b/src/cliproxy/executor/__tests__/launch-settings.test.ts @@ -37,6 +37,7 @@ describe('buildLaunchSettingsOverlay', () => { env: { ANTHROPIC_BASE_URL: 'http://127.0.0.1:8317/api/provider/codex', ANTHROPIC_MODEL: 'gpt-5.5', + ANTHROPIC_DEFAULT_FABLE_MODEL: 'gpt-5.4-mini', ANTHROPIC_AUTH_TOKEN: 'ccs-internal-managed', }, }); @@ -44,12 +45,14 @@ describe('buildLaunchSettingsOverlay', () => { const { settings, changed } = buildLaunchSettingsOverlay(settingsPath, { ANTHROPIC_BASE_URL: 'http://127.0.0.1:50118/api/provider/codex', ANTHROPIC_MODEL: 'gpt-5.5-high', + ANTHROPIC_DEFAULT_FABLE_MODEL: 'gpt-5.4-mini-medium', } as NodeJS.ProcessEnv); expect(changed).toBe(true); const env = settings.env as Record; expect(env.ANTHROPIC_BASE_URL).toBe('http://127.0.0.1:50118/api/provider/codex'); expect(env.ANTHROPIC_MODEL).toBe('gpt-5.5-high'); + expect(env.ANTHROPIC_DEFAULT_FABLE_MODEL).toBe('gpt-5.4-mini-medium'); // Untouched keys are preserved. expect(env.ANTHROPIC_AUTH_TOKEN).toBe('ccs-internal-managed'); }); diff --git a/src/commands/setup-command.ts b/src/commands/setup-command.ts index 60e23b83..4f31130f 100644 --- a/src/commands/setup-command.ts +++ b/src/commands/setup-command.ts @@ -283,7 +283,7 @@ async function runSetupWizard(force: boolean = false): Promise { return; } - console.log('Welcome to CCS (Claude Code Switch)!'); + console.log('Welcome to CCS (Claude Codex Switch)!'); console.log('This wizard will help you configure CCS for first-time use.'); console.log(''); diff --git a/src/commands/version-command.ts b/src/commands/version-command.ts index e355fbc8..b31439c1 100644 --- a/src/commands/version-command.ts +++ b/src/commands/version-command.ts @@ -17,7 +17,7 @@ import { getCcsDir } from '../config/config-loader-facade'; */ export async function handleVersionCommand(): Promise { await initUI(); - console.log(header(`CCS (Claude Code Switch) v${getVersion()}`)); + console.log(header(`CCS (Claude Codex Switch) v${getVersion()}`)); console.log(''); console.log(subheader('Installation:')); diff --git a/src/shared/extended-context-utils.ts b/src/shared/extended-context-utils.ts index d8008462..57468cef 100644 --- a/src/shared/extended-context-utils.ts +++ b/src/shared/extended-context-utils.ts @@ -9,6 +9,7 @@ export const ANTHROPIC_MODEL_ENV_KEYS = [ 'ANTHROPIC_DEFAULT_OPUS_MODEL', 'ANTHROPIC_DEFAULT_SONNET_MODEL', 'ANTHROPIC_DEFAULT_HAIKU_MODEL', + 'ANTHROPIC_DEFAULT_FABLE_MODEL', ] as const; export type AnthropicModelEnvKey = (typeof ANTHROPIC_MODEL_ENV_KEYS)[number]; diff --git a/src/utils/shell-executor.ts b/src/utils/shell-executor.ts index 68a9f1f6..b68b10af 100644 --- a/src/utils/shell-executor.ts +++ b/src/utils/shell-executor.ts @@ -18,6 +18,7 @@ import { import SharedManager from '../management/shared-manager'; import { loadOrCreateUnifiedConfig } from '../config/config-loader-facade'; +import { ANTHROPIC_MODEL_ENV_KEYS as SHARED_ANTHROPIC_MODEL_ENV_KEYS } from '../shared/extended-context-utils'; /** * Strip ANTHROPIC_* env vars from an environment object. @@ -40,17 +41,13 @@ export const ANTHROPIC_ROUTING_ENV_KEYS = [ 'ANTHROPIC_API_KEY', ]; const ANTHROPIC_ROUTING_ENV_KEY_SET = new Set(ANTHROPIC_ROUTING_ENV_KEYS); -// NOTE: This is the intentional routing-overlay SUPERSET of model env keys -// (includes ANTHROPIC_SMALL_FAST_MODEL). A separate 4-key `ANTHROPIC_MODEL_ENV_KEYS` -// exists in src/shared/extended-context-utils.ts (re-exported as MODEL_ENV_VAR_KEYS). -// The two are NOT interchangeable — import deliberately by purpose. See issue #1609. +// NOTE: This is the intentional routing-overlay SUPERSET of shared model env keys +// (includes legacy ANTHROPIC_SMALL_FAST_MODEL). Keep the shared list authoritative +// for current tier mappings while preserving the legacy runtime alias. See issue #1609. export const ANTHROPIC_MODEL_ENV_KEYS = [ - 'ANTHROPIC_MODEL', - 'ANTHROPIC_DEFAULT_OPUS_MODEL', - 'ANTHROPIC_DEFAULT_SONNET_MODEL', - 'ANTHROPIC_DEFAULT_HAIKU_MODEL', + ...SHARED_ANTHROPIC_MODEL_ENV_KEYS, 'ANTHROPIC_SMALL_FAST_MODEL', -]; +] as const; const TMUX_SYNC_ENV_KEYS = [ 'CLAUDE_CONFIG_DIR', 'CCS_PROFILE_TYPE', diff --git a/src/web-server/routes/settings-routes.ts b/src/web-server/routes/settings-routes.ts index 7f26df4e..27b17f26 100644 --- a/src/web-server/routes/settings-routes.ts +++ b/src/web-server/routes/settings-routes.ts @@ -60,6 +60,7 @@ const MODEL_ENV_KEYS = [ 'ANTHROPIC_DEFAULT_OPUS_MODEL', 'ANTHROPIC_DEFAULT_SONNET_MODEL', 'ANTHROPIC_DEFAULT_HAIKU_MODEL', + 'ANTHROPIC_DEFAULT_FABLE_MODEL', ] as const; const PRESET_MODEL_KEYS = ['default', 'opus', 'sonnet', 'haiku'] as const; const SETTINGS_IDENTIFIER_PATTERN = /^[a-zA-Z][a-zA-Z0-9._-]*$/; diff --git a/tests/native/windows/custom-claude-path.ps1 b/tests/native/windows/custom-claude-path.ps1 index 1f09cdd1..7caf1ec0 100644 --- a/tests/native/windows/custom-claude-path.ps1 +++ b/tests/native/windows/custom-claude-path.ps1 @@ -648,7 +648,7 @@ function Test-Category6-Integration { $Output = & $CcsPath --version 2>&1 | Out-String - $ShowsVersion = $Output -match "CCS \(Claude Code Switch\) version" + $ShowsVersion = $Output -match "CCS \(Claude Codex Switch\) v\d+\.\d+\.\d+" if ($ShowsVersion) { $Duration = ((Get-Date) - $TestStart).TotalMilliseconds diff --git a/tests/npm/special-commands.test.js b/tests/npm/special-commands.test.js index 13726274..904bcb2c 100644 --- a/tests/npm/special-commands.test.js +++ b/tests/npm/special-commands.test.js @@ -18,7 +18,7 @@ describe('integration: special commands', () => { it('shows version with --version', () => { const output = execSync(buildCliCommand('--version'), { encoding: 'utf8' }); - assert(output.includes('CCS (Claude Code Switch)')); + assert(output.includes('CCS (Claude Codex Switch)')); assert(/v\d+\.\d+\.\d+/.test(output)); }); diff --git a/tests/unit/api/profile-lifecycle-service.test.ts b/tests/unit/api/profile-lifecycle-service.test.ts index 5d984973..4d124e2b 100644 --- a/tests/unit/api/profile-lifecycle-service.test.ts +++ b/tests/unit/api/profile-lifecycle-service.test.ts @@ -9,6 +9,7 @@ import { importApiProfileBundle, registerApiProfileOrphans, } from '../../../src/api/services/profile-lifecycle-service'; +import { validateApiProfileSettingsPayload } from '../../../src/api/services/profile-lifecycle-validation'; import { createApiProfile } from '../../../src/api/services/profile-writer'; import { loadConfigSafe, @@ -69,6 +70,38 @@ describe('profile lifecycle service', () => { } }); + it('accepts ANTHROPIC_DEFAULT_FABLE_MODEL as a supported model mapping', () => { + const validation = validateApiProfileSettingsPayload({ + env: { + ANTHROPIC_BASE_URL: 'http://127.0.0.1:8317/api/provider/codex', + ANTHROPIC_AUTH_TOKEN: 'token', + ANTHROPIC_DEFAULT_FABLE_MODEL: 'gpt-5.4-mini', + }, + }); + + expect(validation.valid).toBe(true); + expect(validation.issues).toEqual([]); + }); + + it('enforces the provider denylist for ANTHROPIC_DEFAULT_FABLE_MODEL', () => { + const validation = validateApiProfileSettingsPayload({ + env: { + ANTHROPIC_BASE_URL: 'http://127.0.0.1:8317/api/provider/agy', + ANTHROPIC_AUTH_TOKEN: 'token', + ANTHROPIC_DEFAULT_FABLE_MODEL: 'claude-sonnet-4.5', + }, + }); + + expect(validation.valid).toBe(false); + expect(validation.issues).toContainEqual( + expect.objectContaining({ + level: 'error', + code: 'model_denylisted', + field: 'env.ANTHROPIC_DEFAULT_FABLE_MODEL', + }) + ); + }); + it('discovers grandfathered xai/grok orphans while skipping other reserved names', async () => { const ccsDir = path.join(tempHome, '.ccs'); fs.mkdirSync(ccsDir, { recursive: true }); diff --git a/tests/unit/shared/extended-context-utils.test.ts b/tests/unit/shared/extended-context-utils.test.ts new file mode 100644 index 00000000..3694f74c --- /dev/null +++ b/tests/unit/shared/extended-context-utils.test.ts @@ -0,0 +1,24 @@ +import { describe, expect, it } from 'bun:test'; + +import { + ANTHROPIC_MODEL_ENV_KEYS, + isAnthropicModelEnvKey, +} from '../../../src/shared/extended-context-utils'; + +describe('Anthropic model env keys', () => { + it('includes all five tier mapping keys', () => { + expect([...ANTHROPIC_MODEL_ENV_KEYS]).toEqual([ + 'ANTHROPIC_MODEL', + 'ANTHROPIC_DEFAULT_OPUS_MODEL', + 'ANTHROPIC_DEFAULT_SONNET_MODEL', + 'ANTHROPIC_DEFAULT_HAIKU_MODEL', + 'ANTHROPIC_DEFAULT_FABLE_MODEL', + ]); + }); + + it('recognizes the fable tier key like the other tiers', () => { + expect(isAnthropicModelEnvKey('ANTHROPIC_DEFAULT_FABLE_MODEL')).toBe(true); + expect(isAnthropicModelEnvKey('ANTHROPIC_DEFAULT_HAIKU_MODEL')).toBe(true); + expect(isAnthropicModelEnvKey('ANTHROPIC_BASE_URL')).toBe(false); + }); +}); diff --git a/tests/unit/utils/claudecode-env-stripping.test.ts b/tests/unit/utils/claudecode-env-stripping.test.ts index ddb3ab71..5187e094 100644 --- a/tests/unit/utils/claudecode-env-stripping.test.ts +++ b/tests/unit/utils/claudecode-env-stripping.test.ts @@ -264,6 +264,7 @@ describe('CLAUDECODE environment stripping', () => { 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_DEFAULT_FABLE_MODEL; delete process.env.ANTHROPIC_SMALL_FAST_MODEL; delete process.env.CLAUDE_CODE_MAX_OUTPUT_TOKENS; @@ -543,6 +544,7 @@ describe('CLAUDECODE environment stripping', () => { process.env.ANTHROPIC_API_KEY = 'parent-api-key'; process.env.ANTHROPIC_MODEL = 'gpt-5.4'; process.env.ANTHROPIC_DEFAULT_SONNET_MODEL = 'gpt-5.4'; + process.env.ANTHROPIC_DEFAULT_FABLE_MODEL = 'gpt-5.4-mini'; execClaude('claude', ['--help'], { CCS_PROFILE_TYPE: 'settings', @@ -570,10 +572,18 @@ describe('CLAUDECODE environment stripping', () => { call.args[1] === 'ANTHROPIC_MODEL' && call.args[2] === 'gpt-5.4' ); + const fableModelCall = spawnSyncCalls.find( + (call) => + call.command === 'tmux' && + call.args[0] === 'setenv' && + call.args[1] === 'ANTHROPIC_DEFAULT_FABLE_MODEL' && + call.args[2] === 'gpt-5.4-mini' + ); expect(unsetBaseUrlCall).toBeDefined(); expect(unsetAuthTokenCall).toBeDefined(); expect(modelCall).toBeDefined(); + expect(fableModelCall).toBeDefined(); }); it('headless executor spawn path strips CLAUDECODE before spawn', async () => { diff --git a/ui/index.html b/ui/index.html index e36ed302..cac90fb3 100644 --- a/ui/index.html +++ b/ui/index.html @@ -4,7 +4,7 @@ CCS Dashboard - + diff --git a/ui/src/components/cliproxy/provider-editor/index.tsx b/ui/src/components/cliproxy/provider-editor/index.tsx index b4df3f7a..b7506691 100644 --- a/ui/src/components/cliproxy/provider-editor/index.tsx +++ b/ui/src/components/cliproxy/provider-editor/index.tsx @@ -122,6 +122,7 @@ export function ProviderEditor({ opusModel, sonnetModel, haikuModel, + fableModel, extendedContextEnabled, toggleExtendedContext, handleRawJsonChange, @@ -271,6 +272,7 @@ export function ProviderEditor({ opusModel={opusModel} sonnetModel={sonnetModel} haikuModel={haikuModel} + fableModel={fableModel} providerModels={providerModels} routing={routing} extendedContextEnabled={extendedContextEnabled} 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 52eb5aea..ec64f636 100644 --- a/ui/src/components/cliproxy/provider-editor/model-config-section.tsx +++ b/ui/src/components/cliproxy/provider-editor/model-config-section.tsx @@ -43,6 +43,7 @@ export function ModelConfigSection({ opusModel, sonnetModel, haikuModel, + fableModel, providerModels, routing, provider, @@ -67,7 +68,7 @@ export function ModelConfigSection({ const extendedContextModels = useMemo(() => { if (!catalog) return []; - const selectedModels = [currentModel, opusModel, sonnetModel, haikuModel] + const selectedModels = [currentModel, opusModel, sonnetModel, haikuModel, fableModel] .filter((modelId): modelId is string => Boolean(modelId)) .map((modelId) => stripExtendedContextSuffix(modelId)); @@ -75,7 +76,7 @@ export function ModelConfigSection({ return uniqueIds .map((modelId) => findCatalogModel(catalog.provider, modelId, catalog)) .filter((model): model is NonNullable => Boolean(model?.extendedContext)); - }, [catalog, currentModel, opusModel, sonnetModel, haikuModel]); + }, [catalog, currentModel, opusModel, sonnetModel, haikuModel, fableModel]); const resolvedCatalogModels = useMemo( () => getResolvedCatalogModels(catalog, providerModels), @@ -253,6 +254,15 @@ export function ModelConfigSection({ onToggle={onExtendedContextToggle} /> )} + onUpdateEnvValue('ANTHROPIC_DEFAULT_FABLE_MODEL', model)} + catalog={catalog} + allModels={providerModels} + routing={routing} + /> ; routing?: CliproxyProviderRoutingHints; /** Whether extended context (1M tokens) is enabled */ @@ -72,6 +73,7 @@ export function ModelConfigTab({ opusModel, sonnetModel, haikuModel, + fableModel, providerModels, routing, extendedContextEnabled, @@ -163,6 +165,7 @@ export function ModelConfigTab({ opusModel={opusModel} sonnetModel={sonnetModel} haikuModel={haikuModel} + fableModel={fableModel} providerModels={providerModels} routing={routing} provider={provider} diff --git a/ui/src/components/cliproxy/provider-editor/types.ts b/ui/src/components/cliproxy/provider-editor/types.ts index 3e1c6bc2..2f10d017 100644 --- a/ui/src/components/cliproxy/provider-editor/types.ts +++ b/ui/src/components/cliproxy/provider-editor/types.ts @@ -126,6 +126,7 @@ export interface ModelConfigSectionProps { opusModel?: string; sonnetModel?: string; haikuModel?: string; + fableModel?: string; providerModels: Array<{ id: string; owned_by: string }>; routing?: CliproxyProviderRoutingHints; /** Provider name for display */ @@ -154,6 +155,7 @@ export interface UseProviderEditorReturn { opusModel?: string; sonnetModel?: string; haikuModel?: string; + fableModel?: string; /** Whether extended context (1M tokens) is enabled */ extendedContextEnabled: boolean; /** Toggle extended context on/off */ diff --git a/ui/src/components/cliproxy/provider-editor/use-provider-editor.ts b/ui/src/components/cliproxy/provider-editor/use-provider-editor.ts index 739955c1..35ac28b1 100644 --- a/ui/src/components/cliproxy/provider-editor/use-provider-editor.ts +++ b/ui/src/components/cliproxy/provider-editor/use-provider-editor.ts @@ -85,6 +85,7 @@ export function useProviderEditor( const opusModel = currentSettings?.env?.ANTHROPIC_DEFAULT_OPUS_MODEL; const sonnetModel = currentSettings?.env?.ANTHROPIC_DEFAULT_SONNET_MODEL; const haikuModel = currentSettings?.env?.ANTHROPIC_DEFAULT_HAIKU_MODEL; + const fableModel = currentSettings?.env?.ANTHROPIC_DEFAULT_FABLE_MODEL; // Extended context is enabled if any model has [1m] suffix const extendedContextEnabled = useMemo(() => { @@ -224,6 +225,7 @@ export function useProviderEditor( opusModel, sonnetModel, haikuModel, + fableModel, extendedContextEnabled, toggleExtendedContext, handleRawJsonChange, diff --git a/ui/src/components/cliproxy/provider-model-selector.tsx b/ui/src/components/cliproxy/provider-model-selector.tsx index a9c47779..e6607c19 100644 --- a/ui/src/components/cliproxy/provider-model-selector.tsx +++ b/ui/src/components/cliproxy/provider-model-selector.tsx @@ -10,16 +10,31 @@ import { useTranslation } from 'react-i18next'; import { Badge } from '@/components/ui/badge'; import { SearchableSelect } from '@/components/ui/searchable-select'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select'; import { Skeleton } from '@/components/ui/skeleton'; +import { Switch } from '@/components/ui/switch'; import type { CliproxyProviderRoutingHints } from '@/lib/api-client'; import type { CodexEffort, CodexServiceTier } from '@/lib/codex-effort'; import { + applyCodexServiceTierSuffix, getCodexEffortDisplay, getCodexEffortVariants, parseCodexEffort, parseCodexServiceTier, + stripCodexEffortSuffix, } from '@/lib/codex-effort'; -import { getResolvedCatalogModels, getSupplementalCatalogModels } from '@/lib/model-catalogs'; +import { + findCatalogModel, + getResolvedCatalogModels, + getSupplementalCatalogModels, +} from '@/lib/model-catalogs'; +import { getReasoningAdapter } from '@/lib/reasoning-control'; import { cn } from '@/lib/utils'; /** Model entry from catalog */ @@ -47,6 +62,8 @@ export interface ModelEntry { codexEfforts?: CodexEffort[]; /** Additional Codex service-tier suffixes supported by this model in the dashboard UI. */ codexServiceTiers?: CodexServiceTier[]; + /** Reasoning-level suffixes this model supports via paren syntax in the dashboard UI. */ + reasoningLevels?: string[]; } /** Provider catalog */ @@ -561,6 +578,34 @@ export function FlexibleModelSelector({ } : null; const hasAvailableModels = recommendedOptions.length + allModelOptions.length > 0; + const reasoningAdapter = getReasoningAdapter(catalog?.provider); + const currentReasoningLevel = reasoningAdapter?.parse(value); + const selectableReasoningLevels = + reasoningAdapter && catalog + ? reasoningAdapter.getOptions( + reasoningAdapter.strip(normalizeModelValue(value, routing)), + catalog + ) + : null; + const reasoningOptions = + selectableReasoningLevels && + currentReasoningLevel && + !selectableReasoningLevels.includes(currentReasoningLevel) + ? [...selectableReasoningLevels, currentReasoningLevel] + : selectableReasoningLevels; + const currentServiceTier = isCodexProvider ? parseCodexServiceTier(value) : undefined; + const selectedCodexBaseModel = + isCodexProvider && catalog && value + ? findCatalogModel( + catalog.provider, + stripCodexEffortSuffix(normalizeModelValue(value, routing)), + catalog + ) + : undefined; + const showFastToggle = + isCodexProvider && + (selectedCodexBaseModel?.codexServiceTiers?.includes('fast') === true || + currentServiceTier === 'fast'); return (
@@ -568,87 +613,129 @@ export function FlexibleModelSelector({ {description &&

{description}

}
- - {t('providerModelSelector.currentValue')} - - ), - }, - ] - : []), - { - key: 'recommended', - label: ( - {t('providerModelSelector.recommended')} - ), - }, - ...(isCodexProvider - ? [ - { - key: 'codex-reasoning', - label: ( - - {t('providerModelSelector.codexReasoningVariants')} - - ), - }, - { - key: 'codex-fast', - label: ( - - {t('providerModelSelector.codexFastVariants')} - - ), - }, - ] - : []), - ...(allModelOptions.length > 0 - ? [ - { - key: 'all', - label: ( - - {t('providerModelSelector.allModelsCount', { - count: allModelOptions.length, - })} - - ), - }, - ] - : []), - { - key: 'custom', - label: ( - - {t('providerModelSelector.customModel')} - - ), - }, - ]} - options={[ - ...(selectedValueMissing && legacySelectedOption ? [legacySelectedOption] : []), - ...recommendedOptions, - ...allModelOptions, - ]} - /> +
+ + {t('providerModelSelector.currentValue')} + + ), + }, + ] + : []), + { + key: 'recommended', + label: ( + + {t('providerModelSelector.recommended')} + + ), + }, + ...(isCodexProvider + ? [ + { + key: 'codex-reasoning', + label: ( + + {t('providerModelSelector.codexReasoningVariants')} + + ), + }, + { + key: 'codex-fast', + label: ( + + {t('providerModelSelector.codexFastVariants')} + + ), + }, + ] + : []), + ...(allModelOptions.length > 0 + ? [ + { + key: 'all', + label: ( + + {t('providerModelSelector.allModelsCount', { + count: allModelOptions.length, + })} + + ), + }, + ] + : []), + { + key: 'custom', + label: ( + + {t('providerModelSelector.customModel')} + + ), + }, + ]} + options={[ + ...(selectedValueMissing && legacySelectedOption ? [legacySelectedOption] : []), + ...recommendedOptions, + ...allModelOptions, + ]} + /> + {reasoningAdapter && reasoningOptions && ( + + )} + {showFastToggle && ( + + )} +
{selectedRoutingHint ? (
string; auto: string } diff --git a/ui/src/lib/i18n.ts b/ui/src/lib/i18n.ts index 4cb565ca..f75f9118 100644 --- a/ui/src/lib/i18n.ts +++ b/ui/src/lib/i18n.ts @@ -228,6 +228,9 @@ const resources = { 'Pinned model is not currently advertised by the proxy: {{model}}', codexReasoningVariants: 'Reasoning variants', codexFastVariants: 'Fast variants', + effortLabel: 'Effort', + effortAuto: 'Auto', + fastTier: 'Fast', }, createAuthProfileDialog: { title: 'Create New Account', @@ -1582,7 +1585,7 @@ const resources = { // ======================================== heroSection: { title: 'CCS Config', - subtitle: 'Claude Code Switch Dashboard', + subtitle: 'Claude Codex Switch Dashboard', }, hubFooter: { logs: 'Logs', @@ -2960,6 +2963,9 @@ const resources = { pinnedModelNotAdvertised: '固定模型当前未被代理广播:{{model}}', codexReasoningVariants: '推理变体', codexFastVariants: '快速变体', + effortLabel: '推理强度', + effortAuto: '自动', + fastTier: '快速', }, createAuthProfileDialog: { title: '创建新账号', @@ -4233,7 +4239,7 @@ const resources = { }, heroSection: { title: 'CCS Config', - subtitle: 'Claude Code Switch Dashboard', + subtitle: 'Claude Codex Switch Dashboard', }, hubFooter: { logs: '日志', @@ -5575,6 +5581,9 @@ const resources = { pinnedModelNotAdvertised: 'Mô hình ghim hiện không được proxy quảng bá: {{model}}', codexReasoningVariants: 'Biến thể lý luận', codexFastVariants: 'Biến thể nhanh', + effortLabel: 'Mức suy luận', + effortAuto: 'Tự động', + fastTier: 'Nhanh', }, createAuthProfileDialog: { title: 'Tạo tài khoản mới', @@ -6943,7 +6952,7 @@ const resources = { }, heroSection: { title: 'CCS Config', - subtitle: 'Bảng điều khiển Claude Code Switch', + subtitle: 'Bảng điều khiển Claude Codex Switch', }, hubFooter: { logs: 'Nhật ký', @@ -8306,6 +8315,9 @@ const resources = { pinnedModelNotAdvertised: 'ピンモデルは現在プロキシから通知されていません: {{model}}', codexReasoningVariants: '推論バリアント', codexFastVariants: '高速バリアント', + effortLabel: '推論レベル', + effortAuto: '自動', + fastTier: '高速', }, createAuthProfileDialog: { title: '新しいアカウントを作成', @@ -10136,7 +10148,7 @@ const resources = { }, heroSection: { title: 'CCS Config', - subtitle: 'Claude Code Switch Dashboard', + subtitle: 'Claude Codex Switch Dashboard', }, homePageV2: { title: 'ホーム', @@ -11060,6 +11072,9 @@ const resources = { pinnedModelNotAdvertised: '고정된 모델이 현재 프록시에서 알리지 않고 있습니다: {{model}}', codexReasoningVariants: '추론 변형', codexFastVariants: '빠른 변형', + effortLabel: '추론 강도', + effortAuto: '자동', + fastTier: '고속', }, createAuthProfileDialog: { title: '새 계정 생성', @@ -12414,7 +12429,7 @@ const resources = { // ======================================== heroSection: { title: 'CCS Config', - subtitle: 'Claude Code Switch 대시보드', + subtitle: 'Claude Codex Switch 대시보드', }, hubFooter: { logs: '로그', diff --git a/ui/src/lib/model-catalogs.ts b/ui/src/lib/model-catalogs.ts index 34c82ca4..e6b03dcb 100644 --- a/ui/src/lib/model-catalogs.ts +++ b/ui/src/lib/model-catalogs.ts @@ -402,11 +402,13 @@ export const MODEL_CATALOGS: Record = { id: 'grok-4.5', name: 'Grok 4.5', description: 'Frontier model for coding, engineering, and agentic workflows', + reasoningLevels: ['low', 'medium', 'high'], }, { id: 'grok-4.3', name: 'Grok 4.3', description: 'General-purpose Grok model with a one-million-token context window', + reasoningLevels: ['none', 'low', 'medium', 'high'], }, { id: 'grok-4.20-0309-reasoning', @@ -1147,6 +1149,10 @@ export function buildUiCatalog( ? undefined : (model.extendedContext ?? staticModel?.extendedContext), presetMapping: staticModel?.presetMapping, + reasoningLevels: model.reasoningLevels ?? staticModel?.reasoningLevels, + codexMaxEffort: model.codexMaxEffort ?? staticModel?.codexMaxEffort, + codexEfforts: model.codexEfforts ?? staticModel?.codexEfforts, + codexServiceTiers: model.codexServiceTiers ?? staticModel?.codexServiceTiers, }; }); diff --git a/ui/src/lib/reasoning-control.ts b/ui/src/lib/reasoning-control.ts new file mode 100644 index 00000000..9bf6489d --- /dev/null +++ b/ui/src/lib/reasoning-control.ts @@ -0,0 +1,63 @@ +import type { ProviderCatalog } from '@/components/cliproxy/provider-model-selector'; +import { + applyCodexEffortSuffix, + getSelectableCodexEfforts, + parseCodexEffort, + stripCodexEffortSuffix, + type CodexEffort, +} from '@/lib/codex-effort'; +import { findCatalogModel } from '@/lib/model-catalogs'; + +export interface ReasoningAdapter { + parse(value: string | undefined): string | undefined; + strip(value: string | undefined): string; + apply(value: string | undefined, level?: string): string; + getOptions(baseId: string, catalog: ProviderCatalog): string[] | null; +} + +const XAI_LEVEL_SUFFIX_REGEX = /\(([a-z]+)\)$/i; + +export function parseXaiReasoningLevel(modelId: string | undefined): string | undefined { + const match = modelId?.trim().match(XAI_LEVEL_SUFFIX_REGEX); + return match?.[1]?.toLowerCase(); +} + +export function stripXaiReasoningSuffix(modelId: string | undefined): string { + return modelId?.trim().replace(XAI_LEVEL_SUFFIX_REGEX, '') ?? ''; +} + +export function applyXaiReasoningSuffix(modelId: string | undefined, level?: string): string { + const baseModelId = stripXaiReasoningSuffix(modelId); + if (!baseModelId || !level) return baseModelId; + return `${baseModelId}(${level})`; +} + +const codexAdapter: ReasoningAdapter = { + parse: (value) => parseCodexEffort(value), + strip: (value) => stripCodexEffortSuffix(value), + apply: (value, level) => applyCodexEffortSuffix(value, level as CodexEffort | undefined), + getOptions: (baseId, catalog) => { + const model = baseId ? findCatalogModel(catalog.provider, baseId, catalog) : undefined; + return getSelectableCodexEfforts(model?.codexMaxEffort, model?.codexEfforts); + }, +}; + +const xaiAdapter: ReasoningAdapter = { + parse: parseXaiReasoningLevel, + strip: stripXaiReasoningSuffix, + apply: applyXaiReasoningSuffix, + getOptions: (baseId, catalog) => { + if (!baseId) return null; + const levels = findCatalogModel(catalog.provider, baseId, catalog)?.reasoningLevels; + return levels?.length ? [...levels] : null; + }, +}; + +const REASONING_ADAPTERS: Record = { + codex: codexAdapter, + xai: xaiAdapter, +}; + +export function getReasoningAdapter(provider: string | undefined): ReasoningAdapter | undefined { + return provider ? REASONING_ADAPTERS[provider.toLowerCase()] : undefined; +} diff --git a/ui/tests/unit/components/cliproxy/provider-editor/model-config-section.test.tsx b/ui/tests/unit/components/cliproxy/provider-editor/model-config-section.test.tsx index f26e9909..89bf5640 100644 --- a/ui/tests/unit/components/cliproxy/provider-editor/model-config-section.test.tsx +++ b/ui/tests/unit/components/cliproxy/provider-editor/model-config-section.test.tsx @@ -13,6 +13,29 @@ import { ModelConfigSection } from '@/components/cliproxy/provider-editor/model- import { MODEL_CATALOGS } from '@/lib/model-catalogs'; describe('ModelConfigSection presets', () => { + it('renders five tier selectors including the fable row', () => { + render( + + ); + + expect(screen.getAllByTestId('flexible-model-selector')).toHaveLength(5); + }); + it('groups codex presets by free and paid tiers', async () => { const onApplyPreset = vi.fn(); 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 25c3e7ab..f330c78f 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 @@ -152,6 +152,44 @@ describe('useProviderEditor', () => { }); }); + it('derives fableModel and round-trips ANTHROPIC_DEFAULT_FABLE_MODEL 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_FABLE_MODEL: 'gpt-5.6-sol-xhigh', + }, + }, + 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.fableModel).toBe('gpt-5.6-sol-xhigh')); + + act(() => { + result.current.updateEnvValue('ANTHROPIC_DEFAULT_FABLE_MODEL', 'gpt-5.6-sol-max'); + }); + + const nextSettings = JSON.parse(result.current.rawJsonContent); + expect(nextSettings.env.ANTHROPIC_DEFAULT_FABLE_MODEL).toBe('gpt-5.6-sol-max'); + }); + it('preserves explicit codex effort suffixes in editor state updates', async () => { vi.stubGlobal( 'fetch', diff --git a/ui/tests/unit/ui/lib/codex-effort.test.ts b/ui/tests/unit/ui/lib/codex-effort.test.ts index 557da198..a0f377be 100644 --- a/ui/tests/unit/ui/lib/codex-effort.test.ts +++ b/ui/tests/unit/ui/lib/codex-effort.test.ts @@ -1,8 +1,10 @@ import { describe, expect, it } from 'vitest'; import { applyCodexEffortSuffix, + applyCodexServiceTierSuffix, getCodexEffortDisplay, getCodexEffortVariants, + getSelectableCodexEfforts, parseCodexEffort, parseCodexServiceTier, stripCodexEffortSuffix, @@ -85,6 +87,15 @@ describe('codex effort helpers', () => { ]); }); + it('replaces effort while preserving the fast service tier', () => { + expect(applyCodexEffortSuffix('gpt-5.4-high-fast', 'low')).toBe('gpt-5.4-low-fast'); + expect(applyCodexEffortSuffix('gpt-5.4-high-fast', undefined)).toBe('gpt-5.4-fast'); + }); + + it('applies effort suffixes to routing-prefixed model ids', () => { + expect(applyCodexEffortSuffix('codex/gpt-5.6-sol', 'high')).toBe('codex/gpt-5.6-sol-high'); + }); + it('builds fast variants for models with a fast service tier', () => { expect(getCodexEffortVariants('gpt-5.4', 'high', ['fast'])).toEqual([ 'gpt-5.4', @@ -100,3 +111,69 @@ describe('codex effort helpers', () => { ]); }); }); + +describe('applyCodexServiceTierSuffix', () => { + it('adds the fast tier while preserving the effort suffix', () => { + expect(applyCodexServiceTierSuffix('gpt-5.4-high', true)).toBe('gpt-5.4-high-fast'); + }); + + it('removes only the fast tier', () => { + expect(applyCodexServiceTierSuffix('gpt-5.4-high-fast', false)).toBe('gpt-5.4-high'); + expect(applyCodexServiceTierSuffix('gpt-5.4-fast', false)).toBe('gpt-5.4'); + }); + + it('preserves routing prefixes', () => { + expect(applyCodexServiceTierSuffix('codex/gpt-5.6-sol', true)).toBe('codex/gpt-5.6-sol-fast'); + }); + + it('is idempotent and safe on empty input', () => { + expect(applyCodexServiceTierSuffix('gpt-5.4-fast', true)).toBe('gpt-5.4-fast'); + expect(applyCodexServiceTierSuffix('gpt-5.4', false)).toBe('gpt-5.4'); + expect(applyCodexServiceTierSuffix(undefined, true)).toBe(''); + expect(applyCodexServiceTierSuffix('', true)).toBe(''); + }); + + it('composes with applyCodexEffortSuffix in both orders', () => { + expect(applyCodexEffortSuffix(applyCodexServiceTierSuffix('gpt-5.4', true), 'high')).toBe( + 'gpt-5.4-high-fast' + ); + expect(applyCodexServiceTierSuffix(applyCodexEffortSuffix('gpt-5.4', 'high'), true)).toBe( + 'gpt-5.4-high-fast' + ); + expect( + applyCodexEffortSuffix(applyCodexServiceTierSuffix('gpt-5.4-high-fast', false), 'low') + ).toBe('gpt-5.4-low'); + }); +}); + +describe('getSelectableCodexEfforts', () => { + it('returns the explicit efforts list when provided', () => { + expect(getSelectableCodexEfforts('xhigh', ['low', 'medium', 'high', 'xhigh'])).toEqual([ + 'low', + 'medium', + 'high', + 'xhigh', + ]); + }); + + it('returns efforts up to and including the max effort', () => { + expect(getSelectableCodexEfforts('xhigh')).toEqual([ + 'minimal', + 'low', + 'medium', + 'high', + 'xhigh', + ]); + expect(getSelectableCodexEfforts('medium')).toEqual(['minimal', 'low', 'medium']); + }); + + it('returns all efforts for custom models without catalog metadata', () => { + expect(getSelectableCodexEfforts(undefined)).toEqual([ + 'minimal', + 'low', + 'medium', + 'high', + 'xhigh', + ]); + }); +}); diff --git a/ui/tests/unit/ui/lib/model-catalogs-codex-merge.test.ts b/ui/tests/unit/ui/lib/model-catalogs-codex-merge.test.ts new file mode 100644 index 00000000..79b13e09 --- /dev/null +++ b/ui/tests/unit/ui/lib/model-catalogs-codex-merge.test.ts @@ -0,0 +1,30 @@ +import { describe, expect, it } from 'vitest'; +import { buildUiCatalog } from '@/lib/model-catalogs'; + +describe('buildUiCatalog codex tuning metadata merge', () => { + it('preserves static codex effort and service-tier metadata when the live catalog omits them', () => { + const merged = buildUiCatalog('codex', { + provider: 'codex', + displayName: 'Codex', + defaultModel: 'gpt-5.6-sol', + models: [{ id: 'gpt-5.6-sol', name: 'GPT-5.6 Sol' }], + }); + + const model = merged?.models.find((entry) => entry.id === 'gpt-5.6-sol'); + expect(model?.codexMaxEffort).toBe('xhigh'); + expect(model?.codexEfforts).toEqual(['low', 'medium', 'high', 'xhigh']); + expect(model?.codexServiceTiers).toEqual(['fast']); + }); + + it('prefers live metadata over static when the live catalog provides it', () => { + const merged = buildUiCatalog('codex', { + provider: 'codex', + displayName: 'Codex', + defaultModel: 'gpt-5.6-sol', + models: [{ id: 'gpt-5.6-sol', name: 'GPT-5.6 Sol', codexServiceTiers: [] }], + }); + + const model = merged?.models.find((entry) => entry.id === 'gpt-5.6-sol'); + expect(model?.codexServiceTiers).toEqual([]); + }); +}); diff --git a/ui/tests/unit/ui/lib/reasoning-control.test.ts b/ui/tests/unit/ui/lib/reasoning-control.test.ts new file mode 100644 index 00000000..a31f815f --- /dev/null +++ b/ui/tests/unit/ui/lib/reasoning-control.test.ts @@ -0,0 +1,168 @@ +import { describe, expect, it } from 'vitest'; +import type { ProviderCatalog } from '@/components/cliproxy/provider-model-selector'; +import { + applyXaiReasoningSuffix, + getReasoningAdapter, + parseXaiReasoningLevel, + stripXaiReasoningSuffix, +} from '@/lib/reasoning-control'; + +const xaiCatalog: ProviderCatalog = { + provider: 'xai', + displayName: 'xAI (Grok)', + defaultModel: 'grok-build-0.1', + models: [ + { id: 'grok-build-0.1', name: 'Grok Build 0.1' }, + { id: 'grok-4.5', name: 'Grok 4.5', reasoningLevels: ['low', 'medium', 'high'] }, + { id: 'grok-4.3', name: 'Grok 4.3', reasoningLevels: ['none', 'low', 'medium', 'high'] }, + ], +}; + +const codexCatalog: ProviderCatalog = { + provider: 'codex', + displayName: 'Codex', + defaultModel: 'gpt-5.3-codex', + models: [ + { id: 'gpt-5.3-codex', name: 'GPT-5.3 Codex', codexMaxEffort: 'xhigh' }, + { id: 'gpt-5.4-mini', name: 'GPT-5.4 Mini', codexMaxEffort: 'high' }, + { + id: 'gpt-5.6-sol', + name: 'GPT-5.6 Sol', + codexEfforts: ['low', 'medium', 'high', 'xhigh'], + }, + ], +}; + +describe('xai paren codec', () => { + it('parses paren level suffixes', () => { + expect(parseXaiReasoningLevel('grok-4.5(high)')).toBe('high'); + expect(parseXaiReasoningLevel('grok-4.3(none)')).toBe('none'); + expect(parseXaiReasoningLevel('grok-4.5(HIGH)')).toBe('high'); + }); + + it('returns undefined for unsuffixed or non-level values', () => { + expect(parseXaiReasoningLevel('grok-4.5')).toBeUndefined(); + expect(parseXaiReasoningLevel('grok-4(1m)')).toBeUndefined(); + expect(parseXaiReasoningLevel(undefined)).toBeUndefined(); + }); + + it('only matches the suffix at end-of-string', () => { + expect(parseXaiReasoningLevel('grok(high)-fast')).toBeUndefined(); + expect(stripXaiReasoningSuffix('grok(high)-fast')).toBe('grok(high)-fast'); + }); + + it('strips paren suffixes for catalog lookup', () => { + expect(stripXaiReasoningSuffix('grok-4.5(high)')).toBe('grok-4.5'); + expect(stripXaiReasoningSuffix('grok-4.5')).toBe('grok-4.5'); + expect(stripXaiReasoningSuffix(undefined)).toBe(''); + }); + + it('applies and replaces paren suffixes', () => { + expect(applyXaiReasoningSuffix('grok-4.5', 'high')).toBe('grok-4.5(high)'); + expect(applyXaiReasoningSuffix('grok-4.5(high)', 'low')).toBe('grok-4.5(low)'); + expect(applyXaiReasoningSuffix('grok-4.3', 'none')).toBe('grok-4.3(none)'); + }); + + it('strips the suffix when no level is given', () => { + expect(applyXaiReasoningSuffix('grok-4.5(high)', undefined)).toBe('grok-4.5'); + expect(applyXaiReasoningSuffix('grok-4.3(none)', undefined)).toBe('grok-4.3'); + expect(applyXaiReasoningSuffix(undefined, 'high')).toBe(''); + }); + + it('round-trips through parse and apply', () => { + const stored = applyXaiReasoningSuffix('grok-4.5', 'high'); + expect(parseXaiReasoningLevel(stored)).toBe('high'); + expect(applyXaiReasoningSuffix(stored, parseXaiReasoningLevel(stored))).toBe(stored); + }); +}); + +describe('getReasoningAdapter', () => { + it('returns adapters for codex and xai only', () => { + expect(getReasoningAdapter('codex')).toBeDefined(); + expect(getReasoningAdapter('xai')).toBeDefined(); + expect(getReasoningAdapter('XAI')).toBeDefined(); + expect(getReasoningAdapter('gemini')).toBeUndefined(); + expect(getReasoningAdapter(undefined)).toBeUndefined(); + }); +}); + +function requireAdapter(provider: string) { + const adapter = getReasoningAdapter(provider); + if (!adapter) throw new Error(`missing reasoning adapter for ${provider}`); + return adapter; +} + +describe('xai adapter', () => { + const adapter = requireAdapter('xai'); + + it('returns per-model levels from catalog metadata', () => { + expect(adapter.getOptions('grok-4.5', xaiCatalog)).toEqual(['low', 'medium', 'high']); + expect(adapter.getOptions('grok-4.3', xaiCatalog)).toEqual(['none', 'low', 'medium', 'high']); + }); + + it('hides the control for models without levels metadata', () => { + expect(adapter.getOptions('grok-build-0.1', xaiCatalog)).toBeNull(); + expect(adapter.getOptions('grok-unknown-model', xaiCatalog)).toBeNull(); + expect(adapter.getOptions('', xaiCatalog)).toBeNull(); + }); + + it('writes and clears suffixed values through apply', () => { + expect(adapter.apply('grok-4.5', 'high')).toBe('grok-4.5(high)'); + expect(adapter.apply('grok-4.5(high)', undefined)).toBe('grok-4.5'); + expect(adapter.apply('grok-4.3', 'none')).toBe('grok-4.3(none)'); + }); + + it('pre-selects stored suffixed values', () => { + expect(adapter.parse('grok-4.5(high)')).toBe('high'); + expect(adapter.strip('grok-4.5(high)')).toBe('grok-4.5'); + }); +}); + +describe('codex adapter', () => { + const adapter = requireAdapter('codex'); + + it('delegates parse and strip to codex-effort helpers', () => { + expect(adapter.parse('gpt-5.3-codex-high')).toBe('high'); + expect(adapter.parse('gpt-5.4-high-fast')).toBe('high'); + expect(adapter.parse('gpt-5.3-codex')).toBeUndefined(); + expect(adapter.strip('gpt-5.4-fast-high')).toBe('gpt-5.4'); + }); + + it('applies dash suffixes preserving fast tier and routing prefix', () => { + expect(adapter.apply('gpt-5.4-high-fast', 'low')).toBe('gpt-5.4-low-fast'); + expect(adapter.apply('gpt-5.4-high-fast', undefined)).toBe('gpt-5.4-fast'); + expect(adapter.apply('codex/gpt-5.6-sol', 'high')).toBe('codex/gpt-5.6-sol-high'); + }); + + it('returns catalog-driven effort options', () => { + expect(adapter.getOptions('gpt-5.4-mini', codexCatalog)).toEqual([ + 'minimal', + 'low', + 'medium', + 'high', + ]); + expect(adapter.getOptions('gpt-5.6-sol', codexCatalog)).toEqual([ + 'low', + 'medium', + 'high', + 'xhigh', + ]); + }); + + it('falls back to all five efforts for custom or unselected models', () => { + expect(adapter.getOptions('my-org/custom-model', codexCatalog)).toEqual([ + 'minimal', + 'low', + 'medium', + 'high', + 'xhigh', + ]); + expect(adapter.getOptions('', codexCatalog)).toEqual([ + 'minimal', + 'low', + 'medium', + 'high', + 'xhigh', + ]); + }); +}); diff --git a/ui/tests/unit/vite-proxy-origin.test.ts b/ui/tests/unit/vite-proxy-origin.test.ts new file mode 100644 index 00000000..2db01199 --- /dev/null +++ b/ui/tests/unit/vite-proxy-origin.test.ts @@ -0,0 +1,90 @@ +import { describe, expect, it, vi } from 'vitest'; +import type { UserConfig } from 'vite'; + +vi.mock('vite', () => ({ defineConfig: (config: UserConfig) => config })); +vi.mock('@vitejs/plugin-react', () => ({ default: () => ({ name: 'react' }) })); +vi.mock('@tailwindcss/vite', () => ({ default: () => ({ name: 'tailwindcss' }) })); + +const { default: viteConfig } = await import('../../vite.config'); + +type ProxyRequest = { + getHeader(name: string): string | undefined; + setHeader(name: string, value: string): void; +}; + +type IncomingRequest = { + headers: { origin?: string }; +}; + +type ProxyListener = (proxyRequest: ProxyRequest, request: IncomingRequest) => void; + +function getProxyListener(route: '/api' | '/ws', event: 'proxyReq' | 'proxyReqWs'): ProxyListener { + const proxyOptions = (viteConfig as UserConfig).server?.proxy?.[route]; + if (!proxyOptions || typeof proxyOptions === 'string' || !proxyOptions.configure) { + throw new Error(`Missing configure hook for ${route}`); + } + + const listeners = new Map(); + const proxy = { + on(registeredEvent: string, listener: ProxyListener) { + listeners.set(registeredEvent, listener); + }, + }; + + proxyOptions.configure(proxy as never, proxyOptions); + + const listener = listeners.get(event); + if (!listener) { + throw new Error(`Missing ${event} listener for ${route}`); + } + + return listener; +} + +function applyOriginRewrite(listener: ProxyListener, origin?: string): string | undefined { + const headers = new Map(); + if (origin !== undefined) { + headers.set('origin', origin); + } + + listener( + { + getHeader: (name) => headers.get(name), + setHeader: (name, value) => headers.set(name, value), + }, + { headers: { origin } } + ); + + return headers.get('origin'); +} + +it('requires the trusted Vite development port', () => { + expect((viteConfig as UserConfig).server?.strictPort).toBe(true); +}); + +describe.each([ + ['/api', 'proxyReq'], + ['/ws', 'proxyReqWs'], +] as const)('%s Vite dev proxy origin handling', (route, event) => { + const listener = getProxyListener(route, event); + + it.each(['http://localhost:5173', 'http://127.0.0.1:5173', 'http://[::1]:5173'])( + 'rewrites trusted Vite origin %s to the backend origin', + (origin) => { + expect(applyOriginRewrite(listener, origin)).toBe('http://localhost:3000'); + } + ); + + it.each([ + 'http://attacker.example.test', + 'http://localhost.evil:5173', + 'http://localhost:5174', + 'https://localhost:5173', + ])('preserves untrusted origin %s', (origin) => { + expect(applyOriginRewrite(listener, origin)).toBe(origin); + }); + + it('preserves a missing origin', () => { + expect(applyOriginRewrite(listener)).toBeUndefined(); + }); +}); diff --git a/ui/vite.config.ts b/ui/vite.config.ts index 4a31e9f5..96e5459a 100644 --- a/ui/vite.config.ts +++ b/ui/vite.config.ts @@ -1,10 +1,33 @@ +import type { ClientRequest, IncomingMessage } from 'node:http'; import { defineConfig } from 'vite'; +import type { ProxyOptions } from 'vite'; import react from '@vitejs/plugin-react'; import tailwindcss from '@tailwindcss/vite'; import path from 'path'; const UI_ROOT = __dirname; const REPO_ROOT = path.resolve(__dirname, '..'); +const BACKEND_ORIGIN = 'http://localhost:3000'; +const TRUSTED_VITE_DEV_ORIGINS = new Set([ + 'http://localhost:5173', + 'http://127.0.0.1:5173', + 'http://[::1]:5173', +]); + +function rewriteTrustedViteDevOrigin(proxyRequest: ClientRequest, request: IncomingMessage): void { + const origin = request.headers.origin; + if (origin && TRUSTED_VITE_DEV_ORIGINS.has(origin)) { + proxyRequest.setHeader('origin', BACKEND_ORIGIN); + } +} + +const configureHttpProxy: NonNullable = (proxy) => { + proxy.on('proxyReq', rewriteTrustedViteDevOrigin); +}; + +const configureWebSocketProxy: NonNullable = (proxy) => { + proxy.on('proxyReqWs', rewriteTrustedViteDevOrigin); +}; // https://vite.dev/config/ export default defineConfig({ @@ -58,14 +81,23 @@ export default defineConfig({ }, server: { port: 5173, + strictPort: true, fs: { allow: [UI_ROOT, REPO_ROOT], }, proxy: { - '/api': 'http://localhost:3000', + // Translate only trusted local Vite origins for the dashboard's CSRF guard. + // Preserve every other Origin so the backend can reject untrusted callers. + '/api': { + target: BACKEND_ORIGIN, + changeOrigin: true, + configure: configureHttpProxy, + }, '/ws': { target: 'ws://localhost:3000', ws: true, + changeOrigin: true, + configure: configureWebSocketProxy, }, }, },