Merge pull request #759 from kaitranntt/kai/fix/757-gpt54-model-not-supported

fix: recover Codex live-session gpt-5.4 switches
This commit is contained in:
Kai (Tam Nhu) Tran
2026-03-18 12:25:23 -04:00
committed by GitHub
8 changed files with 644 additions and 132 deletions
+95 -1
View File
@@ -1,4 +1,5 @@
import { getDefaultAccount } from './account-manager';
import { getProviderCatalog } from './model-catalog';
import { fetchCodexQuota } from './quota-fetcher-codex';
import { getCachedQuota, setCachedQuota } from './quota-response-cache';
import type { CodexQuotaResult } from './quota-types';
@@ -12,6 +13,9 @@ const FREE_SAFE_FAST_MODEL = 'gpt-5-codex-mini';
const CODEX_EFFORT_SUFFIX_REGEX = /-(xhigh|high|medium)$/i;
const CODEX_PAREN_SUFFIX_REGEX = /\((xhigh|high|medium)\)$/i;
const EXTENDED_CONTEXT_SUFFIX_REGEX = /\[1m\]$/i;
const KNOWN_CODEX_MODELS = new Set(
(getProviderCatalog('codex')?.models ?? []).map((model) => model.id.toLowerCase())
);
const FREE_PLAN_FALLBACKS = new Map<string, string>([
['gpt-5.3-codex', FREE_SAFE_DEFAULT_MODEL],
@@ -19,7 +23,29 @@ const FREE_PLAN_FALLBACKS = new Map<string, string>([
['gpt-5.4', FREE_SAFE_DEFAULT_MODEL],
]);
function normalizeCodexModelId(model: string): string {
export interface CodexRuntimeFallbackModelMap {
defaultModel?: string;
opusModel?: string;
sonnetModel?: string;
haikuModel?: string;
}
export interface CodexUnsupportedModelError {
message: string | null;
code: 'model_not_supported';
param: string | null;
type: string | null;
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value);
}
function isKnownCodexModel(model: string): boolean {
return KNOWN_CODEX_MODELS.has(model);
}
export function normalizeCodexModelId(model: string): string {
return model
.trim()
.replace(EXTENDED_CONTEXT_SUFFIX_REGEX, '')
@@ -37,6 +63,74 @@ export function getFreePlanFallbackCodexModel(model: string): string | null {
return FREE_PLAN_FALLBACKS.get(normalizeCodexModelId(model)) ?? null;
}
export function parseCodexUnsupportedModelError(
statusCode: number | undefined,
responseBody: string
): CodexUnsupportedModelError | null {
if (statusCode !== 400 || !responseBody.trim()) {
return null;
}
try {
const parsed = JSON.parse(responseBody);
if (
!isRecord(parsed) ||
!isRecord(parsed.error) ||
parsed.error.code !== 'model_not_supported'
) {
return null;
}
return {
message: typeof parsed.error.message === 'string' ? parsed.error.message : null,
code: 'model_not_supported',
param: typeof parsed.error.param === 'string' ? parsed.error.param : null,
type: typeof parsed.error.type === 'string' ? parsed.error.type : null,
};
} catch {
return null;
}
}
export function resolveRuntimeCodexFallbackModel(options: {
requestedModel: string;
modelMap: CodexRuntimeFallbackModelMap;
excludeModels?: string[];
}): string | null {
const requestedModel = normalizeCodexModelId(options.requestedModel);
if (!requestedModel) {
return null;
}
const excludedModels = new Set(
(options.excludeModels ?? []).map((model) => normalizeCodexModelId(model)).filter(Boolean)
);
const candidates = [
options.modelMap.defaultModel,
getFreePlanFallbackCodexModel(requestedModel),
options.modelMap.opusModel,
options.modelMap.sonnetModel,
options.modelMap.haikuModel,
getDefaultCodexModel(),
];
for (const candidate of candidates) {
if (!candidate) continue;
const normalizedCandidate = normalizeCodexModelId(candidate);
if (
!normalizedCandidate ||
normalizedCandidate === requestedModel ||
excludedModels.has(normalizedCandidate) ||
!isKnownCodexModel(normalizedCandidate)
) {
continue;
}
return normalizedCandidate;
}
return null;
}
export async function reconcileCodexModelForActivePlan(options: {
settingsPath: string;
currentModel: string | undefined;
+150 -29
View File
@@ -1,6 +1,11 @@
import * as http from 'http';
import * as https from 'https';
import { URL } from 'url';
import {
normalizeCodexModelId,
parseCodexUnsupportedModelError,
resolveRuntimeCodexFallbackModel,
} from './codex-plan-compatibility';
import { getModelMaxLevel } from './model-catalog';
export type CodexReasoningEffort = 'medium' | 'high' | 'xhigh';
@@ -29,6 +34,14 @@ export interface CodexReasoningProxyConfig {
disableEffort?: boolean;
}
interface ForwardJsonContext {
requestPath: string;
requestedModel: string | null;
attemptedUpstreamModel: string | null;
effort: CodexReasoningEffort | null;
retryCount: number;
}
const EXTENDED_CONTEXT_SUFFIX_REGEX = /\[1m\]$/i;
function stripExtendedContextSuffix(model: string): string {
@@ -170,6 +183,7 @@ export class CodexReasoningProxy {
> &
Pick<CodexReasoningProxyConfig, 'modelMap' | 'stripPathPrefix'>;
private readonly modelEffort: Map<string, CodexReasoningEffort>;
private readonly sessionFallbackByModel = new Map<string, string>();
private readonly recent: Array<{
at: string;
model: string | null;
@@ -193,6 +207,41 @@ export class CodexReasoningProxy {
this.modelEffort = buildCodexModelEffortMap(this.config.modelMap, this.config.defaultEffort);
}
private getRememberedFallback(model: string | null): string | null {
if (!model) return null;
return this.sessionFallbackByModel.get(normalizeCodexModelId(model)) ?? null;
}
private rememberFallback(requestedModel: string, fallbackModel: string): void {
const normalizedRequestedModel = normalizeCodexModelId(requestedModel);
const normalizedFallbackModel = normalizeCodexModelId(fallbackModel);
if (!normalizedRequestedModel || !normalizedFallbackModel) return;
this.sessionFallbackByModel.set(normalizedRequestedModel, normalizedFallbackModel);
}
private buildForwardBody(
body: unknown,
upstreamModel: string | null,
effort: CodexReasoningEffort | null
): unknown {
const withUpstreamModel =
upstreamModel && isRecord(body) ? { ...body, model: upstreamModel } : body;
if (this.config.disableEffort || !effort) {
return withUpstreamModel;
}
return injectReasoningEffortIntoBody(withUpstreamModel, effort);
}
private sendBufferedResponse(
clientRes: http.ServerResponse,
statusCode: number,
headers: http.IncomingHttpHeaders,
responseBody: string
): void {
clientRes.writeHead(statusCode, headers);
clientRes.end(responseBody);
}
/**
* Treat trailing "-high/-medium/-xhigh" as an effort alias only for known codex models.
* Prevents stripping legitimate upstream model IDs that happen to end with those tokens.
@@ -365,41 +414,48 @@ export class CodexReasoningProxy {
? stripExtendedContextSuffix(originalModel)
: null;
// When effort is disabled (thinking mode: off), strip model suffix but don't inject reasoning
if (this.config.disableEffort) {
const suffixParsed = this.parseEffortAlias(normalizedRequestModel);
const upstreamModel = suffixParsed?.upstreamModel ?? normalizedRequestModel;
const forwarded =
upstreamModel && isRecord(parsed) ? { ...parsed, model: upstreamModel } : parsed;
this.log(`[disabled] model=${originalModel ?? 'null'} -> passthrough (no reasoning)`);
await this.forwardJson(req, res, fullUpstreamUrl, forwarded);
return;
}
// Support "model aliases" like `gpt-5.2-codex-xhigh` by translating to:
// - upstream model: `gpt-5.2-codex`
// - reasoning.effort: `xhigh`
//
// This allows tier→effort mapping without inventing upstream model IDs.
const suffixParsed = this.parseEffortAlias(normalizedRequestModel);
const upstreamModel = suffixParsed?.upstreamModel ?? normalizedRequestModel;
const effort =
const requestedUpstreamModel = suffixParsed?.upstreamModel ?? normalizedRequestModel;
const rememberedFallback = this.getRememberedFallback(requestedUpstreamModel);
const upstreamModel = rememberedFallback ?? requestedUpstreamModel;
const requestedEffort =
suffixParsed?.effort ??
getEffortForModel(normalizedRequestModel, this.modelEffort, this.config.defaultEffort);
const effort =
!this.config.disableEffort && upstreamModel
? capEffortAtModelMax(upstreamModel, requestedEffort)
: !this.config.disableEffort
? requestedEffort
: null;
const rewritten = this.buildForwardBody(parsed, upstreamModel, effort);
const withUpstreamModel =
upstreamModel && isRecord(parsed) ? { ...parsed, model: upstreamModel } : parsed;
const rewritten = injectReasoningEffortIntoBody(withUpstreamModel, effort);
if (effort) {
this.record(originalModel, upstreamModel, effort, requestPath);
this.trace(
`[${new Date().toISOString()}] model=${originalModel ?? 'null'} upstreamModel=${
upstreamModel ?? 'null'
} effort=${effort} path=${requestPath}`
);
} else {
this.log(`[disabled] model=${originalModel ?? 'null'} -> passthrough (no reasoning)`);
}
this.record(originalModel, upstreamModel, effort, requestPath);
this.trace(
`[${new Date().toISOString()}] model=${originalModel ?? 'null'} upstreamModel=${
upstreamModel ?? 'null'
} effort=${effort} path=${requestPath}`
);
if (rememberedFallback && rememberedFallback !== requestedUpstreamModel) {
this.log(`Using remembered fallback ${requestedUpstreamModel} -> ${rememberedFallback}`);
}
await this.forwardJson(req, res, fullUpstreamUrl, rewritten);
await this.forwardJson(req, res, fullUpstreamUrl, rewritten, {
requestPath,
requestedModel: requestedUpstreamModel,
attemptedUpstreamModel: upstreamModel,
effort,
retryCount: 0,
});
} catch (error) {
const err = error as Error;
if (!res.headersSent) {
@@ -487,8 +543,9 @@ export class CodexReasoningProxy {
originalReq: http.IncomingMessage,
clientRes: http.ServerResponse,
upstreamUrl: URL,
body: unknown
): Promise<void> {
body: unknown,
context: ForwardJsonContext
): Promise<number> {
return new Promise((resolve, reject) => {
const bodyString = JSON.stringify(body);
const requestFn = this.getRequestFn(upstreamUrl);
@@ -503,9 +560,73 @@ export class CodexReasoningProxy {
headers: this.buildForwardHeaders(originalReq.headers, bodyString),
},
(upstreamRes) => {
clientRes.writeHead(upstreamRes.statusCode || 200, upstreamRes.headers);
upstreamRes.pipe(clientRes);
upstreamRes.on('end', () => resolve());
const statusCode = upstreamRes.statusCode || 200;
if (statusCode >= 200 && statusCode < 300) {
clientRes.writeHead(statusCode, upstreamRes.headers);
upstreamRes.pipe(clientRes);
upstreamRes.on('end', () => resolve(statusCode));
upstreamRes.on('error', reject);
return;
}
const chunks: Buffer[] = [];
upstreamRes.on('data', (chunk: Buffer) => chunks.push(chunk));
upstreamRes.on('end', async () => {
try {
const responseBody = Buffer.concat(chunks).toString('utf8');
const unsupportedError =
context.retryCount === 0
? parseCodexUnsupportedModelError(statusCode, responseBody)
: null;
const fallbackModel =
unsupportedError && context.requestedModel
? resolveRuntimeCodexFallbackModel({
requestedModel: context.requestedModel,
modelMap: this.config.modelMap,
excludeModels: context.attemptedUpstreamModel
? [context.attemptedUpstreamModel]
: undefined,
})
: null;
if (unsupportedError && fallbackModel && context.requestedModel) {
const retryEffort =
!this.config.disableEffort && context.effort
? capEffortAtModelMax(fallbackModel, context.effort)
: null;
const retryBody = this.buildForwardBody(body, fallbackModel, retryEffort);
this.log(
`Upstream rejected model "${context.attemptedUpstreamModel}". Retrying ${context.requestPath} with "${fallbackModel}".`
);
const retryStatusCode = await this.forwardJson(
originalReq,
clientRes,
upstreamUrl,
retryBody,
{
...context,
attemptedUpstreamModel: fallbackModel,
effort: retryEffort,
retryCount: context.retryCount + 1,
}
);
if (retryStatusCode >= 200 && retryStatusCode < 300) {
this.rememberFallback(context.requestedModel, fallbackModel);
}
resolve(retryStatusCode);
return;
}
this.sendBufferedResponse(clientRes, statusCode, upstreamRes.headers, responseBody);
resolve(statusCode);
} catch (error) {
reject(error);
}
});
upstreamRes.on('error', reject);
}
);
@@ -3,6 +3,8 @@ import { getProviderCatalog, getModelMaxLevel } from '../../../src/cliproxy/mode
import {
getDefaultCodexModel,
getFreePlanFallbackCodexModel,
parseCodexUnsupportedModelError,
resolveRuntimeCodexFallbackModel,
} from '../../../src/cliproxy/codex-plan-compatibility';
describe('codex plan compatibility', () => {
@@ -25,6 +27,50 @@ describe('codex plan compatibility', () => {
expect(getFreePlanFallbackCodexModel('gpt-5.1-codex-mini')).toBeNull();
});
it('detects upstream Codex model_not_supported responses', () => {
expect(
parseCodexUnsupportedModelError(
400,
JSON.stringify({
error: {
message: 'The requested model is not supported.',
code: 'model_not_supported',
param: 'model',
type: 'invalid_request_error',
},
})
)
).toEqual({
message: 'The requested model is not supported.',
code: 'model_not_supported',
param: 'model',
type: 'invalid_request_error',
});
expect(
parseCodexUnsupportedModelError(500, '{"error":{"code":"model_not_supported"}}')
).toBeNull();
});
it('resolves runtime fallbacks without retrying the rejected model again', () => {
expect(
resolveRuntimeCodexFallbackModel({
requestedModel: 'gpt-5.4',
modelMap: { defaultModel: 'gpt-5-codex' },
})
).toBe('gpt-5-codex');
expect(
resolveRuntimeCodexFallbackModel({
requestedModel: 'gpt-5.4',
modelMap: {
defaultModel: 'gpt-5.4',
haikuModel: 'gpt-5-codex-mini',
},
excludeModels: ['gpt-5-codex'],
})
).toBe('gpt-5-codex-mini');
});
it('tracks Codex thinking caps for current safe defaults and paid models', () => {
expect(getModelMaxLevel('codex', 'gpt-5-codex')).toBe('high');
expect(getModelMaxLevel('codex', 'gpt-5-codex-mini')).toBe('high');
@@ -230,6 +230,90 @@ describe('CodexReasoningProxy extended-context compatibility', () => {
expect(capturedBody?.model).toBe('enterprise-internal-high');
});
it('retries unsupported live-session models once and remembers the fallback', async () => {
const capturedModels: string[] = [];
const capturedEfforts: Array<string | undefined> = [];
const upstream = http.createServer((req, res) => {
let rawBody = '';
req.setEncoding('utf8');
req.on('data', (chunk) => {
rawBody += chunk;
});
req.on('end', () => {
const requestBody = rawBody ? (JSON.parse(rawBody) as JsonRecord) : {};
const reasoning = requestBody.reasoning as JsonRecord | undefined;
const model = String(requestBody.model ?? '');
const effort = typeof reasoning?.effort === 'string' ? reasoning.effort : undefined;
capturedModels.push(model);
capturedEfforts.push(effort);
if (model === 'gpt-5.4') {
res.writeHead(400, { 'Content-Type': 'application/json' });
res.end(
JSON.stringify({
error: {
message: 'The requested model is not supported.',
code: 'model_not_supported',
param: 'model',
type: 'invalid_request_error',
},
})
);
return;
}
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(
JSON.stringify({
ok: true,
model,
effort: effort ?? null,
})
);
});
});
cleanupServers.push(upstream);
const upstreamPort = await listenOnRandomPort(upstream);
const proxy = new CodexReasoningProxy({
upstreamBaseUrl: `http://127.0.0.1:${upstreamPort}`,
modelMap: {
defaultModel: 'gpt-5.4',
haikuModel: 'gpt-5-codex-mini',
},
defaultEffort: 'medium',
});
const proxyPort = await proxy.start();
const firstResponse = await postJson(
`http://127.0.0.1:${proxyPort}/api/provider/codex/v1/messages`,
{
model: 'gpt-5.4-xhigh',
messages: [],
}
);
const secondResponse = await postJson(
`http://127.0.0.1:${proxyPort}/api/provider/codex/v1/messages`,
{
model: 'gpt-5.4-xhigh',
messages: [],
}
);
proxy.stop();
expect(firstResponse.statusCode).toBe(200);
expect(secondResponse.statusCode).toBe(200);
expect(firstResponse.body.model).toBe('gpt-5-codex');
expect(firstResponse.body.effort).toBe('high');
expect(secondResponse.body.model).toBe('gpt-5-codex');
expect(secondResponse.body.effort).toBe('high');
expect(capturedModels).toEqual(['gpt-5.4', 'gpt-5-codex', 'gpt-5-codex']);
expect(capturedEfforts).toEqual(['xhigh', 'high', 'high']);
});
it('keeps reasoning enabled when CCS_THINKING=high overrides config off', async () => {
let capturedBody: JsonRecord | null = null;
@@ -5,6 +5,7 @@
import { useMemo } from 'react';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { Separator } from '@/components/ui/separator';
import { Sparkles, Zap, Star, X, Plus } from 'lucide-react';
import { FlexibleModelSelector } from '../provider-model-selector';
@@ -12,6 +13,24 @@ import { ExtendedContextToggle } from '../extended-context-toggle';
import { stripExtendedContextSuffix } from '@/lib/extended-context-utils';
import type { ModelConfigSectionProps } from './types';
type CatalogPresetModel = NonNullable<ModelConfigSectionProps['catalog']>['models'][number];
function getPresetUpdates(model: CatalogPresetModel): Record<string, string> {
const mapping = model.presetMapping || {
default: model.id,
opus: model.id,
sonnet: model.id,
haiku: model.id,
};
return {
ANTHROPIC_MODEL: mapping.default,
ANTHROPIC_DEFAULT_OPUS_MODEL: mapping.opus,
ANTHROPIC_DEFAULT_SONNET_MODEL: mapping.sonnet,
ANTHROPIC_DEFAULT_HAIKU_MODEL: mapping.haiku,
};
}
export function ModelConfigSection({
catalog,
savedPresets,
@@ -29,8 +48,6 @@ export function ModelConfigSection({
onDeletePreset,
isDeletePending,
}: ModelConfigSectionProps) {
const showPresets = (catalog && catalog.models.length > 0) || savedPresets.length > 0;
// Find current model entry to check for extended context support
// Strip [1m] suffix when looking up in catalog since catalog IDs don't have suffix
const currentModelEntry = useMemo(() => {
@@ -39,6 +56,37 @@ export function ModelConfigSection({
return catalog.models.find((m) => m.id === baseModelId);
}, [catalog, currentModel]);
const presetGroups = useMemo(() => {
const presetModels = (catalog?.models ?? []).filter((model) => model.presetMapping);
if (presetModels.length === 0) return [];
const hasPaidPresets = presetModels.some((model) => model.tier === 'paid');
if (!hasPaidPresets) {
return [{ key: 'default', models: presetModels.slice(0, 4) }];
}
return [
{
key: 'free',
label: 'Free Tier',
description: 'Available on free or paid plans',
badgeClassName: 'text-[10px] bg-green-100 text-green-700 border-green-200',
iconClassName: 'text-green-600',
models: presetModels.filter((model) => model.tier !== 'paid'),
},
{
key: 'paid',
label: 'Paid Tier',
description: 'Requires paid access',
badgeClassName: 'text-[10px] bg-amber-100 text-amber-700 border-amber-200',
iconClassName: 'text-amber-700',
models: presetModels.filter((model) => model.tier === 'paid'),
},
].filter((group) => group.models.length > 0);
}, [catalog]);
const showPresets = presetGroups.length > 0 || savedPresets.length > 0;
return (
<>
{/* Quick Presets */}
@@ -49,77 +97,81 @@ export function ModelConfigSection({
Presets
</h3>
<p className="text-xs text-muted-foreground mb-3">Apply pre-configured model mappings</p>
<div className="flex flex-wrap gap-2">
{/* Recommended presets from catalog */}
{catalog?.models.slice(0, 4).map((model) => (
<Button
key={model.id}
variant="outline"
size="sm"
className="text-xs h-7 gap-1"
onClick={() => {
const mapping = model.presetMapping || {
default: model.id,
opus: model.id,
sonnet: model.id,
haiku: model.id,
};
onApplyPreset({
ANTHROPIC_MODEL: mapping.default,
ANTHROPIC_DEFAULT_OPUS_MODEL: mapping.opus,
ANTHROPIC_DEFAULT_SONNET_MODEL: mapping.sonnet,
ANTHROPIC_DEFAULT_HAIKU_MODEL: mapping.haiku,
});
}}
>
<Zap className="w-3 h-3" />
{model.name}
</Button>
))}
{/* User saved presets */}
{savedPresets.map((preset) => (
<div key={preset.name} className="group relative">
<Button
variant="secondary"
size="sm"
className="text-xs h-7 gap-1 pr-6"
onClick={() => {
onApplyPreset({
ANTHROPIC_MODEL: preset.default,
ANTHROPIC_DEFAULT_OPUS_MODEL: preset.opus,
ANTHROPIC_DEFAULT_SONNET_MODEL: preset.sonnet,
ANTHROPIC_DEFAULT_HAIKU_MODEL: preset.haiku,
});
}}
>
<Star className="w-3 h-3 fill-current" />
{preset.name}
</Button>
<Button
variant="ghost"
size="icon"
className="absolute right-0 top-0 h-7 w-5 opacity-0 group-hover:opacity-100 hover:text-destructive"
onClick={(e) => {
e.stopPropagation();
onDeletePreset(preset.name);
}}
disabled={isDeletePending}
>
<X className="w-3 h-3" />
</Button>
<div className="space-y-4">
{presetGroups.map((group) => (
<div key={group.key}>
{'label' in group && group.label && (
<div className="flex items-center gap-2 mb-2">
<Badge variant="outline" className={group.badgeClassName}>
{group.label}
</Badge>
<span className="text-[10px] text-muted-foreground">{group.description}</span>
</div>
)}
<div className="flex flex-wrap gap-2">
{group.models.map((model) => (
<Button
key={model.id}
variant="outline"
size="sm"
className="text-xs h-7 gap-1"
onClick={() => onApplyPreset(getPresetUpdates(model))}
>
<Zap
className={`w-3 h-3 ${'iconClassName' in group ? group.iconClassName : ''}`}
/>
{model.name}
</Button>
))}
</div>
</div>
))}
<Button
variant="outline"
size="sm"
className="text-xs h-7 gap-1 border-primary/50 text-primary hover:bg-primary/10 hover:border-primary"
onClick={onOpenCustomPreset}
>
<Plus className="w-3 h-3" />
Custom
</Button>
<div className="flex flex-wrap gap-2">
{/* User saved presets */}
{savedPresets.map((preset) => (
<div key={preset.name} className="group relative">
<Button
variant="secondary"
size="sm"
className="text-xs h-7 gap-1 pr-6"
onClick={() => {
onApplyPreset({
ANTHROPIC_MODEL: preset.default,
ANTHROPIC_DEFAULT_OPUS_MODEL: preset.opus,
ANTHROPIC_DEFAULT_SONNET_MODEL: preset.sonnet,
ANTHROPIC_DEFAULT_HAIKU_MODEL: preset.haiku,
});
}}
>
<Star className="w-3 h-3 fill-current" />
{preset.name}
</Button>
<Button
variant="ghost"
size="icon"
className="absolute right-0 top-0 h-7 w-5 opacity-0 group-hover:opacity-100 hover:text-destructive"
onClick={(e) => {
e.stopPropagation();
onDeletePreset(preset.name);
}}
disabled={isDeletePending}
>
<X className="w-3 h-3" />
</Button>
</div>
))}
<Button
variant="outline"
size="sm"
className="text-xs h-7 gap-1 border-primary/50 text-primary hover:bg-primary/10 hover:border-primary"
onClick={onOpenCustomPreset}
>
<Plus className="w-3 h-3" />
Custom
</Button>
</div>
</div>
</div>
)}
+71 -29
View File
@@ -111,45 +111,56 @@ export const MODEL_CATALOGS: Record<string, ProviderCatalog> = {
codex: {
provider: 'codex',
displayName: 'Codex',
defaultModel: 'gpt-5.3-codex',
defaultModel: 'gpt-5-codex',
models: [
{
id: 'gpt-5.3-codex',
name: 'GPT-5.3 Codex',
description: 'Supports up to xhigh effort',
id: 'gpt-5-codex',
name: 'GPT-5 Codex',
description: 'Cross-plan safe Codex default',
presetMapping: {
default: 'gpt-5.3-codex',
opus: 'gpt-5.3-codex',
sonnet: 'gpt-5.3-codex',
haiku: 'gpt-5.1-codex-mini',
default: 'gpt-5-codex',
opus: 'gpt-5-codex',
sonnet: 'gpt-5-codex',
haiku: 'gpt-5-codex-mini',
},
},
{
id: 'gpt-5.2-codex',
name: 'GPT-5.2 Codex',
description: 'Previous stable Codex model',
id: 'gpt-5-codex-mini',
name: 'GPT-5 Codex Mini',
description: 'Faster and cheaper Codex option',
presetMapping: {
default: 'gpt-5.2-codex',
opus: 'gpt-5.2-codex',
sonnet: 'gpt-5.2-codex',
haiku: 'gpt-5.1-codex-mini',
default: 'gpt-5-codex-mini',
opus: 'gpt-5-codex',
sonnet: 'gpt-5-codex',
haiku: 'gpt-5-codex-mini',
},
},
{
id: 'gpt-5-mini',
name: 'GPT-5 Mini',
description: 'Fast, capped at high effort (no xhigh)',
description: 'Legacy mini model ID kept for backwards compatibility',
presetMapping: {
default: 'gpt-5-mini',
opus: 'gpt-5.3-codex',
opus: 'gpt-5-codex',
sonnet: 'gpt-5-mini',
haiku: 'gpt-5-mini',
},
},
{
id: 'gpt-5.1-codex-mini',
name: 'GPT-5.1 Codex Mini',
description: 'Legacy fast Codex mini model',
presetMapping: {
default: 'gpt-5.1-codex-mini',
opus: 'gpt-5.1-codex-max',
sonnet: 'gpt-5.1-codex-max',
haiku: 'gpt-5.1-codex-mini',
},
},
{
id: 'gpt-5.1-codex-max',
name: 'Codex Max (5.1)',
description: 'Legacy most capable Codex model',
name: 'GPT-5.1 Codex Max',
description: 'Higher-effort Codex model with xhigh support',
presetMapping: {
default: 'gpt-5.1-codex-max',
opus: 'gpt-5.1-codex-max',
@@ -158,20 +169,51 @@ export const MODEL_CATALOGS: Record<string, ProviderCatalog> = {
},
},
{
id: 'gpt-5.2',
name: 'GPT 5.2',
description: 'Latest GPT model',
id: 'gpt-5.2-codex',
name: 'GPT-5.2 Codex',
description: 'Cross-plan Codex model with xhigh support',
presetMapping: {
default: 'gpt-5.2',
opus: 'gpt-5.2',
sonnet: 'gpt-5.2',
haiku: 'gpt-5.2',
default: 'gpt-5.2-codex',
opus: 'gpt-5.2-codex',
sonnet: 'gpt-5.2-codex',
haiku: 'gpt-5-codex-mini',
},
},
{
id: 'gpt-5.1-codex-mini',
name: 'Codex Mini',
description: 'Fast and efficient Codex model',
id: 'gpt-5.3-codex',
name: 'GPT-5.3 Codex',
tier: 'paid',
description: 'Paid Codex plans only',
presetMapping: {
default: 'gpt-5.3-codex',
opus: 'gpt-5.3-codex',
sonnet: 'gpt-5.3-codex',
haiku: 'gpt-5-codex-mini',
},
},
{
id: 'gpt-5.3-codex-spark',
name: 'GPT-5.3 Codex Spark',
tier: 'paid',
description: 'Paid Codex plans only, ultra-fast coding model',
presetMapping: {
default: 'gpt-5.3-codex-spark',
opus: 'gpt-5.3-codex',
sonnet: 'gpt-5.3-codex',
haiku: 'gpt-5-codex-mini',
},
},
{
id: 'gpt-5.4',
name: 'GPT-5.4',
tier: 'paid',
description: 'Paid Codex plans only, latest GPT-5 family model',
presetMapping: {
default: 'gpt-5.4',
opus: 'gpt-5.4',
sonnet: 'gpt-5.4',
haiku: 'gpt-5-codex-mini',
},
},
],
},
@@ -0,0 +1,73 @@
import { describe, expect, it, vi } from 'vitest';
import { render, screen, userEvent } from '@tests/setup/test-utils';
vi.mock('@/components/cliproxy/provider-model-selector', () => ({
FlexibleModelSelector: () => <div data-testid="flexible-model-selector" />,
}));
vi.mock('@/components/cliproxy/extended-context-toggle', () => ({
ExtendedContextToggle: () => <div data-testid="extended-context-toggle" />,
}));
import { ModelConfigSection } from '@/components/cliproxy/provider-editor/model-config-section';
import { MODEL_CATALOGS } from '@/lib/model-catalogs';
describe('ModelConfigSection presets', () => {
it('groups codex presets by free and paid tiers', async () => {
const onApplyPreset = vi.fn();
render(
<ModelConfigSection
catalog={MODEL_CATALOGS.codex}
savedPresets={[]}
currentModel="gpt-5-codex"
opusModel="gpt-5-codex"
sonnetModel="gpt-5-codex"
haikuModel="gpt-5-codex-mini"
providerModels={[]}
provider="codex"
onApplyPreset={onApplyPreset}
onUpdateEnvValue={vi.fn()}
onOpenCustomPreset={vi.fn()}
onDeletePreset={vi.fn()}
/>
);
expect(screen.getByText('Free Tier')).toBeInTheDocument();
expect(screen.getByText('Paid Tier')).toBeInTheDocument();
expect(screen.getByText('Available on free or paid plans')).toBeInTheDocument();
expect(screen.getByText('Requires paid access')).toBeInTheDocument();
await userEvent.click(screen.getByRole('button', { name: 'GPT-5.4' }));
expect(onApplyPreset).toHaveBeenCalledWith({
ANTHROPIC_MODEL: 'gpt-5.4',
ANTHROPIC_DEFAULT_OPUS_MODEL: 'gpt-5.4',
ANTHROPIC_DEFAULT_SONNET_MODEL: 'gpt-5.4',
ANTHROPIC_DEFAULT_HAIKU_MODEL: 'gpt-5-codex-mini',
});
});
it('keeps non-tiered provider presets ungrouped', () => {
render(
<ModelConfigSection
catalog={MODEL_CATALOGS.agy}
savedPresets={[]}
currentModel="claude-opus-4-6-thinking"
opusModel="claude-opus-4-6-thinking"
sonnetModel="claude-sonnet-4-6"
haikuModel="claude-sonnet-4-6"
providerModels={[]}
provider="agy"
onApplyPreset={vi.fn()}
onUpdateEnvValue={vi.fn()}
onOpenCustomPreset={vi.fn()}
onDeletePreset={vi.fn()}
/>
);
expect(screen.queryByText('Free Tier')).not.toBeInTheDocument();
expect(screen.queryByText('Paid Tier')).not.toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Claude Opus 4.6 Thinking' })).toBeInTheDocument();
});
});
@@ -2,12 +2,12 @@ import { describe, expect, it } from 'vitest';
import { MODEL_CATALOGS } from '@/lib/model-catalogs';
describe('codex model catalog defaults', () => {
it('uses gpt-5.1-codex-mini as the haiku mapping for codex presets', () => {
it('uses gpt-5-codex-mini as the haiku mapping for cross-plan codex presets', () => {
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');
expect(codex53?.presetMapping?.haiku).toBe('gpt-5.1-codex-mini');
expect(codex52?.presetMapping?.haiku).toBe('gpt-5.1-codex-mini');
expect(codex53?.presetMapping?.haiku).toBe('gpt-5-codex-mini');
expect(codex52?.presetMapping?.haiku).toBe('gpt-5-codex-mini');
});
});