mirror of
https://github.com/tiennm99/ccs.git
synced 2026-07-17 12:16:59 +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:
@@ -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}`;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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');
|
||||
|
||||
Reference in New Issue
Block a user