From 9fac214051a2e30fd58ea7341ebd7f9de112f426 Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Wed, 18 Mar 2026 11:56:39 -0400 Subject: [PATCH] fix(codex): recover unsupported live model switches --- src/cliproxy/codex-plan-compatibility.ts | 96 +++++++++- src/cliproxy/codex-reasoning-proxy.ts | 179 +++++++++++++++--- .../cliproxy/codex-plan-compatibility.test.ts | 46 +++++ ...x-reasoning-proxy-extended-context.test.ts | 84 ++++++++ 4 files changed, 375 insertions(+), 30 deletions(-) diff --git a/src/cliproxy/codex-plan-compatibility.ts b/src/cliproxy/codex-plan-compatibility.ts index 41177e00..459d31a4 100644 --- a/src/cliproxy/codex-plan-compatibility.ts +++ b/src/cliproxy/codex-plan-compatibility.ts @@ -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([ ['gpt-5.3-codex', FREE_SAFE_DEFAULT_MODEL], @@ -19,7 +23,29 @@ const FREE_PLAN_FALLBACKS = new Map([ ['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 { + 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; diff --git a/src/cliproxy/codex-reasoning-proxy.ts b/src/cliproxy/codex-reasoning-proxy.ts index 57eb7e39..b24508e4 100644 --- a/src/cliproxy/codex-reasoning-proxy.ts +++ b/src/cliproxy/codex-reasoning-proxy.ts @@ -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; private readonly modelEffort: Map; + private readonly sessionFallbackByModel = new Map(); 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 { + body: unknown, + context: ForwardJsonContext + ): Promise { 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); } ); diff --git a/tests/unit/cliproxy/codex-plan-compatibility.test.ts b/tests/unit/cliproxy/codex-plan-compatibility.test.ts index cf84fd28..23bb7bc9 100644 --- a/tests/unit/cliproxy/codex-plan-compatibility.test.ts +++ b/tests/unit/cliproxy/codex-plan-compatibility.test.ts @@ -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'); diff --git a/tests/unit/cliproxy/codex-reasoning-proxy-extended-context.test.ts b/tests/unit/cliproxy/codex-reasoning-proxy-extended-context.test.ts index 1e6f0cd3..5a6dc3a2 100644 --- a/tests/unit/cliproxy/codex-reasoning-proxy-extended-context.test.ts +++ b/tests/unit/cliproxy/codex-reasoning-proxy-extended-context.test.ts @@ -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 = []; + + 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;