fix: preserve codex effort suffixes in dashboard profiles

This commit is contained in:
Tam Nhu Tran committed 2026-04-22 14:24:00 -04:00
1 parent b376a485e0
commit e2ca197397
19 files changed
+557 -202

No files matched your search

@@ -229,7 +229,8 @@ export function ModelConfigSection({
{provider === 'codex' && (
<p className="text-[11px] text-muted-foreground mb-3 rounded-md border bg-muted/30 px-2.5 py-2">
Codex tip: suffixes <code>-medium</code>, <code>-high</code>, and <code>-xhigh</code>{' '}
pin reasoning effort. Unsuffixed models use Thinking settings.
pin reasoning effort. Select a suffixed model to pin effort; unsuffixed models use
Thinking settings.
</p>
)}
<div className="space-y-4">
@@ -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: (
<div className="flex min-w-0 items-center gap-2">
<span className="truncate font-mono text-xs">
{getPreferredOptionValue(model.id, routingHints.get(model.id.toLowerCase()))}
</span>
{routingHints.get(model.id.toLowerCase())?.pinnedAvailable ? (
<Badge variant="secondary" className="text-[9px] h-4 px-1 uppercase">
{routingHints.get(model.id.toLowerCase())?.prefix}
</Badge>
) : null}
{isCodexProvider && <CodexEffortBadge modelId={model.id} />}
</div>
),
itemContent: (
<div className="flex min-w-0 items-center gap-2">
<span className="truncate font-mono text-xs">
{getPreferredOptionValue(model.id, routingHints.get(model.id.toLowerCase()))}
</span>
{model.tier === 'paid' && <PaidBadge label={t('providerModelSelector.paid')} />}
{routingHints.get(model.id.toLowerCase())?.unprefixedStatus === 'shadowed' ? (
<Badge variant="outline" className="text-[9px] h-4 px-1">
{t('providerModelSelector.shadowed')}
</Badge>
) : null}
{routingHints.get(model.id.toLowerCase())?.unprefixedStatus === 'prefix-only' ? (
<Badge variant="outline" className="text-[9px] h-4 px-1">
{t('providerModelSelector.prefixOnly')}
</Badge>
) : null}
{isCodexProvider && <CodexEffortBadge modelId={model.id} />}
</div>
),
}));
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: (
<div className="flex min-w-0 items-center gap-2">
<span className="truncate font-mono text-xs">{optionValue}</span>
{routingHint?.pinnedAvailable ? (
<Badge variant="secondary" className="text-[9px] h-4 px-1 uppercase">
{routingHint.prefix}
</Badge>
) : null}
{isCodexProvider && <CodexEffortBadge modelId={optionValue} />}
</div>
),
itemContent: (
<div className="flex min-w-0 items-center gap-2">
<span className="truncate font-mono text-xs">{optionValue}</span>
{model.tier === 'paid' && <PaidBadge label={t('providerModelSelector.paid')} />}
{routingHint?.unprefixedStatus === 'shadowed' ? (
<Badge variant="outline" className="text-[9px] h-4 px-1">
{t('providerModelSelector.shadowed')}
</Badge>
) : null}
{routingHint?.unprefixedStatus === 'prefix-only' ? (
<Badge variant="outline" className="text-[9px] h-4 px-1">
{t('providerModelSelector.prefixOnly')}
</Badge>
) : null}
{isCodexProvider && <CodexEffortBadge modelId={optionValue} />}
</div>
),
}));
});
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: (
<div className="flex min-w-0 items-center gap-2">
<span className="truncate font-mono text-xs">
{getPreferredOptionValue(model.id, routingHints.get(model.id.toLowerCase()))}
</span>
{routingHints.get(model.id.toLowerCase())?.pinnedAvailable ? (
<Badge variant="secondary" className="text-[9px] h-4 px-1 uppercase">
{routingHints.get(model.id.toLowerCase())?.prefix}
</Badge>
) : null}
{isCodexProvider && <CodexEffortBadge modelId={model.id} />}
</div>
),
itemContent: (
<div className="flex min-w-0 items-center gap-2">
<span className="truncate font-mono text-xs">
{getPreferredOptionValue(model.id, routingHints.get(model.id.toLowerCase()))}
</span>
{routingHints.get(model.id.toLowerCase())?.unprefixedStatus === 'shadowed' ? (
<Badge variant="outline" className="text-[9px] h-4 px-1">
{t('providerModelSelector.shadowed')}
</Badge>
) : null}
{routingHints.get(model.id.toLowerCase())?.unprefixedStatus === 'prefix-only' ? (
<Badge variant="outline" className="text-[9px] h-4 px-1">
{t('providerModelSelector.prefixOnly')}
</Badge>
) : null}
{isCodexProvider && <CodexEffortBadge modelId={model.id} />}
</div>
),
}));
.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: (
<div className="flex min-w-0 items-center gap-2">
<span className="truncate font-mono text-xs">{optionValue}</span>
{routingHint?.pinnedAvailable ? (
<Badge variant="secondary" className="text-[9px] h-4 px-1 uppercase">
{routingHint.prefix}
</Badge>
) : null}
{isCodexProvider && <CodexEffortBadge modelId={optionValue} />}
</div>
),
itemContent: (
<div className="flex min-w-0 items-center gap-2">
<span className="truncate font-mono text-xs">{optionValue}</span>
{routingHint?.unprefixedStatus === 'shadowed' ? (
<Badge variant="outline" className="text-[9px] h-4 px-1">
{t('providerModelSelector.shadowed')}
</Badge>
) : null}
{routingHint?.unprefixedStatus === 'prefix-only' ? (
<Badge variant="outline" className="text-[9px] h-4 px-1">
{t('providerModelSelector.prefixOnly')}
</Badge>
) : null}
{isCodexProvider && <CodexEffortBadge modelId={optionValue} />}
</div>
),
}));
});
const selectedValueMissing =
Boolean(value) &&
!recommendedOptions.some((option) => option.value === value) &&
+42 -1
View File
@@ -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 }
+9
View File
@@ -263,6 +263,7 @@ export const MODEL_CATALOGS: Record<string, ProviderCatalog> = {
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<string, ProviderCatalog> = {
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<string, ProviderCatalog> = {
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<string, ProviderCatalog> = {
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<string, ProviderCatalog> = {
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<string, ProviderCatalog> = {
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<string, ProviderCatalog> = {
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<string, ProviderCatalog> = {
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<string, ProviderCatalog> = {
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',
@@ -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',
});
});
});
@@ -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(
<FlexibleModelSelector
label="Primary model"
value={undefined}
onChange={onChange}
catalog={MODEL_CATALOGS.codex}
allModels={[]}
/>
);
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(
<FlexibleModelSelector
label="Primary model"
value="gpt-5.3-codex-high"
onChange={vi.fn()}
catalog={MODEL_CATALOGS.codex}
allModels={[]}
/>
);
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);
});
});
+29 -1
View File
@@ -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',
]);
});
});
@@ -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');
});
});