Skip to content

Commit c236f2c

Browse files
committed
feat: add Extended Thinking support for Anthropic Claude + Google Gemini
- Add getThinkingProviderOptions() helper in constants.ts for Anthropic extended thinking and Google thinkingConfig - Expand isReasoningModel() to detect DeepSeek R1, QWQ, Kimi - Add enableThinkingStore atom + useSettings hook integration - Wire enableThinking through Chat.client api.chat stream-text - Inject providerOptions into streamText and generateText calls - Add ThinkingBlock component in AssistantMessage.tsx with collapsible reasoning display (brain icon, caret toggle) - Add 'Extended Thinking' toggle in FeaturesTab (Core Features) - All 452 tests passing
1 parent 27b5503 commit c236f2c

14 files changed

Lines changed: 228 additions & 74 deletions

File tree

‎README.md‎

Lines changed: 3 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -14,13 +14,11 @@
1414

1515
<div align="center">
1616

17-
[Features](#features) - [Installation](#installation) - [Configuration](#configuration)
17+
[Features](#features) ━━ [Installation](#installation) ━━ [Configuration](#configuration)
1818

19-
[Docker](#docker) - [Scripts](#scripts) - [Keeping Up to Date](#keeping-up-to-date)
19+
[Docker](#docker) ━━ [Scripts](#scripts) ━━ [Keeping Up to Date](#keeping-up-to-date)
2020

21-
[Project Structure](#project-structure)
22-
23-
[Contributing](#contributing) - [Acknowledgments](#acknowledgments)
21+
[Project Structure](#project-structure) ━━ [Contributing](#contributing) ━━ [Acknowledgments](#acknowledgments)
2422

2523
</div>
2624

‎app/components/@settings/tabs/features/FeaturesTab.tsx‎

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -127,11 +127,13 @@ export default function FeaturesTab() {
127127
contextOptimizationEnabled,
128128
eventLogs,
129129
autoSwitchToFile,
130+
enableThinking,
130131
setAutoSelectTemplate,
131132
enableLatestBranch,
132133
enableContextOptimization,
133134
setEventLogs,
134135
setAutoSwitchToFile,
136+
setEnableThinking,
135137
setPromptId,
136138
promptId,
137139
} = useSettings();
@@ -259,11 +261,24 @@ export default function FeaturesTab() {
259261
break;
260262
}
261263

264+
case 'enableThinkingToggle': {
265+
setEnableThinking(enabled);
266+
toast.success(`Extended Thinking ${enabled ? 'enabled' : 'disabled'}`);
267+
break;
268+
}
269+
262270
default:
263271
break;
264272
}
265273
},
266-
[enableLatestBranch, setAutoSelectTemplate, enableContextOptimization, setEventLogs, setAutoSwitchToFile],
274+
[
275+
enableLatestBranch,
276+
setAutoSelectTemplate,
277+
enableContextOptimization,
278+
setEventLogs,
279+
setAutoSwitchToFile,
280+
setEnableThinking,
281+
],
267282
);
268283

269284
const features = {
@@ -309,6 +324,15 @@ export default function FeaturesTab() {
309324
tooltip:
310325
'When enabled, the editor will automatically switch to show each file as the AI edits it. When disabled, you can stay in preview mode while the AI works.',
311326
},
327+
{
328+
id: 'enableThinkingToggle',
329+
title: 'Extended Thinking',
330+
description: 'Enable extended thinking for Anthropic Claude and Google Gemini models',
331+
icon: 'i-ph:brain',
332+
enabled: enableThinking,
333+
tooltip:
334+
'When enabled, supported models will use extended thinking/reasoning for deeper analysis. Uses ~25% of output token budget for thinking.',
335+
},
312336
],
313337
beta: [
314338
{

‎app/components/chat/AssistantMessage.tsx‎

Lines changed: 38 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { memo, Fragment } from 'react';
1+
import { memo, Fragment, useState } from 'react';
22
import { Markdown } from './Markdown';
33
import type { JSONValue } from 'ai';
44
import Popover from '~/components/ui/Popover';
@@ -18,6 +18,39 @@ import type {
1818
import { ToolInvocations } from './ToolInvocations';
1919
import type { ToolCallAnnotation } from '~/types/context';
2020

21+
/**
22+
* Collapsible block that displays AI reasoning / thinking content.
23+
* Renders as a styled <details> element with a brain icon header.
24+
*/
25+
const ThinkingBlock = memo(({ reasoningParts }: { reasoningParts: ReasoningUIPart[] }) => {
26+
const [isOpen, setIsOpen] = useState(false);
27+
const combinedText = reasoningParts.map((p) => p.reasoning).join('\n');
28+
29+
if (!combinedText.trim()) {
30+
return null;
31+
}
32+
33+
return (
34+
<div className="mb-3 rounded-lg border border-bolt-elements-borderColor overflow-hidden">
35+
<button
36+
onClick={() => setIsOpen(!isOpen)}
37+
className="w-full flex items-center gap-2 px-3 py-2 text-xs font-medium text-bolt-elements-textSecondary bg-bolt-elements-background-depth-2 hover:bg-bolt-elements-background-depth-3 transition-colors"
38+
>
39+
<div className="i-ph:brain w-4 h-4 text-purple-400" />
40+
<span>Thinking</span>
41+
<div
42+
className={`i-ph:caret-right w-3 h-3 ml-auto transition-transform duration-200 ${isOpen ? 'rotate-90' : ''}`}
43+
/>
44+
</button>
45+
{isOpen && (
46+
<div className="px-3 py-2 text-xs text-bolt-elements-textSecondary bg-bolt-elements-background-depth-1 border-t border-bolt-elements-borderColor max-h-64 overflow-y-auto whitespace-pre-wrap leading-relaxed">
47+
{combinedText}
48+
</div>
49+
)}
50+
</div>
51+
);
52+
});
53+
2154
interface AssistantMessageProps {
2255
content: string;
2356
annotations?: JSONValue[];
@@ -96,6 +129,7 @@ export const AssistantMessage = memo(
96129
| undefined;
97130

98131
const toolInvocations = parts?.filter((part) => part.type === 'tool-invocation');
132+
const reasoningParts = parts?.filter((part) => part.type === 'reasoning') as ReasoningUIPart[] | undefined;
99133
const toolCallAnnotations = filteredAnnotations.filter(
100134
(annotation) => annotation.type === 'toolCall',
101135
) as ToolCallAnnotation[];
@@ -181,6 +215,9 @@ export const AssistantMessage = memo(
181215
)}
182216
</div>
183217

218+
{/* Reasoning / Thinking Display */}
219+
{reasoningParts && reasoningParts.length > 0 && <ThinkingBlock reasoningParts={reasoningParts} />}
220+
184221
{/* Message Content */}
185222
<div className="text-bolt-elements-textPrimary text-sm leading-relaxed">
186223
<Markdown

‎app/components/chat/Chat.client.tsx‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -112,7 +112,7 @@ export const ChatImpl = memo(
112112
(project) => project.id === supabaseConn.selectedProjectId,
113113
);
114114
const supabaseAlert = useStore(workbenchStore.supabaseAlert);
115-
const { activeProviders, promptId, autoSelectTemplate, contextOptimizationEnabled } = useSettings();
115+
const { activeProviders, promptId, autoSelectTemplate, contextOptimizationEnabled, enableThinking } = useSettings();
116116
const [llmErrorAlert, setLlmErrorAlert] = useState<LlmErrorAlertType | undefined>(undefined);
117117
const [model, setModel] = useState(() => {
118118
const savedModel = Cookies.get('selectedModel');
@@ -150,6 +150,7 @@ export const ChatImpl = memo(
150150
files,
151151
promptId,
152152
contextOptimization: contextOptimizationEnabled,
153+
enableThinking,
153154
chatMode,
154155
designScheme,
155156
supabase: {

‎app/lib/.server/llm/constants.ts‎

Lines changed: 48 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import type { JSONValue } from 'ai';
12
import { createScopedLogger } from '~/utils/logger';
23

34
const logger = createScopedLogger('LLMConstants');
@@ -39,13 +40,59 @@ export const PROVIDER_COMPLETION_LIMITS: Record<string, number> = {
3940
* These models use internal reasoning tokens and have different API parameter requirements
4041
*/
4142
export function isReasoningModel(modelName: string): boolean {
42-
const result = /^(o1|o3|gpt-5)/i.test(modelName);
43+
const result =
44+
/^(o1|o3|gpt-5)/i.test(modelName) ||
45+
/deepseek[-_]?r1/i.test(modelName) ||
46+
/qwq/i.test(modelName) ||
47+
/kimi[-_]?thinking/i.test(modelName);
4348

4449
logger.debug(`REGEX TEST: "${modelName}" matches reasoning pattern: ${result}`);
4550

4651
return result;
4752
}
4853

54+
/**
55+
* Determines if a model supports extended thinking via providerOptions.
56+
* Returns the appropriate providerOptions object for the given provider/model,
57+
* or undefined if the model/provider doesn't support extended thinking.
58+
*/
59+
export function getThinkingProviderOptions(
60+
providerName: string,
61+
modelName: string,
62+
maxOutputTokens: number,
63+
): Record<string, Record<string, JSONValue>> | undefined {
64+
const budgetTokens = Math.max(1024, Math.min(Math.floor(maxOutputTokens * 0.25), 32000));
65+
66+
if (providerName === 'Anthropic') {
67+
// Claude 3.5 Sonnet, Claude 4 Opus, Claude 4 Sonnet support extended thinking
68+
if (/claude/i.test(modelName)) {
69+
logger.info(`Enabling Anthropic extended thinking for ${modelName} (budget: ${budgetTokens} tokens)`);
70+
71+
return {
72+
anthropic: {
73+
thinking: { type: 'enabled', budgetTokens },
74+
},
75+
};
76+
}
77+
}
78+
79+
if (providerName === 'Google') {
80+
// Gemini 2.5 Pro/Flash and thinking models support thinkingConfig
81+
if (/gemini-2\.5|gemini-2\.0-flash-thinking/i.test(modelName)) {
82+
logger.info(`Enabling Google thinking for ${modelName} (budget: ${budgetTokens} tokens)`);
83+
84+
return {
85+
google: {
86+
thinkingConfig: { thinkingBudget: budgetTokens },
87+
},
88+
};
89+
}
90+
}
91+
92+
// DeepSeek R1, QWQ, Kimi thinking — thinking is built into the model, no providerOptions needed
93+
return undefined;
94+
}
95+
4996
// limits the number of model responses that can be returned in a single request
5097
export const MAX_RESPONSE_SEGMENTS = 2;
5198

‎app/lib/.server/llm/stream-text.ts‎

Lines changed: 28 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,11 @@
11
import { convertToCoreMessages, streamText as _streamText, type Message } from 'ai';
2-
import { MAX_TOKENS, PROVIDER_COMPLETION_LIMITS, isReasoningModel, type FileMap } from './constants';
2+
import {
3+
MAX_TOKENS,
4+
PROVIDER_COMPLETION_LIMITS,
5+
isReasoningModel,
6+
getThinkingProviderOptions,
7+
type FileMap,
8+
} from './constants';
39
import { getFineTunedPrompt } from '~/lib/common/prompts/new-prompt';
410
import { AGENT_MODE_FULL_SYSTEM_PROMPT } from '~/lib/agent/prompts';
511
import { DEFAULT_MODEL, DEFAULT_PROVIDER, MODIFICATIONS_TAG_NAME, PROVIDER_LIST, WORK_DIR } from '~/utils/constants';
@@ -64,6 +70,7 @@ export async function streamText(props: {
6470
providerSettings?: Record<string, IProviderSetting>;
6571
promptId?: string;
6672
contextOptimization?: boolean;
73+
enableThinking?: boolean;
6774
contextFiles?: FileMap;
6875
summary?: string;
6976
messageSliceId?: number;
@@ -84,6 +91,7 @@ export async function streamText(props: {
8491
chatMode,
8592
designScheme,
8693
} = props;
94+
const enableThinking = props.enableThinking ?? false;
8795
let currentModel = DEFAULT_MODEL;
8896
let currentProvider = DEFAULT_PROVIDER.name;
8997
let processedMessages = messages.map((message) => {
@@ -240,6 +248,22 @@ ${projectMemoryContent}
240248
// Use maxCompletionTokens for reasoning models (o1, GPT-5), maxTokens for traditional models
241249
const tokenParams = isReasoning ? { maxCompletionTokens: safeMaxTokens } : { maxTokens: safeMaxTokens };
242250

251+
// Build providerOptions for extended thinking (Anthropic / Google)
252+
let thinkingProviderOptions: ReturnType<typeof getThinkingProviderOptions> | undefined;
253+
254+
if (enableThinking) {
255+
thinkingProviderOptions = getThinkingProviderOptions(provider.name, modelDetails.name, safeMaxTokens);
256+
257+
if (thinkingProviderOptions) {
258+
logger.info(
259+
`Extended thinking enabled for ${provider.name}/${modelDetails.name}:`,
260+
JSON.stringify(thinkingProviderOptions),
261+
);
262+
} else {
263+
logger.info(`Extended thinking requested but not supported for ${provider.name}/${modelDetails.name}`);
264+
}
265+
}
266+
243267
// Filter out unsupported parameters for reasoning models
244268
const filteredOptions =
245269
isReasoning && options
@@ -318,6 +342,9 @@ ${codeContext}
318342

319343
// Set temperature to 1 for reasoning models (required by OpenAI API)
320344
...(isReasoning ? { temperature: 1 } : {}),
345+
346+
// Inject provider-specific thinking options (Anthropic thinking / Google thinkingConfig)
347+
...(thinkingProviderOptions ? { providerOptions: thinkingProviderOptions } : {}),
321348
};
322349

323350
// DEBUG: Log final streaming parameters

‎app/lib/hooks/useSettings.ts‎

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import {
99
enableContextOptimizationStore,
1010
tabConfigurationStore,
1111
autoSwitchToFileStore,
12+
enableThinkingStore,
1213
resetTabConfiguration as resetTabConfig,
1314
updateProviderSettings as updateProviderSettingsStore,
1415
updateLatestBranch,
@@ -17,6 +18,7 @@ import {
1718
updateEventLogs,
1819
updatePromptId,
1920
updateAutoSwitchToFile,
21+
updateEnableThinking,
2022
} from '~/lib/stores/settings';
2123
import { useCallback, useEffect, useState } from 'react';
2224
import Cookies from 'js-cookie';
@@ -62,6 +64,8 @@ export interface UseSettingsReturn {
6264
enableContextOptimization: (enabled: boolean) => void;
6365
autoSwitchToFile: boolean;
6466
setAutoSwitchToFile: (enabled: boolean) => void;
67+
enableThinking: boolean;
68+
setEnableThinking: (enabled: boolean) => void;
6569

6670
// Tab configuration
6771
tabConfiguration: TabWindowConfig;
@@ -78,6 +82,7 @@ export function useSettings(): UseSettingsReturn {
7882
const isLatestBranch = useStore(latestBranchStore);
7983
const autoSelectTemplate = useStore(autoSelectStarterTemplate);
8084
const autoSwitchToFile = useStore(autoSwitchToFileStore);
85+
const enableThinking = useStore(enableThinkingStore);
8186
const [activeProviders, setActiveProviders] = useState<ProviderInfo[]>([]);
8287
const contextOptimizationEnabled = useStore(enableContextOptimizationStore);
8388
const tabConfiguration = useStore(tabConfigurationStore);
@@ -150,6 +155,11 @@ export function useSettings(): UseSettingsReturn {
150155
logStore.logSystem(`Auto-switch to file during AI edits ${enabled ? 'enabled' : 'disabled'}`);
151156
}, []);
152157

158+
const setEnableThinking = useCallback((enabled: boolean) => {
159+
updateEnableThinking(enabled);
160+
logStore.logSystem(`Extended thinking ${enabled ? 'enabled' : 'disabled'}`);
161+
}, []);
162+
153163
const setTheme = useCallback(
154164
(theme: Settings['theme']) => {
155165
saveSettings({ theme });
@@ -206,6 +216,8 @@ export function useSettings(): UseSettingsReturn {
206216
enableContextOptimization,
207217
autoSwitchToFile,
208218
setAutoSwitchToFile,
219+
enableThinking,
220+
setEnableThinking,
209221
setTheme,
210222
setLanguage,
211223
setNotifications,

‎app/lib/stores/settings.ts‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -331,6 +331,7 @@ const SETTINGS_KEYS = {
331331
PROMPT_ID: 'promptId',
332332
DEVELOPER_MODE: 'isDeveloperMode',
333333
AUTO_SWITCH_TO_FILE: 'autoSwitchToFile',
334+
ENABLE_THINKING: 'enableThinking',
334335
} as const;
335336

336337
// Initialize settings from localStorage or defaults
@@ -361,6 +362,7 @@ const getInitialSettings = () => {
361362
promptId: isBrowser ? localStorage.getItem(SETTINGS_KEYS.PROMPT_ID) || 'default' : 'default',
362363
developerMode: getStoredBoolean(SETTINGS_KEYS.DEVELOPER_MODE, false),
363364
autoSwitchToFile: getStoredBoolean(SETTINGS_KEYS.AUTO_SWITCH_TO_FILE, false),
365+
enableThinking: getStoredBoolean(SETTINGS_KEYS.ENABLE_THINKING, false),
364366
};
365367
};
366368

@@ -373,6 +375,7 @@ export const enableContextOptimizationStore = atom<boolean>(initialSettings.cont
373375
export const isEventLogsEnabled = atom<boolean>(initialSettings.eventLogs);
374376
export const promptStore = atom<string>(initialSettings.promptId);
375377
export const autoSwitchToFileStore = atom<boolean>(initialSettings.autoSwitchToFile);
378+
export const enableThinkingStore = atom<boolean>(initialSettings.enableThinking);
376379

377380
// Helper functions to update settings with persistence
378381
export const updateLatestBranch = (enabled: boolean) => {
@@ -385,6 +388,11 @@ export const updateAutoSwitchToFile = (enabled: boolean) => {
385388
localStorage.setItem(SETTINGS_KEYS.AUTO_SWITCH_TO_FILE, JSON.stringify(enabled));
386389
};
387390

391+
export const updateEnableThinking = (enabled: boolean) => {
392+
enableThinkingStore.set(enabled);
393+
localStorage.setItem(SETTINGS_KEYS.ENABLE_THINKING, JSON.stringify(enabled));
394+
};
395+
388396
export const updateAutoSelectTemplate = (enabled: boolean) => {
389397
autoSelectStarterTemplate.set(enabled);
390398
localStorage.setItem(SETTINGS_KEYS.AUTO_SELECT_TEMPLATE, JSON.stringify(enabled));

0 commit comments

Comments
 (0)