diff --git a/src/commands/effort/effort.tsx b/src/commands/effort/effort.tsx index ebc2373510..57c0d17e23 100644 --- a/src/commands/effort/effort.tsx +++ b/src/commands/effort/effort.tsx @@ -13,6 +13,7 @@ import { getEffortEnvOverride, getEffortValueDescription, isEffortLevel, + modelSupportsEffort, toPersistableEffort, } from '../../utils/effort.js'; import { updateSettingsForSource } from '../../utils/settings/settings.js'; @@ -130,14 +131,12 @@ function ShowCurrentEffort({ onDone }: { onDone: (result: string) => void }): Re return null; } -function ApplyEffortAndClose({ - result, - onDone, -}: { - result: EffortCommandResult; - onDone: (result: string) => void; -}): React.ReactNode { +function ApplyEffortAndClose({ args, onDone }: { args: string; onDone: (result: string) => void }): React.ReactNode { const setAppState = useSetAppState(); + const model = useMainLoopModel(); + const result = modelSupportsEffort(model) + ? executeEffort(args) + : { message: 'Effort is disabled because thinking is disabled for the current model.' }; const { effortUpdate, message } = result; React.useEffect(() => { if (effortUpdate) { @@ -169,11 +168,20 @@ export async function call(onDone: LocalJSXCommandOnDone, _context: unknown, arg return ; } - const result = executeEffort(args); - return ; + return ; } function EffortPanelWrapper({ onDone }: { onDone: (result: string) => void }): React.ReactNode { const effortValue = useAppState(s => s.effortValue); + const model = useMainLoopModel(); + const supported = modelSupportsEffort(model); + React.useEffect(() => { + if (!supported) { + onDone('Effort is disabled because thinking is disabled for the current model.'); + } + }, [supported, onDone]); + if (!supported) { + return null; + } return ; } diff --git a/src/components/ConsoleOAuthFlow.tsx b/src/components/ConsoleOAuthFlow.tsx index 8f4219ca30..a34ae990fe 100644 --- a/src/components/ConsoleOAuthFlow.tsx +++ b/src/components/ConsoleOAuthFlow.tsx @@ -1,4 +1,4 @@ -import React, { useCallback, useEffect, useRef, useState } from 'react'; +import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, logEvent, @@ -52,7 +52,8 @@ type OAuthStatus = haikuModel: string; sonnetModel: string; opusModel: string; - activeField: 'base_url' | 'api_key' | 'haiku_model' | 'sonnet_model' | 'opus_model'; + thinkingEnabled: boolean; + activeField: 'base_url' | 'api_key' | 'haiku_model' | 'sonnet_model' | 'opus_model' | 'thinking_enabled'; } // OpenAI Chat Completions API platform | { state: 'chatgpt_subscription'; @@ -84,6 +85,10 @@ type OAuthStatus = }; const PASTE_HERE_MSG = 'Paste code here if prompted > '; + +export function getOpenAIThinkingEnabled(value: string | undefined): boolean { + return !['0', 'false', 'no', 'off'].includes(value?.toLowerCase().trim() ?? ''); +} export function ConsoleOAuthFlow({ onDone, startingMessage, @@ -550,6 +555,7 @@ function OAuthStatusMessage({ haikuModel: process.env.OPENAI_DEFAULT_HAIKU_MODEL ?? '', sonnetModel: process.env.OPENAI_DEFAULT_SONNET_MODEL ?? '', opusModel: process.env.OPENAI_DEFAULT_OPUS_MODEL ?? '', + thinkingEnabled: getOpenAIThinkingEnabled(process.env.OPENAI_ENABLE_THINKING), activeField: 'base_url', }); } else if (value === 'china_providers') { @@ -799,8 +805,15 @@ function OAuthStatusMessage({ } case 'openai_chat_api': { - type OpenAIField = 'base_url' | 'api_key' | 'haiku_model' | 'sonnet_model' | 'opus_model'; - const OPENAI_FIELDS: OpenAIField[] = ['base_url', 'api_key', 'haiku_model', 'sonnet_model', 'opus_model']; + type OpenAIField = 'base_url' | 'api_key' | 'haiku_model' | 'sonnet_model' | 'opus_model' | 'thinking_enabled'; + const OPENAI_FIELDS: OpenAIField[] = [ + 'base_url', + 'api_key', + 'haiku_model', + 'sonnet_model', + 'opus_model', + 'thinking_enabled', + ]; const op = oauthStatus as { state: 'openai_chat_api'; activeField: OpenAIField; @@ -809,19 +822,25 @@ function OAuthStatusMessage({ haikuModel: string; sonnetModel: string; opusModel: string; + thinkingEnabled: boolean; }; - const { activeField, baseUrl, apiKey, haikuModel, sonnetModel, opusModel } = op; - const openaiDisplayValues: Record = { - base_url: baseUrl, - api_key: apiKey, - haiku_model: haikuModel, - sonnet_model: sonnetModel, - opus_model: opusModel, - }; + const { activeField, baseUrl, apiKey, haikuModel, sonnetModel, opusModel, thinkingEnabled } = op; + const openaiDisplayValues = useMemo, string>>( + () => ({ + base_url: baseUrl, + api_key: apiKey, + haiku_model: haikuModel, + sonnet_model: sonnetModel, + opus_model: opusModel, + }), + [baseUrl, apiKey, haikuModel, sonnetModel, opusModel], + ); + const getOpenAIFieldValue = (field: OpenAIField): string => + field === 'thinking_enabled' ? '' : openaiDisplayValues[field]; - const [openaiInputValue, setOpenaiInputValue] = useState(() => openaiDisplayValues[activeField]); + const [openaiInputValue, setOpenaiInputValue] = useState(() => getOpenAIFieldValue(activeField)); const [openaiInputCursorOffset, setOpenaiInputCursorOffset] = useState( - () => openaiDisplayValues[activeField].length, + () => getOpenAIFieldValue(activeField).length, ); const buildOpenAIState = useCallback( @@ -834,6 +853,7 @@ function OAuthStatusMessage({ haikuModel, sonnetModel, opusModel, + thinkingEnabled, }; switch (field) { case 'base_url': @@ -846,15 +866,21 @@ function OAuthStatusMessage({ return { ...s, sonnetModel: value }; case 'opus_model': return { ...s, opusModel: value }; + case 'thinking_enabled': + return s; } }, - [activeField, baseUrl, apiKey, haikuModel, sonnetModel, opusModel], + [activeField, baseUrl, apiKey, haikuModel, sonnetModel, opusModel, thinkingEnabled], ); const doOpenAISave = useCallback(() => { - const finalVals = { ...openaiDisplayValues, [activeField]: openaiInputValue }; + const finalVals = + activeField === 'thinking_enabled' + ? openaiDisplayValues + : { ...openaiDisplayValues, [activeField]: openaiInputValue }; const env: Record = { OPENAI_AUTH_MODE: undefined, + OPENAI_ENABLE_THINKING: String(thinkingEnabled), }; // Validate base_url if provided @@ -872,6 +898,7 @@ function OAuthStatusMessage({ haikuModel: '', sonnetModel: '', opusModel: '', + thinkingEnabled, activeField: 'base_url', }, }); @@ -900,6 +927,7 @@ function OAuthStatusMessage({ haikuModel: finalVals.haiku_model ?? '', sonnetModel: finalVals.sonnet_model ?? '', opusModel: finalVals.opus_model ?? '', + thinkingEnabled, activeField: 'base_url', }, }); @@ -919,7 +947,7 @@ function OAuthStatusMessage({ setOAuthStatus({ state: 'success' }); void onDone(); } - }, [activeField, openaiInputValue, openaiDisplayValues, setOAuthStatus, onDone]); + }, [activeField, openaiInputValue, openaiDisplayValues, thinkingEnabled, setOAuthStatus, onDone]); const handleOpenAIEnter = useCallback(() => { const idx = OPENAI_FIELDS.indexOf(activeField); @@ -929,8 +957,8 @@ function OAuthStatusMessage({ } else { const next = OPENAI_FIELDS[idx + 1]!; setOAuthStatus(buildOpenAIState(activeField, openaiInputValue, next)); - setOpenaiInputValue(openaiDisplayValues[next] ?? ''); - setOpenaiInputCursorOffset((openaiDisplayValues[next] ?? '').length); + setOpenaiInputValue(getOpenAIFieldValue(next)); + setOpenaiInputCursorOffset(getOpenAIFieldValue(next).length); } }, [activeField, openaiInputValue, buildOpenAIState, doOpenAISave, openaiDisplayValues, setOAuthStatus]); @@ -939,9 +967,10 @@ function OAuthStatusMessage({ () => { const idx = OPENAI_FIELDS.indexOf(activeField); if (idx < OPENAI_FIELDS.length - 1) { - setOAuthStatus(buildOpenAIState(activeField, openaiInputValue, OPENAI_FIELDS[idx + 1])); - setOpenaiInputValue(openaiDisplayValues[OPENAI_FIELDS[idx + 1]!] ?? ''); - setOpenaiInputCursorOffset((openaiDisplayValues[OPENAI_FIELDS[idx + 1]!] ?? '').length); + const next = OPENAI_FIELDS[idx + 1]!; + setOAuthStatus(buildOpenAIState(activeField, openaiInputValue, next)); + setOpenaiInputValue(getOpenAIFieldValue(next)); + setOpenaiInputCursorOffset(getOpenAIFieldValue(next).length); } }, { context: 'FormField' }, @@ -952,12 +981,24 @@ function OAuthStatusMessage({ const idx = OPENAI_FIELDS.indexOf(activeField); if (idx > 0) { setOAuthStatus(buildOpenAIState(activeField, openaiInputValue, OPENAI_FIELDS[idx - 1])); - setOpenaiInputValue(openaiDisplayValues[OPENAI_FIELDS[idx - 1]!] ?? ''); - setOpenaiInputCursorOffset((openaiDisplayValues[OPENAI_FIELDS[idx - 1]!] ?? '').length); + const previous = OPENAI_FIELDS[idx - 1]!; + setOpenaiInputValue(getOpenAIFieldValue(previous)); + setOpenaiInputCursorOffset(getOpenAIFieldValue(previous).length); } }, { context: 'FormField' }, ); + useKeybinding( + 'confirm:toggle', + () => { + setOAuthStatus({ ...op, thinkingEnabled: !thinkingEnabled }); + }, + { context: 'Confirmation', isActive: activeField === 'thinking_enabled' }, + ); + useKeybinding('confirm:yes', doOpenAISave, { + context: 'Confirmation', + isActive: activeField === 'thinking_enabled', + }); useKeybinding( 'confirm:no', () => { @@ -968,7 +1009,11 @@ function OAuthStatusMessage({ const openaiColumns = useTerminalSize().columns - 20; - const renderOpenAIRow = (field: OpenAIField, label: string, opts?: { mask?: boolean }) => { + const renderOpenAIRow = ( + field: Exclude, + label: string, + opts?: { mask?: boolean }, + ) => { const active = activeField === field; const val = openaiDisplayValues[field]; return ( @@ -1007,8 +1052,18 @@ function OAuthStatusMessage({ {renderOpenAIRow('haiku_model', 'Haiku ')} {renderOpenAIRow('sonnet_model', 'Sonnet ')} {renderOpenAIRow('opus_model', 'Opus ')} + + + {' Thinking/effort '} + + + {String(thinkingEnabled)} + - ↑↓/Tab to switch · Enter on last field to save · Esc to go back + ↑↓/Tab to switch · Space to toggle · Enter to save · Esc to go back ); } diff --git a/src/services/api/openai/__tests__/thinking.test.ts b/src/services/api/openai/__tests__/thinking.test.ts index 9ff9d92e38..97615b6e56 100644 --- a/src/services/api/openai/__tests__/thinking.test.ts +++ b/src/services/api/openai/__tests__/thinking.test.ts @@ -43,29 +43,11 @@ describe('isOpenAIThinkingEnabled', () => { }) describe('OPENAI_ENABLE_THINKING env var', () => { - test('returns true when OPENAI_ENABLE_THINKING=1', () => { - process.env.OPENAI_ENABLE_THINKING = '1' - expect(isOpenAIThinkingEnabled('gpt-4o')).toBe(true) - }) - - test('returns true when OPENAI_ENABLE_THINKING=true', () => { - process.env.OPENAI_ENABLE_THINKING = 'true' - expect(isOpenAIThinkingEnabled('gpt-4o')).toBe(true) - }) - - test('returns true when OPENAI_ENABLE_THINKING=yes', () => { - process.env.OPENAI_ENABLE_THINKING = 'yes' - expect(isOpenAIThinkingEnabled('gpt-4o')).toBe(true) - }) - - test('returns true when OPENAI_ENABLE_THINKING=on', () => { - process.env.OPENAI_ENABLE_THINKING = 'on' - expect(isOpenAIThinkingEnabled('gpt-4o')).toBe(true) - }) - - test('returns true when OPENAI_ENABLE_THINKING=TRUE (case insensitive)', () => { - process.env.OPENAI_ENABLE_THINKING = 'TRUE' - expect(isOpenAIThinkingEnabled('gpt-4o')).toBe(true) + test('does not send provider-specific thinking fields to generic models', () => { + for (const value of ['1', 'true', 'yes', 'on', 'TRUE']) { + process.env.OPENAI_ENABLE_THINKING = value + expect(isOpenAIThinkingEnabled('gpt-4o')).toBe(false) + } }) test('returns false when OPENAI_ENABLE_THINKING=0', () => { @@ -173,11 +155,11 @@ describe('isOpenAIThinkingEnabled', () => { }) describe('priority and combined detection', () => { - test('OPENAI_ENABLE_THINKING=1 enables thinking for any model', () => { + test('OPENAI_ENABLE_THINKING=1 only enables provider-specific fields for detected models', () => { process.env.OPENAI_ENABLE_THINKING = '1' - expect(isOpenAIThinkingEnabled('gpt-4o')).toBe(true) + expect(isOpenAIThinkingEnabled('gpt-4o')).toBe(false) expect(isOpenAIThinkingEnabled('deepseek-v3')).toBe(true) - expect(isOpenAIThinkingEnabled('qwen-3')).toBe(true) + expect(isOpenAIThinkingEnabled('qwen-3')).toBe(false) }) test('OPENAI_ENABLE_THINKING=false disables thinking even for deepseek-reasoner', () => { @@ -221,8 +203,14 @@ describe('buildOpenAIRequestBody — thinking params', () => { expect(body.prompt_cache_key).toBe('ccb:test-session') }) - test('includes vLLM/self-hosted thinking format when enabled', () => { - const body = buildOpenAIRequestBody({ ...baseParams, enableThinking: true }) + test('sends reasoning effort with the DeepSeek thinking body', () => { + const body = buildOpenAIRequestBody({ + ...baseParams, + enableThinking: true, + reasoningEffort: 'high', + }) as Record + expect(body.reasoning_effort).toBe('high') + expect(body.thinking).toEqual({ type: 'enabled' }) expect(body.enable_thinking).toBe(true) expect(body.chat_template_kwargs).toEqual({ thinking: true, @@ -230,21 +218,14 @@ describe('buildOpenAIRequestBody — thinking params', () => { }) }) - test('includes both formats simultaneously when enabled', () => { - const body = buildOpenAIRequestBody({ ...baseParams, enableThinking: true }) - expect(body.thinking).toEqual({ type: 'enabled' }) - expect(body.enable_thinking).toBe(true) - expect(body.chat_template_kwargs!.thinking).toBe(true) - }) - test('does NOT include thinking params when disabled', () => { const body = buildOpenAIRequestBody({ ...baseParams, enableThinking: false, - }) + }) as Record expect(body.thinking).toBeUndefined() - expect(body.enable_thinking).toBeUndefined() - expect(body.chat_template_kwargs).toBeUndefined() + expect('enable_thinking' in body).toBe(false) + expect('chat_template_kwargs' in body).toBe(false) }) test('always includes stream and stream_options', () => { diff --git a/src/services/api/openai/index.ts b/src/services/api/openai/index.ts index c201f12992..035fe001ab 100644 --- a/src/services/api/openai/index.ts +++ b/src/services/api/openai/index.ts @@ -56,6 +56,13 @@ export { } import { getModelMaxOutputTokens } from '../../../utils/context.js' import type { Options } from '../claude.js' +import { + convertEffortValueToLevel, + getEffortEnvOverride, + modelSupportsEffort, + modelSupportsMaxEffort, + modelSupportsXhighEffort, +} from '../../../utils/effort.js' import { randomUUID } from 'crypto' import { createAssistantAPIErrorMessage, @@ -97,6 +104,25 @@ function getChatGPTResponsesReasoningEffort( ) } +function getChatCompletionsReasoningEffort( + model: string, + effortValue: Options['effortValue'], +): ResponsesReasoningEffort | undefined { + if (!modelSupportsEffort(model)) return undefined + const envOverride = getEffortEnvOverride() + if (envOverride === null) return undefined + const applied = envOverride ?? effortValue + if (applied === undefined) return undefined + const level = convertEffortValueToLevel(applied) + if (level === 'max' && !modelSupportsMaxEffort(model)) { + return modelSupportsXhighEffort(model) ? 'xhigh' : 'high' + } + if (level === 'xhigh' && !modelSupportsXhighEffort(model)) { + return 'high' + } + return level +} + /** * Mirrors the Anthropic request path's deferred-tool announcement for OpenAI. * @@ -288,7 +314,8 @@ export async function* queryModelOpenAI( ) // 8. Convert messages and tools to OpenAI format - const enableThinking = isOpenAIThinkingEnabled(openaiModel) + const enableThinking = + modelSupportsEffort(openaiModel) && isOpenAIThinkingEnabled(openaiModel) const openAIConvertibleMessages = messagesForAPI.filter( isOpenAIConvertibleMessage, ) @@ -305,7 +332,11 @@ export async function* queryModelOpenAI( ) const openaiTools = anthropicToolsToOpenAI(standardTools) const openaiToolChoice = anthropicToolChoiceToOpenAI(options.toolChoice) - const reasoningEffort = getChatGPTResponsesReasoningEffort( + const responsesReasoningEffort = getChatGPTResponsesReasoningEffort( + options.effortValue, + ) + const chatCompletionsReasoningEffort = getChatCompletionsReasoningEffort( + openaiModel, options.effortValue, ) @@ -366,7 +397,7 @@ export async function* queryModelOpenAI( messages: openaiMessages, tools: openaiTools, toolChoice: openaiToolChoice, - reasoningEffort, + reasoningEffort: responsesReasoningEffort, promptCacheKey, }), signal, @@ -388,8 +419,9 @@ export async function* queryModelOpenAI( enableThinking, maxTokens, temperatureOverride: options.temperatureOverride, + reasoningEffort: chatCompletionsReasoningEffort, promptCacheKey, - }), + }) as unknown as import('openai/resources/chat/completions/completions.mjs').ChatCompletionCreateParamsStreaming, { signal }, ), openaiModel, diff --git a/src/services/api/openai/requestBody.ts b/src/services/api/openai/requestBody.ts index a31e377667..c60220bafe 100644 --- a/src/services/api/openai/requestBody.ts +++ b/src/services/api/openai/requestBody.ts @@ -4,26 +4,24 @@ * triggering heavy module side-effects (OpenAI client, stream adapter, etc.). */ import type { ChatCompletionCreateParamsStreaming } from 'openai/resources/chat/completions/completions.mjs' -import { isEnvTruthy, isEnvDefinedFalsy } from '../../../utils/envUtils.js' +import type { EffortLevel } from '../../../entrypoints/sdk/runtimeTypes.js' +import { isEnvDefinedFalsy } from '../../../utils/envUtils.js' import { getOpenAIPromptCacheKey } from './openaiShared.js' /** * Detect whether thinking mode should be enabled for this model. * - * Enabled when: - * 1. OPENAI_ENABLE_THINKING=1 is set (explicit enable), OR - * 2. Model name contains "deepseek" or "mimo" (auto-detect, case-insensitive) - * - * Disabled when: - * - OPENAI_ENABLE_THINKING=0/false/no/off is explicitly set (overrides model detection) + * Enabled for DeepSeek and MiMo models unless OPENAI_ENABLE_THINKING is + * explicitly disabled. Other models use standard reasoning_effort without + * receiving provider-specific thinking fields. * * @param model - The resolved OpenAI model name */ export function isOpenAIThinkingEnabled(model: string): boolean { // Explicit disable takes priority (overrides model auto-detect) if (isEnvDefinedFalsy(process.env.OPENAI_ENABLE_THINKING)) return false - // Explicit enable - if (isEnvTruthy(process.env.OPENAI_ENABLE_THINKING)) return true + // DeepSeek and MiMo use the provider-specific thinking body below. For other + // models OPENAI_ENABLE_THINKING only gates standard reasoning_effort. // Auto-detect from model name (DeepSeek and MiMo models support thinking mode). // Grok is intentionally excluded — Grok reasoning models reason automatically // and do NOT require thinking/enable_thinking request body parameters. @@ -59,15 +57,18 @@ export function resolveOpenAIMaxTokens( /** * Build the request body for OpenAI chat.completions.create(). - * Extracted for testability — the thinking mode params are injected here. - * - * Three thinking-mode formats are sent simultaneously; each endpoint uses the - * format it recognizes and ignores the others: - * - Official DeepSeek API: `thinking: { type: 'enabled' }` - * - Self-hosted DeepSeek: `enable_thinking: true` + `chat_template_kwargs: { thinking: true }` - * - MiMo (Xiaomi): `chat_template_kwargs: { enable_thinking: true }` - * OpenAI SDK passes unknown keys through to the HTTP body. + * Reasoning effort uses the standard top-level `reasoning_effort` field. + * DeepSeek and MiMo additionally receive `thinking: { type: 'enabled' }`. */ +type OpenAIChatRequestBody = Omit< + ChatCompletionCreateParamsStreaming, + 'reasoning_effort' +> & { + reasoning_effort?: EffortLevel + thinking?: { type: 'enabled' } + prompt_cache_key?: string +} + export function buildOpenAIRequestBody(params: { model: string messages: any[] @@ -76,15 +77,10 @@ export function buildOpenAIRequestBody(params: { enableThinking: boolean maxTokens: number temperatureOverride?: number + reasoningEffort?: EffortLevel /** Override for tests; production uses the current CCB session id. */ promptCacheKey?: string -}): ChatCompletionCreateParamsStreaming & { - thinking?: { type: string } - enable_thinking?: boolean - chat_template_kwargs?: { thinking: boolean; enable_thinking: boolean } - /** OpenAI prompt-cache routing key (not always in SDK types yet). */ - prompt_cache_key?: string -} { +}): OpenAIChatRequestBody { const { model, messages, @@ -93,12 +89,14 @@ export function buildOpenAIRequestBody(params: { enableThinking, maxTokens, temperatureOverride, + reasoningEffort, promptCacheKey, } = params return { model, messages, max_tokens: maxTokens, + ...(reasoningEffort && { reasoning_effort: reasoningEffort }), ...(tools.length > 0 && { tools, ...(toolChoice && { tool_choice: toolChoice }), diff --git a/src/utils/__tests__/effort.test.ts b/src/utils/__tests__/effort.test.ts index eb7c0a5005..d924f88cf6 100644 --- a/src/utils/__tests__/effort.test.ts +++ b/src/utils/__tests__/effort.test.ts @@ -16,20 +16,39 @@ mock.module('src/services/analytics/growthbook.js', () => ({ getFeatureValue_CACHED_MAY_BE_STALE: (_key: string, defaultValue: unknown) => defaultValue ?? {}, })) -mock.module('src/utils/model/modelSupportOverrides.js', () => ({ - get3PModelCapabilityOverride: () => undefined, -})) - const { isEffortLevel, parseEffortValue, isValidNumericEffort, convertEffortValueToLevel, getEffortLevelDescription, + resolveOpenAICompatibleEffortSupport, resolvePickerEffortPersistence, EFFORT_LEVELS, } = await import('src/utils/effort.js') +describe('resolveOpenAICompatibleEffortSupport', () => { + test('defaults to enabled when no override is configured', () => { + expect(resolveOpenAICompatibleEffortSupport(undefined, undefined)).toBe( + true, + ) + }) + + test('disables effort when OPENAI_ENABLE_THINKING is explicitly false', () => { + for (const value of ['0', 'false', 'no', 'off']) { + expect(resolveOpenAICompatibleEffortSupport(value, true)).toBe(false) + } + }) + + test('disables effort when the tier capability omits thinking', () => { + expect(resolveOpenAICompatibleEffortSupport('true', false)).toBe(false) + }) + + test('keeps effort enabled when thinking is explicitly supported', () => { + expect(resolveOpenAICompatibleEffortSupport('true', true)).toBe(true) + }) +}) + // ─── EFFORT_LEVELS constant ──────────────────────────────────────────── describe('EFFORT_LEVELS', () => { diff --git a/src/utils/effort.ts b/src/utils/effort.ts index df1a756dae..c5314b3f20 100644 --- a/src/utils/effort.ts +++ b/src/utils/effort.ts @@ -5,7 +5,7 @@ import { isProSubscriber, isMaxSubscriber, isTeamSubscriber } from './auth.js' import { getFeatureValue_CACHED_MAY_BE_STALE } from 'src/services/analytics/growthbook.js' import { getAPIProvider } from './model/providers.js' import { get3PModelCapabilityOverride } from './model/modelSupportOverrides.js' -import { isEnvTruthy } from './envUtils.js' +import { isEnvDefinedFalsy, isEnvTruthy } from './envUtils.js' import type { EffortLevel } from 'src/entrypoints/sdk/runtimeTypes.js' import { resolveAntModel } from './model/antModels.js' import { getAntModelOverrideConfig } from './model/antModels.js' @@ -30,9 +30,26 @@ export const EFFORT_LEVELS = [ export type EffortValue = EffortLevel | number +export function resolveOpenAICompatibleEffortSupport( + thinkingEnv: string | undefined, + thinkingCapability: boolean | undefined, +): boolean { + return !isEnvDefinedFalsy(thinkingEnv) && thinkingCapability !== false +} + // @[MODEL LAUNCH]: Add the new model to the allowlist if it supports the effort parameter. export function modelSupportsEffort(model: string): boolean { const m = model.toLowerCase() + const provider = getAPIProvider() + if (provider === 'openai') { + if (isChatGPTAuthMode()) { + return isChatGPTCodexReasoningModel(model) + } + return resolveOpenAICompatibleEffortSupport( + process.env.OPENAI_ENABLE_THINKING, + get3PModelCapabilityOverride(model, 'thinking'), + ) + } if (isEnvTruthy(process.env.CLAUDE_CODE_ALWAYS_ENABLE_EFFORT)) { return true } @@ -40,13 +57,6 @@ export function modelSupportsEffort(model: string): boolean { if (supported3P !== undefined) { return supported3P } - if ( - getAPIProvider() === 'openai' && - isChatGPTAuthMode() && - isChatGPTCodexReasoningModel(model) - ) { - return true - } // Supported by a subset of Claude 4 models if ( m.includes('opus-4-7') || diff --git a/src/utils/model/__tests__/modelSupportOverrides.test.ts b/src/utils/model/__tests__/modelSupportOverrides.test.ts new file mode 100644 index 0000000000..e85e3683de --- /dev/null +++ b/src/utils/model/__tests__/modelSupportOverrides.test.ts @@ -0,0 +1,38 @@ +import { afterEach, beforeEach, describe, expect, test } from 'bun:test' +import { get3PModelCapabilityOverride } from '../modelSupportOverrides.js' + +const ENV_KEYS = [ + 'CLAUDE_CODE_USE_OPENAI', + 'OPENAI_DEFAULT_SONNET_MODEL', + 'OPENAI_DEFAULT_SONNET_MODEL_SUPPORTED_CAPABILITIES', +] as const + +const savedEnv: Record = {} + +describe('get3PModelCapabilityOverride', () => { + beforeEach(() => { + for (const key of ENV_KEYS) savedEnv[key] = process.env[key] + process.env.CLAUDE_CODE_USE_OPENAI = '1' + process.env.OPENAI_DEFAULT_SONNET_MODEL = 'cache-test-model' + }) + + afterEach(() => { + for (const key of ENV_KEYS) { + const value = savedEnv[key] + if (value === undefined) delete process.env[key] + else process.env[key] = value + } + }) + + test('re-evaluates capabilities after environment changes', () => { + process.env.OPENAI_DEFAULT_SONNET_MODEL_SUPPORTED_CAPABILITIES = 'thinking' + expect(get3PModelCapabilityOverride('cache-test-model', 'thinking')).toBe( + true, + ) + + process.env.OPENAI_DEFAULT_SONNET_MODEL_SUPPORTED_CAPABILITIES = '' + expect(get3PModelCapabilityOverride('cache-test-model', 'thinking')).toBe( + false, + ) + }) +}) diff --git a/src/utils/model/modelSupportOverrides.ts b/src/utils/model/modelSupportOverrides.ts index 7e842a1cac..11f05ae57d 100644 --- a/src/utils/model/modelSupportOverrides.ts +++ b/src/utils/model/modelSupportOverrides.ts @@ -64,5 +64,15 @@ export const get3PModelCapabilityOverride = memoize( } return undefined }, - (model, capability) => `${model.toLowerCase()}:${capability}`, + (model, capability) => { + const provider = getAPIProvider() + const tiers = provider === 'openai' ? OPENAI_TIERS : ANTHROPIC_TIERS + const tierState = tiers + .map( + tier => + `${process.env[tier.modelEnvVar] ?? ''}=${process.env[tier.capabilitiesEnvVar] ?? ''}`, + ) + .join('|') + return `${provider}:${model.toLowerCase()}:${capability}:${tierState}` + }, )