mirror of
https://github.com/tiennm99/ccs.git
synced 2026-09-03 12:21:20 +00:00
fix(codex): recover unsupported live model switches
This commit is contained in:
@@ -1,4 +1,5 @@
|
|||||||
import { getDefaultAccount } from './account-manager';
|
import { getDefaultAccount } from './account-manager';
|
||||||
|
import { getProviderCatalog } from './model-catalog';
|
||||||
import { fetchCodexQuota } from './quota-fetcher-codex';
|
import { fetchCodexQuota } from './quota-fetcher-codex';
|
||||||
import { getCachedQuota, setCachedQuota } from './quota-response-cache';
|
import { getCachedQuota, setCachedQuota } from './quota-response-cache';
|
||||||
import type { CodexQuotaResult } from './quota-types';
|
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_EFFORT_SUFFIX_REGEX = /-(xhigh|high|medium)$/i;
|
||||||
const CODEX_PAREN_SUFFIX_REGEX = /\((xhigh|high|medium)\)$/i;
|
const CODEX_PAREN_SUFFIX_REGEX = /\((xhigh|high|medium)\)$/i;
|
||||||
const EXTENDED_CONTEXT_SUFFIX_REGEX = /\[1m\]$/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>([
|
const FREE_PLAN_FALLBACKS = new Map<string, string>([
|
||||||
['gpt-5.3-codex', FREE_SAFE_DEFAULT_MODEL],
|
['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],
|
['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
|
return model
|
||||||
.trim()
|
.trim()
|
||||||
.replace(EXTENDED_CONTEXT_SUFFIX_REGEX, '')
|
.replace(EXTENDED_CONTEXT_SUFFIX_REGEX, '')
|
||||||
@@ -37,6 +63,74 @@ export function getFreePlanFallbackCodexModel(model: string): string | null {
|
|||||||
return FREE_PLAN_FALLBACKS.get(normalizeCodexModelId(model)) ?? 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: {
|
export async function reconcileCodexModelForActivePlan(options: {
|
||||||
settingsPath: string;
|
settingsPath: string;
|
||||||
currentModel: string | undefined;
|
currentModel: string | undefined;
|
||||||
|
|||||||
@@ -1,6 +1,11 @@
|
|||||||
import * as http from 'http';
|
import * as http from 'http';
|
||||||
import * as https from 'https';
|
import * as https from 'https';
|
||||||
import { URL } from 'url';
|
import { URL } from 'url';
|
||||||
|
import {
|
||||||
|
normalizeCodexModelId,
|
||||||
|
parseCodexUnsupportedModelError,
|
||||||
|
resolveRuntimeCodexFallbackModel,
|
||||||
|
} from './codex-plan-compatibility';
|
||||||
import { getModelMaxLevel } from './model-catalog';
|
import { getModelMaxLevel } from './model-catalog';
|
||||||
|
|
||||||
export type CodexReasoningEffort = 'medium' | 'high' | 'xhigh';
|
export type CodexReasoningEffort = 'medium' | 'high' | 'xhigh';
|
||||||
@@ -29,6 +34,14 @@ export interface CodexReasoningProxyConfig {
|
|||||||
disableEffort?: boolean;
|
disableEffort?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface ForwardJsonContext {
|
||||||
|
requestPath: string;
|
||||||
|
requestedModel: string | null;
|
||||||
|
attemptedUpstreamModel: string | null;
|
||||||
|
effort: CodexReasoningEffort | null;
|
||||||
|
retryCount: number;
|
||||||
|
}
|
||||||
|
|
||||||
const EXTENDED_CONTEXT_SUFFIX_REGEX = /\[1m\]$/i;
|
const EXTENDED_CONTEXT_SUFFIX_REGEX = /\[1m\]$/i;
|
||||||
|
|
||||||
function stripExtendedContextSuffix(model: string): string {
|
function stripExtendedContextSuffix(model: string): string {
|
||||||
@@ -170,6 +183,7 @@ export class CodexReasoningProxy {
|
|||||||
> &
|
> &
|
||||||
Pick<CodexReasoningProxyConfig, 'modelMap' | 'stripPathPrefix'>;
|
Pick<CodexReasoningProxyConfig, 'modelMap' | 'stripPathPrefix'>;
|
||||||
private readonly modelEffort: Map<string, CodexReasoningEffort>;
|
private readonly modelEffort: Map<string, CodexReasoningEffort>;
|
||||||
|
private readonly sessionFallbackByModel = new Map<string, string>();
|
||||||
private readonly recent: Array<{
|
private readonly recent: Array<{
|
||||||
at: string;
|
at: string;
|
||||||
model: string | null;
|
model: string | null;
|
||||||
@@ -193,6 +207,41 @@ export class CodexReasoningProxy {
|
|||||||
this.modelEffort = buildCodexModelEffortMap(this.config.modelMap, this.config.defaultEffort);
|
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.
|
* 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.
|
* Prevents stripping legitimate upstream model IDs that happen to end with those tokens.
|
||||||
@@ -365,41 +414,48 @@ export class CodexReasoningProxy {
|
|||||||
? stripExtendedContextSuffix(originalModel)
|
? stripExtendedContextSuffix(originalModel)
|
||||||
: null;
|
: 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:
|
// Support "model aliases" like `gpt-5.2-codex-xhigh` by translating to:
|
||||||
// - upstream model: `gpt-5.2-codex`
|
// - upstream model: `gpt-5.2-codex`
|
||||||
// - reasoning.effort: `xhigh`
|
// - reasoning.effort: `xhigh`
|
||||||
//
|
//
|
||||||
// This allows tier→effort mapping without inventing upstream model IDs.
|
// This allows tier→effort mapping without inventing upstream model IDs.
|
||||||
const suffixParsed = this.parseEffortAlias(normalizedRequestModel);
|
const suffixParsed = this.parseEffortAlias(normalizedRequestModel);
|
||||||
const upstreamModel = suffixParsed?.upstreamModel ?? normalizedRequestModel;
|
const requestedUpstreamModel = suffixParsed?.upstreamModel ?? normalizedRequestModel;
|
||||||
const effort =
|
const rememberedFallback = this.getRememberedFallback(requestedUpstreamModel);
|
||||||
|
const upstreamModel = rememberedFallback ?? requestedUpstreamModel;
|
||||||
|
const requestedEffort =
|
||||||
suffixParsed?.effort ??
|
suffixParsed?.effort ??
|
||||||
getEffortForModel(normalizedRequestModel, this.modelEffort, this.config.defaultEffort);
|
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 =
|
if (effort) {
|
||||||
upstreamModel && isRecord(parsed) ? { ...parsed, model: upstreamModel } : parsed;
|
this.record(originalModel, upstreamModel, effort, requestPath);
|
||||||
const rewritten = injectReasoningEffortIntoBody(withUpstreamModel, effort);
|
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);
|
if (rememberedFallback && rememberedFallback !== requestedUpstreamModel) {
|
||||||
this.trace(
|
this.log(`Using remembered fallback ${requestedUpstreamModel} -> ${rememberedFallback}`);
|
||||||
`[${new Date().toISOString()}] model=${originalModel ?? 'null'} upstreamModel=${
|
}
|
||||||
upstreamModel ?? 'null'
|
|
||||||
} effort=${effort} path=${requestPath}`
|
|
||||||
);
|
|
||||||
|
|
||||||
await this.forwardJson(req, res, fullUpstreamUrl, rewritten);
|
await this.forwardJson(req, res, fullUpstreamUrl, rewritten, {
|
||||||
|
requestPath,
|
||||||
|
requestedModel: requestedUpstreamModel,
|
||||||
|
attemptedUpstreamModel: upstreamModel,
|
||||||
|
effort,
|
||||||
|
retryCount: 0,
|
||||||
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const err = error as Error;
|
const err = error as Error;
|
||||||
if (!res.headersSent) {
|
if (!res.headersSent) {
|
||||||
@@ -487,8 +543,9 @@ export class CodexReasoningProxy {
|
|||||||
originalReq: http.IncomingMessage,
|
originalReq: http.IncomingMessage,
|
||||||
clientRes: http.ServerResponse,
|
clientRes: http.ServerResponse,
|
||||||
upstreamUrl: URL,
|
upstreamUrl: URL,
|
||||||
body: unknown
|
body: unknown,
|
||||||
): Promise<void> {
|
context: ForwardJsonContext
|
||||||
|
): Promise<number> {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
const bodyString = JSON.stringify(body);
|
const bodyString = JSON.stringify(body);
|
||||||
const requestFn = this.getRequestFn(upstreamUrl);
|
const requestFn = this.getRequestFn(upstreamUrl);
|
||||||
@@ -503,9 +560,73 @@ export class CodexReasoningProxy {
|
|||||||
headers: this.buildForwardHeaders(originalReq.headers, bodyString),
|
headers: this.buildForwardHeaders(originalReq.headers, bodyString),
|
||||||
},
|
},
|
||||||
(upstreamRes) => {
|
(upstreamRes) => {
|
||||||
clientRes.writeHead(upstreamRes.statusCode || 200, upstreamRes.headers);
|
const statusCode = upstreamRes.statusCode || 200;
|
||||||
upstreamRes.pipe(clientRes);
|
if (statusCode >= 200 && statusCode < 300) {
|
||||||
upstreamRes.on('end', () => resolve());
|
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);
|
upstreamRes.on('error', reject);
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ import { getProviderCatalog, getModelMaxLevel } from '../../../src/cliproxy/mode
|
|||||||
import {
|
import {
|
||||||
getDefaultCodexModel,
|
getDefaultCodexModel,
|
||||||
getFreePlanFallbackCodexModel,
|
getFreePlanFallbackCodexModel,
|
||||||
|
parseCodexUnsupportedModelError,
|
||||||
|
resolveRuntimeCodexFallbackModel,
|
||||||
} from '../../../src/cliproxy/codex-plan-compatibility';
|
} from '../../../src/cliproxy/codex-plan-compatibility';
|
||||||
|
|
||||||
describe('codex plan compatibility', () => {
|
describe('codex plan compatibility', () => {
|
||||||
@@ -25,6 +27,50 @@ describe('codex plan compatibility', () => {
|
|||||||
expect(getFreePlanFallbackCodexModel('gpt-5.1-codex-mini')).toBeNull();
|
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', () => {
|
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')).toBe('high');
|
||||||
expect(getModelMaxLevel('codex', 'gpt-5-codex-mini')).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');
|
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 () => {
|
it('keeps reasoning enabled when CCS_THINKING=high overrides config off', async () => {
|
||||||
let capturedBody: JsonRecord | null = null;
|
let capturedBody: JsonRecord | null = null;
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user