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');
});
});