diff --git a/.github/pr-assets/issue-1058/codex-selector-variants.png b/.github/pr-assets/issue-1058/codex-selector-variants.png new file mode 100644 index 00000000..0775cae0 Binary files /dev/null and b/.github/pr-assets/issue-1058/codex-selector-variants.png differ diff --git a/.github/pr-assets/issue-1058/index.html b/.github/pr-assets/issue-1058/index.html new file mode 100644 index 00000000..77fcccae --- /dev/null +++ b/.github/pr-assets/issue-1058/index.html @@ -0,0 +1,138 @@ + + + + + + Issue 1058 UI Evidence + + + +
+
+

Issue 1058

+

Codex selector now exposes pinned effort variants

+

+ Capture environment: local dashboard at http://127.0.0.1:3001/cliproxy, + light theme, Codex provider editor, Model Config tab. +

+
+
+

What changed

+

The selector now offers real codex model variants like -high and -xhigh.

+
+
+

Why it matters

+

The dashboard can now emit the exact suffixed model IDs that runtime code already understands.

+
+
+

Review cue

+

Look at the opened model picker: suffixed entries appear as first-class options with pinned-effort badges.

+
+
+
+ +
+
+

Targeted evidence

+

Codex provider editor model picker

+

+ The open selector shows canonical rows plus generated pinned variants such as + gpt-5.3-codex-high, gpt-5.3-codex-xhigh, and + gpt-5.4-xhigh. +

