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:
Kai (Tam Nhu) Tran
2026-05-07 15:34:05 -04:00
committed by GitHub
parent a8d412a0eb
commit 74d73748ee
17 changed files with 471 additions and 86 deletions
+1
View File
@@ -41,6 +41,7 @@ All major modularization work is complete. The codebase evolved from monolithic
### Recent Fixes
- **2026-05-07**: **#760** Codex GPT fast mode is now a first-class CLIProxy model tuning suffix. CCS accepts `gpt-5.4-fast`, `gpt-5.4-high-fast`, and equivalent canonicalized forms in raw env configs, CLI variant creation, and the dashboard model picker; runtime requests now send the base upstream model with `reasoning.effort` plus `service_tier: "priority"` instead of leaking the suffixed alias to CLIProxy upstream routing.
- **2026-05-07**: **#1103** GitHub Copilot is now treated as a deprecated compatibility bridge. The dashboard moves Copilot out of the active Identity & Access section and into Deprecated, quick setup no longer offers `ghcp` for new onboarding, CLI/help/config copy marks Copilot as deprecated, and existing `ccs copilot` / `ghcp` compatibility paths remain available for current setups.
- **2026-05-07**: **#1189** Headless settings-profile delegation now preserves native Claude passthrough args without a Claude flag allowlist. Explicit `--channels` values reach Claude Code, future native flags can carry multiple adjacent values, malformed CCS-owned flags no longer swallow the next native flag, and `--prompt=<text>` routes through headless delegation consistently with `--prompt <text>`.
- **2026-05-03**: **#1172** Local CLIProxy config generation now keeps the CPAMC management dashboard aligned with backend selection. `backend: original` points to the upstream dashboard, `backend: plus` points to the CCS-maintained CPAMC fork, `cliproxy.management_panel_repository` lets advanced users override the panel repository, and stale generated configs are regenerated when the expected panel source changes.
@@ -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}`;
}
/**
+14 -8
View File
@@ -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}`;
}
}
+4
View File
@@ -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',
+70 -12
View File
@@ -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),
});
}
}
@@ -229,8 +229,8 @@ export function ModelConfigSection({
{provider === 'codex' && (
<p className="text-[11px] text-muted-foreground mb-3 rounded-md border bg-muted/30 px-2.5 py-2">
Codex tip: suffixes <code>-medium</code>, <code>-high</code>, and <code>-xhigh</code>{' '}
pin reasoning effort. Select a suffixed model to pin effort; unsuffixed models use
Thinking settings.
pin reasoning effort. <code>-fast</code> enables the Codex fast service tier on models
that support it, including combined forms such as <code>gpt-5.4-high-fast</code>.
</p>
)}
<div className="space-y-4">
@@ -12,8 +12,12 @@ import { Badge } from '@/components/ui/badge';
import { SearchableSelect } from '@/components/ui/searchable-select';
import { Skeleton } from '@/components/ui/skeleton';
import type { CliproxyProviderRoutingHints } from '@/lib/api-client';
import type { CodexEffort } from '@/lib/codex-effort';
import { getCodexEffortDisplay, getCodexEffortVariants } from '@/lib/codex-effort';
import type { CodexEffort, CodexServiceTier } from '@/lib/codex-effort';
import {
getCodexEffortDisplay,
getCodexEffortVariants,
parseCodexServiceTier,
} from '@/lib/codex-effort';
import { getResolvedCatalogModels, getSupplementalCatalogModels } from '@/lib/model-catalogs';
import { cn } from '@/lib/utils';
@@ -38,6 +42,8 @@ export interface ModelEntry {
};
/** Highest codex reasoning-effort suffix this model supports in the dashboard UI. */
codexMaxEffort?: CodexEffort;
/** Additional Codex service-tier suffixes supported by this model in the dashboard UI. */
codexServiceTiers?: CodexServiceTier[];
}
/** Provider catalog */
@@ -112,6 +118,17 @@ function CodexEffortBadge({ modelId }: { modelId: string | undefined }) {
);
}
function CodexServiceTierBadge({ modelId }: { modelId: string | undefined }) {
const serviceTier = parseCodexServiceTier(modelId);
if (!serviceTier) return null;
return (
<Badge variant="secondary" className="text-[9px] h-4 px-1 uppercase">
{serviceTier}
</Badge>
);
}
export function ProviderModelSelector({
catalog,
isLoading,
@@ -318,10 +335,13 @@ function getPreferredOptionValue(
function getModelOptionValues(
codexMaxEffort: CodexEffort | undefined,
codexServiceTiers: readonly CodexServiceTier[] | undefined,
optionValue: string,
isCodexProvider: boolean
): string[] {
return isCodexProvider ? getCodexEffortVariants(optionValue, codexMaxEffort) : [optionValue];
return isCodexProvider
? getCodexEffortVariants(optionValue, codexMaxEffort, codexServiceTiers)
: [optionValue];
}
export function FlexibleModelSelector({
@@ -356,6 +376,7 @@ export function FlexibleModelSelector({
resolvedCatalogModels.flatMap((model) =>
getModelOptionValues(
model.codexMaxEffort,
model.codexServiceTiers,
getPreferredOptionValue(model.id, routingHints.get(model.id.toLowerCase())),
isCodexProvider
)
@@ -372,6 +393,7 @@ export function FlexibleModelSelector({
const routingHint = routingHints.get(model.id.toLowerCase());
const optionValues = getModelOptionValues(
model.codexMaxEffort,
model.codexServiceTiers,
getPreferredOptionValue(model.id, routingHint),
isCodexProvider
);
@@ -390,6 +412,7 @@ export function FlexibleModelSelector({
</Badge>
) : null}
{isCodexProvider && <CodexEffortBadge modelId={optionValue} />}
{isCodexProvider && <CodexServiceTierBadge modelId={optionValue} />}
</div>
),
itemContent: (
@@ -407,6 +430,7 @@ export function FlexibleModelSelector({
</Badge>
) : null}
{isCodexProvider && <CodexEffortBadge modelId={optionValue} />}
{isCodexProvider && <CodexServiceTierBadge modelId={optionValue} />}
</div>
),
}));
@@ -423,6 +447,7 @@ export function FlexibleModelSelector({
.flatMap((model) => {
const routingHint = routingHints.get(model.id.toLowerCase());
const optionValues = getModelOptionValues(
undefined,
undefined,
getPreferredOptionValue(model.id, routingHint),
isCodexProvider
@@ -442,6 +467,7 @@ export function FlexibleModelSelector({
</Badge>
) : null}
{isCodexProvider && <CodexEffortBadge modelId={optionValue} />}
{isCodexProvider && <CodexServiceTierBadge modelId={optionValue} />}
</div>
),
itemContent: (
@@ -458,6 +484,7 @@ export function FlexibleModelSelector({
</Badge>
) : null}
{isCodexProvider && <CodexEffortBadge modelId={optionValue} />}
{isCodexProvider && <CodexServiceTierBadge modelId={optionValue} />}
</div>
),
}));
+56 -12
View File
@@ -1,37 +1,69 @@
export type CodexEffort = 'medium' | 'high' | 'xhigh';
export type CodexServiceTier = 'fast';
const CODEX_EFFORT_SUFFIX_REGEX = /-(medium|high|xhigh)$/i;
const CODEX_TUNING_SUFFIX_TOKEN_REGEX = /-(medium|high|xhigh|fast)$/i;
const CODEX_EFFORTS_IN_ORDER: readonly CodexEffort[] = ['medium', 'high', 'xhigh'];
function trimModelId(modelId: string | undefined): string {
return modelId?.trim() ?? '';
}
function parseCodexTuningSuffix(modelId: string | undefined): {
baseModel: string;
effort: CodexEffort | undefined;
serviceTier: CodexServiceTier | undefined;
} {
let baseModel = trimModelId(modelId);
let effort: CodexEffort | undefined;
let serviceTier: CodexServiceTier | undefined;
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 as CodexEffort;
}
baseModel = baseModel.slice(0, -match[0].length);
}
return { baseModel, effort, serviceTier };
}
export function parseCodexEffort(modelId: string | undefined): CodexEffort | undefined {
if (!modelId) return undefined;
const match = trimModelId(modelId).match(CODEX_EFFORT_SUFFIX_REGEX);
if (!match?.[1]) return undefined;
return match[1].toLowerCase() as CodexEffort;
return parseCodexTuningSuffix(modelId).effort;
}
export function parseCodexServiceTier(modelId: string | undefined): CodexServiceTier | undefined {
return parseCodexTuningSuffix(modelId).serviceTier;
}
export function stripCodexEffortSuffix(modelId: string | undefined): string {
return trimModelId(modelId).replace(CODEX_EFFORT_SUFFIX_REGEX, '');
return parseCodexTuningSuffix(modelId).baseModel;
}
export function applyCodexEffortSuffix(
modelId: string | undefined,
effort: CodexEffort | undefined
): string {
const normalizedModelId = stripCodexEffortSuffix(modelId);
const parsed = parseCodexTuningSuffix(modelId);
const normalizedModelId = parsed.baseModel;
if (!normalizedModelId || !effort) {
return normalizedModelId;
return parsed.serviceTier ? `${normalizedModelId}-${parsed.serviceTier}` : normalizedModelId;
}
return `${normalizedModelId}-${effort}`;
return [normalizedModelId, effort, parsed.serviceTier].filter(Boolean).join('-');
}
export function getCodexEffortVariants(
modelId: string,
maxEffort: CodexEffort | undefined
maxEffort: CodexEffort | undefined,
serviceTiers: readonly CodexServiceTier[] = []
): string[] {
if (!maxEffort) {
const explicitEffort = parseCodexEffort(modelId);
@@ -39,10 +71,22 @@ export function getCodexEffortVariants(
}
const normalizedModelId = stripCodexEffortSuffix(modelId);
const variantIds = [normalizedModelId];
const variantIds: string[] = [];
const appendVariantsForEffort = (effort: CodexEffort | undefined) => {
const effortModel = effort
? applyCodexEffortSuffix(normalizedModelId, effort)
: normalizedModelId;
variantIds.push(effortModel);
for (const serviceTier of serviceTiers) {
variantIds.push(`${effortModel}-${serviceTier}`);
}
};
appendVariantsForEffort(undefined);
for (const effort of CODEX_EFFORTS_IN_ORDER) {
variantIds.push(applyCodexEffortSuffix(normalizedModelId, effort));
appendVariantsForEffort(effort);
if (effort === maxEffort) {
break;
}
+2
View File
@@ -266,6 +266,7 @@ export const MODEL_CATALOGS: Record<string, ProviderCatalog> = {
description:
'Newest Codex-released GPT-5 family model; falls back to GPT-5.4 on free plans',
codexMaxEffort: 'xhigh',
codexServiceTiers: ['fast'],
presetMapping: {
default: 'gpt-5.5',
opus: 'gpt-5.5',
@@ -278,6 +279,7 @@ export const MODEL_CATALOGS: Record<string, ProviderCatalog> = {
name: 'GPT-5.4',
description: 'Recommended Codex default for most coding and agentic tasks',
codexMaxEffort: 'xhigh',
codexServiceTiers: ['fast'],
presetMapping: {
default: 'gpt-5.4',
opus: 'gpt-5.4',
@@ -123,11 +123,34 @@ describe('FlexibleModelSelector', () => {
expect(screen.getByText('gpt-5.3-codex-high')).toBeInTheDocument();
expect(screen.getByText('gpt-5.3-codex-xhigh')).toBeInTheDocument();
expect(screen.getByText('gpt-5.4-high-fast')).toBeInTheDocument();
await userEvent.click(screen.getByText('gpt-5.3-codex-high'));
expect(onChange).toHaveBeenCalledWith('gpt-5.3-codex-high');
});
it('offers codex fast variants without relegating saved fast selections', async () => {
const onChange = vi.fn();
render(
<FlexibleModelSelector
label="Primary model"
value="gpt-5.4-high-fast"
onChange={onChange}
catalog={MODEL_CATALOGS.codex}
allModels={[]}
/>
);
expect(screen.getByRole('button', { name: /gpt-5\.4-high-fast/i })).toBeInTheDocument();
await userEvent.click(screen.getByRole('button', { name: /gpt-5\.4-high-fast/i }));
expect(screen.queryByText('Current value')).not.toBeInTheDocument();
expect(screen.getByText('gpt-5.4-fast')).toBeInTheDocument();
expect(screen.getAllByText('gpt-5.4-high-fast').length).toBeGreaterThan(0);
});
it('does not relegate saved codex effort variants to the legacy current-value fallback', async () => {
render(
<FlexibleModelSelector
+25
View File
@@ -4,12 +4,15 @@ import {
getCodexEffortDisplay,
getCodexEffortVariants,
parseCodexEffort,
parseCodexServiceTier,
stripCodexEffortSuffix,
} from '@/lib/codex-effort';
describe('parseCodexEffort', () => {
it('parses lowercase suffixes', () => {
expect(parseCodexEffort('gpt-5.3-codex-high')).toBe('high');
expect(parseCodexEffort('gpt-5.4-high-fast')).toBe('high');
expect(parseCodexEffort('gpt-5.4-fast-high')).toBe('high');
expect(parseCodexEffort('gpt-5.3-codex-xhigh')).toBe('xhigh');
expect(parseCodexEffort('gpt-5-mini-medium')).toBe('medium');
});
@@ -21,10 +24,19 @@ describe('parseCodexEffort', () => {
it('returns undefined for unsuffixed or unsupported values', () => {
expect(parseCodexEffort('gpt-5.3-codex')).toBeUndefined();
expect(parseCodexEffort('gpt-5.3-codex-low')).toBeUndefined();
expect(parseCodexEffort('gpt-5.4-fast')).toBeUndefined();
expect(parseCodexEffort(undefined)).toBeUndefined();
});
});
describe('parseCodexServiceTier', () => {
it('parses fast service-tier suffixes', () => {
expect(parseCodexServiceTier('gpt-5.4-fast')).toBe('fast');
expect(parseCodexServiceTier('gpt-5.4-high-fast')).toBe('fast');
expect(parseCodexServiceTier('gpt-5.4-fast-high')).toBe('fast');
});
});
describe('getCodexEffortDisplay', () => {
it('returns pinned label for suffixed models', () => {
expect(getCodexEffortDisplay('gpt-5.3-codex-high')).toEqual({
@@ -48,7 +60,9 @@ describe('getCodexEffortDisplay', () => {
describe('codex effort helpers', () => {
it('strips and reapplies codex effort suffixes', () => {
expect(stripCodexEffortSuffix('gpt-5.3-codex-high')).toBe('gpt-5.3-codex');
expect(stripCodexEffortSuffix('gpt-5.4-fast-high')).toBe('gpt-5.4');
expect(applyCodexEffortSuffix('gpt-5.3-codex-high', 'xhigh')).toBe('gpt-5.3-codex-xhigh');
expect(applyCodexEffortSuffix('gpt-5.4-fast', 'high')).toBe('gpt-5.4-high-fast');
expect(applyCodexEffortSuffix('gpt-5.3-codex', undefined)).toBe('gpt-5.3-codex');
});
@@ -65,4 +79,15 @@ describe('codex effort helpers', () => {
'gpt-5.4-mini-high',
]);
});
it('builds fast variants for models with a fast service tier', () => {
expect(getCodexEffortVariants('gpt-5.4', 'high', ['fast'])).toEqual([
'gpt-5.4',
'gpt-5.4-fast',
'gpt-5.4-medium',
'gpt-5.4-medium-fast',
'gpt-5.4-high',
'gpt-5.4-high-fast',
]);
});
});
@@ -14,8 +14,10 @@ describe('codex model catalog defaults', () => {
expect(codex55?.tier).toBe('paid');
expect(codex55?.presetMapping?.haiku).toBe('gpt-5.4-mini');
expect(codex55?.codexMaxEffort).toBe('xhigh');
expect(codex55?.codexServiceTiers).toEqual(['fast']);
expect(codex54?.tier).toBeUndefined();
expect(codex54?.presetMapping?.haiku).toBe('gpt-5.4-mini');
expect(codex54?.codexServiceTiers).toEqual(['fast']);
expect(codex53?.presetMapping?.haiku).toBe('gpt-5.4-mini');
expect(codex52?.presetMapping?.haiku).toBe('gpt-5.4-mini');
expect(codex53?.codexMaxEffort).toBe('xhigh');