From be96b84e613ac4be217ea05c8090318df1114469 Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Mon, 23 Feb 2026 22:52:44 +0700 Subject: [PATCH] fix(codex): canonicalize dashboard presets and migrate suffixed settings --- src/cliproxy/config/env-builder.ts | 65 +++++++++++++ src/web-server/routes/settings-routes.ts | 91 +++++++++++++++++-- .../cliproxy/env-builder-provider-url.test.ts | 55 ++++++++++- ui/src/lib/model-catalogs.ts | 28 +++--- 4 files changed, 213 insertions(+), 26 deletions(-) diff --git a/src/cliproxy/config/env-builder.ts b/src/cliproxy/config/env-builder.ts index 277f4d41..92933208 100644 --- a/src/cliproxy/config/env-builder.ts +++ b/src/cliproxy/config/env-builder.ts @@ -28,12 +28,19 @@ import { /** Settings file structure for user overrides */ interface ProviderSettings { env: NodeJS.ProcessEnv; + presets?: Array>; } /** Model name prefix that was deprecated in CLIProxyAPI registry */ const DEPRECATED_MODEL_PREFIX = 'gemini-claude-'; /** Replacement prefix matching actual upstream model names */ const UPSTREAM_MODEL_PREFIX = 'claude-'; +const CODEX_EFFORT_SUFFIX_REGEX = /-(xhigh|high|medium)$/i; +const PRESET_MODEL_KEYS = ['default', 'opus', 'sonnet', 'haiku'] as const; + +function stripCodexEffortSuffix(modelId: string): string { + return modelId.replace(CODEX_EFFORT_SUFFIX_REGEX, ''); +} /** * Migrate deprecated gemini-claude-* model names to upstream claude-* names in a settings file. @@ -68,6 +75,58 @@ function migrateDeprecatedModelNames(settingsPath: string, settings: ProviderSet return migrated; } +/** + * Migrate codex effort-suffixed model IDs in settings to canonical IDs. + * Example: gpt-5.3-codex-xhigh -> gpt-5.3-codex + */ +function migrateCodexEffortSuffixes( + settingsPath: string, + provider: CLIProxyProvider, + settings: ProviderSettings +): boolean { + if (provider !== 'codex') return false; + if (!settings.env || typeof settings.env !== 'object') return false; + + let migrated = false; + + for (const key of MODEL_ENV_VAR_KEYS) { + const value = settings.env[key]; + if (typeof value !== 'string') continue; + const canonical = stripCodexEffortSuffix(value); + if (canonical !== value) { + settings.env[key] = canonical; + migrated = true; + } + } + + if (Array.isArray(settings.presets)) { + for (const preset of settings.presets) { + if (!preset || typeof preset !== 'object') continue; + const presetRecord = preset as Record; + + for (const key of PRESET_MODEL_KEYS) { + const value = presetRecord[key]; + if (typeof value !== 'string') continue; + const canonical = stripCodexEffortSuffix(value); + if (canonical !== value) { + presetRecord[key] = canonical; + migrated = true; + } + } + } + } + + if (migrated) { + try { + fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + '\n', { mode: 0o600 }); + } catch { + // Best-effort migration — don't block startup if write fails + } + } + + return migrated; +} + /** Remote proxy configuration for URL rewriting */ export interface RemoteProxyRewriteConfig { host: string; @@ -285,6 +344,8 @@ export function getEffectiveEnvVars( if (settings.env && typeof settings.env === 'object') { // Migrate deprecated gemini-claude-* model names if present migrateDeprecatedModelNames(expandedPath, settings); + // Migrate codex effort suffixes to canonical IDs if present + migrateCodexEffortSuffixes(expandedPath, provider, settings); // Custom variant settings found - merge with global env envVars = { ...globalEnv, ...settings.env }; // Ensure required vars are present (fall back to defaults if missing) @@ -316,6 +377,8 @@ export function getEffectiveEnvVars( if (settings.env && typeof settings.env === 'object') { // Migrate deprecated gemini-claude-* model names if present migrateDeprecatedModelNames(settingsPath, settings); + // Migrate codex effort suffixes to canonical IDs if present + migrateCodexEffortSuffixes(settingsPath, provider, settings); // User override found - merge with global env envVars = { ...globalEnv, ...settings.env }; // Ensure required vars are present (fall back to defaults if missing) @@ -403,6 +466,7 @@ export function getRemoteEnvVars( const settings: ProviderSettings = JSON.parse(content); if (settings.env && typeof settings.env === 'object') { migrateDeprecatedModelNames(expandedPath, settings); + migrateCodexEffortSuffixes(expandedPath, provider, settings); userEnvVars = settings.env as Record; } } catch { @@ -421,6 +485,7 @@ export function getRemoteEnvVars( const settings: ProviderSettings = JSON.parse(content); if (settings.env && typeof settings.env === 'object') { migrateDeprecatedModelNames(settingsPath, settings); + migrateCodexEffortSuffixes(settingsPath, provider, settings); userEnvVars = settings.env as Record; } } catch { diff --git a/src/web-server/routes/settings-routes.ts b/src/web-server/routes/settings-routes.ts index dc4c5063..f5cfb39b 100644 --- a/src/web-server/routes/settings-routes.ts +++ b/src/web-server/routes/settings-routes.ts @@ -22,6 +22,14 @@ import { deduplicateCcsHooks } from '../../utils/websearch/hook-utils'; import type { Settings } from '../../types/config'; const router = Router(); +const CODEX_EFFORT_SUFFIX_REGEX = /-(xhigh|high|medium)$/i; +const MODEL_ENV_KEYS = [ + 'ANTHROPIC_MODEL', + 'ANTHROPIC_DEFAULT_OPUS_MODEL', + 'ANTHROPIC_DEFAULT_SONNET_MODEL', + 'ANTHROPIC_DEFAULT_HAIKU_MODEL', +] as const; +const PRESET_MODEL_KEYS = ['default', 'opus', 'sonnet', 'haiku'] as const; /** * Helper: Resolve settings path for profile or variant @@ -42,6 +50,59 @@ function resolveSettingsPath(profileOrVariant: string): string { return path.join(ccsDir, `${profileOrVariant}.settings.json`); } +function isCodexProfile(profileOrVariant: string): boolean { + if (profileOrVariant.toLowerCase() === 'codex') return true; + const variants = listVariants(); + return variants[profileOrVariant]?.provider === 'codex'; +} + +function stripCodexEffortSuffix(modelId: string): string { + return modelId.replace(CODEX_EFFORT_SUFFIX_REGEX, ''); +} + +function canonicalizeCodexSettings(profileOrVariant: string, settings: Settings): Settings { + if (!isCodexProfile(profileOrVariant)) return settings; + + let changed = false; + const next: Settings = { ...settings }; + + if (settings.env && typeof settings.env === 'object') { + const env = { ...settings.env }; + for (const key of MODEL_ENV_KEYS) { + const value = env[key]; + if (typeof value !== 'string') continue; + const canonical = stripCodexEffortSuffix(value); + if (canonical !== value) { + env[key] = canonical; + changed = true; + } + } + next.env = env; + } + + if (Array.isArray(settings.presets)) { + const normalizedPresets = settings.presets.map((preset) => { + const normalizedPreset = { ...preset }; + let presetChanged = false; + + for (const key of PRESET_MODEL_KEYS) { + const value = normalizedPreset[key]; + const canonical = stripCodexEffortSuffix(value); + if (canonical !== value) { + normalizedPreset[key] = canonical; + presetChanged = true; + } + } + + if (presetChanged) changed = true; + return normalizedPreset; + }); + next.presets = normalizedPresets; + } + + return changed ? next : settings; +} + /** * Helper: Mask API keys in settings */ @@ -73,7 +134,7 @@ router.get('/:profile', (req: Request, res: Response): void => { } const stat = fs.statSync(settingsPath); - const settings = loadSettings(settingsPath); + const settings = canonicalizeCodexSettings(profile, loadSettings(settingsPath)); const masked = maskApiKeys(settings); res.json({ @@ -101,7 +162,7 @@ router.get('/:profile/raw', (req: Request, res: Response): void => { } const stat = fs.statSync(settingsPath); - const settings = loadSettings(settingsPath); + const settings = canonicalizeCodexSettings(profile, loadSettings(settingsPath)); res.json({ profile, @@ -137,14 +198,16 @@ router.put('/:profile', (req: Request, res: Response): void => { return; } + const normalizedSettings = canonicalizeCodexSettings(profile, settings as Settings); + // Deduplicate CCS hooks to prevent accumulation (fixes #450) // This handles cases where duplicate hooks were added by previous versions - deduplicateCcsHooks(settings as Record); + deduplicateCcsHooks(normalizedSettings as Record); const ccsDir = getCcsDir(); // Check for missing required fields (warning, not blocking - runtime fills defaults) - const missingFields = checkRequiredEnvVars(settings); + const missingFields = checkRequiredEnvVars(normalizedSettings); const settingsPath = resolveSettingsPath(profile); const fileExists = fs.existsSync(settingsPath); @@ -163,7 +226,7 @@ router.put('/:profile', (req: Request, res: Response): void => { // Create backup only if file exists AND content actually changed let backupPath: string | undefined; - const newContent = JSON.stringify(settings, null, 2) + '\n'; + const newContent = JSON.stringify(normalizedSettings, null, 2) + '\n'; if (fileExists) { const existingContent = fs.readFileSync(settingsPath, 'utf8'); // Only create backup if content differs @@ -220,7 +283,7 @@ router.get('/:profile/presets', (req: Request, res: Response): void => { return; } - const settings = loadSettings(settingsPath); + const settings = canonicalizeCodexSettings(profile, loadSettings(settingsPath)); res.json({ presets: settings.presets || [] }); } catch (error) { res.status(500).json({ error: (error as Error).message }); @@ -257,12 +320,20 @@ router.post('/:profile/presets', (req: Request, res: Response): void => { return; } + const normalizePresetModel = (modelId: string): string => + isCodexProfile(profile) ? stripCodexEffortSuffix(modelId) : modelId; + + const normalizedDefaultModel = normalizePresetModel(defaultModel); + const normalizedOpusModel = normalizePresetModel(opus || defaultModel); + const normalizedSonnetModel = normalizePresetModel(sonnet || defaultModel); + const normalizedHaikuModel = normalizePresetModel(haiku || defaultModel); + const preset = { name, - default: defaultModel, - opus: opus || defaultModel, - sonnet: sonnet || defaultModel, - haiku: haiku || defaultModel, + default: normalizedDefaultModel, + opus: normalizedOpusModel, + sonnet: normalizedSonnetModel, + haiku: normalizedHaikuModel, }; settings.presets.push(preset); diff --git a/tests/unit/cliproxy/env-builder-provider-url.test.ts b/tests/unit/cliproxy/env-builder-provider-url.test.ts index bae0639d..38370495 100644 --- a/tests/unit/cliproxy/env-builder-provider-url.test.ts +++ b/tests/unit/cliproxy/env-builder-provider-url.test.ts @@ -13,8 +13,12 @@ interface EnvSettings { ANTHROPIC_DEFAULT_HAIKU_MODEL: string; } -function writeSettings(settingsPath: string, env: EnvSettings): void { - fs.writeFileSync(settingsPath, JSON.stringify({ env }, null, 2)); +function writeSettings( + settingsPath: string, + env: EnvSettings, + extras?: Record +): void { + fs.writeFileSync(settingsPath, JSON.stringify({ env, ...(extras || {}) }, null, 2)); } describe('getEffectiveEnvVars local provider URL normalization', () => { @@ -42,6 +46,18 @@ describe('getEffectiveEnvVars local provider URL normalization', () => { const env = getEffectiveEnvVars('codex', 8317, settingsPath); expect(env.ANTHROPIC_BASE_URL).toBe('http://127.0.0.1:8317/api/provider/codex'); + expect(env.ANTHROPIC_MODEL).toBe('gpt-5.3-codex'); + expect(env.ANTHROPIC_DEFAULT_OPUS_MODEL).toBe('gpt-5.3-codex'); + expect(env.ANTHROPIC_DEFAULT_SONNET_MODEL).toBe('gpt-5.3-codex'); + expect(env.ANTHROPIC_DEFAULT_HAIKU_MODEL).toBe('gpt-5-mini'); + + const persisted = JSON.parse(fs.readFileSync(settingsPath, 'utf-8')) as { + env: Record; + }; + expect(persisted.env.ANTHROPIC_MODEL).toBe('gpt-5.3-codex'); + expect(persisted.env.ANTHROPIC_DEFAULT_OPUS_MODEL).toBe('gpt-5.3-codex'); + expect(persisted.env.ANTHROPIC_DEFAULT_SONNET_MODEL).toBe('gpt-5.3-codex'); + expect(persisted.env.ANTHROPIC_DEFAULT_HAIKU_MODEL).toBe('gpt-5-mini'); }); it('rewrites wrong local provider path to the requested provider', () => { @@ -88,4 +104,39 @@ describe('getEffectiveEnvVars local provider URL normalization', () => { expect(env.ANTHROPIC_DEFAULT_SONNET_MODEL).toBe('claude-sonnet-4-6-thinking'); expect(env.ANTHROPIC_DEFAULT_HAIKU_MODEL).toBe('claude-haiku-4-5'); }); + + it('migrates codex preset model mappings to canonical IDs', () => { + writeSettings( + settingsPath, + { + ANTHROPIC_BASE_URL: 'http://127.0.0.1:8317/api/provider/codex', + ANTHROPIC_AUTH_TOKEN: 'ccs-internal-managed', + ANTHROPIC_MODEL: 'gpt-5.3-codex-xhigh', + ANTHROPIC_DEFAULT_OPUS_MODEL: 'gpt-5.3-codex-xhigh', + ANTHROPIC_DEFAULT_SONNET_MODEL: 'gpt-5.3-codex-high', + ANTHROPIC_DEFAULT_HAIKU_MODEL: 'gpt-5-mini-medium', + }, + { + presets: [ + { + name: 'legacy-codex', + default: 'gpt-5.3-codex-xhigh', + opus: 'gpt-5.3-codex-xhigh', + sonnet: 'gpt-5.3-codex-high', + haiku: 'gpt-5-mini-medium', + }, + ], + } + ); + + getEffectiveEnvVars('codex', 8317, settingsPath); + + const persisted = JSON.parse(fs.readFileSync(settingsPath, 'utf-8')) as { + presets: Array>; + }; + expect(persisted.presets[0]?.default).toBe('gpt-5.3-codex'); + expect(persisted.presets[0]?.opus).toBe('gpt-5.3-codex'); + expect(persisted.presets[0]?.sonnet).toBe('gpt-5.3-codex'); + expect(persisted.presets[0]?.haiku).toBe('gpt-5-mini'); + }); }); diff --git a/ui/src/lib/model-catalogs.ts b/ui/src/lib/model-catalogs.ts index 1736b19a..244f570a 100644 --- a/ui/src/lib/model-catalogs.ts +++ b/ui/src/lib/model-catalogs.ts @@ -140,10 +140,10 @@ export const MODEL_CATALOGS: Record = { name: 'GPT-5.3 Codex', description: 'Supports up to xhigh effort', presetMapping: { - default: 'gpt-5.3-codex-xhigh', - opus: 'gpt-5.3-codex-xhigh', - sonnet: 'gpt-5.3-codex-high', - haiku: 'gpt-5-mini-medium', + default: 'gpt-5.3-codex', + opus: 'gpt-5.3-codex', + sonnet: 'gpt-5.3-codex', + haiku: 'gpt-5-mini', }, }, { @@ -151,10 +151,10 @@ export const MODEL_CATALOGS: Record = { name: 'GPT-5.2 Codex', description: 'Previous stable Codex model', presetMapping: { - default: 'gpt-5.2-codex-xhigh', - opus: 'gpt-5.2-codex-xhigh', - sonnet: 'gpt-5.2-codex-high', - haiku: 'gpt-5-mini-medium', + default: 'gpt-5.2-codex', + opus: 'gpt-5.2-codex', + sonnet: 'gpt-5.2-codex', + haiku: 'gpt-5-mini', }, }, { @@ -162,10 +162,10 @@ export const MODEL_CATALOGS: Record = { name: 'GPT-5 Mini', description: 'Fast, capped at high effort (no xhigh)', presetMapping: { - default: 'gpt-5-mini-medium', - opus: 'gpt-5.3-codex-xhigh', - sonnet: 'gpt-5-mini-high', - haiku: 'gpt-5-mini-medium', + default: 'gpt-5-mini', + opus: 'gpt-5.3-codex', + sonnet: 'gpt-5-mini', + haiku: 'gpt-5-mini', }, }, { @@ -174,9 +174,9 @@ export const MODEL_CATALOGS: Record = { description: 'Legacy most capable Codex model', presetMapping: { default: 'gpt-5.1-codex-max', - opus: 'gpt-5.1-codex-max-high', + opus: 'gpt-5.1-codex-max', sonnet: 'gpt-5.1-codex-max', - haiku: 'gpt-5.1-codex-mini-high', + haiku: 'gpt-5.1-codex-mini', }, }, {