+
+ Codex provider editor with the model selector opened and suffixed effort variants visible. +
+
+ + diff --git a/src/cliproxy/config/env-builder.ts b/src/cliproxy/config/env-builder.ts index dafd0c95..417d5f04 100644 --- a/src/cliproxy/config/env-builder.ts +++ b/src/cliproxy/config/env-builder.ts @@ -42,7 +42,6 @@ interface ProviderSettings { 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; const REQUIRED_PROVIDER_ENV_KEYS = [ 'ANTHROPIC_BASE_URL', @@ -62,10 +61,6 @@ function isObjectRecord(value: unknown): value is Record { return typeof value === 'object' && value !== null && !Array.isArray(value); } -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. * CLIProxyAPI registry no longer recognizes the gemini-claude-* prefix convention. @@ -130,58 +125,6 @@ function migrateDeprecatedModelNames( 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; -} - /** * Migrate legacy iFlow model IDs to current upstream model IDs. * Example: iflow-default -> qwen3-coder-plus, kimi-k2.5 -> kimi-k2 @@ -510,8 +453,6 @@ export function getEffectiveEnvVars( if (settings.env && typeof settings.env === 'object') { // Migrate deprecated gemini-claude-* model names if present migrateDeprecatedModelNames(expandedPath, provider, settings); - // Migrate codex effort suffixes to canonical IDs if present - migrateCodexEffortSuffixes(expandedPath, provider, settings); // Migrate legacy iFlow placeholders to supported model IDs migrateIFlowPlaceholderModel(expandedPath, provider, settings); // Custom variant settings found - merge with global env @@ -545,8 +486,6 @@ export function getEffectiveEnvVars( if (settings.env && typeof settings.env === 'object') { // Migrate deprecated gemini-claude-* model names if present migrateDeprecatedModelNames(settingsPath, provider, settings); - // Migrate codex effort suffixes to canonical IDs if present - migrateCodexEffortSuffixes(settingsPath, provider, settings); // Migrate legacy iFlow placeholders to supported model IDs migrateIFlowPlaceholderModel(settingsPath, provider, settings); // User override found - merge with global env @@ -719,7 +658,6 @@ export function getRemoteEnvVars( const settings: ProviderSettings = JSON.parse(content); if (settings.env && typeof settings.env === 'object') { migrateDeprecatedModelNames(expandedPath, provider, settings); - migrateCodexEffortSuffixes(expandedPath, provider, settings); migrateIFlowPlaceholderModel(expandedPath, provider, settings); userEnvVars = settings.env as Record; } @@ -739,7 +677,6 @@ export function getRemoteEnvVars( const settings: ProviderSettings = JSON.parse(content); if (settings.env && typeof settings.env === 'object') { migrateDeprecatedModelNames(settingsPath, provider, settings); - migrateCodexEffortSuffixes(settingsPath, provider, settings); migrateIFlowPlaceholderModel(settingsPath, provider, settings); userEnvVars = settings.env as Record; } diff --git a/src/cliproxy/model-config.ts b/src/cliproxy/model-config.ts index 8b99316d..843bfa1b 100644 --- a/src/cliproxy/model-config.ts +++ b/src/cliproxy/model-config.ts @@ -15,16 +15,8 @@ import { initUI, color, bold, dim, ok, info, header } from '../utils/ui'; import { getCcsDir } from '../utils/config-manager'; import { normalizeModelIdForProvider } from './model-id-normalizer'; -const CODEX_EFFORT_SUFFIX_REGEX = /-(xhigh|high|medium)$/i; - -function stripCodexEffortSuffix(model: string, provider: CLIProxyProvider): string { - if (provider !== 'codex') return model; - return model.replace(CODEX_EFFORT_SUFFIX_REGEX, ''); -} - function canonicalizeModelForProvider(provider: CLIProxyProvider, model: string): string { - const withoutCodexSuffix = stripCodexEffortSuffix(model, provider); - return normalizeModelIdForProvider(withoutCodexSuffix, provider); + return normalizeModelIdForProvider(model, provider); } /** diff --git a/src/cliproxy/model-id-normalizer.ts b/src/cliproxy/model-id-normalizer.ts index 192a369f..7bdaf503 100644 --- a/src/cliproxy/model-id-normalizer.ts +++ b/src/cliproxy/model-id-normalizer.ts @@ -65,6 +65,18 @@ function splitBaseModelAndSuffix(model: string): { baseModel: string; suffix: st }; } +function splitCodexEffortSuffix(model: string): { baseModel: string; suffix: string } { + const match = model.match(CODEX_EFFORT_SUFFIX_REGEX); + if (!match?.[0]) { + return { baseModel: model, suffix: '' }; + } + + return { + baseModel: model.slice(0, -match[0].length), + suffix: match[0], + }; +} + /** * Extract provider segment from `/api/provider/{provider}` request paths. * @@ -97,20 +109,24 @@ export function isIFlowProvider(provider: ProviderLike): boolean { return provider.trim().toLowerCase() === 'iflow'; } -/** Normalize Codex effort-suffixed IDs to canonical IDs. */ +/** Strip Codex effort suffixes while preserving trailing config suffixes. */ export function stripCodexEffortSuffix(model: string): string { - return model.replace(CODEX_EFFORT_SUFFIX_REGEX, ''); + const trimmed = trimModelId(model); + const { baseModel, suffix } = splitBaseModelAndSuffix(trimmed); + const { baseModel: withoutEffort } = splitCodexEffortSuffix(baseModel); + return `${withoutEffort}${suffix}`; } /** Normalize legacy Codex aliases to the current public Codex model IDs. */ export function normalizeCodexLegacyModelAliases(model: string): string { const trimmed = trimModelId(model); const { baseModel, suffix } = splitBaseModelAndSuffix(trimmed); - const replacement = CODEX_LEGACY_MODEL_ALIASES[baseModel.trim().toLowerCase()]; + const { baseModel: baseWithoutEffort, suffix: effortSuffix } = splitCodexEffortSuffix(baseModel); + const replacement = CODEX_LEGACY_MODEL_ALIASES[baseWithoutEffort.trim().toLowerCase()]; if (!replacement) { return trimmed; } - return `${replacement}${suffix}`; + return `${replacement}${effortSuffix}${suffix}`; } /** @@ -217,15 +233,12 @@ export function normalizeModelIdForProvider(model: string, provider: ProviderLik /** * Canonicalize model ID for provider-specific compatibility. - * - Codex: strip effort suffixes. + * - Codex: preserve valid effort suffixes while normalizing legacy aliases. * - Antigravity: normalize dotted/historical aliases. */ export function canonicalizeModelIdForProvider(model: string, provider: ProviderLike): string { const trimmedModel = trimModelId(model); - const withoutCodexSuffix = isCodexProvider(provider) - ? stripCodexEffortSuffix(trimmedModel) - : trimmedModel; - return normalizeModelIdForProvider(withoutCodexSuffix, provider); + return normalizeModelIdForProvider(trimmedModel, provider); } /** diff --git a/src/cliproxy/services/variant-settings.ts b/src/cliproxy/services/variant-settings.ts index f0aa20eb..3b22998d 100644 --- a/src/cliproxy/services/variant-settings.ts +++ b/src/cliproxy/services/variant-settings.ts @@ -41,16 +41,12 @@ interface SettingsFile { [key: string]: unknown; } -const CODEX_EFFORT_SUFFIX_REGEX = /-(xhigh|high|medium)$/i; - function canonicalizeModelForProvider( provider: CLIProxyProfileName | undefined, model: string ): string { - const withoutCodexSuffix = - provider === 'codex' ? model.replace(CODEX_EFFORT_SUFFIX_REGEX, '') : model; - if (!provider) return withoutCodexSuffix; - return normalizeModelIdForProvider(withoutCodexSuffix, provider); + if (!provider) return model; + return normalizeModelIdForProvider(model, provider); } /** diff --git a/src/web-server/middleware/auth-middleware.ts b/src/web-server/middleware/auth-middleware.ts index c42272a2..7ea23145 100644 --- a/src/web-server/middleware/auth-middleware.ts +++ b/src/web-server/middleware/auth-middleware.ts @@ -84,7 +84,11 @@ export const loginRateLimiter = rateLimit({ /** * Create session middleware configured for CCS dashboard. */ -export function createSessionMiddleware() { +export function createSessionMiddleware(): ( + req: Request, + res: Response, + next: NextFunction +) => void { const authConfig = getDashboardAuthConfig(); const maxAge = (authConfig.session_timeout_hours ?? 24) * 60 * 60 * 1000; diff --git a/tests/unit/cliproxy/env-builder-provider-url.test.ts b/tests/unit/cliproxy/env-builder-provider-url.test.ts index 7f96cde9..acb41cca 100644 --- a/tests/unit/cliproxy/env-builder-provider-url.test.ts +++ b/tests/unit/cliproxy/env-builder-provider-url.test.ts @@ -40,7 +40,7 @@ describe('getEffectiveEnvVars local provider URL normalization', () => { fs.rmSync(tempHome, { recursive: true, force: true }); }); - it('rewrites local root URL to provider endpoint', () => { + it('rewrites local root URL to provider endpoint without stripping codex effort suffixes', () => { writeSettings(settingsPath, { ANTHROPIC_BASE_URL: 'http://127.0.0.1:8317', ANTHROPIC_AUTH_TOKEN: 'ccs-internal-managed', @@ -52,18 +52,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.4-mini'); + expect(env.ANTHROPIC_MODEL).toBe('gpt-5.3-codex-xhigh'); + expect(env.ANTHROPIC_DEFAULT_OPUS_MODEL).toBe('gpt-5.3-codex-xhigh'); + expect(env.ANTHROPIC_DEFAULT_SONNET_MODEL).toBe('gpt-5.3-codex-high'); + expect(env.ANTHROPIC_DEFAULT_HAIKU_MODEL).toBe('gpt-5.4-mini-medium'); 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.4-mini'); + expect(persisted.env.ANTHROPIC_MODEL).toBe('gpt-5.3-codex-xhigh'); + expect(persisted.env.ANTHROPIC_DEFAULT_OPUS_MODEL).toBe('gpt-5.3-codex-xhigh'); + expect(persisted.env.ANTHROPIC_DEFAULT_SONNET_MODEL).toBe('gpt-5.3-codex-high'); + expect(persisted.env.ANTHROPIC_DEFAULT_HAIKU_MODEL).toBe('gpt-5.4-mini-medium'); }); it('rewrites wrong local provider path to the requested provider', () => { @@ -162,7 +162,7 @@ describe('getEffectiveEnvVars local provider URL normalization', () => { expect(persisted.presets?.[0]?.haiku).toBe('claude-sonnet-4-6'); }); - it('migrates codex preset model mappings to canonical IDs', () => { + it('preserves codex preset effort suffixes while loading provider env vars', () => { writeSettings( settingsPath, { @@ -191,10 +191,10 @@ describe('getEffectiveEnvVars local provider URL normalization', () => { 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'); + expect(persisted.presets[0]?.default).toBe('gpt-5.3-codex-xhigh'); + expect(persisted.presets[0]?.opus).toBe('gpt-5.3-codex-xhigh'); + expect(persisted.presets[0]?.sonnet).toBe('gpt-5.3-codex-high'); + expect(persisted.presets[0]?.haiku).toBe('gpt-5-mini-medium'); }); it('migrates iflow placeholder model IDs to a supported default', () => { @@ -424,7 +424,7 @@ describe('getEffectiveEnvVars local provider URL normalization', () => { expect(repaired.presets?.[0]?.haiku).toBe('claude-sonnet-4-6'); }); - it('migrates codex effort-suffixed preset IDs during ensureProviderSettings', () => { + it('preserves codex effort-suffixed IDs during ensureProviderSettings', () => { process.env.CCS_HOME = tempHome; const codexSettingsPath = path.join(tempHome, '.ccs', 'codex.settings.json'); fs.mkdirSync(path.dirname(codexSettingsPath), { recursive: true }); @@ -461,14 +461,14 @@ describe('getEffectiveEnvVars local provider URL normalization', () => { env?: Record; presets?: Array>; }; - expect(repaired.env?.ANTHROPIC_MODEL).toBe('gpt-5.3-codex'); - expect(repaired.env?.ANTHROPIC_DEFAULT_OPUS_MODEL).toBe('gpt-5.3-codex'); - expect(repaired.env?.ANTHROPIC_DEFAULT_SONNET_MODEL).toBe('gpt-5.3-codex'); - expect(repaired.env?.ANTHROPIC_DEFAULT_HAIKU_MODEL).toBe('gpt-5.4-mini'); - expect(repaired.presets?.[0]?.default).toBe('gpt-5.3-codex'); - expect(repaired.presets?.[0]?.opus).toBe('gpt-5.3-codex'); - expect(repaired.presets?.[0]?.sonnet).toBe('gpt-5.3-codex'); - expect(repaired.presets?.[0]?.haiku).toBe('gpt-5.4-mini'); + expect(repaired.env?.ANTHROPIC_MODEL).toBe('gpt-5.3-codex-xhigh'); + expect(repaired.env?.ANTHROPIC_DEFAULT_OPUS_MODEL).toBe('gpt-5.3-codex-xhigh'); + expect(repaired.env?.ANTHROPIC_DEFAULT_SONNET_MODEL).toBe('gpt-5.3-codex-high'); + expect(repaired.env?.ANTHROPIC_DEFAULT_HAIKU_MODEL).toBe('gpt-5.4-mini-medium'); + expect(repaired.presets?.[0]?.default).toBe('gpt-5.3-codex-xhigh'); + expect(repaired.presets?.[0]?.opus).toBe('gpt-5.3-codex-xhigh'); + expect(repaired.presets?.[0]?.sonnet).toBe('gpt-5.3-codex-high'); + expect(repaired.presets?.[0]?.haiku).toBe('gpt-5.4-mini-medium'); }); it('recovers malformed provider settings files by writing defaults and backup copy', () => { diff --git a/tests/unit/cliproxy/model-id-normalizer.test.ts b/tests/unit/cliproxy/model-id-normalizer.test.ts index d7e969cf..638ad419 100644 --- a/tests/unit/cliproxy/model-id-normalizer.test.ts +++ b/tests/unit/cliproxy/model-id-normalizer.test.ts @@ -83,7 +83,9 @@ describe('model-id-normalizer', () => { }); it('applies provider canonicalization for codex and antigravity', () => { - expect(canonicalizeModelIdForProvider('gpt-5.3-codex-xhigh', 'codex')).toBe('gpt-5.3-codex'); + expect(canonicalizeModelIdForProvider('gpt-5.3-codex-xhigh', 'codex')).toBe( + 'gpt-5.3-codex-xhigh' + ); expect(canonicalizeModelIdForProvider('claude-sonnet-4.6-thinking', 'agy')).toBe( 'claude-sonnet-4-6' ); @@ -94,7 +96,7 @@ describe('model-id-normalizer', () => { it('trims and canonicalizes provider model IDs with surrounding whitespace', () => { expect(canonicalizeModelIdForProvider(' gpt-5.3-codex-high ', 'codex')).toBe( - 'gpt-5.3-codex' + 'gpt-5.3-codex-high' ); expect(canonicalizeModelIdForProvider(' claude-sonnet-4.6-thinking ', 'agy')).toBe( 'claude-sonnet-4-6' @@ -120,9 +122,11 @@ describe('model-id-normalizer', () => { it('normalizes legacy codex aliases to the current supported model IDs', () => { expect(normalizeCodexLegacyModelAliases('gpt-5-codex')).toBe('gpt-5.4'); expect(normalizeCodexLegacyModelAliases('gpt-5-codex-mini[1m]')).toBe('gpt-5.4-mini[1m]'); + expect(normalizeCodexLegacyModelAliases('gpt-5-codex-high')).toBe('gpt-5.4-high'); + expect(normalizeCodexLegacyModelAliases('gpt-5-codex-high[1m]')).toBe('gpt-5.4-high[1m]'); expect(normalizeModelIdForProvider('gpt-5.2-codex', 'codex')).toBe('gpt-5.2'); expect(normalizeModelIdForProvider('gpt-5.1-codex-mini', 'codex')).toBe('gpt-5.4-mini'); - expect(canonicalizeModelIdForProvider('gpt-5-codex-high', 'codex')).toBe('gpt-5.4'); + expect(canonicalizeModelIdForProvider('gpt-5-codex-high', 'codex')).toBe('gpt-5.4-high'); }); }); diff --git a/tests/unit/web-server/cliproxy-stats-routes-model-update.test.ts b/tests/unit/web-server/cliproxy-stats-routes-model-update.test.ts index 538152a4..3a22363c 100644 --- a/tests/unit/web-server/cliproxy-stats-routes-model-update.test.ts +++ b/tests/unit/web-server/cliproxy-stats-routes-model-update.test.ts @@ -129,7 +129,7 @@ describe('cliproxy-stats-routes model update canonicalization', () => { expect(persisted.env.ANTHROPIC_MODEL).toBe('claude-sonnet-4-6'); }); - it('canonicalizes Codex effort suffix and syncs linked core model env vars', async () => { + it('preserves Codex effort suffixes while syncing linked core model env vars', async () => { const settingsPath = path.join(tempHome, '.ccs', 'cliproxy', 'codex.settings.json'); writeSettings(settingsPath, { ANTHROPIC_BASE_URL: 'http://127.0.0.1:8317/api/provider/codex', @@ -148,14 +148,14 @@ describe('cliproxy-stats-routes model update canonicalization', () => { expect(response.status).toBe(200); const body = (await response.json()) as { model: string }; - expect(body.model).toBe('gpt-5.3-codex'); + expect(body.model).toBe('gpt-5.3-codex-xhigh'); const persisted = JSON.parse(fs.readFileSync(settingsPath, 'utf8')) 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_MODEL).toBe('gpt-5.3-codex-xhigh'); + expect(persisted.env.ANTHROPIC_DEFAULT_OPUS_MODEL).toBe('gpt-5.3-codex-xhigh'); + expect(persisted.env.ANTHROPIC_DEFAULT_SONNET_MODEL).toBe('gpt-5.3-codex-xhigh'); expect(persisted.env.ANTHROPIC_DEFAULT_HAIKU_MODEL).toBe('gpt-5.4-mini'); }); diff --git a/tests/unit/web-server/settings-routes-model-canonicalization.test.ts b/tests/unit/web-server/settings-routes-model-canonicalization.test.ts index bf70d518..ec3d9b06 100644 --- a/tests/unit/web-server/settings-routes-model-canonicalization.test.ts +++ b/tests/unit/web-server/settings-routes-model-canonicalization.test.ts @@ -154,6 +154,83 @@ describe('settings-routes model canonicalization', () => { expect(persisted.presets[0]?.haiku).toBe('qwen3-coder-plus'); }); + it('preserves codex effort suffixes on PUT /:profile while still normalizing legacy aliases', async () => { + const settingsPath = path.join(tempHome, '.ccs', 'codex.settings.json'); + writeSettings(settingsPath, { env: {} }); + + const response = await fetch(`${baseUrl}/api/settings/codex`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + settings: { + env: { + 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-codex-high', + 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-codex-high', + sonnet: 'gpt-5.3-codex-high', + haiku: 'gpt-5-mini-medium', + }, + ], + }, + }), + }); + expect(response.status).toBe(200); + + const persisted = JSON.parse(fs.readFileSync(settingsPath, 'utf8')) as { + env: Record; + presets: Array>; + }; + + expect(persisted.env.ANTHROPIC_MODEL).toBe('gpt-5.3-codex-xhigh'); + expect(persisted.env.ANTHROPIC_DEFAULT_OPUS_MODEL).toBe('gpt-5.4-high'); + expect(persisted.env.ANTHROPIC_DEFAULT_SONNET_MODEL).toBe('gpt-5.3-codex-high'); + expect(persisted.env.ANTHROPIC_DEFAULT_HAIKU_MODEL).toBe('gpt-5.4-mini-medium'); + expect(persisted.presets[0]?.default).toBe('gpt-5.3-codex-xhigh'); + expect(persisted.presets[0]?.opus).toBe('gpt-5.4-high'); + expect(persisted.presets[0]?.sonnet).toBe('gpt-5.3-codex-high'); + expect(persisted.presets[0]?.haiku).toBe('gpt-5.4-mini-medium'); + }); + + it('preserves codex effort suffixes on GET /:profile/raw canonicalization', async () => { + const settingsPath = path.join(tempHome, '.ccs', 'codex.settings.json'); + writeSettings(settingsPath, { + env: { + ANTHROPIC_BASE_URL: 'http://127.0.0.1:8317/api/provider/codex', + ANTHROPIC_AUTH_TOKEN: 'ccs-internal-managed', + ANTHROPIC_MODEL: 'gpt-5-codex-high', + 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', + }, + }); + + const response = await fetch(`${baseUrl}/api/settings/codex/raw`); + expect(response.status).toBe(200); + + const body = (await response.json()) as { settings: { env: Record } }; + expect(body.settings.env.ANTHROPIC_MODEL).toBe('gpt-5.4-high'); + expect(body.settings.env.ANTHROPIC_DEFAULT_OPUS_MODEL).toBe('gpt-5.3-codex-xhigh'); + expect(body.settings.env.ANTHROPIC_DEFAULT_SONNET_MODEL).toBe('gpt-5.3-codex-high'); + expect(body.settings.env.ANTHROPIC_DEFAULT_HAIKU_MODEL).toBe('gpt-5.4-mini-medium'); + + const persisted = JSON.parse(fs.readFileSync(settingsPath, 'utf8')) as { + env: Record; + }; + expect(persisted.env.ANTHROPIC_MODEL).toBe('gpt-5.4-high'); + expect(persisted.env.ANTHROPIC_DEFAULT_OPUS_MODEL).toBe('gpt-5.3-codex-xhigh'); + expect(persisted.env.ANTHROPIC_DEFAULT_SONNET_MODEL).toBe('gpt-5.3-codex-high'); + expect(persisted.env.ANTHROPIC_DEFAULT_HAIKU_MODEL).toBe('gpt-5.4-mini-medium'); + }); + it('canonicalizes AGY preset values on POST /:profile/presets', async () => { const settingsPath = path.join(tempHome, '.ccs', 'agy.settings.json'); writeSettings(settingsPath, { diff --git a/ui/src/components/cliproxy/provider-editor/model-config-section.tsx b/ui/src/components/cliproxy/provider-editor/model-config-section.tsx index 027a05ba..966f964e 100644 --- a/ui/src/components/cliproxy/provider-editor/model-config-section.tsx +++ b/ui/src/components/cliproxy/provider-editor/model-config-section.tsx @@ -229,7 +229,8 @@ export function ModelConfigSection({ {provider === 'codex' && (

Codex tip: suffixes -medium, -high, and -xhigh{' '} - pin reasoning effort. Unsuffixed models use Thinking settings. + pin reasoning effort. Select a suffixed model to pin effort; unsuffixed models use + Thinking settings.

)}
diff --git a/ui/src/components/cliproxy/provider-model-selector.tsx b/ui/src/components/cliproxy/provider-model-selector.tsx index 75a2977e..471bd71a 100644 --- a/ui/src/components/cliproxy/provider-model-selector.tsx +++ b/ui/src/components/cliproxy/provider-model-selector.tsx @@ -12,7 +12,8 @@ import { Badge } from '@/components/ui/badge'; import { SearchableSelect } from '@/components/ui/searchable-select'; import { Skeleton } from '@/components/ui/skeleton'; import type { CliproxyProviderRoutingHints } from '@/lib/api-client'; -import { getCodexEffortDisplay } from '@/lib/codex-effort'; +import type { CodexEffort } from '@/lib/codex-effort'; +import { getCodexEffortDisplay, getCodexEffortVariants } from '@/lib/codex-effort'; import { getResolvedCatalogModels, getSupplementalCatalogModels } from '@/lib/model-catalogs'; import { cn } from '@/lib/utils'; @@ -35,6 +36,8 @@ export interface ModelEntry { sonnet: string; haiku: string; }; + /** Highest codex reasoning-effort suffix this model supports in the dashboard UI. */ + codexMaxEffort?: CodexEffort; } /** Provider catalog */ @@ -313,6 +316,14 @@ function getPreferredOptionValue( return routingHint?.recommendedModelId ?? modelId; } +function getModelOptionValues( + codexMaxEffort: CodexEffort | undefined, + optionValue: string, + isCodexProvider: boolean +): string[] { + return isCodexProvider ? getCodexEffortVariants(optionValue, codexMaxEffort) : [optionValue]; +} + export function FlexibleModelSelector({ label, description, @@ -342,55 +353,64 @@ export function FlexibleModelSelector({ const recommendedOptionValues = useMemo( () => new Set( - resolvedCatalogModels.map((model) => - getPreferredOptionValue(model.id, routingHints.get(model.id.toLowerCase())) + resolvedCatalogModels.flatMap((model) => + getModelOptionValues( + model.codexMaxEffort, + getPreferredOptionValue(model.id, routingHints.get(model.id.toLowerCase())), + isCodexProvider + ) ) ), - [resolvedCatalogModels, routingHints] + [isCodexProvider, resolvedCatalogModels, routingHints] ); const selectedRoutingHint = useMemo( () => routingHints.get(normalizeModelValue(value, routing).toLowerCase()), [routing, routingHints, value] ); - const recommendedOptions = resolvedCatalogModels.map((model) => ({ - value: getPreferredOptionValue(model.id, routingHints.get(model.id.toLowerCase())), - groupKey: 'recommended', - searchText: `${model.id} ${model.name} ${routingHints.get(model.id.toLowerCase())?.recommendedModelId ?? ''}`, - keywords: [model.tier ?? '', catalog?.provider ?? ''], - triggerContent: ( -
- - {getPreferredOptionValue(model.id, routingHints.get(model.id.toLowerCase()))} - - {routingHints.get(model.id.toLowerCase())?.pinnedAvailable ? ( - - {routingHints.get(model.id.toLowerCase())?.prefix} - - ) : null} - {isCodexProvider && } -
- ), - itemContent: ( -
- - {getPreferredOptionValue(model.id, routingHints.get(model.id.toLowerCase()))} - - {model.tier === 'paid' && } - {routingHints.get(model.id.toLowerCase())?.unprefixedStatus === 'shadowed' ? ( - - {t('providerModelSelector.shadowed')} - - ) : null} - {routingHints.get(model.id.toLowerCase())?.unprefixedStatus === 'prefix-only' ? ( - - {t('providerModelSelector.prefixOnly')} - - ) : null} - {isCodexProvider && } -
- ), - })); + const recommendedOptions = resolvedCatalogModels.flatMap((model) => { + const routingHint = routingHints.get(model.id.toLowerCase()); + const optionValues = getModelOptionValues( + model.codexMaxEffort, + getPreferredOptionValue(model.id, routingHint), + isCodexProvider + ); + + return optionValues.map((optionValue) => ({ + value: optionValue, + groupKey: 'recommended', + searchText: `${optionValue} ${model.id} ${model.name} ${routingHint?.recommendedModelId ?? ''}`, + keywords: [model.tier ?? '', catalog?.provider ?? ''], + triggerContent: ( +
+ {optionValue} + {routingHint?.pinnedAvailable ? ( + + {routingHint.prefix} + + ) : null} + {isCodexProvider && } +
+ ), + itemContent: ( +
+ {optionValue} + {model.tier === 'paid' && } + {routingHint?.unprefixedStatus === 'shadowed' ? ( + + {t('providerModelSelector.shadowed')} + + ) : null} + {routingHint?.unprefixedStatus === 'prefix-only' ? ( + + {t('providerModelSelector.prefixOnly')} + + ) : null} + {isCodexProvider && } +
+ ), + })); + }); const allModelOptions = supplementalModels .filter((model) => !catalogModelIds.has(model.id)) @@ -400,43 +420,48 @@ export function FlexibleModelSelector({ getPreferredOptionValue(model.id, routingHints.get(model.id.toLowerCase())) ) ) - .map((model) => ({ - value: getPreferredOptionValue(model.id, routingHints.get(model.id.toLowerCase())), - groupKey: 'all', - searchText: `${model.id} ${routingHints.get(model.id.toLowerCase())?.recommendedModelId ?? ''}`, - keywords: [model.owned_by], - triggerContent: ( -
- - {getPreferredOptionValue(model.id, routingHints.get(model.id.toLowerCase()))} - - {routingHints.get(model.id.toLowerCase())?.pinnedAvailable ? ( - - {routingHints.get(model.id.toLowerCase())?.prefix} - - ) : null} - {isCodexProvider && } -
- ), - itemContent: ( -
- - {getPreferredOptionValue(model.id, routingHints.get(model.id.toLowerCase()))} - - {routingHints.get(model.id.toLowerCase())?.unprefixedStatus === 'shadowed' ? ( - - {t('providerModelSelector.shadowed')} - - ) : null} - {routingHints.get(model.id.toLowerCase())?.unprefixedStatus === 'prefix-only' ? ( - - {t('providerModelSelector.prefixOnly')} - - ) : null} - {isCodexProvider && } -
- ), - })); + .flatMap((model) => { + const routingHint = routingHints.get(model.id.toLowerCase()); + const optionValues = getModelOptionValues( + undefined, + getPreferredOptionValue(model.id, routingHint), + isCodexProvider + ); + + return optionValues.map((optionValue) => ({ + value: optionValue, + groupKey: 'all', + searchText: `${optionValue} ${model.id} ${routingHint?.recommendedModelId ?? ''}`, + keywords: [model.owned_by], + triggerContent: ( +
+ {optionValue} + {routingHint?.pinnedAvailable ? ( + + {routingHint.prefix} + + ) : null} + {isCodexProvider && } +
+ ), + itemContent: ( +
+ {optionValue} + {routingHint?.unprefixedStatus === 'shadowed' ? ( + + {t('providerModelSelector.shadowed')} + + ) : null} + {routingHint?.unprefixedStatus === 'prefix-only' ? ( + + {t('providerModelSelector.prefixOnly')} + + ) : null} + {isCodexProvider && } +
+ ), + })); + }); const selectedValueMissing = Boolean(value) && !recommendedOptions.some((option) => option.value === value) && diff --git a/ui/src/lib/codex-effort.ts b/ui/src/lib/codex-effort.ts index 9f3b26f2..5bc13019 100644 --- a/ui/src/lib/codex-effort.ts +++ b/ui/src/lib/codex-effort.ts @@ -1,14 +1,55 @@ export type CodexEffort = 'medium' | 'high' | 'xhigh'; const CODEX_EFFORT_SUFFIX_REGEX = /-(medium|high|xhigh)$/i; +const CODEX_EFFORTS_IN_ORDER: readonly CodexEffort[] = ['medium', 'high', 'xhigh']; + +function trimModelId(modelId: string | undefined): string { + return modelId?.trim() ?? ''; +} export function parseCodexEffort(modelId: string | undefined): CodexEffort | undefined { if (!modelId) return undefined; - const match = modelId.trim().match(CODEX_EFFORT_SUFFIX_REGEX); + const match = trimModelId(modelId).match(CODEX_EFFORT_SUFFIX_REGEX); if (!match?.[1]) return undefined; return match[1].toLowerCase() as CodexEffort; } +export function stripCodexEffortSuffix(modelId: string | undefined): string { + return trimModelId(modelId).replace(CODEX_EFFORT_SUFFIX_REGEX, ''); +} + +export function applyCodexEffortSuffix( + modelId: string | undefined, + effort: CodexEffort | undefined +): string { + const normalizedModelId = stripCodexEffortSuffix(modelId); + if (!normalizedModelId || !effort) { + return normalizedModelId; + } + return `${normalizedModelId}-${effort}`; +} + +export function getCodexEffortVariants( + modelId: string, + maxEffort: CodexEffort | undefined +): string[] { + if (!maxEffort) { + return [stripCodexEffortSuffix(modelId)]; + } + + const normalizedModelId = stripCodexEffortSuffix(modelId); + const variantIds = [normalizedModelId]; + + for (const effort of CODEX_EFFORTS_IN_ORDER) { + variantIds.push(applyCodexEffortSuffix(normalizedModelId, effort)); + if (effort === maxEffort) { + break; + } + } + + return variantIds; +} + export function getCodexEffortDisplay( modelId: string | undefined, effortLabels?: { pinned: (effort: string) => string; auto: string } diff --git a/ui/src/lib/model-catalogs.ts b/ui/src/lib/model-catalogs.ts index 025ddc01..5398645e 100644 --- a/ui/src/lib/model-catalogs.ts +++ b/ui/src/lib/model-catalogs.ts @@ -263,6 +263,7 @@ export const MODEL_CATALOGS: Record = { id: 'gpt-5-codex', name: 'GPT-5 Codex', description: 'Cross-plan safe Codex default', + codexMaxEffort: 'xhigh', presetMapping: { default: 'gpt-5-codex', opus: 'gpt-5-codex', @@ -274,6 +275,7 @@ export const MODEL_CATALOGS: Record = { id: 'gpt-5-codex-mini', name: 'GPT-5 Codex Mini', description: 'Faster and cheaper Codex option', + codexMaxEffort: 'high', presetMapping: { default: 'gpt-5-codex-mini', opus: 'gpt-5-codex', @@ -285,6 +287,7 @@ export const MODEL_CATALOGS: Record = { id: 'gpt-5-mini', name: 'GPT-5 Mini', description: 'Legacy mini model ID kept for backwards compatibility', + codexMaxEffort: 'high', presetMapping: { default: 'gpt-5-mini', opus: 'gpt-5-codex', @@ -296,6 +299,7 @@ export const MODEL_CATALOGS: Record = { id: 'gpt-5.1-codex-mini', name: 'GPT-5.1 Codex Mini', description: 'Legacy fast Codex mini model', + codexMaxEffort: 'high', presetMapping: { default: 'gpt-5.1-codex-mini', opus: 'gpt-5.1-codex-max', @@ -307,6 +311,7 @@ export const MODEL_CATALOGS: Record = { id: 'gpt-5.1-codex-max', name: 'GPT-5.1 Codex Max', description: 'Higher-effort Codex model with xhigh support', + codexMaxEffort: 'xhigh', presetMapping: { default: 'gpt-5.1-codex-max', opus: 'gpt-5.1-codex-max', @@ -318,6 +323,7 @@ export const MODEL_CATALOGS: Record = { id: 'gpt-5.2-codex', name: 'GPT-5.2 Codex', description: 'Cross-plan Codex model with xhigh support', + codexMaxEffort: 'xhigh', presetMapping: { default: 'gpt-5.2-codex', opus: 'gpt-5.2-codex', @@ -330,6 +336,7 @@ export const MODEL_CATALOGS: Record = { name: 'GPT-5.3 Codex', tier: 'paid', description: 'Paid Codex plans only', + codexMaxEffort: 'xhigh', presetMapping: { default: 'gpt-5.3-codex', opus: 'gpt-5.3-codex', @@ -342,6 +349,7 @@ export const MODEL_CATALOGS: Record = { name: 'GPT-5.3 Codex Spark', tier: 'paid', description: 'Paid Codex plans only, ultra-fast coding model', + codexMaxEffort: 'xhigh', presetMapping: { default: 'gpt-5.3-codex-spark', opus: 'gpt-5.3-codex', @@ -354,6 +362,7 @@ export const MODEL_CATALOGS: Record = { name: 'GPT-5.4', tier: 'paid', description: 'Paid Codex plans only, latest GPT-5 family model', + codexMaxEffort: 'xhigh', presetMapping: { default: 'gpt-5.4', opus: 'gpt-5.4', diff --git a/ui/tests/unit/components/cliproxy/provider-editor/use-provider-editor.test.tsx b/ui/tests/unit/components/cliproxy/provider-editor/use-provider-editor.test.tsx index c1546fb2..25c3e7ab 100644 --- a/ui/tests/unit/components/cliproxy/provider-editor/use-provider-editor.test.tsx +++ b/ui/tests/unit/components/cliproxy/provider-editor/use-provider-editor.test.tsx @@ -151,4 +151,49 @@ describe('useProviderEditor', () => { ANTHROPIC_DEFAULT_HAIKU_MODEL: 'claude-haiku-4-5-20251001', }); }); + + it('preserves explicit codex effort suffixes in editor state 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_OPUS_MODEL: 'gpt-5.3-codex-xhigh', + ANTHROPIC_DEFAULT_SONNET_MODEL: 'gpt-5.3-codex-high', + ANTHROPIC_DEFAULT_HAIKU_MODEL: 'gpt-5.4-mini-medium', + }, + }, + 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.currentModel).toBe('gpt-5.3-codex-high')); + + act(() => { + result.current.updateEnvValue('ANTHROPIC_MODEL', 'gpt-5.3-codex-xhigh'); + }); + + const nextSettings = JSON.parse(result.current.rawJsonContent); + expect(nextSettings.env).toMatchObject({ + 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.4-mini-medium', + }); + }); }); diff --git a/ui/tests/unit/components/cliproxy/provider-model-selector.test.tsx b/ui/tests/unit/components/cliproxy/provider-model-selector.test.tsx index 3b8b94b1..198a839a 100644 --- a/ui/tests/unit/components/cliproxy/provider-model-selector.test.tsx +++ b/ui/tests/unit/components/cliproxy/provider-model-selector.test.tsx @@ -1,7 +1,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; import { FlexibleModelSelector } from '@/components/cliproxy/provider-model-selector'; -import { buildUiCatalogs } from '@/lib/model-catalogs'; +import { MODEL_CATALOGS, buildUiCatalogs } from '@/lib/model-catalogs'; import { render, screen, userEvent } from '@tests/setup/test-utils'; const noisyAgyModels = [ @@ -87,4 +87,46 @@ describe('FlexibleModelSelector', () => { expect(screen.getAllByText('gemini-3.1-pro-preview').length).toBeGreaterThan(0); expect(screen.getByText('gemini-3.1-pro-high')).toBeInTheDocument(); }); + + it('offers codex effort-suffixed variants as first-class selectable options', async () => { + const onChange = vi.fn(); + + render( + + ); + + await userEvent.click(screen.getByRole('button', { name: /select model/i })); + + expect(screen.getByText('gpt-5.3-codex-high')).toBeInTheDocument(); + expect(screen.getByText('gpt-5.3-codex-xhigh')).toBeInTheDocument(); + + await userEvent.click(screen.getByText('gpt-5.3-codex-high')); + expect(onChange).toHaveBeenCalledWith('gpt-5.3-codex-high'); + }); + + it('does not relegate saved codex effort variants to the legacy current-value fallback', async () => { + render( + + ); + + expect(screen.getByRole('button', { name: /gpt-5\.3-codex-high/i })).toBeInTheDocument(); + + await userEvent.click(screen.getByRole('button', { name: /gpt-5\.3-codex-high/i })); + + expect(screen.queryByText('Current value')).not.toBeInTheDocument(); + expect(screen.getByText('gpt-5.3-codex')).toBeInTheDocument(); + expect(screen.getAllByText('gpt-5.3-codex-high').length).toBeGreaterThan(0); + }); }); diff --git a/ui/tests/unit/ui/lib/codex-effort.test.ts b/ui/tests/unit/ui/lib/codex-effort.test.ts index 965d1963..90b30e3e 100644 --- a/ui/tests/unit/ui/lib/codex-effort.test.ts +++ b/ui/tests/unit/ui/lib/codex-effort.test.ts @@ -1,5 +1,11 @@ import { describe, expect, it } from 'vitest'; -import { getCodexEffortDisplay, parseCodexEffort } from '@/lib/codex-effort'; +import { + applyCodexEffortSuffix, + getCodexEffortDisplay, + getCodexEffortVariants, + parseCodexEffort, + stripCodexEffortSuffix, +} from '@/lib/codex-effort'; describe('parseCodexEffort', () => { it('parses lowercase suffixes', () => { @@ -38,3 +44,25 @@ describe('getCodexEffortDisplay', () => { expect(getCodexEffortDisplay(undefined)).toBeNull(); }); }); + +describe('codex effort helpers', () => { + it('strips and reapplies codex effort suffixes', () => { + expect(stripCodexEffortSuffix('gpt-5.3-codex-high')).toBe('gpt-5.3-codex'); + expect(applyCodexEffortSuffix('gpt-5.3-codex-high', 'xhigh')).toBe('gpt-5.3-codex-xhigh'); + expect(applyCodexEffortSuffix('gpt-5.3-codex', undefined)).toBe('gpt-5.3-codex'); + }); + + it('builds ordered codex effort variants up to the supported max level', () => { + expect(getCodexEffortVariants('gpt-5.3-codex', 'xhigh')).toEqual([ + 'gpt-5.3-codex', + 'gpt-5.3-codex-medium', + 'gpt-5.3-codex-high', + 'gpt-5.3-codex-xhigh', + ]); + expect(getCodexEffortVariants('gpt-5.4-mini', 'high')).toEqual([ + 'gpt-5.4-mini', + 'gpt-5.4-mini-medium', + 'gpt-5.4-mini-high', + ]); + }); +}); diff --git a/ui/tests/unit/ui/lib/model-catalogs-codex.test.ts b/ui/tests/unit/ui/lib/model-catalogs-codex.test.ts index 8e302458..b8ceaf9a 100644 --- a/ui/tests/unit/ui/lib/model-catalogs-codex.test.ts +++ b/ui/tests/unit/ui/lib/model-catalogs-codex.test.ts @@ -6,8 +6,11 @@ describe('codex model catalog defaults', () => { const codexCatalog = MODEL_CATALOGS.codex; const codex53 = codexCatalog.models.find((model) => model.id === 'gpt-5.3-codex'); const codex52 = codexCatalog.models.find((model) => model.id === 'gpt-5.2-codex'); + const codexMini = codexCatalog.models.find((model) => model.id === 'gpt-5-codex-mini'); expect(codex53?.presetMapping?.haiku).toBe('gpt-5-codex-mini'); expect(codex52?.presetMapping?.haiku).toBe('gpt-5-codex-mini'); + expect(codex53?.codexMaxEffort).toBe('xhigh'); + expect(codexMini?.codexMaxEffort).toBe('high'); }); });