From eec44d54e2ba8d2f4e5f0bc48a7e9a03f25de2d9 Mon Sep 17 00:00:00 2001 From: kaitranntt Date: Wed, 21 Jan 2026 14:27:46 -0500 Subject: [PATCH] feat(cliproxy): add model-specific reasoning effort caps Add maxLevel field to ThinkingSupport interface to cap reasoning effort at model's maximum supported level. This fixes Issue #344 where gpt-5-mini fails with xhigh reasoning (only supports high). - Add Codex provider to model catalog with gpt-5.2-codex and gpt-5-mini - Add capLevelAtMax() and capEffortAtModelMax() for runtime capping - Update UI presets with new Codex models and tier mappings --- src/cliproxy/codex-reasoning-proxy.ts | 27 +++++++++- src/cliproxy/model-catalog.ts | 43 +++++++++++++++ src/cliproxy/thinking-validator.ts | 75 +++++++++++++++++++++------ ui/src/lib/model-catalogs.ts | 26 +++++++++- 4 files changed, 152 insertions(+), 19 deletions(-) diff --git a/src/cliproxy/codex-reasoning-proxy.ts b/src/cliproxy/codex-reasoning-proxy.ts index 6931288b..d846cb14 100644 --- a/src/cliproxy/codex-reasoning-proxy.ts +++ b/src/cliproxy/codex-reasoning-proxy.ts @@ -1,6 +1,7 @@ import * as http from 'http'; import * as https from 'https'; import { URL } from 'url'; +import { getModelMaxLevel } from './model-catalog'; export type CodexReasoningEffort = 'medium' | 'high' | 'xhigh'; @@ -51,10 +52,32 @@ const EFFORT_RANK: Record = { xhigh: 3, }; +/** All valid codex effort levels in rank order */ +const EFFORT_BY_RANK: CodexReasoningEffort[] = ['medium', 'high', 'xhigh']; + function minEffort(a: CodexReasoningEffort, b: CodexReasoningEffort): CodexReasoningEffort { return EFFORT_RANK[a] <= EFFORT_RANK[b] ? a : b; } +/** + * Cap effort at model's max level from catalog. + * Returns the capped effort (or original if no cap applies). + */ +function capEffortAtModelMax(model: string, effort: CodexReasoningEffort): CodexReasoningEffort { + const maxLevel = getModelMaxLevel('codex', model); + if (!maxLevel) return effort; + + // Map maxLevel to CodexReasoningEffort (only medium/high/xhigh are valid) + const maxEffort = EFFORT_BY_RANK.find((e) => e === maxLevel); + if (!maxEffort) return effort; + + // Cap if effort exceeds max + if (EFFORT_RANK[effort] > EFFORT_RANK[maxEffort]) { + return maxEffort; + } + return effort; +} + export function buildCodexModelEffortMap( models: CodexReasoningModelMap, defaultEffort: CodexReasoningEffort = 'medium' @@ -85,7 +108,9 @@ export function getEffortForModel( defaultEffort: CodexReasoningEffort ): CodexReasoningEffort { if (!model) return defaultEffort; - return modelEffort.get(model) ?? defaultEffort; + const effort = modelEffort.get(model) ?? defaultEffort; + // Apply model-specific cap from catalog + return capEffortAtModelMax(model, effort); } export function injectReasoningEffortIntoBody( diff --git a/src/cliproxy/model-catalog.ts b/src/cliproxy/model-catalog.ts index 5a3d1909..b4c17898 100644 --- a/src/cliproxy/model-catalog.ts +++ b/src/cliproxy/model-catalog.ts @@ -20,6 +20,8 @@ export interface ThinkingSupport { max?: number; /** Valid level names (for levels type) */ levels?: string[]; + /** Maximum reasoning effort level (caps effort at this level for levels type) */ + maxLevel?: 'minimal' | 'low' | 'medium' | 'high' | 'xhigh'; /** Whether zero/disabled thinking is allowed */ zeroAllowed?: boolean; /** Whether dynamic/auto thinking is allowed */ @@ -135,6 +137,35 @@ export const MODEL_CATALOG: Partial> = }, ], }, + codex: { + provider: 'codex', + displayName: 'Copilot Codex', + defaultModel: 'gpt-5.2-codex', + models: [ + { + id: 'gpt-5.2-codex', + name: 'GPT-5.2 Codex', + description: 'Full reasoning support (xhigh)', + thinking: { + type: 'levels', + levels: ['medium', 'high', 'xhigh'], + maxLevel: 'xhigh', + dynamicAllowed: false, + }, + }, + { + id: 'gpt-5-mini', + name: 'GPT-5 Mini', + description: 'Capped at high reasoning (no xhigh)', + thinking: { + type: 'levels', + levels: ['medium', 'high'], + maxLevel: 'high', + dynamicAllowed: false, + }, + }, + ], + }, }; /** @@ -208,6 +239,18 @@ export function getModelThinkingSupport( return model?.thinking; } +/** + * Get the maximum reasoning effort level for a model. + * Returns undefined if model has no cap or is not in catalog. + */ +export function getModelMaxLevel( + provider: CLIProxyProvider, + modelId: string +): ThinkingSupport['maxLevel'] | undefined { + const thinking = getModelThinkingSupport(provider, modelId); + return thinking?.maxLevel; +} + /** * Check if model supports thinking/reasoning */ diff --git a/src/cliproxy/thinking-validator.ts b/src/cliproxy/thinking-validator.ts index 9414eae5..e64e1c46 100644 --- a/src/cliproxy/thinking-validator.ts +++ b/src/cliproxy/thinking-validator.ts @@ -40,12 +40,40 @@ export const THINKING_LEVEL_BUDGETS: Record = { xhigh: 32768, }; +/** + * Level rank for comparison (higher = more intensive) + */ +export const THINKING_LEVEL_RANK: Record = { + minimal: 1, + low: 2, + medium: 3, + high: 4, + xhigh: 5, +}; + /** * Valid thinking level names */ export const VALID_THINKING_LEVELS = ['minimal', 'low', 'medium', 'high', 'xhigh', 'auto'] as const; export type ThinkingLevel = (typeof VALID_THINKING_LEVELS)[number]; +/** + * Cap a level at the model's maximum supported level. + * Returns the capped level and whether capping occurred. + */ +export function capLevelAtMax( + level: string, + maxLevel: string | undefined +): { level: string; capped: boolean } { + if (!maxLevel) return { level, capped: false }; + const levelRank = THINKING_LEVEL_RANK[level] ?? 0; + const maxRank = THINKING_LEVEL_RANK[maxLevel] ?? 5; + if (levelRank > maxRank) { + return { level: maxLevel, capped: true }; + } + return { level, capped: false }; +} + /** * Special thinking values */ @@ -263,6 +291,24 @@ function validateLevelThinking( modelId: string ): ThinkingValidationResult { const validLevels = thinking.levels ?? []; + const maxLevel = thinking.maxLevel; + + // Helper to apply maxLevel cap and build result + const applyMaxCap = (level: string, baseWarning?: string): ThinkingValidationResult => { + const { level: cappedLevel, capped } = capLevelAtMax(level, maxLevel); + const warnings: string[] = []; + if (baseWarning) warnings.push(baseWarning); + if (capped) { + warnings.push( + `Level "${level}" exceeds max "${maxLevel}" for ${modelId}. Capped to "${cappedLevel}".` + ); + } + return { + valid: true, + value: cappedLevel, + warning: warnings.length > 0 ? warnings.join(' ') : undefined, + }; + }; // If numeric, try to map to closest level by budget if (typeof value === 'number') { @@ -279,11 +325,10 @@ function validateLevelThinking( } } - return { - valid: true, - value: closestLevel, - warning: `Model ${modelId} uses named levels. Mapped budget ${value} to "${closestLevel}".`, - }; + return applyMaxCap( + closestLevel, + `Model ${modelId} uses named levels. Mapped budget ${value} to "${closestLevel}".` + ); } // String level @@ -291,17 +336,16 @@ function validateLevelThinking( // Check if it's a valid level for this model if (validLevels.includes(normalizedLevel)) { - return { valid: true, value: normalizedLevel }; + return applyMaxCap(normalizedLevel); } // Try to find closest match const closest = findClosestLevel(normalizedLevel, validLevels); if (closest) { - return { - valid: true, - value: closest, - warning: `Level "${value}" not valid for ${modelId}. Mapped to "${closest}".`, - }; + return applyMaxCap( + closest, + `Level "${value}" not valid for ${modelId}. Mapped to "${closest}".` + ); } // Try to map from standard level names to model's levels @@ -321,11 +365,10 @@ function validateLevelThinking( const mapped = standardToModelLevel[normalizedLevel]; if (mapped) { - return { - valid: true, - value: mapped, - warning: `Level "${value}" mapped to "${mapped}" for ${modelId} (available: ${validLevels.join(', ')}).`, - }; + return applyMaxCap( + mapped, + `Level "${value}" mapped to "${mapped}" for ${modelId} (available: ${validLevels.join(', ')}).` + ); } // Default to first level diff --git a/ui/src/lib/model-catalogs.ts b/ui/src/lib/model-catalogs.ts index 13b25fc0..09c056ba 100644 --- a/ui/src/lib/model-catalogs.ts +++ b/ui/src/lib/model-catalogs.ts @@ -114,12 +114,34 @@ export const MODEL_CATALOGS: Record = { codex: { provider: 'codex', displayName: 'Codex', - defaultModel: 'gpt-5.1-codex-max', + defaultModel: 'gpt-5.2-codex', models: [ + { + id: 'gpt-5.2-codex', + name: 'GPT-5.2 Codex', + description: 'Full reasoning support (xhigh)', + presetMapping: { + default: 'gpt-5.2-codex', + opus: 'gpt-5.2-codex', + sonnet: 'gpt-5.2-codex', + haiku: 'gpt-5-mini', + }, + }, + { + id: 'gpt-5-mini', + name: 'GPT-5 Mini', + description: 'Fast, capped at high reasoning (no xhigh)', + presetMapping: { + default: 'gpt-5-mini', + opus: 'gpt-5.2-codex', + sonnet: 'gpt-5-mini', + haiku: 'gpt-5-mini', + }, + }, { id: 'gpt-5.1-codex-max', name: 'Codex Max (5.1)', - description: 'Most capable Codex model', + description: 'Legacy most capable Codex model', presetMapping: { default: 'gpt-5.1-codex-max', opus: 'gpt-5.1-codex-max-high',