fix(codex): recover unsupported live model switches

This commit is contained in:
Tam Nhu Tran
2026-03-18 11:56:39 -04:00
parent 7eb75edd56
commit 9fac214051
4 changed files with 375 additions and 30 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;