Merge remote-tracking branch 'origin/dev' into kai/review/pr-1655-retry-followup

This commit is contained in:
Tam Nhu Tran committed 2026-07-22 15:08:11 -04:00
commit 2d909a162f
43 files changed
+953 -218

No files matched your search

+1 -1
View File
@@ -1,6 +1,6 @@
<div align="center">
# CCS - Claude Code Switch
# CCS - Claude Codex Switch
![CCS Logo](assets/ccs-logo-medium.png)
Binary file not shown.

Before

Width:  |  Height:  |  Size: 252 KiB

After

Width:  |  Height:  |  Size: 87 KiB

+1 -1
View File
@@ -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
+81 -73
View File
@@ -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
}
]
}
+20 -20
View File
@@ -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 |
+1 -1
View File
@@ -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.
---
+1 -1
View File
@@ -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
+1 -1
View File
@@ -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
+2 -2
View File
@@ -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",
+1 -1
View File
@@ -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() {
+1 -1
View File
@@ -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)
+1 -1
View File
@@ -1,4 +1,4 @@
# PowerShell completion for CCS (Claude Code Switch)
# PowerShell completion for CCS (Claude Codex Switch)
function Invoke-CcsCompletionBackend {
param(
+1 -1
View File
@@ -1,6 +1,6 @@
#compdef ccs
# Zsh completion for CCS (Claude Code Switch)
# Zsh completion for CCS (Claude Codex Switch)
_ccs() {
local current
@@ -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<string>([
'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);
@@ -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<string, string>;
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');
});
+1 -1
View File
@@ -283,7 +283,7 @@ async function runSetupWizard(force: boolean = false): Promise<void> {
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('');
+1 -1
View File
@@ -17,7 +17,7 @@ import { getCcsDir } from '../config/config-loader-facade';
*/
export async function handleVersionCommand(): Promise<void> {
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:'));
+1
View File
@@ -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];
+6 -9
View File
@@ -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',
+1
View File
@@ -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._-]*$/;
+1 -1
View File
@@ -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
+1 -1
View File
@@ -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));
});
@@ -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 });
@@ -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);
});
});
@@ -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 () => {
+1 -1
View File
@@ -4,7 +4,7 @@
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>CCS Dashboard</title>
<meta name="description" content="Claude Code Switch - Profile management dashboard" />
<meta name="description" content="Claude Codex Switch - Profile management dashboard" />
<!-- Favicons -->
<link rel="icon" type="image/x-icon" href="/favicon.ico" />
@@ -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}
@@ -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<typeof model> => 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}
/>
)}
<FlexibleModelSelector
label="Fable (Frontier)"
description="Maps the fable subagent tier; leave empty to keep the native Claude Fable model"
value={fableModel}
onChange={(model) => onUpdateEnvValue('ANTHROPIC_DEFAULT_FABLE_MODEL', model)}
catalog={catalog}
allModels={providerModels}
routing={routing}
/>
<FlexibleModelSelector
label="Opus (Most capable)"
description="For complex reasoning tasks"
@@ -27,6 +27,7 @@ interface ModelConfigTabProps {
opusModel?: string;
sonnetModel?: string;
haikuModel?: string;
fableModel?: string;
providerModels: Array<{ id: string; owned_by: string }>;
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}
@@ -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 */
@@ -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,
@@ -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 (
<div className="space-y-1.5">
@@ -568,87 +613,129 @@ export function FlexibleModelSelector({
<label className="text-xs font-medium">{label}</label>
{description && <p className="text-[10px] text-muted-foreground">{description}</p>}
</div>
<SearchableSelect
value={value || undefined}
onChange={onChange}
disabled={disabled}
placeholder={t('providerModelSelector.selectModel')}
searchPlaceholder={t('searchableSelect.searchModels')}
emptyText={
hasAvailableModels
? t('searchableSelect.noResults')
: t('providerModelSelector.noModelsAvailable')
}
triggerClassName="h-9"
createOption={createCustomModelOption}
groups={[
...(selectedValueMissing && legacySelectedOption
? [
{
key: 'current',
label: (
<span className="text-xs text-muted-foreground">
{t('providerModelSelector.currentValue')}
</span>
),
},
]
: []),
{
key: 'recommended',
label: (
<span className="text-xs text-primary">{t('providerModelSelector.recommended')}</span>
),
},
...(isCodexProvider
? [
{
key: 'codex-reasoning',
label: (
<span className="text-xs text-muted-foreground">
{t('providerModelSelector.codexReasoningVariants')}
</span>
),
},
{
key: 'codex-fast',
label: (
<span className="text-xs text-amber-600">
{t('providerModelSelector.codexFastVariants')}
</span>
),
},
]
: []),
...(allModelOptions.length > 0
? [
{
key: 'all',
label: (
<span className="text-xs text-muted-foreground">
{t('providerModelSelector.allModelsCount', {
count: allModelOptions.length,
})}
</span>
),
},
]
: []),
{
key: 'custom',
label: (
<span className="text-xs text-muted-foreground">
{t('providerModelSelector.customModel')}
</span>
),
},
]}
options={[
...(selectedValueMissing && legacySelectedOption ? [legacySelectedOption] : []),
...recommendedOptions,
...allModelOptions,
]}
/>
<div className="flex gap-2">
<SearchableSelect
value={value || undefined}
onChange={onChange}
disabled={disabled}
placeholder={t('providerModelSelector.selectModel')}
searchPlaceholder={t('searchableSelect.searchModels')}
emptyText={
hasAvailableModels
? t('searchableSelect.noResults')
: t('providerModelSelector.noModelsAvailable')
}
className="min-w-0 flex-1"
triggerClassName="h-9"
createOption={createCustomModelOption}
groups={[
...(selectedValueMissing && legacySelectedOption
? [
{
key: 'current',
label: (
<span className="text-xs text-muted-foreground">
{t('providerModelSelector.currentValue')}
</span>
),
},
]
: []),
{
key: 'recommended',
label: (
<span className="text-xs text-primary">
{t('providerModelSelector.recommended')}
</span>
),
},
...(isCodexProvider
? [
{
key: 'codex-reasoning',
label: (
<span className="text-xs text-muted-foreground">
{t('providerModelSelector.codexReasoningVariants')}
</span>
),
},
{
key: 'codex-fast',
label: (
<span className="text-xs text-amber-600">
{t('providerModelSelector.codexFastVariants')}
</span>
),
},
]
: []),
...(allModelOptions.length > 0
? [
{
key: 'all',
label: (
<span className="text-xs text-muted-foreground">
{t('providerModelSelector.allModelsCount', {
count: allModelOptions.length,
})}
</span>
),
},
]
: []),
{
key: 'custom',
label: (
<span className="text-xs text-muted-foreground">
{t('providerModelSelector.customModel')}
</span>
),
},
]}
options={[
...(selectedValueMissing && legacySelectedOption ? [legacySelectedOption] : []),
...recommendedOptions,
...allModelOptions,
]}
/>
{reasoningAdapter && reasoningOptions && (
<Select
value={currentReasoningLevel ?? 'auto'}
onValueChange={(level) =>
onChange(reasoningAdapter.apply(value, level === 'auto' ? undefined : level))
}
disabled={disabled || !value}
>
<SelectTrigger
className="h-9 w-28 shrink-0"
aria-label={t('providerModelSelector.effortLabel')}
>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="auto">{t('providerModelSelector.effortAuto')}</SelectItem>
{reasoningOptions.map((level) => (
<SelectItem key={level} value={level}>
{level}
</SelectItem>
))}
</SelectContent>
</Select>
)}
{showFastToggle && (
<label className="flex h-9 shrink-0 items-center gap-1.5">
<Switch
checked={currentServiceTier === 'fast'}
onCheckedChange={(checked) => onChange(applyCodexServiceTierSuffix(value, checked))}
disabled={disabled || !value}
aria-label={t('providerModelSelector.fastTier')}
/>
<span className="text-xs text-muted-foreground">
{t('providerModelSelector.fastTier')}
</span>
</label>
)}
</div>
{selectedRoutingHint ? (
<div
className={cn(
+17 -1
View File
@@ -2,7 +2,7 @@ export type CodexEffort = 'minimal' | 'low' | 'medium' | 'high' | 'xhigh';
export type CodexServiceTier = 'fast';
const CODEX_TUNING_SUFFIX_TOKEN_REGEX = /-(minimal|low|medium|high|xhigh|fast)$/i;
const CODEX_EFFORTS_IN_ORDER: readonly CodexEffort[] = [
export const CODEX_EFFORTS_IN_ORDER: readonly CodexEffort[] = [
'minimal',
'low',
'medium',
@@ -66,6 +66,12 @@ export function applyCodexEffortSuffix(
return [normalizedModelId, effort, parsed.serviceTier].filter(Boolean).join('-');
}
export function applyCodexServiceTierSuffix(modelId: string | undefined, enabled: boolean): string {
const parsed = parseCodexTuningSuffix(modelId);
if (!parsed.baseModel) return parsed.baseModel;
return [parsed.baseModel, parsed.effort, enabled ? 'fast' : undefined].filter(Boolean).join('-');
}
export function getCodexEffortVariants(
modelId: string,
maxEffort: CodexEffort | undefined,
@@ -102,6 +108,16 @@ export function getCodexEffortVariants(
return variantIds;
}
export function getSelectableCodexEfforts(
maxEffort: CodexEffort | undefined,
efforts?: readonly CodexEffort[]
): CodexEffort[] {
if (efforts?.length) return [...efforts];
if (!maxEffort) return [...CODEX_EFFORTS_IN_ORDER];
const maxEffortIndex = CODEX_EFFORTS_IN_ORDER.indexOf(maxEffort);
return CODEX_EFFORTS_IN_ORDER.slice(0, maxEffortIndex + 1);
}
export function getCodexEffortDisplay(
modelId: string | undefined,
effortLabels?: { pinned: (effort: string) => string; auto: string }
+20 -5
View File
@@ -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: '로그',
+6
View File
@@ -402,11 +402,13 @@ export const MODEL_CATALOGS: Record<string, ProviderCatalog> = {
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,
};
});
+63
View File
@@ -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<string, ReasoningAdapter> = {
codex: codexAdapter,
xai: xaiAdapter,
};
export function getReasoningAdapter(provider: string | undefined): ReasoningAdapter | undefined {
return provider ? REASONING_ADAPTERS[provider.toLowerCase()] : undefined;
}
@@ -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(
<ModelConfigSection
catalog={MODEL_CATALOGS.codex}
savedPresets={[]}
currentModel="gpt-5-codex"
opusModel="gpt-5-codex"
sonnetModel="gpt-5-codex"
haikuModel="gpt-5-codex-mini"
fableModel="gpt-5.6-sol-xhigh"
providerModels={[]}
provider="codex"
onExtendedContextToggle={vi.fn()}
onApplyPreset={vi.fn()}
onUpdateEnvValue={vi.fn()}
onOpenCustomPreset={vi.fn()}
onDeletePreset={vi.fn()}
/>
);
expect(screen.getAllByTestId('flexible-model-selector')).toHaveLength(5);
});
it('groups codex presets by free and paid tiers', async () => {
const onApplyPreset = vi.fn();
@@ -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',
+77
View File
@@ -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',
]);
});
});
@@ -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([]);
});
});
@@ -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',
]);
});
});
+90
View File
@@ -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<string, ProxyListener>();
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<string, string>();
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();
});
});
+33 -1
View File
@@ -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<ProxyOptions['configure']> = (proxy) => {
proxy.on('proxyReq', rewriteTrustedViteDevOrigin);
};
const configureWebSocketProxy: NonNullable<ProxyOptions['configure']> = (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,
},
},
},