Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 17 additions & 9 deletions src/commands/effort/effort.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import {
getEffortEnvOverride,
getEffortValueDescription,
isEffortLevel,
modelSupportsEffort,
toPersistableEffort,
} from '../../utils/effort.js';
import { updateSettingsForSource } from '../../utils/settings/settings.js';
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -169,11 +168,20 @@ export async function call(onDone: LocalJSXCommandOnDone, _context: unknown, arg
return <EffortPanelWrapper onDone={onDone} />;
}

const result = executeEffort(args);
return <ApplyEffortAndClose result={result} onDone={onDone} />;
return <ApplyEffortAndClose args={args} onDone={onDone} />;
}

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 <EffortPanel appStateEffort={effortValue} onDone={onDone} />;
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
107 changes: 81 additions & 26 deletions src/components/ConsoleOAuthFlow.tsx
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -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';
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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') {
Expand Down Expand Up @@ -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;
Expand All @@ -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<OpenAIField, string> = {
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<Record<Exclude<OpenAIField, 'thinking_enabled'>, 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(
Expand All @@ -834,6 +853,7 @@ function OAuthStatusMessage({
haikuModel,
sonnetModel,
opusModel,
thinkingEnabled,
};
switch (field) {
case 'base_url':
Expand All @@ -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<string, string | undefined> = {
OPENAI_AUTH_MODE: undefined,
OPENAI_ENABLE_THINKING: String(thinkingEnabled),
};

// Validate base_url if provided
Expand All @@ -872,6 +898,7 @@ function OAuthStatusMessage({
haikuModel: '',
sonnetModel: '',
opusModel: '',
thinkingEnabled,
activeField: 'base_url',
},
});
Expand Down Expand Up @@ -900,6 +927,7 @@ function OAuthStatusMessage({
haikuModel: finalVals.haiku_model ?? '',
sonnetModel: finalVals.sonnet_model ?? '',
opusModel: finalVals.opus_model ?? '',
thinkingEnabled,
activeField: 'base_url',
},
});
Expand All @@ -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);
Expand All @@ -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]);

Expand All @@ -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' },
Expand All @@ -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',
() => {
Expand All @@ -968,7 +1009,11 @@ function OAuthStatusMessage({

const openaiColumns = useTerminalSize().columns - 20;

const renderOpenAIRow = (field: OpenAIField, label: string, opts?: { mask?: boolean }) => {
const renderOpenAIRow = (
field: Exclude<OpenAIField, 'thinking_enabled'>,
label: string,
opts?: { mask?: boolean },
) => {
const active = activeField === field;
const val = openaiDisplayValues[field];
return (
Expand Down Expand Up @@ -1007,8 +1052,18 @@ function OAuthStatusMessage({
{renderOpenAIRow('haiku_model', 'Haiku ')}
{renderOpenAIRow('sonnet_model', 'Sonnet ')}
{renderOpenAIRow('opus_model', 'Opus ')}
<Box>
<Text
backgroundColor={activeField === 'thinking_enabled' ? 'suggestion' : undefined}
color={activeField === 'thinking_enabled' ? 'inverseText' : undefined}
>
{' Thinking/effort '}
</Text>
<Text> </Text>
<Text color={thinkingEnabled ? 'success' : 'warning'}>{String(thinkingEnabled)}</Text>
</Box>
</Box>
<Text dimColor>↑↓/Tab to switch · Enter on last field to save · Esc to go back</Text>
<Text dimColor>↑↓/Tab to switch · Space to toggle · Enter to save · Esc to go back</Text>
</Box>
);
}
Expand Down
57 changes: 19 additions & 38 deletions src/services/api/openai/__tests__/thinking.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down Expand Up @@ -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', () => {
Expand Down Expand Up @@ -221,30 +203,29 @@ 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<string, unknown>
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,
enable_thinking: true,
})
})

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<string, unknown>
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', () => {
Expand Down
Loading