From 74d73748eef656902d857c8fae7b8e1af7ff5cb4 Mon Sep 17 00:00:00 2001 From: "Kai (Tam Nhu) Tran" <61256810+kaitranntt@users.noreply.github.com> Date: Thu, 7 May 2026 15:34:05 -0400 Subject: [PATCH] feat: support Codex fast service-tier aliases * feat: support Codex fast service-tier aliases * fix: send Codex fast tier as priority --- docs/project-roadmap.md | 1 + .../codex-plan-compatibility.test.ts | 2 + ...x-reasoning-proxy-extended-context.test.ts | 97 ++++++++++++ .../__tests__/model-id-normalizer.test.ts | 7 + .../ai-providers/codex-plan-compatibility.ts | 5 +- .../ai-providers/codex-reasoning-proxy.ts | 142 +++++++++++++----- .../ai-providers/model-id-normalizer.ts | 38 +++-- src/cliproxy/config/thinking-config.ts | 22 ++- src/cliproxy/model-catalog.ts | 4 + src/commands/cliproxy/variant-subcommand.ts | 82 ++++++++-- .../provider-editor/model-config-section.tsx | 4 +- .../cliproxy/provider-model-selector.tsx | 33 +++- ui/src/lib/codex-effort.ts | 68 +++++++-- ui/src/lib/model-catalogs.ts | 2 + .../cliproxy/provider-model-selector.test.tsx | 23 +++ ui/tests/unit/ui/lib/codex-effort.test.ts | 25 +++ .../unit/ui/lib/model-catalogs-codex.test.ts | 2 + 17 files changed, 471 insertions(+), 86 deletions(-) diff --git a/docs/project-roadmap.md b/docs/project-roadmap.md index 2e9441d1..a0d928ba 100644 --- a/docs/project-roadmap.md +++ b/docs/project-roadmap.md @@ -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=` routes through headless delegation consistently with `--prompt `. - **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. diff --git a/src/cliproxy/ai-providers/__tests__/codex-plan-compatibility.test.ts b/src/cliproxy/ai-providers/__tests__/codex-plan-compatibility.test.ts index dd4833e0..793a6623 100644 --- a/src/cliproxy/ai-providers/__tests__/codex-plan-compatibility.test.ts +++ b/src/cliproxy/ai-providers/__tests__/codex-plan-compatibility.test.ts @@ -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'); diff --git a/src/cliproxy/ai-providers/__tests__/codex-reasoning-proxy-extended-context.test.ts b/src/cliproxy/ai-providers/__tests__/codex-reasoning-proxy-extended-context.test.ts index f0cfd728..6fd5be8f 100644 --- a/src/cliproxy/ai-providers/__tests__/codex-reasoning-proxy-extended-context.test.ts +++ b/src/cliproxy/ai-providers/__tests__/codex-reasoning-proxy-extended-context.test.ts @@ -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; diff --git a/src/cliproxy/ai-providers/__tests__/model-id-normalizer.test.ts b/src/cliproxy/ai-providers/__tests__/model-id-normalizer.test.ts index 4a3dadb3..76ee7dc8 100644 --- a/src/cliproxy/ai-providers/__tests__/model-id-normalizer.test.ts +++ b/src/cliproxy/ai-providers/__tests__/model-id-normalizer.test.ts @@ -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' + ); }); }); diff --git a/src/cliproxy/ai-providers/codex-plan-compatibility.ts b/src/cliproxy/ai-providers/codex-plan-compatibility.ts index 3205b069..7a9a0ce1 100644 --- a/src/cliproxy/ai-providers/codex-plan-compatibility.ts +++ b/src/cliproxy/ai-providers/codex-plan-compatibility.ts @@ -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(); } diff --git a/src/cliproxy/ai-providers/codex-reasoning-proxy.ts b/src/cliproxy/ai-providers/codex-reasoning-proxy.ts index f79dec22..c63852ea 100644 --- a/src/cliproxy/ai-providers/codex-reasoning-proxy.ts +++ b/src/cliproxy/ai-providers/codex-reasoning-proxy.ts @@ -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 = { + 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 { 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 = { 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}".` diff --git a/src/cliproxy/ai-providers/model-id-normalizer.ts b/src/cliproxy/ai-providers/model-id-normalizer.ts index 3c1c6ffa..9686311a 100644 --- a/src/cliproxy/ai-providers/model-id-normalizer.ts +++ b/src/cliproxy/ai-providers/model-id-normalizer.ts @@ -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> = 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}`; } /** diff --git a/src/cliproxy/config/thinking-config.ts b/src/cliproxy/config/thinking-config.ts index b4d545f9..8f5e043f 100644 --- a/src/cliproxy/config/thinking-config.ts +++ b/src/cliproxy/config/thinking-config.ts @@ -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}`; } } diff --git a/src/cliproxy/model-catalog.ts b/src/cliproxy/model-catalog.ts index 3317f96d..29f6052e 100644 --- a/src/cliproxy/model-catalog.ts +++ b/src/cliproxy/model-catalog.ts @@ -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> = maxLevel: 'xhigh', dynamicAllowed: false, }, + codexServiceTiers: ['fast'], }, { id: 'gpt-5.4', @@ -211,6 +214,7 @@ export const MODEL_CATALOG: Partial> = maxLevel: 'xhigh', dynamicAllowed: false, }, + codexServiceTiers: ['fast'], }, { id: 'gpt-5.4-mini', diff --git a/src/commands/cliproxy/variant-subcommand.ts b/src/commands/cliproxy/variant-subcommand.ts index aedf6001..1a538e14 100644 --- a/src/commands/cliproxy/variant-subcommand.ts +++ b/src/commands/cliproxy/variant-subcommand.ts @@ -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>, + 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>, + 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), }); } } diff --git a/ui/src/components/cliproxy/provider-editor/model-config-section.tsx b/ui/src/components/cliproxy/provider-editor/model-config-section.tsx index 966f964e..a0db2ea9 100644 --- a/ui/src/components/cliproxy/provider-editor/model-config-section.tsx +++ b/ui/src/components/cliproxy/provider-editor/model-config-section.tsx @@ -229,8 +229,8 @@ export function ModelConfigSection({ {provider === 'codex' && (

Codex tip: suffixes -medium, -high, and -xhigh{' '} - pin reasoning effort. Select a suffixed model to pin effort; unsuffixed models use - Thinking settings. + pin reasoning effort. -fast enables the Codex fast service tier on models + that support it, including combined forms such as gpt-5.4-high-fast.

)}
diff --git a/ui/src/components/cliproxy/provider-model-selector.tsx b/ui/src/components/cliproxy/provider-model-selector.tsx index 471bd71a..3af5c431 100644 --- a/ui/src/components/cliproxy/provider-model-selector.tsx +++ b/ui/src/components/cliproxy/provider-model-selector.tsx @@ -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 ( + + {serviceTier} + + ); +} + 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({ ) : null} {isCodexProvider && } + {isCodexProvider && }
), itemContent: ( @@ -407,6 +430,7 @@ export function FlexibleModelSelector({ ) : null} {isCodexProvider && } + {isCodexProvider && } ), })); @@ -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({ ) : null} {isCodexProvider && } + {isCodexProvider && } ), itemContent: ( @@ -458,6 +484,7 @@ export function FlexibleModelSelector({ ) : null} {isCodexProvider && } + {isCodexProvider && } ), })); diff --git a/ui/src/lib/codex-effort.ts b/ui/src/lib/codex-effort.ts index ff2d311a..95581103 100644 --- a/ui/src/lib/codex-effort.ts +++ b/ui/src/lib/codex-effort.ts @@ -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; } diff --git a/ui/src/lib/model-catalogs.ts b/ui/src/lib/model-catalogs.ts index ef516b32..df7d35dd 100644 --- a/ui/src/lib/model-catalogs.ts +++ b/ui/src/lib/model-catalogs.ts @@ -266,6 +266,7 @@ export const MODEL_CATALOGS: Record = { 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 = { 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', diff --git a/ui/tests/unit/components/cliproxy/provider-model-selector.test.tsx b/ui/tests/unit/components/cliproxy/provider-model-selector.test.tsx index cddf5b38..d7ba0fb7 100644 --- a/ui/tests/unit/components/cliproxy/provider-model-selector.test.tsx +++ b/ui/tests/unit/components/cliproxy/provider-model-selector.test.tsx @@ -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( + + ); + + 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( { 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', + ]); + }); }); diff --git a/ui/tests/unit/ui/lib/model-catalogs-codex.test.ts b/ui/tests/unit/ui/lib/model-catalogs-codex.test.ts index c6f7f1fa..150de1c4 100644 --- a/ui/tests/unit/ui/lib/model-catalogs-codex.test.ts +++ b/ui/tests/unit/ui/lib/model-catalogs-codex.test.ts @@ -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');