feat(cliproxy): clarify codex effort mapping and preset UX

This commit is contained in:
Tam Nhu Tran
2026-02-14 11:31:18 +07:00
parent a0d7c14ea0
commit 945db1d690
13 changed files with 245 additions and 73 deletions
+3 -3
View File
@@ -2,9 +2,9 @@
"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",
"ANTHROPIC_DEFAULT_OPUS_MODEL": "gpt-5.3-codex",
"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"
"ANTHROPIC_DEFAULT_HAIKU_MODEL": "gpt-5-mini-medium"
}
}
+2 -2
View File
@@ -165,7 +165,7 @@ export const MODEL_CATALOG: Partial<Record<CLIProxyProvider, ProviderCatalog>> =
{
id: 'gpt-5.3-codex',
name: 'GPT-5.3 Codex',
description: 'Full reasoning support (xhigh)',
description: 'Supports up to xhigh effort',
thinking: {
type: 'levels',
levels: ['medium', 'high', 'xhigh'],
@@ -187,7 +187,7 @@ export const MODEL_CATALOG: Partial<Record<CLIProxyProvider, ProviderCatalog>> =
{
id: 'gpt-5-mini',
name: 'GPT-5 Mini',
description: 'Capped at high reasoning (no xhigh)',
description: 'Capped at high effort (no xhigh)',
thinking: {
type: 'levels',
levels: ['medium', 'high'],
+36 -11
View File
@@ -14,6 +14,22 @@ import { CLIProxyProvider } from './types';
import { initUI, color, bold, dim, ok, info, header } from '../utils/ui';
import { getCcsDir } from '../utils/config-manager';
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 normalizeCodexTierModel(
provider: CLIProxyProvider,
model: string,
fallbackEffort: 'medium' | 'high' | 'xhigh'
): string {
if (provider !== 'codex') return model;
return CODEX_EFFORT_SUFFIX_REGEX.test(model) ? model : `${model}-${fallbackEffort}`;
}
/**
* Check if provider has user settings configured
*/
@@ -120,7 +136,9 @@ export async function configureProviderModel(
// Find default index - use current model if configured, otherwise catalog default
const currentModel = getCurrentModel(provider, customSettingsPath);
const targetModel = currentModel || catalog.defaultModel;
const targetModel = currentModel
? stripCodexEffortSuffix(currentModel, provider)
: catalog.defaultModel;
const defaultIdx = catalog.models.findIndex((m) => m.id === targetModel);
const safeDefaultIdx = defaultIdx >= 0 ? defaultIdx : 0;
@@ -142,10 +160,14 @@ export async function configureProviderModel(
// Get base env vars for defaults
const baseEnv = getClaudeEnvVars(provider);
const codexSonnetModel =
provider === 'codex' && !selectedModel.match(/-(xhigh|high|medium)$/)
? `${selectedModel}-high`
: selectedModel;
const selectedDefaultModel = normalizeCodexTierModel(provider, selectedModel, 'xhigh');
const selectedOpusModel = normalizeCodexTierModel(provider, selectedModel, 'xhigh');
const selectedSonnetModel = normalizeCodexTierModel(provider, selectedModel, 'high');
const selectedHaikuModel = normalizeCodexTierModel(
provider,
baseEnv.ANTHROPIC_DEFAULT_HAIKU_MODEL || selectedModel,
'medium'
);
// Read existing settings to preserve user customizations
let existingSettings: Record<string, unknown> = {};
@@ -167,10 +189,10 @@ export async function configureProviderModel(
const ccsControlledEnv: Record<string, string> = {
ANTHROPIC_BASE_URL: baseEnv.ANTHROPIC_BASE_URL || '',
ANTHROPIC_AUTH_TOKEN: baseEnv.ANTHROPIC_AUTH_TOKEN || '',
ANTHROPIC_MODEL: selectedModel,
ANTHROPIC_DEFAULT_OPUS_MODEL: selectedModel,
ANTHROPIC_DEFAULT_SONNET_MODEL: codexSonnetModel,
ANTHROPIC_DEFAULT_HAIKU_MODEL: baseEnv.ANTHROPIC_DEFAULT_HAIKU_MODEL || '',
ANTHROPIC_MODEL: selectedDefaultModel,
ANTHROPIC_DEFAULT_OPUS_MODEL: selectedOpusModel,
ANTHROPIC_DEFAULT_SONNET_MODEL: selectedSonnetModel,
ANTHROPIC_DEFAULT_HAIKU_MODEL: selectedHaikuModel,
};
// Merge: user env vars (preserved) + CCS controlled (override)
@@ -232,13 +254,16 @@ export async function showCurrentConfig(provider: CLIProxyProvider): Promise<voi
const currentModel = getCurrentModel(provider);
const settingsPath = getProviderSettingsPath(provider);
const normalizedCurrentModel = currentModel
? stripCodexEffortSuffix(currentModel, provider)
: undefined;
console.error('');
console.error(header(`${catalog.displayName} Model Configuration`));
console.error('');
if (currentModel) {
const entry = catalog.models.find((m) => m.id === currentModel);
const entry = catalog.models.find((m) => m.id === normalizedCurrentModel);
const displayName = entry?.name || 'Unknown';
console.error(
` ${bold('Current:')} ${color(displayName, 'success')} ${dim(`(${currentModel})`)}`
@@ -255,7 +280,7 @@ export async function showCurrentConfig(provider: CLIProxyProvider): Promise<voi
console.error(dim(' [DEPRECATED] = Not recommended for use'));
console.error('');
catalog.models.forEach((m) => {
const isCurrent = m.id === currentModel;
const isCurrent = m.id === normalizedCurrentModel;
console.error(formatModelDetailed(m, isCurrent));
});
+41 -17
View File
@@ -35,6 +35,21 @@ interface SettingsFile {
[key: string]: unknown;
}
const CODEX_EFFORT_SUFFIX_REGEX = /-(xhigh|high|medium)$/i;
function hasCodexEffortSuffix(model: string): boolean {
return CODEX_EFFORT_SUFFIX_REGEX.test(model);
}
function normalizeCodexTierModel(
provider: CLIProxyProfileName | undefined,
model: string,
fallbackEffort: 'medium' | 'high' | 'xhigh'
): string {
if (provider !== 'codex') return model;
return hasCodexEffortSuffix(model) ? model : `${model}-${fallbackEffort}`;
}
/**
* Build settings env object for a variant
*/
@@ -44,26 +59,25 @@ function buildSettingsEnv(
port: number = CLIPROXY_DEFAULT_PORT
): SettingsEnv {
const baseEnv = getClaudeEnvVars(provider as CLIProxyProvider, port);
const codexSonnetModel = normalizeCodexSonnetModel(provider, model);
const defaultModel = normalizeCodexTierModel(provider, model, 'xhigh');
const opusModel = normalizeCodexTierModel(provider, model, 'xhigh');
const sonnetModel = normalizeCodexTierModel(provider, model, 'high');
const haikuModel = normalizeCodexTierModel(
provider,
baseEnv.ANTHROPIC_DEFAULT_HAIKU_MODEL || model,
'medium'
);
return {
ANTHROPIC_BASE_URL: baseEnv.ANTHROPIC_BASE_URL || '',
ANTHROPIC_AUTH_TOKEN: baseEnv.ANTHROPIC_AUTH_TOKEN || '',
ANTHROPIC_MODEL: model,
ANTHROPIC_DEFAULT_OPUS_MODEL: model,
ANTHROPIC_DEFAULT_SONNET_MODEL: codexSonnetModel,
ANTHROPIC_DEFAULT_HAIKU_MODEL: baseEnv.ANTHROPIC_DEFAULT_HAIKU_MODEL || model,
ANTHROPIC_MODEL: defaultModel,
ANTHROPIC_DEFAULT_OPUS_MODEL: opusModel,
ANTHROPIC_DEFAULT_SONNET_MODEL: sonnetModel,
ANTHROPIC_DEFAULT_HAIKU_MODEL: haikuModel,
};
}
function normalizeCodexSonnetModel(
provider: CLIProxyProfileName | undefined,
model: string
): string {
if (provider !== 'codex') return model;
return /-(xhigh|high|medium)$/.test(model) ? model : `${model}-high`;
}
/**
* Ensure directory exists
*/
@@ -299,10 +313,20 @@ export function updateSettingsModel(
if (model) {
settings.env = settings.env || ({} as SettingsEnv);
const codexSonnetModel = normalizeCodexSonnetModel(provider, model);
settings.env.ANTHROPIC_MODEL = model;
settings.env.ANTHROPIC_DEFAULT_OPUS_MODEL = model;
settings.env.ANTHROPIC_DEFAULT_SONNET_MODEL = codexSonnetModel;
settings.env.ANTHROPIC_MODEL = normalizeCodexTierModel(provider, model, 'xhigh');
settings.env.ANTHROPIC_DEFAULT_OPUS_MODEL = normalizeCodexTierModel(provider, model, 'xhigh');
settings.env.ANTHROPIC_DEFAULT_SONNET_MODEL = normalizeCodexTierModel(
provider,
model,
'high'
);
if (provider === 'codex' && settings.env.ANTHROPIC_DEFAULT_HAIKU_MODEL) {
settings.env.ANTHROPIC_DEFAULT_HAIKU_MODEL = normalizeCodexTierModel(
provider,
settings.env.ANTHROPIC_DEFAULT_HAIKU_MODEL,
'medium'
);
}
} else {
// Clear model settings to use defaults
delete (settings.env as unknown as Record<string, string>).ANTHROPIC_MODEL;
+3 -2
View File
@@ -168,7 +168,7 @@ Run ${color('ccs config', 'command')} for web dashboard`.trim();
],
[
['ccs gemini', 'Google Gemini (gemini-2.5-pro or 3-pro)'],
['ccs codex', 'OpenAI Codex (gpt-5.3-codex)'],
['ccs codex', 'OpenAI Codex (supports -medium/-high/-xhigh model suffixes)'],
['ccs agy', 'Antigravity (Claude/Gemini models)'],
['ccs qwen', 'Qwen Code (qwen3-coder)'],
['ccs kiro', 'Kiro (AWS CodeWhisperer Claude models)'],
@@ -346,11 +346,12 @@ Run ${color('ccs config', 'command')} for web dashboard`.trim();
['--thinking <number>', 'Custom token budget (512-100000)'],
['', ''],
['--effort <level>', 'Codex alias for reasoning effort (medium/high/xhigh)'],
['--effort xhigh', 'Codex 5.3 full-depth reasoning'],
['--effort xhigh', 'Pin Codex effort to xhigh for this run'],
['', ''],
['Note:', 'Extended thinking allocates compute for step-by-step reasoning'],
['', 'before responding.'],
['', 'Providers: agy/gemini use --thinking, codex uses --effort (or --thinking alias).'],
['', 'Codex model suffixes also pin effort: -medium / -high / -xhigh.'],
]);
// Extended Context (1M)
@@ -100,7 +100,10 @@ cliproxy:
};
expect(settings.env.ANTHROPIC_BASE_URL).toContain('/api/provider/codex');
expect(settings.env.ANTHROPIC_MODEL).toBe('gpt-5.1-codex-mini');
expect(settings.env.ANTHROPIC_MODEL).toBe('gpt-5.1-codex-mini-xhigh');
expect(settings.env.ANTHROPIC_DEFAULT_OPUS_MODEL).toBe('gpt-5.1-codex-mini-xhigh');
expect(settings.env.ANTHROPIC_DEFAULT_SONNET_MODEL).toBe('gpt-5.1-codex-mini-high');
expect(settings.env.ANTHROPIC_DEFAULT_HAIKU_MODEL).toBe('gpt-5-mini-medium');
expect(settings.env.CUSTOM_FLAG).toBe('keep-me');
expect(settings.hooks.PreToolUse.length).toBe(1);
@@ -119,7 +122,10 @@ cliproxy:
let settings = JSON.parse(fs.readFileSync(settingsPath, 'utf-8')) as {
env: Record<string, string>;
};
expect(settings.env.ANTHROPIC_MODEL).toBe('gpt-5.3-codex-xhigh');
expect(settings.env.ANTHROPIC_DEFAULT_OPUS_MODEL).toBe('gpt-5.3-codex-xhigh');
expect(settings.env.ANTHROPIC_DEFAULT_SONNET_MODEL).toBe('gpt-5.3-codex-high');
expect(settings.env.ANTHROPIC_DEFAULT_HAIKU_MODEL).toBe('gpt-5-mini-medium');
const modelOnly = updateVariant('demo', { model: 'gpt-5.3-codex' });
expect(modelOnly.success).toBe(true);
@@ -127,8 +133,9 @@ cliproxy:
settings = JSON.parse(fs.readFileSync(settingsPath, 'utf-8')) as {
env: Record<string, string>;
};
expect(settings.env.ANTHROPIC_MODEL).toBe('gpt-5.3-codex');
expect(settings.env.ANTHROPIC_DEFAULT_OPUS_MODEL).toBe('gpt-5.3-codex');
expect(settings.env.ANTHROPIC_MODEL).toBe('gpt-5.3-codex-xhigh');
expect(settings.env.ANTHROPIC_DEFAULT_OPUS_MODEL).toBe('gpt-5.3-codex-xhigh');
expect(settings.env.ANTHROPIC_DEFAULT_SONNET_MODEL).toBe('gpt-5.3-codex-high');
expect(settings.env.ANTHROPIC_DEFAULT_HAIKU_MODEL).toBe('gpt-5-mini-medium');
});
});
@@ -63,6 +63,12 @@ export function CustomPresetDialog({
/>
</div>
<Separator />
{catalog?.provider === 'codex' && (
<p className="text-[11px] text-muted-foreground 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 effort. Unsuffixed models use Thinking settings.
</p>
)}
<FlexibleModelSelector
label="Default Model"
description="Used when no specific tier is requested"
@@ -132,6 +132,12 @@ export function ModelConfigSection({
<p className="text-xs text-muted-foreground mb-4">
Configure which models to use for each tier
</p>
{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.
</p>
)}
<div className="space-y-4">
<FlexibleModelSelector
label="Default Model"
@@ -18,6 +18,7 @@ import {
import { Skeleton } from '@/components/ui/skeleton';
import { AlertTriangle, AlertCircle, Check } from 'lucide-react';
import { cn } from '@/lib/utils';
import { getCodexEffortDisplay } from '@/lib/codex-effort';
/** Model entry from catalog */
export interface ModelEntry {
@@ -263,6 +264,8 @@ export function FlexibleModelSelector({
}: FlexibleModelSelectorProps) {
// Combine catalog models (recommended) with all available models
const catalogModelIds = new Set(catalog?.models.map((m) => m.id) || []);
const isCodexProvider = catalog?.provider === 'codex';
const selectedCodexEffort = isCodexProvider ? getCodexEffortDisplay(value) : null;
return (
<div className="space-y-1.5">
@@ -273,7 +276,19 @@ export function FlexibleModelSelector({
<Select value={value || ''} onValueChange={onChange} disabled={disabled}>
<SelectTrigger className="h-9">
<SelectValue placeholder="Select model">
{value && <span className="truncate font-mono text-xs">{value}</span>}
{value && (
<div className="flex items-center gap-2">
<span className="truncate font-mono text-xs">{value}</span>
{selectedCodexEffort && (
<Badge
variant={selectedCodexEffort.explicit ? 'secondary' : 'outline'}
className="text-[9px] h-4 px-1 uppercase"
>
{selectedCodexEffort.label}
</Badge>
)}
</div>
)}
</SelectValue>
</SelectTrigger>
<SelectContent className="max-h-[300px]">
@@ -281,19 +296,30 @@ export function FlexibleModelSelector({
{catalog && catalog.models.length > 0 && (
<SelectGroup>
<SelectLabel className="text-xs text-primary">Recommended</SelectLabel>
{catalog.models.map((model) => (
<SelectItem key={model.id} value={model.id}>
<div className="flex items-center gap-2">
<span className="truncate font-mono text-xs">{model.id}</span>
{model.tier === 'paid' && (
<Badge variant="outline" className="text-[9px] h-4 px-1">
PAID
</Badge>
)}
{value === model.id && <Check className="w-3 h-3 text-primary ml-auto" />}
</div>
</SelectItem>
))}
{catalog.models.map((model) => {
const codexEffort = isCodexProvider ? getCodexEffortDisplay(model.id) : null;
return (
<SelectItem key={model.id} value={model.id}>
<div className="flex items-center gap-2">
<span className="truncate font-mono text-xs">{model.id}</span>
{model.tier === 'paid' && (
<Badge variant="outline" className="text-[9px] h-4 px-1">
PAID
</Badge>
)}
{codexEffort && (
<Badge
variant={codexEffort.explicit ? 'secondary' : 'outline'}
className="text-[9px] h-4 px-1 uppercase"
>
{codexEffort.label}
</Badge>
)}
{value === model.id && <Check className="w-3 h-3 text-primary ml-auto" />}
</div>
</SelectItem>
);
})}
</SelectGroup>
)}
@@ -305,14 +331,25 @@ export function FlexibleModelSelector({
</SelectLabel>
{allModels
.filter((m) => !catalogModelIds.has(m.id))
.map((model) => (
<SelectItem key={model.id} value={model.id}>
<div className="flex items-center gap-2">
<span className="truncate font-mono text-xs">{model.id}</span>
{value === model.id && <Check className="w-3 h-3 text-primary ml-auto" />}
</div>
</SelectItem>
))}
.map((model) => {
const codexEffort = isCodexProvider ? getCodexEffortDisplay(model.id) : null;
return (
<SelectItem key={model.id} value={model.id}>
<div className="flex items-center gap-2">
<span className="truncate font-mono text-xs">{model.id}</span>
{codexEffort && (
<Badge
variant={codexEffort.explicit ? 'secondary' : 'outline'}
className="text-[9px] h-4 px-1 uppercase"
>
{codexEffort.label}
</Badge>
)}
{value === model.id && <Check className="w-3 h-3 text-primary ml-auto" />}
</div>
</SelectItem>
);
})}
</SelectGroup>
)}
+21
View File
@@ -0,0 +1,21 @@
export type CodexEffort = 'medium' | 'high' | 'xhigh';
const CODEX_EFFORT_SUFFIX_REGEX = /-(medium|high|xhigh)$/i;
export function parseCodexEffort(modelId: string | undefined): CodexEffort | undefined {
if (!modelId) return undefined;
const match = modelId.trim().match(CODEX_EFFORT_SUFFIX_REGEX);
if (!match?.[1]) return undefined;
return match[1].toLowerCase() as CodexEffort;
}
export function getCodexEffortDisplay(
modelId: string | undefined
): { label: string; explicit: boolean } | null {
if (!modelId) return null;
const effort = parseCodexEffort(modelId);
if (effort) {
return { label: `Pinned ${effort}`, explicit: true };
}
return { label: 'Auto effort', explicit: false };
}
+12 -12
View File
@@ -138,12 +138,12 @@ export const MODEL_CATALOGS: Record<string, ProviderCatalog> = {
{
id: 'gpt-5.3-codex',
name: 'GPT-5.3 Codex',
description: 'Full reasoning support (xhigh)',
description: 'Supports up to xhigh effort',
presetMapping: {
default: 'gpt-5.3-codex',
opus: 'gpt-5.3-codex',
default: 'gpt-5.3-codex-xhigh',
opus: 'gpt-5.3-codex-xhigh',
sonnet: 'gpt-5.3-codex-high',
haiku: 'gpt-5-mini',
haiku: 'gpt-5-mini-medium',
},
},
{
@@ -151,21 +151,21 @@ export const MODEL_CATALOGS: Record<string, ProviderCatalog> = {
name: 'GPT-5.2 Codex',
description: 'Previous stable Codex model',
presetMapping: {
default: 'gpt-5.2-codex',
opus: 'gpt-5.2-codex',
default: 'gpt-5.2-codex-xhigh',
opus: 'gpt-5.2-codex-xhigh',
sonnet: 'gpt-5.2-codex-high',
haiku: 'gpt-5-mini',
haiku: 'gpt-5-mini-medium',
},
},
{
id: 'gpt-5-mini',
name: 'GPT-5 Mini',
description: 'Fast, capped at high reasoning (no xhigh)',
description: 'Fast, capped at high effort (no xhigh)',
presetMapping: {
default: 'gpt-5-mini',
opus: 'gpt-5.3-codex',
sonnet: 'gpt-5-mini',
haiku: 'gpt-5-mini',
default: 'gpt-5-mini-medium',
opus: 'gpt-5.3-codex-xhigh',
sonnet: 'gpt-5-mini-high',
haiku: 'gpt-5-mini-medium',
},
},
{
@@ -115,7 +115,12 @@ export default function ThinkingSection() {
Thinking budget: <strong>agy</strong>, <strong>gemini</strong> (token-based)
</li>
<li>
Reasoning effort: <strong>codex</strong> (level-based: medium/high/xhigh)
Reasoning effort: <strong>codex</strong> (suffix or <code>--effort</code>:
medium/high/xhigh)
</li>
<li>
Codex suffixes pin effort (for example <code>-high</code>); unsuffixed models use
Thinking mode.
</li>
</ul>
</div>
+40
View File
@@ -0,0 +1,40 @@
import { describe, expect, it } from 'vitest';
import { getCodexEffortDisplay, parseCodexEffort } from '@/lib/codex-effort';
describe('parseCodexEffort', () => {
it('parses lowercase suffixes', () => {
expect(parseCodexEffort('gpt-5.3-codex-high')).toBe('high');
expect(parseCodexEffort('gpt-5.3-codex-xhigh')).toBe('xhigh');
expect(parseCodexEffort('gpt-5-mini-medium')).toBe('medium');
});
it('parses mixed-case suffixes', () => {
expect(parseCodexEffort('gpt-5.3-codex-XHIGH')).toBe('xhigh');
});
it('returns undefined for unsuffixed or unsupported values', () => {
expect(parseCodexEffort('gpt-5.3-codex')).toBeUndefined();
expect(parseCodexEffort('gpt-5.3-codex-low')).toBeUndefined();
expect(parseCodexEffort(undefined)).toBeUndefined();
});
});
describe('getCodexEffortDisplay', () => {
it('returns pinned label for suffixed models', () => {
expect(getCodexEffortDisplay('gpt-5.3-codex-high')).toEqual({
label: 'Pinned high',
explicit: true,
});
});
it('returns auto label for unsuffixed models', () => {
expect(getCodexEffortDisplay('gpt-5.3-codex')).toEqual({
label: 'Auto effort',
explicit: false,
});
});
it('returns null for empty input', () => {
expect(getCodexEffortDisplay(undefined)).toBeNull();
});
});