mirror of
https://github.com/tiennm99/ccs.git
synced 2026-10-11 03:13:12 +00:00
Merge remote-tracking branch 'origin/dev' into kai/review/pr-1655-retry-followup
This commit is contained in:
commit
2d909a162f
43 files changed
+953
-218
No files matched your search
@@ -1,6 +1,6 @@
|
||||
<div align="center">
|
||||
|
||||
# CCS - Claude Code Switch
|
||||
# CCS - Claude Codex Switch
|
||||
|
||||

|
||||
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 252 KiB After Width: | Height: | Size: 87 KiB |
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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,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
@@ -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
@@ -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,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,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,4 +1,4 @@
|
||||
# PowerShell completion for CCS (Claude Code Switch)
|
||||
# PowerShell completion for CCS (Claude Codex Switch)
|
||||
|
||||
function Invoke-CcsCompletionBackend {
|
||||
param(
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
|
||||
@@ -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('');
|
||||
|
||||
|
||||
@@ -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:'));
|
||||
|
||||
@@ -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];
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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._-]*$/;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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(
|
||||
|
||||
@@ -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
@@ -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: '로그',
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
});
|
||||
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -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
@@ -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,
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
Reference in new issue
Block a user