mirror of
https://github.com/tiennm99/ccs.git
synced 2026-07-16 10:16:49 +00:00
fix(codex): recover unsupported live model switches
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user