From 9fac214051a2e30fd58ea7341ebd7f9de112f426 Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Wed, 18 Mar 2026 11:56:39 -0400 Subject: [PATCH 1/3] fix(codex): recover unsupported live model switches --- src/cliproxy/codex-plan-compatibility.ts | 96 +++++++++- src/cliproxy/codex-reasoning-proxy.ts | 179 +++++++++++++++--- .../cliproxy/codex-plan-compatibility.test.ts | 46 +++++ ...x-reasoning-proxy-extended-context.test.ts | 84 ++++++++ 4 files changed, 375 insertions(+), 30 deletions(-) diff --git a/src/cliproxy/codex-plan-compatibility.ts b/src/cliproxy/codex-plan-compatibility.ts index 41177e00..459d31a4 100644 --- a/src/cliproxy/codex-plan-compatibility.ts +++ b/src/cliproxy/codex-plan-compatibility.ts @@ -1,4 +1,5 @@ import { getDefaultAccount } from './account-manager'; +import { getProviderCatalog } from './model-catalog'; import { fetchCodexQuota } from './quota-fetcher-codex'; import { getCachedQuota, setCachedQuota } from './quota-response-cache'; import type { CodexQuotaResult } from './quota-types'; @@ -12,6 +13,9 @@ const FREE_SAFE_FAST_MODEL = 'gpt-5-codex-mini'; const CODEX_EFFORT_SUFFIX_REGEX = /-(xhigh|high|medium)$/i; const CODEX_PAREN_SUFFIX_REGEX = /\((xhigh|high|medium)\)$/i; const EXTENDED_CONTEXT_SUFFIX_REGEX = /\[1m\]$/i; +const KNOWN_CODEX_MODELS = new Set( + (getProviderCatalog('codex')?.models ?? []).map((model) => model.id.toLowerCase()) +); const FREE_PLAN_FALLBACKS = new Map([ ['gpt-5.3-codex', FREE_SAFE_DEFAULT_MODEL], @@ -19,7 +23,29 @@ const FREE_PLAN_FALLBACKS = new Map([ ['gpt-5.4', FREE_SAFE_DEFAULT_MODEL], ]); -function normalizeCodexModelId(model: string): string { +export interface CodexRuntimeFallbackModelMap { + defaultModel?: string; + opusModel?: string; + sonnetModel?: string; + haikuModel?: string; +} + +export interface CodexUnsupportedModelError { + message: string | null; + code: 'model_not_supported'; + param: string | null; + type: string | null; +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +function isKnownCodexModel(model: string): boolean { + return KNOWN_CODEX_MODELS.has(model); +} + +export function normalizeCodexModelId(model: string): string { return model .trim() .replace(EXTENDED_CONTEXT_SUFFIX_REGEX, '') @@ -37,6 +63,74 @@ export function getFreePlanFallbackCodexModel(model: string): string | null { return FREE_PLAN_FALLBACKS.get(normalizeCodexModelId(model)) ?? null; } +export function parseCodexUnsupportedModelError( + statusCode: number | undefined, + responseBody: string +): CodexUnsupportedModelError | null { + if (statusCode !== 400 || !responseBody.trim()) { + return null; + } + + try { + const parsed = JSON.parse(responseBody); + if ( + !isRecord(parsed) || + !isRecord(parsed.error) || + parsed.error.code !== 'model_not_supported' + ) { + return null; + } + + return { + message: typeof parsed.error.message === 'string' ? parsed.error.message : null, + code: 'model_not_supported', + param: typeof parsed.error.param === 'string' ? parsed.error.param : null, + type: typeof parsed.error.type === 'string' ? parsed.error.type : null, + }; + } catch { + return null; + } +} + +export function resolveRuntimeCodexFallbackModel(options: { + requestedModel: string; + modelMap: CodexRuntimeFallbackModelMap; + excludeModels?: string[]; +}): string | null { + const requestedModel = normalizeCodexModelId(options.requestedModel); + if (!requestedModel) { + return null; + } + + const excludedModels = new Set( + (options.excludeModels ?? []).map((model) => normalizeCodexModelId(model)).filter(Boolean) + ); + const candidates = [ + options.modelMap.defaultModel, + getFreePlanFallbackCodexModel(requestedModel), + options.modelMap.opusModel, + options.modelMap.sonnetModel, + options.modelMap.haikuModel, + getDefaultCodexModel(), + ]; + + for (const candidate of candidates) { + if (!candidate) continue; + const normalizedCandidate = normalizeCodexModelId(candidate); + if ( + !normalizedCandidate || + normalizedCandidate === requestedModel || + excludedModels.has(normalizedCandidate) || + !isKnownCodexModel(normalizedCandidate) + ) { + continue; + } + return normalizedCandidate; + } + + return null; +} + export async function reconcileCodexModelForActivePlan(options: { settingsPath: string; currentModel: string | undefined; diff --git a/src/cliproxy/codex-reasoning-proxy.ts b/src/cliproxy/codex-reasoning-proxy.ts index 57eb7e39..b24508e4 100644 --- a/src/cliproxy/codex-reasoning-proxy.ts +++ b/src/cliproxy/codex-reasoning-proxy.ts @@ -1,6 +1,11 @@ import * as http from 'http'; import * as https from 'https'; import { URL } from 'url'; +import { + normalizeCodexModelId, + parseCodexUnsupportedModelError, + resolveRuntimeCodexFallbackModel, +} from './codex-plan-compatibility'; import { getModelMaxLevel } from './model-catalog'; export type CodexReasoningEffort = 'medium' | 'high' | 'xhigh'; @@ -29,6 +34,14 @@ export interface CodexReasoningProxyConfig { disableEffort?: boolean; } +interface ForwardJsonContext { + requestPath: string; + requestedModel: string | null; + attemptedUpstreamModel: string | null; + effort: CodexReasoningEffort | null; + retryCount: number; +} + const EXTENDED_CONTEXT_SUFFIX_REGEX = /\[1m\]$/i; function stripExtendedContextSuffix(model: string): string { @@ -170,6 +183,7 @@ export class CodexReasoningProxy { > & Pick; private readonly modelEffort: Map; + private readonly sessionFallbackByModel = new Map(); private readonly recent: Array<{ at: string; model: string | null; @@ -193,6 +207,41 @@ export class CodexReasoningProxy { this.modelEffort = buildCodexModelEffortMap(this.config.modelMap, this.config.defaultEffort); } + private getRememberedFallback(model: string | null): string | null { + if (!model) return null; + return this.sessionFallbackByModel.get(normalizeCodexModelId(model)) ?? null; + } + + private rememberFallback(requestedModel: string, fallbackModel: string): void { + const normalizedRequestedModel = normalizeCodexModelId(requestedModel); + const normalizedFallbackModel = normalizeCodexModelId(fallbackModel); + if (!normalizedRequestedModel || !normalizedFallbackModel) return; + this.sessionFallbackByModel.set(normalizedRequestedModel, normalizedFallbackModel); + } + + private buildForwardBody( + body: unknown, + upstreamModel: string | null, + effort: CodexReasoningEffort | null + ): unknown { + const withUpstreamModel = + upstreamModel && isRecord(body) ? { ...body, model: upstreamModel } : body; + if (this.config.disableEffort || !effort) { + return withUpstreamModel; + } + return injectReasoningEffortIntoBody(withUpstreamModel, effort); + } + + private sendBufferedResponse( + clientRes: http.ServerResponse, + statusCode: number, + headers: http.IncomingHttpHeaders, + responseBody: string + ): void { + clientRes.writeHead(statusCode, headers); + clientRes.end(responseBody); + } + /** * Treat trailing "-high/-medium/-xhigh" as an effort alias only for known codex models. * Prevents stripping legitimate upstream model IDs that happen to end with those tokens. @@ -365,41 +414,48 @@ export class CodexReasoningProxy { ? stripExtendedContextSuffix(originalModel) : null; - // When effort is disabled (thinking mode: off), strip model suffix but don't inject reasoning - if (this.config.disableEffort) { - const suffixParsed = this.parseEffortAlias(normalizedRequestModel); - const upstreamModel = suffixParsed?.upstreamModel ?? normalizedRequestModel; - const forwarded = - upstreamModel && isRecord(parsed) ? { ...parsed, model: upstreamModel } : parsed; - - this.log(`[disabled] model=${originalModel ?? 'null'} -> passthrough (no reasoning)`); - await this.forwardJson(req, res, fullUpstreamUrl, forwarded); - return; - } - // Support "model aliases" like `gpt-5.2-codex-xhigh` by translating to: // - upstream model: `gpt-5.2-codex` // - reasoning.effort: `xhigh` // // This allows tier→effort mapping without inventing upstream model IDs. const suffixParsed = this.parseEffortAlias(normalizedRequestModel); - const upstreamModel = suffixParsed?.upstreamModel ?? normalizedRequestModel; - const effort = + const requestedUpstreamModel = suffixParsed?.upstreamModel ?? normalizedRequestModel; + const rememberedFallback = this.getRememberedFallback(requestedUpstreamModel); + const upstreamModel = rememberedFallback ?? requestedUpstreamModel; + const requestedEffort = suffixParsed?.effort ?? getEffortForModel(normalizedRequestModel, this.modelEffort, this.config.defaultEffort); + const effort = + !this.config.disableEffort && upstreamModel + ? capEffortAtModelMax(upstreamModel, requestedEffort) + : !this.config.disableEffort + ? requestedEffort + : null; + const rewritten = this.buildForwardBody(parsed, upstreamModel, effort); - const withUpstreamModel = - upstreamModel && isRecord(parsed) ? { ...parsed, model: upstreamModel } : parsed; - const rewritten = injectReasoningEffortIntoBody(withUpstreamModel, effort); + if (effort) { + this.record(originalModel, upstreamModel, effort, requestPath); + this.trace( + `[${new Date().toISOString()}] model=${originalModel ?? 'null'} upstreamModel=${ + upstreamModel ?? 'null' + } effort=${effort} path=${requestPath}` + ); + } else { + this.log(`[disabled] model=${originalModel ?? 'null'} -> passthrough (no reasoning)`); + } - this.record(originalModel, upstreamModel, effort, requestPath); - this.trace( - `[${new Date().toISOString()}] model=${originalModel ?? 'null'} upstreamModel=${ - upstreamModel ?? 'null' - } effort=${effort} path=${requestPath}` - ); + if (rememberedFallback && rememberedFallback !== requestedUpstreamModel) { + this.log(`Using remembered fallback ${requestedUpstreamModel} -> ${rememberedFallback}`); + } - await this.forwardJson(req, res, fullUpstreamUrl, rewritten); + await this.forwardJson(req, res, fullUpstreamUrl, rewritten, { + requestPath, + requestedModel: requestedUpstreamModel, + attemptedUpstreamModel: upstreamModel, + effort, + retryCount: 0, + }); } catch (error) { const err = error as Error; if (!res.headersSent) { @@ -487,8 +543,9 @@ export class CodexReasoningProxy { originalReq: http.IncomingMessage, clientRes: http.ServerResponse, upstreamUrl: URL, - body: unknown - ): Promise { + body: unknown, + context: ForwardJsonContext + ): Promise { return new Promise((resolve, reject) => { const bodyString = JSON.stringify(body); const requestFn = this.getRequestFn(upstreamUrl); @@ -503,9 +560,73 @@ export class CodexReasoningProxy { headers: this.buildForwardHeaders(originalReq.headers, bodyString), }, (upstreamRes) => { - clientRes.writeHead(upstreamRes.statusCode || 200, upstreamRes.headers); - upstreamRes.pipe(clientRes); - upstreamRes.on('end', () => resolve()); + const statusCode = upstreamRes.statusCode || 200; + if (statusCode >= 200 && statusCode < 300) { + clientRes.writeHead(statusCode, upstreamRes.headers); + upstreamRes.pipe(clientRes); + upstreamRes.on('end', () => resolve(statusCode)); + upstreamRes.on('error', reject); + return; + } + + const chunks: Buffer[] = []; + upstreamRes.on('data', (chunk: Buffer) => chunks.push(chunk)); + upstreamRes.on('end', async () => { + try { + const responseBody = Buffer.concat(chunks).toString('utf8'); + const unsupportedError = + context.retryCount === 0 + ? parseCodexUnsupportedModelError(statusCode, responseBody) + : null; + const fallbackModel = + unsupportedError && context.requestedModel + ? resolveRuntimeCodexFallbackModel({ + requestedModel: context.requestedModel, + modelMap: this.config.modelMap, + excludeModels: context.attemptedUpstreamModel + ? [context.attemptedUpstreamModel] + : undefined, + }) + : null; + + if (unsupportedError && fallbackModel && context.requestedModel) { + const retryEffort = + !this.config.disableEffort && context.effort + ? capEffortAtModelMax(fallbackModel, context.effort) + : null; + const retryBody = this.buildForwardBody(body, fallbackModel, retryEffort); + + this.log( + `Upstream rejected model "${context.attemptedUpstreamModel}". Retrying ${context.requestPath} with "${fallbackModel}".` + ); + + const retryStatusCode = await this.forwardJson( + originalReq, + clientRes, + upstreamUrl, + retryBody, + { + ...context, + attemptedUpstreamModel: fallbackModel, + effort: retryEffort, + retryCount: context.retryCount + 1, + } + ); + + if (retryStatusCode >= 200 && retryStatusCode < 300) { + this.rememberFallback(context.requestedModel, fallbackModel); + } + + resolve(retryStatusCode); + return; + } + + this.sendBufferedResponse(clientRes, statusCode, upstreamRes.headers, responseBody); + resolve(statusCode); + } catch (error) { + reject(error); + } + }); upstreamRes.on('error', reject); } ); diff --git a/tests/unit/cliproxy/codex-plan-compatibility.test.ts b/tests/unit/cliproxy/codex-plan-compatibility.test.ts index cf84fd28..23bb7bc9 100644 --- a/tests/unit/cliproxy/codex-plan-compatibility.test.ts +++ b/tests/unit/cliproxy/codex-plan-compatibility.test.ts @@ -3,6 +3,8 @@ import { getProviderCatalog, getModelMaxLevel } from '../../../src/cliproxy/mode import { getDefaultCodexModel, getFreePlanFallbackCodexModel, + parseCodexUnsupportedModelError, + resolveRuntimeCodexFallbackModel, } from '../../../src/cliproxy/codex-plan-compatibility'; describe('codex plan compatibility', () => { @@ -25,6 +27,50 @@ describe('codex plan compatibility', () => { expect(getFreePlanFallbackCodexModel('gpt-5.1-codex-mini')).toBeNull(); }); + it('detects upstream Codex model_not_supported responses', () => { + expect( + parseCodexUnsupportedModelError( + 400, + JSON.stringify({ + error: { + message: 'The requested model is not supported.', + code: 'model_not_supported', + param: 'model', + type: 'invalid_request_error', + }, + }) + ) + ).toEqual({ + message: 'The requested model is not supported.', + code: 'model_not_supported', + param: 'model', + type: 'invalid_request_error', + }); + expect( + parseCodexUnsupportedModelError(500, '{"error":{"code":"model_not_supported"}}') + ).toBeNull(); + }); + + it('resolves runtime fallbacks without retrying the rejected model again', () => { + expect( + resolveRuntimeCodexFallbackModel({ + requestedModel: 'gpt-5.4', + modelMap: { defaultModel: 'gpt-5-codex' }, + }) + ).toBe('gpt-5-codex'); + + expect( + resolveRuntimeCodexFallbackModel({ + requestedModel: 'gpt-5.4', + modelMap: { + defaultModel: 'gpt-5.4', + haikuModel: 'gpt-5-codex-mini', + }, + excludeModels: ['gpt-5-codex'], + }) + ).toBe('gpt-5-codex-mini'); + }); + it('tracks Codex thinking caps for current safe defaults and paid models', () => { expect(getModelMaxLevel('codex', 'gpt-5-codex')).toBe('high'); expect(getModelMaxLevel('codex', 'gpt-5-codex-mini')).toBe('high'); diff --git a/tests/unit/cliproxy/codex-reasoning-proxy-extended-context.test.ts b/tests/unit/cliproxy/codex-reasoning-proxy-extended-context.test.ts index 1e6f0cd3..5a6dc3a2 100644 --- a/tests/unit/cliproxy/codex-reasoning-proxy-extended-context.test.ts +++ b/tests/unit/cliproxy/codex-reasoning-proxy-extended-context.test.ts @@ -230,6 +230,90 @@ describe('CodexReasoningProxy extended-context compatibility', () => { expect(capturedBody?.model).toBe('enterprise-internal-high'); }); + it('retries unsupported live-session models once and remembers the fallback', async () => { + const capturedModels: string[] = []; + const capturedEfforts: Array = []; + + const upstream = http.createServer((req, res) => { + let rawBody = ''; + req.setEncoding('utf8'); + req.on('data', (chunk) => { + rawBody += chunk; + }); + req.on('end', () => { + const requestBody = rawBody ? (JSON.parse(rawBody) as JsonRecord) : {}; + const reasoning = requestBody.reasoning as JsonRecord | undefined; + const model = String(requestBody.model ?? ''); + const effort = typeof reasoning?.effort === 'string' ? reasoning.effort : undefined; + + capturedModels.push(model); + capturedEfforts.push(effort); + + if (model === 'gpt-5.4') { + res.writeHead(400, { 'Content-Type': 'application/json' }); + res.end( + JSON.stringify({ + error: { + message: 'The requested model is not supported.', + code: 'model_not_supported', + param: 'model', + type: 'invalid_request_error', + }, + }) + ); + return; + } + + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end( + JSON.stringify({ + ok: true, + model, + effort: effort ?? null, + }) + ); + }); + }); + cleanupServers.push(upstream); + + const upstreamPort = await listenOnRandomPort(upstream); + const proxy = new CodexReasoningProxy({ + upstreamBaseUrl: `http://127.0.0.1:${upstreamPort}`, + modelMap: { + defaultModel: 'gpt-5.4', + haikuModel: 'gpt-5-codex-mini', + }, + defaultEffort: 'medium', + }); + + const proxyPort = await proxy.start(); + const firstResponse = await postJson( + `http://127.0.0.1:${proxyPort}/api/provider/codex/v1/messages`, + { + model: 'gpt-5.4-xhigh', + messages: [], + } + ); + const secondResponse = await postJson( + `http://127.0.0.1:${proxyPort}/api/provider/codex/v1/messages`, + { + model: 'gpt-5.4-xhigh', + messages: [], + } + ); + + proxy.stop(); + + expect(firstResponse.statusCode).toBe(200); + expect(secondResponse.statusCode).toBe(200); + expect(firstResponse.body.model).toBe('gpt-5-codex'); + expect(firstResponse.body.effort).toBe('high'); + expect(secondResponse.body.model).toBe('gpt-5-codex'); + expect(secondResponse.body.effort).toBe('high'); + expect(capturedModels).toEqual(['gpt-5.4', 'gpt-5-codex', 'gpt-5-codex']); + expect(capturedEfforts).toEqual(['xhigh', 'high', 'high']); + }); + it('keeps reasoning enabled when CCS_THINKING=high overrides config off', async () => { let capturedBody: JsonRecord | null = null; From 2114a4b96e1b78e8e4f5a00bd29a866cd147348e Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Wed, 18 Mar 2026 11:56:54 -0400 Subject: [PATCH 2/3] fix(ui): sync codex model catalog defaults --- ui/src/lib/model-catalogs.ts | 100 +++++++++++++++++++++++++---------- 1 file changed, 71 insertions(+), 29 deletions(-) diff --git a/ui/src/lib/model-catalogs.ts b/ui/src/lib/model-catalogs.ts index 9ecabf72..9e711d72 100644 --- a/ui/src/lib/model-catalogs.ts +++ b/ui/src/lib/model-catalogs.ts @@ -111,45 +111,56 @@ export const MODEL_CATALOGS: Record = { codex: { provider: 'codex', displayName: 'Codex', - defaultModel: 'gpt-5.3-codex', + defaultModel: 'gpt-5-codex', models: [ { - id: 'gpt-5.3-codex', - name: 'GPT-5.3 Codex', - description: 'Supports up to xhigh effort', + id: 'gpt-5-codex', + name: 'GPT-5 Codex', + description: 'Cross-plan safe Codex default', presetMapping: { - default: 'gpt-5.3-codex', - opus: 'gpt-5.3-codex', - sonnet: 'gpt-5.3-codex', - haiku: 'gpt-5.1-codex-mini', + default: 'gpt-5-codex', + opus: 'gpt-5-codex', + sonnet: 'gpt-5-codex', + haiku: 'gpt-5-codex-mini', }, }, { - id: 'gpt-5.2-codex', - name: 'GPT-5.2 Codex', - description: 'Previous stable Codex model', + id: 'gpt-5-codex-mini', + name: 'GPT-5 Codex Mini', + description: 'Faster and cheaper Codex option', presetMapping: { - default: 'gpt-5.2-codex', - opus: 'gpt-5.2-codex', - sonnet: 'gpt-5.2-codex', - haiku: 'gpt-5.1-codex-mini', + default: 'gpt-5-codex-mini', + opus: 'gpt-5-codex', + sonnet: 'gpt-5-codex', + haiku: 'gpt-5-codex-mini', }, }, { id: 'gpt-5-mini', name: 'GPT-5 Mini', - description: 'Fast, capped at high effort (no xhigh)', + description: 'Legacy mini model ID kept for backwards compatibility', presetMapping: { default: 'gpt-5-mini', - opus: 'gpt-5.3-codex', + opus: 'gpt-5-codex', sonnet: 'gpt-5-mini', haiku: 'gpt-5-mini', }, }, + { + id: 'gpt-5.1-codex-mini', + name: 'GPT-5.1 Codex Mini', + description: 'Legacy fast Codex mini model', + presetMapping: { + default: 'gpt-5.1-codex-mini', + opus: 'gpt-5.1-codex-max', + sonnet: 'gpt-5.1-codex-max', + haiku: 'gpt-5.1-codex-mini', + }, + }, { id: 'gpt-5.1-codex-max', - name: 'Codex Max (5.1)', - description: 'Legacy most capable Codex model', + name: 'GPT-5.1 Codex Max', + description: 'Higher-effort Codex model with xhigh support', presetMapping: { default: 'gpt-5.1-codex-max', opus: 'gpt-5.1-codex-max', @@ -158,20 +169,51 @@ export const MODEL_CATALOGS: Record = { }, }, { - id: 'gpt-5.2', - name: 'GPT 5.2', - description: 'Latest GPT model', + id: 'gpt-5.2-codex', + name: 'GPT-5.2 Codex', + description: 'Cross-plan Codex model with xhigh support', presetMapping: { - default: 'gpt-5.2', - opus: 'gpt-5.2', - sonnet: 'gpt-5.2', - haiku: 'gpt-5.2', + default: 'gpt-5.2-codex', + opus: 'gpt-5.2-codex', + sonnet: 'gpt-5.2-codex', + haiku: 'gpt-5-codex-mini', }, }, { - id: 'gpt-5.1-codex-mini', - name: 'Codex Mini', - description: 'Fast and efficient Codex model', + id: 'gpt-5.3-codex', + name: 'GPT-5.3 Codex', + tier: 'paid', + description: 'Paid Codex plans only', + presetMapping: { + default: 'gpt-5.3-codex', + opus: 'gpt-5.3-codex', + sonnet: 'gpt-5.3-codex', + haiku: 'gpt-5-codex-mini', + }, + }, + { + id: 'gpt-5.3-codex-spark', + name: 'GPT-5.3 Codex Spark', + tier: 'paid', + description: 'Paid Codex plans only, ultra-fast coding model', + presetMapping: { + default: 'gpt-5.3-codex-spark', + opus: 'gpt-5.3-codex', + sonnet: 'gpt-5.3-codex', + haiku: 'gpt-5-codex-mini', + }, + }, + { + id: 'gpt-5.4', + name: 'GPT-5.4', + tier: 'paid', + description: 'Paid Codex plans only, latest GPT-5 family model', + presetMapping: { + default: 'gpt-5.4', + opus: 'gpt-5.4', + sonnet: 'gpt-5.4', + haiku: 'gpt-5-codex-mini', + }, }, ], }, From ef36ad4600282aae7680316a084ec1eb2d74ab63 Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Wed, 18 Mar 2026 12:15:30 -0400 Subject: [PATCH 3/3] fix(ui): reflect cliproxy preset plan tiers --- .../provider-editor/model-config-section.tsx | 192 +++++++++++------- .../model-config-section.test.tsx | 73 +++++++ .../unit/ui/lib/model-catalogs-codex.test.ts | 6 +- 3 files changed, 198 insertions(+), 73 deletions(-) create mode 100644 ui/tests/unit/components/cliproxy/provider-editor/model-config-section.test.tsx 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 b9b92c0a..d22a9632 100644 --- a/ui/src/components/cliproxy/provider-editor/model-config-section.tsx +++ b/ui/src/components/cliproxy/provider-editor/model-config-section.tsx @@ -5,6 +5,7 @@ import { useMemo } from 'react'; import { Button } from '@/components/ui/button'; +import { Badge } from '@/components/ui/badge'; import { Separator } from '@/components/ui/separator'; import { Sparkles, Zap, Star, X, Plus } from 'lucide-react'; import { FlexibleModelSelector } from '../provider-model-selector'; @@ -12,6 +13,24 @@ import { ExtendedContextToggle } from '../extended-context-toggle'; import { stripExtendedContextSuffix } from '@/lib/extended-context-utils'; import type { ModelConfigSectionProps } from './types'; +type CatalogPresetModel = NonNullable['models'][number]; + +function getPresetUpdates(model: CatalogPresetModel): Record { + const mapping = model.presetMapping || { + default: model.id, + opus: model.id, + sonnet: model.id, + haiku: model.id, + }; + + return { + ANTHROPIC_MODEL: mapping.default, + ANTHROPIC_DEFAULT_OPUS_MODEL: mapping.opus, + ANTHROPIC_DEFAULT_SONNET_MODEL: mapping.sonnet, + ANTHROPIC_DEFAULT_HAIKU_MODEL: mapping.haiku, + }; +} + export function ModelConfigSection({ catalog, savedPresets, @@ -29,8 +48,6 @@ export function ModelConfigSection({ onDeletePreset, isDeletePending, }: ModelConfigSectionProps) { - const showPresets = (catalog && catalog.models.length > 0) || savedPresets.length > 0; - // Find current model entry to check for extended context support // Strip [1m] suffix when looking up in catalog since catalog IDs don't have suffix const currentModelEntry = useMemo(() => { @@ -39,6 +56,37 @@ export function ModelConfigSection({ return catalog.models.find((m) => m.id === baseModelId); }, [catalog, currentModel]); + const presetGroups = useMemo(() => { + const presetModels = (catalog?.models ?? []).filter((model) => model.presetMapping); + if (presetModels.length === 0) return []; + + const hasPaidPresets = presetModels.some((model) => model.tier === 'paid'); + if (!hasPaidPresets) { + return [{ key: 'default', models: presetModels.slice(0, 4) }]; + } + + return [ + { + key: 'free', + label: 'Free Tier', + description: 'Available on free or paid plans', + badgeClassName: 'text-[10px] bg-green-100 text-green-700 border-green-200', + iconClassName: 'text-green-600', + models: presetModels.filter((model) => model.tier !== 'paid'), + }, + { + key: 'paid', + label: 'Paid Tier', + description: 'Requires paid access', + badgeClassName: 'text-[10px] bg-amber-100 text-amber-700 border-amber-200', + iconClassName: 'text-amber-700', + models: presetModels.filter((model) => model.tier === 'paid'), + }, + ].filter((group) => group.models.length > 0); + }, [catalog]); + + const showPresets = presetGroups.length > 0 || savedPresets.length > 0; + return ( <> {/* Quick Presets */} @@ -49,77 +97,81 @@ export function ModelConfigSection({ Presets

Apply pre-configured model mappings

-
- {/* Recommended presets from catalog */} - {catalog?.models.slice(0, 4).map((model) => ( - - ))} - - {/* User saved presets */} - {savedPresets.map((preset) => ( -
- - +
+ {presetGroups.map((group) => ( +
+ {'label' in group && group.label && ( +
+ + {group.label} + + {group.description} +
+ )} +
+ {group.models.map((model) => ( + + ))} +
))} - +
+ {/* User saved presets */} + {savedPresets.map((preset) => ( +
+ + +
+ ))} + + +
)} diff --git a/ui/tests/unit/components/cliproxy/provider-editor/model-config-section.test.tsx b/ui/tests/unit/components/cliproxy/provider-editor/model-config-section.test.tsx new file mode 100644 index 00000000..9fb5d916 --- /dev/null +++ b/ui/tests/unit/components/cliproxy/provider-editor/model-config-section.test.tsx @@ -0,0 +1,73 @@ +import { describe, expect, it, vi } from 'vitest'; +import { render, screen, userEvent } from '@tests/setup/test-utils'; + +vi.mock('@/components/cliproxy/provider-model-selector', () => ({ + FlexibleModelSelector: () =>
, +})); + +vi.mock('@/components/cliproxy/extended-context-toggle', () => ({ + ExtendedContextToggle: () =>
, +})); + +import { ModelConfigSection } from '@/components/cliproxy/provider-editor/model-config-section'; +import { MODEL_CATALOGS } from '@/lib/model-catalogs'; + +describe('ModelConfigSection presets', () => { + it('groups codex presets by free and paid tiers', async () => { + const onApplyPreset = vi.fn(); + + render( + + ); + + expect(screen.getByText('Free Tier')).toBeInTheDocument(); + expect(screen.getByText('Paid Tier')).toBeInTheDocument(); + expect(screen.getByText('Available on free or paid plans')).toBeInTheDocument(); + expect(screen.getByText('Requires paid access')).toBeInTheDocument(); + + await userEvent.click(screen.getByRole('button', { name: 'GPT-5.4' })); + + expect(onApplyPreset).toHaveBeenCalledWith({ + ANTHROPIC_MODEL: 'gpt-5.4', + ANTHROPIC_DEFAULT_OPUS_MODEL: 'gpt-5.4', + ANTHROPIC_DEFAULT_SONNET_MODEL: 'gpt-5.4', + ANTHROPIC_DEFAULT_HAIKU_MODEL: 'gpt-5-codex-mini', + }); + }); + + it('keeps non-tiered provider presets ungrouped', () => { + render( + + ); + + expect(screen.queryByText('Free Tier')).not.toBeInTheDocument(); + expect(screen.queryByText('Paid Tier')).not.toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Claude Opus 4.6 Thinking' })).toBeInTheDocument(); + }); +}); 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 ed57cc8a..8e302458 100644 --- a/ui/tests/unit/ui/lib/model-catalogs-codex.test.ts +++ b/ui/tests/unit/ui/lib/model-catalogs-codex.test.ts @@ -2,12 +2,12 @@ import { describe, expect, it } from 'vitest'; import { MODEL_CATALOGS } from '@/lib/model-catalogs'; describe('codex model catalog defaults', () => { - it('uses gpt-5.1-codex-mini as the haiku mapping for codex presets', () => { + it('uses gpt-5-codex-mini as the haiku mapping for cross-plan codex presets', () => { const codexCatalog = MODEL_CATALOGS.codex; const codex53 = codexCatalog.models.find((model) => model.id === 'gpt-5.3-codex'); const codex52 = codexCatalog.models.find((model) => model.id === 'gpt-5.2-codex'); - expect(codex53?.presetMapping?.haiku).toBe('gpt-5.1-codex-mini'); - expect(codex52?.presetMapping?.haiku).toBe('gpt-5.1-codex-mini'); + expect(codex53?.presetMapping?.haiku).toBe('gpt-5-codex-mini'); + expect(codex52?.presetMapping?.haiku).toBe('gpt-5-codex-mini'); }); });