mirror of
https://github.com/tiennm99/ccs.git
synced 2026-10-14 20:17:03 +00:00
feat: support Codex fast service-tier aliases
* feat: support Codex fast service-tier aliases * fix: send Codex fast tier as priority
This commit is contained in:
1 parent
a8d412a0eb
commit
74d73748ee
17 files changed
+471
-86
No files matched your search
@@ -16,6 +16,8 @@ describe('codex plan compatibility', () => {
|
||||
it('maps paid-only free-plan models to safe fallbacks', () => {
|
||||
expect(getFreePlanFallbackCodexModel('gpt-5.5')).toBe('gpt-5.4');
|
||||
expect(getFreePlanFallbackCodexModel('gpt-5.5-xhigh')).toBe('gpt-5.4');
|
||||
expect(getFreePlanFallbackCodexModel('gpt-5.5-high-fast')).toBe('gpt-5.4');
|
||||
expect(getFreePlanFallbackCodexModel('gpt-5.5-fast-high')).toBe('gpt-5.4');
|
||||
expect(getFreePlanFallbackCodexModel('gpt-5.3-codex')).toBe('gpt-5.4');
|
||||
expect(getFreePlanFallbackCodexModel('gpt-5.3-codex-xhigh')).toBe('gpt-5.4');
|
||||
expect(getFreePlanFallbackCodexModel('gpt-5.3-codex(high)')).toBe('gpt-5.4');
|
||||
|
||||
@@ -147,6 +147,60 @@ describe('CodexReasoningProxy extended-context compatibility', () => {
|
||||
expect((capturedBody?.reasoning as JsonRecord | undefined)?.effort).toBe('high');
|
||||
});
|
||||
|
||||
it('translates codex fast model suffixes into service_tier', async () => {
|
||||
const capturedBodies: JsonRecord[] = [];
|
||||
|
||||
const upstream = http.createServer((req, res) => {
|
||||
let rawBody = '';
|
||||
req.setEncoding('utf8');
|
||||
req.on('data', (chunk) => {
|
||||
rawBody += chunk;
|
||||
});
|
||||
req.on('end', () => {
|
||||
capturedBodies.push(rawBody ? (JSON.parse(rawBody) as JsonRecord) : {});
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ ok: true }));
|
||||
});
|
||||
});
|
||||
cleanupServers.push(upstream);
|
||||
|
||||
const upstreamPort = await listenOnRandomPort(upstream);
|
||||
const proxy = new CodexReasoningProxy({
|
||||
upstreamBaseUrl: `http://127.0.0.1:${upstreamPort}`,
|
||||
modelMap: {
|
||||
defaultModel: 'gpt-5.4',
|
||||
},
|
||||
defaultEffort: 'medium',
|
||||
});
|
||||
|
||||
const proxyPort = await proxy.start();
|
||||
const highFastResponse = await postJson(
|
||||
`http://127.0.0.1:${proxyPort}/api/provider/codex/v1/messages`,
|
||||
{
|
||||
model: 'gpt-5.4-high-fast',
|
||||
messages: [],
|
||||
}
|
||||
);
|
||||
const fastHighResponse = await postJson(
|
||||
`http://127.0.0.1:${proxyPort}/api/provider/codex/v1/messages`,
|
||||
{
|
||||
model: 'gpt-5.4-fast-high',
|
||||
messages: [],
|
||||
}
|
||||
);
|
||||
|
||||
proxy.stop();
|
||||
|
||||
expect(highFastResponse.statusCode).toBe(200);
|
||||
expect(fastHighResponse.statusCode).toBe(200);
|
||||
expect(capturedBodies[0]?.model).toBe('gpt-5.4');
|
||||
expect((capturedBodies[0]?.reasoning as JsonRecord | undefined)?.effort).toBe('high');
|
||||
expect(capturedBodies[0]?.service_tier).toBe('priority');
|
||||
expect(capturedBodies[1]?.model).toBe('gpt-5.4');
|
||||
expect((capturedBodies[1]?.reasoning as JsonRecord | undefined)?.effort).toBe('high');
|
||||
expect(capturedBodies[1]?.service_tier).toBe('priority');
|
||||
});
|
||||
|
||||
it('skips reasoning injection when disableEffort is enabled', async () => {
|
||||
let capturedBody: JsonRecord | null = null;
|
||||
|
||||
@@ -189,6 +243,49 @@ describe('CodexReasoningProxy extended-context compatibility', () => {
|
||||
expect((capturedBody?.reasoning as JsonRecord | undefined)?.effort).toBeUndefined();
|
||||
});
|
||||
|
||||
it('keeps fast service tier when disableEffort is enabled', async () => {
|
||||
let capturedBody: JsonRecord | null = null;
|
||||
|
||||
const upstream = http.createServer((req, res) => {
|
||||
let rawBody = '';
|
||||
req.setEncoding('utf8');
|
||||
req.on('data', (chunk) => {
|
||||
rawBody += chunk;
|
||||
});
|
||||
req.on('end', () => {
|
||||
capturedBody = rawBody ? (JSON.parse(rawBody) as JsonRecord) : {};
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ ok: true }));
|
||||
});
|
||||
});
|
||||
cleanupServers.push(upstream);
|
||||
|
||||
const upstreamPort = await listenOnRandomPort(upstream);
|
||||
const proxy = new CodexReasoningProxy({
|
||||
upstreamBaseUrl: `http://127.0.0.1:${upstreamPort}`,
|
||||
modelMap: {
|
||||
defaultModel: 'gpt-5.4',
|
||||
},
|
||||
disableEffort: true,
|
||||
});
|
||||
|
||||
const proxyPort = await proxy.start();
|
||||
const response = await postJson(
|
||||
`http://127.0.0.1:${proxyPort}/api/provider/codex/v1/messages`,
|
||||
{
|
||||
model: 'gpt-5.4-high-fast',
|
||||
messages: [],
|
||||
}
|
||||
);
|
||||
|
||||
proxy.stop();
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(capturedBody?.model).toBe('gpt-5.4');
|
||||
expect((capturedBody?.reasoning as JsonRecord | undefined)?.effort).toBeUndefined();
|
||||
expect(capturedBody?.service_tier).toBe('priority');
|
||||
});
|
||||
|
||||
it('does not strip unknown model ids that merely end with "-high"', async () => {
|
||||
let capturedBody: JsonRecord | null = null;
|
||||
|
||||
|
||||
@@ -123,10 +123,17 @@ describe('model-id-normalizer', () => {
|
||||
expect(normalizeCodexLegacyModelAliases('gpt-5-codex')).toBe('gpt-5.4');
|
||||
expect(normalizeCodexLegacyModelAliases('gpt-5-codex-mini[1m]')).toBe('gpt-5.4-mini[1m]');
|
||||
expect(normalizeCodexLegacyModelAliases('gpt-5-codex-high')).toBe('gpt-5.4-high');
|
||||
expect(normalizeCodexLegacyModelAliases('gpt-5-codex-fast-high')).toBe('gpt-5.4-high-fast');
|
||||
expect(normalizeCodexLegacyModelAliases('gpt-5-codex-high[1m]')).toBe('gpt-5.4-high[1m]');
|
||||
expect(normalizeCodexLegacyModelAliases('gpt-5-codex-high-fast[1m]')).toBe(
|
||||
'gpt-5.4-high-fast[1m]'
|
||||
);
|
||||
expect(normalizeModelIdForProvider('gpt-5.2-codex', 'codex')).toBe('gpt-5.2');
|
||||
expect(normalizeModelIdForProvider('gpt-5.1-codex-mini', 'codex')).toBe('gpt-5.4-mini');
|
||||
expect(canonicalizeModelIdForProvider('gpt-5-codex-high', 'codex')).toBe('gpt-5.4-high');
|
||||
expect(canonicalizeModelIdForProvider('gpt-5-codex-fast-high', 'codex')).toBe(
|
||||
'gpt-5.4-high-fast'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -10,7 +10,8 @@ export type CodexPlanType = CodexQuotaResult['planType'];
|
||||
|
||||
const FREE_SAFE_DEFAULT_MODEL = 'gpt-5.4';
|
||||
const FREE_SAFE_FAST_MODEL = 'gpt-5.4-mini';
|
||||
const CODEX_EFFORT_SUFFIX_REGEX = /-(xhigh|high|medium)$/i;
|
||||
const CODEX_TUNING_SUFFIX_REGEX =
|
||||
/(?:-(?:xhigh|high|medium)(?:-fast)?|-fast(?:-(?: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(
|
||||
@@ -57,7 +58,7 @@ export function normalizeCodexModelId(model: string): string {
|
||||
.trim()
|
||||
.replace(EXTENDED_CONTEXT_SUFFIX_REGEX, '')
|
||||
.replace(CODEX_PAREN_SUFFIX_REGEX, '')
|
||||
.replace(CODEX_EFFORT_SUFFIX_REGEX, '')
|
||||
.replace(CODEX_TUNING_SUFFIX_REGEX, '')
|
||||
.trim();
|
||||
return normalizeModelIdForProvider(stripped, 'codex').trim().toLowerCase();
|
||||
}
|
||||
|
||||
@@ -9,6 +9,8 @@ import {
|
||||
import { getModelMaxLevel } from '../model-catalog';
|
||||
|
||||
export type CodexReasoningEffort = 'medium' | 'high' | 'xhigh';
|
||||
export type CodexServiceTier = 'fast';
|
||||
type CodexServiceTierRequestValue = 'priority';
|
||||
|
||||
export interface CodexReasoningModelMap {
|
||||
opusModel?: string;
|
||||
@@ -41,10 +43,15 @@ interface ForwardJsonContext {
|
||||
requestedModel: string | null;
|
||||
attemptedUpstreamModel: string | null;
|
||||
effort: CodexReasoningEffort | null;
|
||||
serviceTier: CodexServiceTier | null;
|
||||
retryCount: number;
|
||||
}
|
||||
|
||||
const EXTENDED_CONTEXT_SUFFIX_REGEX = /\[1m\]$/i;
|
||||
const CODEX_TUNING_SUFFIX_TOKEN_REGEX = /-(xhigh|high|medium|fast)$/i;
|
||||
const CODEX_SERVICE_TIER_REQUEST_VALUE: Record<CodexServiceTier, CodexServiceTierRequestValue> = {
|
||||
fast: 'priority',
|
||||
};
|
||||
|
||||
function stripExtendedContextSuffix(model: string): string {
|
||||
return model.replace(EXTENDED_CONTEXT_SUFFIX_REGEX, '').trim();
|
||||
@@ -58,16 +65,35 @@ function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function parseModelEffortSuffix(
|
||||
model: string
|
||||
): { upstreamModel: string; effort: CodexReasoningEffort } | null {
|
||||
function parseModelTuningSuffix(model: string): {
|
||||
upstreamModel: string;
|
||||
effort: CodexReasoningEffort | null;
|
||||
serviceTier: CodexServiceTier | null;
|
||||
} | null {
|
||||
const normalizedModel = stripExtendedContextSuffix(model);
|
||||
const match = normalizedModel.match(/^(.*)-(xhigh|high|medium)$/i);
|
||||
if (!match) return null;
|
||||
const upstreamModel = match[1]?.trim();
|
||||
const effort = match[2]?.toLowerCase() as CodexReasoningEffort;
|
||||
let upstreamModel = normalizedModel;
|
||||
let effort: CodexReasoningEffort | null = null;
|
||||
let serviceTier: CodexServiceTier | null = null;
|
||||
|
||||
for (let consumed = 0; consumed < 2; consumed += 1) {
|
||||
const match = upstreamModel.match(CODEX_TUNING_SUFFIX_TOKEN_REGEX);
|
||||
if (!match?.[1]) break;
|
||||
|
||||
const token = match[1].toLowerCase();
|
||||
if (token === 'fast') {
|
||||
if (serviceTier) break;
|
||||
serviceTier = 'fast';
|
||||
} else {
|
||||
if (effort) break;
|
||||
effort = token as CodexReasoningEffort;
|
||||
}
|
||||
|
||||
upstreamModel = upstreamModel.slice(0, -match[0].length).trim();
|
||||
}
|
||||
|
||||
if (!effort && !serviceTier) return null;
|
||||
if (!upstreamModel) return null;
|
||||
return { upstreamModel, effort };
|
||||
return { upstreamModel, effort, serviceTier };
|
||||
}
|
||||
|
||||
function isKnownCodexModelId(
|
||||
@@ -154,19 +180,36 @@ export function getEffortForModel(
|
||||
export function injectReasoningEffortIntoBody(
|
||||
body: unknown,
|
||||
effort: CodexReasoningEffort
|
||||
): unknown {
|
||||
return injectCodexRequestTuningIntoBody(body, { effort, serviceTier: null });
|
||||
}
|
||||
|
||||
export function injectCodexRequestTuningIntoBody(
|
||||
body: unknown,
|
||||
tuning: {
|
||||
effort: CodexReasoningEffort | null;
|
||||
serviceTier: CodexServiceTier | null;
|
||||
}
|
||||
): unknown {
|
||||
if (!isRecord(body)) return body;
|
||||
|
||||
// OpenAI Responses API knob: reasoning: { effort: "..." }
|
||||
// Always override effort (user expectation).
|
||||
const existingReasoning = isRecord(body.reasoning) ? body.reasoning : {};
|
||||
return {
|
||||
...body,
|
||||
reasoning: {
|
||||
const tunedBody = { ...body };
|
||||
|
||||
if (tuning.effort) {
|
||||
tunedBody.reasoning = {
|
||||
...existingReasoning,
|
||||
effort,
|
||||
},
|
||||
};
|
||||
effort: tuning.effort,
|
||||
};
|
||||
}
|
||||
|
||||
if (tuning.serviceTier) {
|
||||
tunedBody.service_tier = CODEX_SERVICE_TIER_REQUEST_VALUE[tuning.serviceTier];
|
||||
}
|
||||
|
||||
return tunedBody;
|
||||
}
|
||||
|
||||
export class CodexReasoningProxy {
|
||||
@@ -191,7 +234,8 @@ export class CodexReasoningProxy {
|
||||
at: string;
|
||||
model: string | null;
|
||||
upstreamModel: string | null;
|
||||
effort: CodexReasoningEffort;
|
||||
effort: CodexReasoningEffort | null;
|
||||
serviceTier: CodexServiceTier | null;
|
||||
path: string;
|
||||
}> = [];
|
||||
private readonly counts: Record<CodexReasoningEffort, number> = { medium: 0, high: 0, xhigh: 0 };
|
||||
@@ -226,14 +270,21 @@ export class CodexReasoningProxy {
|
||||
private buildForwardBody(
|
||||
body: unknown,
|
||||
upstreamModel: string | null,
|
||||
effort: CodexReasoningEffort | null
|
||||
tuning: {
|
||||
effort: CodexReasoningEffort | null;
|
||||
serviceTier: CodexServiceTier | null;
|
||||
}
|
||||
): unknown {
|
||||
const withUpstreamModel =
|
||||
upstreamModel && isRecord(body) ? { ...body, model: upstreamModel } : body;
|
||||
if (this.config.disableEffort || !effort) {
|
||||
const effort = this.config.disableEffort ? null : tuning.effort;
|
||||
if (!effort && !tuning.serviceTier) {
|
||||
return withUpstreamModel;
|
||||
}
|
||||
return injectReasoningEffortIntoBody(withUpstreamModel, effort);
|
||||
return injectCodexRequestTuningIntoBody(withUpstreamModel, {
|
||||
effort,
|
||||
serviceTier: tuning.serviceTier,
|
||||
});
|
||||
}
|
||||
|
||||
private sendBufferedResponse(
|
||||
@@ -247,14 +298,17 @@ export class CodexReasoningProxy {
|
||||
}
|
||||
|
||||
/**
|
||||
* Treat trailing "-high/-medium/-xhigh" as an effort alias only for known codex models.
|
||||
* Treat trailing "-high/-medium/-xhigh" and "-fast" as Codex tuning aliases
|
||||
* only for known codex models.
|
||||
* Prevents stripping legitimate upstream model IDs that happen to end with those tokens.
|
||||
*/
|
||||
private parseEffortAlias(
|
||||
model: string | null
|
||||
): { upstreamModel: string; effort: CodexReasoningEffort } | null {
|
||||
private parseTuningAlias(model: string | null): {
|
||||
upstreamModel: string;
|
||||
effort: CodexReasoningEffort | null;
|
||||
serviceTier: CodexServiceTier | null;
|
||||
} | null {
|
||||
if (!model) return null;
|
||||
const parsed = parseModelEffortSuffix(model);
|
||||
const parsed = parseModelTuningSuffix(model);
|
||||
if (!parsed) return null;
|
||||
if (!isKnownCodexModelId(parsed.upstreamModel, this.modelEffort)) {
|
||||
return null;
|
||||
@@ -296,11 +350,21 @@ export class CodexReasoningProxy {
|
||||
private record(
|
||||
model: string | null,
|
||||
upstreamModel: string | null,
|
||||
effort: CodexReasoningEffort,
|
||||
effort: CodexReasoningEffort | null,
|
||||
serviceTier: CodexServiceTier | null,
|
||||
path: string
|
||||
): void {
|
||||
this.counts[effort] += 1;
|
||||
this.recent.push({ at: new Date().toISOString(), model, upstreamModel, effort, path });
|
||||
if (effort) {
|
||||
this.counts[effort] += 1;
|
||||
}
|
||||
this.recent.push({
|
||||
at: new Date().toISOString(),
|
||||
model,
|
||||
upstreamModel,
|
||||
effort,
|
||||
serviceTier,
|
||||
path,
|
||||
});
|
||||
if (this.recent.length > 50) this.recent.shift();
|
||||
}
|
||||
|
||||
@@ -418,12 +482,13 @@ export class CodexReasoningProxy {
|
||||
? stripExtendedContextSuffix(originalModel)
|
||||
: null;
|
||||
|
||||
// Support "model aliases" like `gpt-5.2-codex-xhigh` by translating to:
|
||||
// - upstream model: `gpt-5.2-codex`
|
||||
// - reasoning.effort: `xhigh`
|
||||
// Support "model aliases" like `gpt-5.4-high-fast` by translating to:
|
||||
// - upstream model: `gpt-5.4`
|
||||
// - reasoning.effort: `high`
|
||||
// - service_tier: `priority` (Codex request value for fast mode)
|
||||
//
|
||||
// This allows tier→effort mapping without inventing upstream model IDs.
|
||||
const suffixParsed = this.parseEffortAlias(normalizedRequestModel);
|
||||
// This allows tier/speed mapping without inventing upstream model IDs.
|
||||
const suffixParsed = this.parseTuningAlias(normalizedRequestModel);
|
||||
const requestedUpstreamModel = suffixParsed?.upstreamModel ?? normalizedRequestModel;
|
||||
const rememberedFallback = this.getRememberedFallback(requestedUpstreamModel);
|
||||
const upstreamModel = rememberedFallback ?? requestedUpstreamModel;
|
||||
@@ -436,14 +501,15 @@ export class CodexReasoningProxy {
|
||||
: !this.config.disableEffort
|
||||
? requestedEffort
|
||||
: null;
|
||||
const rewritten = this.buildForwardBody(parsed, upstreamModel, effort);
|
||||
const serviceTier = suffixParsed?.serviceTier ?? null;
|
||||
const rewritten = this.buildForwardBody(parsed, upstreamModel, { effort, serviceTier });
|
||||
|
||||
if (effort) {
|
||||
this.record(originalModel, upstreamModel, effort, requestPath);
|
||||
if (effort || serviceTier) {
|
||||
this.record(originalModel, upstreamModel, effort, serviceTier, requestPath);
|
||||
this.trace(
|
||||
`[${new Date().toISOString()}] model=${originalModel ?? 'null'} upstreamModel=${
|
||||
upstreamModel ?? 'null'
|
||||
} effort=${effort} path=${requestPath}`
|
||||
} effort=${effort ?? 'null'} serviceTier=${serviceTier ?? 'null'} path=${requestPath}`
|
||||
);
|
||||
} else {
|
||||
this.log(`[disabled] model=${originalModel ?? 'null'} -> passthrough (no reasoning)`);
|
||||
@@ -458,6 +524,7 @@ export class CodexReasoningProxy {
|
||||
requestedModel: requestedUpstreamModel,
|
||||
attemptedUpstreamModel: upstreamModel,
|
||||
effort,
|
||||
serviceTier,
|
||||
retryCount: 0,
|
||||
});
|
||||
} catch (error) {
|
||||
@@ -630,7 +697,10 @@ export class CodexReasoningProxy {
|
||||
!this.config.disableEffort && context.effort
|
||||
? capEffortAtModelMax(fallbackModel, context.effort)
|
||||
: null;
|
||||
const retryBody = this.buildForwardBody(body, fallbackModel, retryEffort);
|
||||
const retryBody = this.buildForwardBody(body, fallbackModel, {
|
||||
effort: retryEffort,
|
||||
serviceTier: context.serviceTier,
|
||||
});
|
||||
|
||||
this.log(
|
||||
`Upstream rejected model "${context.attemptedUpstreamModel}". Retrying ${context.requestPath} with "${fallbackModel}".`
|
||||
|
||||
@@ -26,7 +26,7 @@ const DENIED_ANTIGRAVITY_OPUS_45_REGEX =
|
||||
const DENIED_ANTIGRAVITY_SONNET_45_REGEX =
|
||||
/claude-sonnet-4(?:[.-])5(?:-thinking)?(?=(?:$|[^a-z0-9]))/gi;
|
||||
const CANONICAL_ANTIGRAVITY_OPUS_46_MODEL = 'claude-opus-4-6-thinking';
|
||||
const CODEX_EFFORT_SUFFIX_REGEX = /-(xhigh|high|medium)$/i;
|
||||
const CODEX_TUNING_SUFFIX_TOKEN_REGEX = /-(xhigh|high|medium|fast)$/i;
|
||||
const CODEX_LEGACY_MODEL_ALIASES: Readonly<Record<string, string>> = Object.freeze({
|
||||
'gpt-5-codex': 'gpt-5.4',
|
||||
'gpt-5-codex-mini': 'gpt-5.4-mini',
|
||||
@@ -65,15 +65,31 @@ function splitBaseModelAndSuffix(model: string): { baseModel: string; suffix: st
|
||||
};
|
||||
}
|
||||
|
||||
function splitCodexEffortSuffix(model: string): { baseModel: string; suffix: string } {
|
||||
const match = model.match(CODEX_EFFORT_SUFFIX_REGEX);
|
||||
if (!match?.[0]) {
|
||||
return { baseModel: model, suffix: '' };
|
||||
function splitCodexTuningSuffix(model: string): { baseModel: string; suffix: string } {
|
||||
let baseModel = model;
|
||||
let effort: string | null = null;
|
||||
let serviceTier: 'fast' | null = null;
|
||||
|
||||
for (let consumed = 0; consumed < 2; consumed += 1) {
|
||||
const match = baseModel.match(CODEX_TUNING_SUFFIX_TOKEN_REGEX);
|
||||
if (!match?.[1]) break;
|
||||
|
||||
const token = match[1].toLowerCase();
|
||||
if (token === 'fast') {
|
||||
if (serviceTier) break;
|
||||
serviceTier = 'fast';
|
||||
} else {
|
||||
if (effort) break;
|
||||
effort = token;
|
||||
}
|
||||
|
||||
baseModel = baseModel.slice(0, -match[0].length);
|
||||
}
|
||||
|
||||
const suffix = [effort, serviceTier].filter(Boolean).join('-');
|
||||
return {
|
||||
baseModel: model.slice(0, -match[0].length),
|
||||
suffix: match[0],
|
||||
baseModel,
|
||||
suffix: suffix ? `-${suffix}` : '',
|
||||
};
|
||||
}
|
||||
|
||||
@@ -109,11 +125,11 @@ export function isIFlowProvider(provider: ProviderLike): boolean {
|
||||
return provider.trim().toLowerCase() === 'iflow';
|
||||
}
|
||||
|
||||
/** Strip Codex effort suffixes while preserving trailing config suffixes. */
|
||||
/** Strip Codex effort/service-tier suffixes while preserving trailing config suffixes. */
|
||||
export function stripCodexEffortSuffix(model: string): string {
|
||||
const trimmed = trimModelId(model);
|
||||
const { baseModel, suffix } = splitBaseModelAndSuffix(trimmed);
|
||||
const { baseModel: withoutEffort } = splitCodexEffortSuffix(baseModel);
|
||||
const { baseModel: withoutEffort } = splitCodexTuningSuffix(baseModel);
|
||||
return `${withoutEffort}${suffix}`;
|
||||
}
|
||||
|
||||
@@ -121,12 +137,12 @@ export function stripCodexEffortSuffix(model: string): string {
|
||||
export function normalizeCodexLegacyModelAliases(model: string): string {
|
||||
const trimmed = trimModelId(model);
|
||||
const { baseModel, suffix } = splitBaseModelAndSuffix(trimmed);
|
||||
const { baseModel: baseWithoutEffort, suffix: effortSuffix } = splitCodexEffortSuffix(baseModel);
|
||||
const { baseModel: baseWithoutEffort, suffix: tuningSuffix } = splitCodexTuningSuffix(baseModel);
|
||||
const replacement = CODEX_LEGACY_MODEL_ALIASES[baseWithoutEffort.trim().toLowerCase()];
|
||||
if (!replacement) {
|
||||
return trimmed;
|
||||
}
|
||||
return `${replacement}${effortSuffix}${suffix}`;
|
||||
return `${replacement}${tuningSuffix}${suffix}`;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -16,9 +16,15 @@ import { getThinkingConfig } from '../../config/config-loader-facade';
|
||||
/** Model tier types for thinking budget defaults */
|
||||
export type ModelTier = 'opus' | 'sonnet' | 'haiku';
|
||||
|
||||
const CODEX_EFFORT_REGEX = /^(medium|high|xhigh)$/i;
|
||||
const CODEX_FAST_TUNING_VALUE_REGEX =
|
||||
/^(?:(medium|high|xhigh)-fast|fast-(medium|high|xhigh)|fast)$/i;
|
||||
const CODEX_TUNING_SUFFIX_REGEX =
|
||||
/(?:-(?:xhigh|high|medium)(?:-fast)?|-fast(?:-(?:xhigh|high|medium))?)$/i;
|
||||
|
||||
/**
|
||||
* Normalize model ID for provider capability lookup.
|
||||
* Codex may carry effort suffixes in either legacy "(high)" or current "-high" forms.
|
||||
* Codex may carry effort/service-tier suffixes in legacy "(high)" or current "-high-fast" forms.
|
||||
*/
|
||||
function normalizeModelForThinkingLookup(model: string, provider: CLIProxyProvider): string {
|
||||
const withoutExtendedContext = model.replace(/\[1m\]$/i, '').trim();
|
||||
@@ -26,8 +32,10 @@ function normalizeModelForThinkingLookup(model: string, provider: CLIProxyProvid
|
||||
|
||||
if (provider !== 'codex') return providerNormalized;
|
||||
|
||||
// New codex suffix form: gpt-5.3-codex-high -> gpt-5.3-codex
|
||||
const codexSuffixMatch = providerNormalized.match(/^(.*)-(xhigh|high|medium)$/i);
|
||||
// New codex suffix forms: gpt-5.4-high-fast, gpt-5.4-fast-high -> gpt-5.4
|
||||
const codexSuffixMatch = providerNormalized.match(
|
||||
/^(.*?)(?:-(?:xhigh|high|medium)(?:-fast)?|-fast(?:-(?:xhigh|high|medium))?)$/i
|
||||
);
|
||||
if (codexSuffixMatch?.[1]) {
|
||||
return codexSuffixMatch[1].trim();
|
||||
}
|
||||
@@ -82,8 +90,6 @@ function applyThinkingSuffixForProvider(
|
||||
thinkingValue: string | number,
|
||||
provider?: CLIProxyProvider
|
||||
): string {
|
||||
const codexEffortRegex = /^(medium|high|xhigh)$/i;
|
||||
const codexModelSuffixRegex = /-(medium|high|xhigh)$/i;
|
||||
const parenthesizedSuffixMatch = model.match(/\(([^)]+)\)$/);
|
||||
|
||||
// Existing parenthesized suffix:
|
||||
@@ -93,7 +99,7 @@ function applyThinkingSuffixForProvider(
|
||||
if (parenthesizedSuffixMatch) {
|
||||
if (provider === 'codex') {
|
||||
const normalizedParensValue = parenthesizedSuffixMatch[1]?.trim().toLowerCase() || '';
|
||||
if (codexEffortRegex.test(normalizedParensValue)) {
|
||||
if (CODEX_EFFORT_REGEX.test(normalizedParensValue)) {
|
||||
return model.replace(/\([^)]+\)$/, `-${normalizedParensValue}`);
|
||||
}
|
||||
}
|
||||
@@ -110,11 +116,11 @@ function applyThinkingSuffixForProvider(
|
||||
}
|
||||
|
||||
if (provider === 'codex') {
|
||||
if (codexModelSuffixRegex.test(model)) {
|
||||
if (CODEX_TUNING_SUFFIX_REGEX.test(model)) {
|
||||
return model;
|
||||
}
|
||||
const normalized = String(thinkingValue).trim().toLowerCase();
|
||||
if (codexEffortRegex.test(normalized)) {
|
||||
if (CODEX_EFFORT_REGEX.test(normalized) || CODEX_FAST_TUNING_VALUE_REGEX.test(normalized)) {
|
||||
return `${model}-${normalized}`;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -66,6 +66,8 @@ export interface ModelEntry {
|
||||
extendedContext?: boolean;
|
||||
/** Whether model can read image inputs natively without the Image transformer */
|
||||
nativeImageInput?: boolean;
|
||||
/** Additional Codex service-tier suffixes supported by this model. */
|
||||
codexServiceTiers?: Array<'fast'>;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -200,6 +202,7 @@ export const MODEL_CATALOG: Partial<Record<CLIProxyProvider, ProviderCatalog>> =
|
||||
maxLevel: 'xhigh',
|
||||
dynamicAllowed: false,
|
||||
},
|
||||
codexServiceTiers: ['fast'],
|
||||
},
|
||||
{
|
||||
id: 'gpt-5.4',
|
||||
@@ -211,6 +214,7 @@ export const MODEL_CATALOG: Partial<Record<CLIProxyProvider, ProviderCatalog>> =
|
||||
maxLevel: 'xhigh',
|
||||
dynamicAllowed: false,
|
||||
},
|
||||
codexServiceTiers: ['fast'],
|
||||
},
|
||||
{
|
||||
id: 'gpt-5.4-mini',
|
||||
|
||||
@@ -130,6 +130,64 @@ function formatModelOption(model: ModelEntry): string {
|
||||
return `${model.name}${tierBadge}`;
|
||||
}
|
||||
|
||||
const CODEX_EFFORTS_IN_ORDER = ['medium', 'high', 'xhigh'] as const;
|
||||
type CodexSelectableEffort = (typeof CODEX_EFFORTS_IN_ORDER)[number];
|
||||
|
||||
function getCodexSelectableEfforts(model: ModelEntry): CodexSelectableEffort[] {
|
||||
const maxLevel = model.thinking?.maxLevel;
|
||||
const maxIndex = CODEX_EFFORTS_IN_ORDER.findIndex((effort) => effort === maxLevel);
|
||||
if (maxIndex < 0) return [];
|
||||
return CODEX_EFFORTS_IN_ORDER.slice(0, maxIndex + 1);
|
||||
}
|
||||
|
||||
function getCodexModelOptionValues(model: ModelEntry, modelId: string): string[] {
|
||||
const serviceTiers = model.codexServiceTiers ?? [];
|
||||
const optionValues: string[] = [modelId];
|
||||
|
||||
for (const serviceTier of serviceTiers) {
|
||||
optionValues.push(`${modelId}-${serviceTier}`);
|
||||
}
|
||||
|
||||
for (const effort of getCodexSelectableEfforts(model)) {
|
||||
const effortModelId = `${modelId}-${effort}`;
|
||||
optionValues.push(effortModelId);
|
||||
for (const serviceTier of serviceTiers) {
|
||||
optionValues.push(`${effortModelId}-${serviceTier}`);
|
||||
}
|
||||
}
|
||||
|
||||
return optionValues;
|
||||
}
|
||||
|
||||
function getModelOptionsForProvider(
|
||||
provider: CLIProxyProvider,
|
||||
catalog: NonNullable<ReturnType<typeof getProviderCatalog>>,
|
||||
routing: CliproxyProviderRoutingHints | undefined
|
||||
): Array<{ id: string; label: string }> {
|
||||
return catalog.models.flatMap((model) => {
|
||||
const modelId = getSelectableModelId(model.id, routing);
|
||||
const optionValues =
|
||||
provider === 'codex' ? getCodexModelOptionValues(model, modelId) : [modelId];
|
||||
return optionValues.map((optionValue) => ({
|
||||
id: optionValue,
|
||||
label:
|
||||
provider === 'codex'
|
||||
? `${optionValue} (${formatModelOption(model)})`
|
||||
: formatModelOption(model),
|
||||
}));
|
||||
});
|
||||
}
|
||||
|
||||
function getDefaultModelOptionIndex(
|
||||
modelOptions: Array<{ id: string }>,
|
||||
catalog: NonNullable<ReturnType<typeof getProviderCatalog>>,
|
||||
routing: CliproxyProviderRoutingHints | undefined
|
||||
): number {
|
||||
const defaultModelId = getSelectableModelId(catalog.defaultModel, routing);
|
||||
const defaultIdx = modelOptions.findIndex((option) => option.id === defaultModelId);
|
||||
return defaultIdx >= 0 ? defaultIdx : 0;
|
||||
}
|
||||
|
||||
function getSelectableModelId(
|
||||
modelId: string,
|
||||
routing: CliproxyProviderRoutingHints | undefined
|
||||
@@ -207,13 +265,13 @@ async function selectTierConfig(
|
||||
const routing = (await getCatalogRoutingSnapshot()).routing[provider as CLIProxyProvider];
|
||||
const catalog = getProviderCatalog(provider as CLIProxyProvider);
|
||||
if (catalog) {
|
||||
const modelOptions = catalog.models.map((m) => ({
|
||||
id: getSelectableModelId(m.id, routing),
|
||||
label: formatModelOption(m),
|
||||
}));
|
||||
const defaultIdx = catalog.models.findIndex((m) => m.id === catalog.defaultModel);
|
||||
const modelOptions = getModelOptionsForProvider(
|
||||
provider as CLIProxyProvider,
|
||||
catalog,
|
||||
routing
|
||||
);
|
||||
model = await InteractivePrompt.selectFromList(`Model for ${tierName}:`, modelOptions, {
|
||||
defaultIndex: defaultIdx >= 0 ? defaultIdx : 0,
|
||||
defaultIndex: getDefaultModelOptionIndex(modelOptions, catalog, routing),
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -468,13 +526,13 @@ export async function handleCreate(
|
||||
const routing = (await getCatalogRoutingSnapshot()).routing[provider as CLIProxyProvider];
|
||||
const catalog = getProviderCatalog(provider as CLIProxyProvider);
|
||||
if (catalog) {
|
||||
const modelOptions = catalog.models.map((m) => ({
|
||||
id: getSelectableModelId(m.id, routing),
|
||||
label: formatModelOption(m),
|
||||
}));
|
||||
const defaultIdx = catalog.models.findIndex((m) => m.id === catalog.defaultModel);
|
||||
const modelOptions = getModelOptionsForProvider(
|
||||
provider as CLIProxyProvider,
|
||||
catalog,
|
||||
routing
|
||||
);
|
||||
model = await InteractivePrompt.selectFromList('Select model:', modelOptions, {
|
||||
defaultIndex: defaultIdx >= 0 ? defaultIdx : 0,
|
||||
defaultIndex: getDefaultModelOptionIndex(modelOptions, catalog, routing),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user