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

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,
},
},
},