diff --git a/.changeset/slimy-keys-add.md b/.changeset/slimy-keys-add.md new file mode 100644 index 0000000000..2d8c21f389 --- /dev/null +++ b/.changeset/slimy-keys-add.md @@ -0,0 +1,5 @@ +--- +"claude-dev": minor +--- + +Added thinking tokens budget slider for Sonnet 3.7 with Anthropic diff --git a/package.json b/package.json index 44b9bf49f0..2cc0b291ba 100644 --- a/package.json +++ b/package.json @@ -211,6 +211,11 @@ "type": "boolean", "default": true, "description": "Controls whether the MCP Marketplace is enabled." + }, + "cline.modelSettings.anthropic.thinkingBudgetTokens": { + "type": "number", + "default": 0, + "description": "Controls the token budget for Claude's thinking capability. Set to 0 to disable thinking. When enabled, must be ≥1024 and less than the model's max token output." } } } diff --git a/src/api/providers/anthropic.ts b/src/api/providers/anthropic.ts index 1b087bcda7..7c9582145c 100644 --- a/src/api/providers/anthropic.ts +++ b/src/api/providers/anthropic.ts @@ -19,6 +19,8 @@ export class AnthropicHandler implements ApiHandler { @withRetry() async *createMessage(systemPrompt: string, messages: Anthropic.Messages.MessageParam[]): ApiStream { + let budget_tokens = this.options.thinkingBudgetTokens || 0 + const reasoningOn = budget_tokens !== 0 ? true : false const model = this.getModel() let stream: AnthropicStream const modelId = model.id @@ -41,8 +43,11 @@ export class AnthropicHandler implements ApiHandler { stream = await this.client.messages.create( { model: modelId, + thinking: reasoningOn ? { type: "enabled", budget_tokens: budget_tokens } : undefined, max_tokens: model.info.maxTokens || 8192, - temperature: 0, + // "Thinking isn’t compatible with temperature, top_p, or top_k modifications as well as forced tool use." + // (https://docs.anthropic.com/en/docs/build-with-claude/extended-thinking#important-considerations-when-using-extended-thinking) + temperature: reasoningOn ? 1 : 0, system: [ { text: systemPrompt, @@ -148,6 +153,20 @@ export class AnthropicHandler implements ApiHandler { break case "content_block_start": switch (chunk.content_block.type) { + case "thinking": + yield { + type: "reasoning", + reasoning: chunk.content_block.thinking || "", + } + break + case "redacted_thinking": + // Handle redacted thinking blocks - we still mark it as reasoning + // but note that the content is encrypted + yield { + type: "reasoning", + reasoning: "[Redacted thinking block]", + } + break case "text": // we may receive multiple text blocks, in which case just insert a line break between them if (chunk.index > 0) { @@ -165,12 +184,22 @@ export class AnthropicHandler implements ApiHandler { break case "content_block_delta": switch (chunk.delta.type) { + case "thinking_delta": + yield { + type: "reasoning", + reasoning: chunk.delta.thinking, + } + break case "text_delta": yield { type: "text", text: chunk.delta.text, } break + case "signature_delta": + // We don't need to do anything with the signature in the client + // It's used when sending the thinking block back to the API + break } break case "content_block_stop": diff --git a/src/core/webview/ClineProvider.ts b/src/core/webview/ClineProvider.ts index 586fb56590..d5b9440bc1 100644 --- a/src/core/webview/ClineProvider.ts +++ b/src/core/webview/ClineProvider.ts @@ -35,6 +35,7 @@ import { getNonce } from "./getNonce" import { getUri } from "./getUri" import { telemetryService } from "../../services/telemetry/TelemetryService" import { TelemetrySetting } from "../../shared/TelemetrySetting" +import { validateThinkingBudget } from "../../utils/validation" /* https://github.com/microsoft/vscode-webview-ui-toolkit-samples/blob/main/default/weather-webview/src/providers/WeatherViewProvider.ts @@ -261,6 +262,19 @@ export class ClineProvider implements vscode.WebviewViewProvider { // Update state when marketplace tab setting changes await this.postStateToWebview() } + if (e && e.affectsConfiguration("cline.modelSettings.anthropic.thinkingBudgetTokens")) { + const config = vscode.workspace.getConfiguration("cline.modelSettings.anthropic") + const thinkingBudget = config.get("thinkingBudgetTokens", 0) + + const validatedValue = validateThinkingBudget(thinkingBudget) + + // Only update if the value changed + if (validatedValue !== thinkingBudget) { + await config.update("thinkingBudgetTokens", validatedValue, true) + } + + await this.postStateToWebview() + } }, null, this.disposables, @@ -976,6 +990,15 @@ export class ClineProvider implements vscode.WebviewViewProvider { } break } + case "updateThinkingBudgetTokens": { + if (message.number !== undefined) { + const validatedValue = validateThinkingBudget(message.number) + + const config = vscode.workspace.getConfiguration("cline.modelSettings.anthropic") + await config.update("thinkingBudgetTokens", validatedValue, true) + } + break + } case "openExtensionSettings": { const settingsFilter = message.text || "" await vscode.commands.executeCommand( @@ -2042,6 +2065,10 @@ Here is the project's README to help you get started:\n\n${mcpDetails.readmeCont .getConfiguration("cline.modelSettings.o3Mini") .get("reasoningEffort", "medium") + const thinkingBudgetTokens = vscode.workspace + .getConfiguration("cline.modelSettings.anthropic") + .get("thinkingBudgetTokens", 0) + const mcpMarketplaceEnabled = vscode.workspace.getConfiguration("cline").get("mcpMarketplace.enabled", true) return { @@ -2084,6 +2111,7 @@ Here is the project's README to help you get started:\n\n${mcpDetails.readmeCont openRouterModelInfo, vsCodeLmModelSelector, o3MiniReasoningEffort, + thinkingBudgetTokens, liteLlmBaseUrl, liteLlmModelId, liteLlmApiKey, diff --git a/src/shared/WebviewMessage.ts b/src/shared/WebviewMessage.ts index 2d5c88c5df..7b72f1ccd4 100644 --- a/src/shared/WebviewMessage.ts +++ b/src/shared/WebviewMessage.ts @@ -57,6 +57,7 @@ export interface WebviewMessage { | "updateMcpTimeout" | "fetchOpenGraphData" | "checkIsImageUrl" + | "updateThinkingBudgetTokens" // | "relaunchChromeDebugMode" text?: string disabled?: boolean diff --git a/src/shared/api.ts b/src/shared/api.ts index e062db7bfe..e9acc8a8c5 100644 --- a/src/shared/api.ts +++ b/src/shared/api.ts @@ -59,6 +59,7 @@ export interface ApiHandlerOptions { o3MiniReasoningEffort?: string qwenApiLine?: string xaiApiKey?: string + thinkingBudgetTokens?: number } export type ApiConfiguration = ApiHandlerOptions & { diff --git a/src/utils/validation.ts b/src/utils/validation.ts new file mode 100644 index 0000000000..d285e20c0d --- /dev/null +++ b/src/utils/validation.ts @@ -0,0 +1,36 @@ +import { anthropicModels } from "../shared/api" + +/** + * Validates the thinking budget token value according to the specified rules: + * - If disabled (0), return as is + * - If enabled but less than minimum (1024), set to minimum + * - If greater than or equal to max tokens, set to max tokens - 1 + * - Otherwise, return the original value + * + * @param value The thinking budget token value to validate + * @param maxTokens The maximum tokens for the current model + * @returns The validated thinking budget token value + */ +export function validateThinkingBudget( + value: number, + maxTokens: number = anthropicModels["claude-3-7-sonnet-20250219"].maxTokens, +): number { + // If disabled (0), return as is + if (value === 0) { + return 0 + } + + // If enabled but less than minimum, set to minimum + if (value > 0 && value < 1024) { + return 1024 + } + + // If greater than or equal to max allowed tokens (80% of max tokens), cap at that value + const maxAllowedTokens = Math.floor(maxTokens * 0.8) + if (value >= maxAllowedTokens) { + return maxAllowedTokens + } + + // Otherwise, return the original value + return value +} diff --git a/webview-ui/src/components/common/cline-ui/ClineSlider.tsx b/webview-ui/src/components/common/cline-ui/ClineSlider.tsx new file mode 100644 index 0000000000..2506fd6c27 --- /dev/null +++ b/webview-ui/src/components/common/cline-ui/ClineSlider.tsx @@ -0,0 +1,203 @@ +import { memo, useEffect, useState, useMemo } from "react" +import debounce from "debounce" + +interface ClineSliderProps { + id: string + label: string + value: number + min: number + max: number + step: number + onChange: (value: number) => void + onChangeEnd?: (value: number) => void + description?: string + validateValue?: (value: number) => number + dynamicColor?: boolean + secondaryLabel?: string + getSecondaryLabel?: (value: number, min: number, max: number) => string | JSX.Element +} + +// Constants +const THUMB_SIZE = 24 +const DEBOUNCE_DELAY = 300 + +const ClineSlider = ({ + id, + label, + value, + min, + max, + step, + onChange, + onChangeEnd, + description, + validateValue, + dynamicColor = false, + secondaryLabel, + getSecondaryLabel, +}: ClineSliderProps) => { + // State + const [localValue, setLocalValue] = useState(value) + const [isDragging, setIsDragging] = useState(false) + + // Update local value when prop value changes (if not dragging) + useEffect(() => { + if (!isDragging) { + setLocalValue(value) + } + }, [value, isDragging]) + + // Create debounced onChange handler + const debouncedOnChange = useMemo(() => debounce((val: number) => onChange(val), DEBOUNCE_DELAY), [onChange]) + + // Clear debounce on unmount + useEffect(() => () => debouncedOnChange.clear(), [debouncedOnChange]) + + // Event handlers + const handleChange = (e: React.ChangeEvent) => { + const newValue = parseInt(e.target.value, 10) + setLocalValue(newValue) + setIsDragging(true) + debouncedOnChange(newValue) + } + + const handleSlideEnd = () => { + if (onChangeEnd) { + const finalValue = validateValue ? validateValue(localValue) : localValue + setLocalValue(finalValue) + onChangeEnd(finalValue) + } + setTimeout(() => setIsDragging(false), 50) + } + + // Calculate percentage for dynamic color + const percentage = (localValue - min) / (max - min) + const intensity = dynamicColor ? percentage : 0 + + // Initialize styles once + useEffect(() => { + if (document.getElementById("cline-slider-styles")) return + + const styleElement = document.createElement("style") + styleElement.id = "cline-slider-styles" + styleElement.innerHTML = ` + .cline-slider { + width: 100%; + height: 16px; + appearance: none; + background-color: var(--track-color, var(--vscode-scrollbarSlider-background)); + border-radius: 8px; + outline: none; + cursor: pointer; + margin: 0; + padding: 0; + box-sizing: border-box; + } + + .cline-slider::-webkit-slider-thumb { + appearance: none; + width: var(--thumb-size, 24px); + height: var(--thumb-size, 24px); + border-radius: 50%; + background: white; + cursor: pointer; + border: 2px solid var(--thumb-color, var(--vscode-button-background)); + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2); + margin: 0; + } + + .cline-slider:focus { outline: none; } + + .cline-slider:focus::-webkit-slider-thumb, + .cline-slider:hover::-webkit-slider-thumb { + background: white; + border-color: var(--thumb-color, var(--vscode-button-hoverBackground)); + box-shadow: 0 2px 6px rgba(0, 0, 0, 0.3); + } + + .cline-slider:active::-webkit-slider-thumb { + background: white; + border-color: var(--thumb-color, var(--vscode-button-hoverBackground)); + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.4); + } + ` + document.head.appendChild(styleElement) + }, []) + + // Common styles + const containerStyle: React.CSSProperties = { marginTop: "10px" } + const labelContainerStyle: React.CSSProperties = { + display: "flex", + justifyContent: "space-between", + marginBottom: "10px", + flexWrap: "wrap", + gap: "12px", + } + const labelStyle: React.CSSProperties = { + fontWeight: 500, + display: "block", + marginRight: "auto", + } + const valueStyle: React.CSSProperties = { + color: "var(--vscode-button-foreground)", + backgroundColor: "var(--vscode-button-background)", + padding: "2px 6px", + borderRadius: "4px", + } + const secondaryLabelStyle: React.CSSProperties = { + fontWeight: 500, + textAlign: "right", + whiteSpace: "nowrap", + flexShrink: 0, + } + const descriptionStyle: React.CSSProperties = { + fontSize: "12px", + marginTop: "5px", + color: "var(--vscode-descriptionForeground)", + } + + // Input style with dynamic color if enabled + const inputStyle = { + marginTop: "5px", + "--thumb-size": `${THUMB_SIZE}px`, + ...(dynamicColor + ? { + "--thumb-color": `var(--vscode-button-background)`, + "--track-color": `var(--vscode-button-background)`, + opacity: 0.5 + intensity * 0.5, + } + : {}), + } as React.CSSProperties + + return ( +
+
+ + {(secondaryLabel || getSecondaryLabel) && ( +
+ {secondaryLabel || (getSecondaryLabel && getSecondaryLabel(localValue, min, max))} +
+ )} +
+ + {description &&

{description}

} +
+ ) +} + +export default memo(ClineSlider) diff --git a/webview-ui/src/components/settings/ApiOptions.tsx b/webview-ui/src/components/settings/ApiOptions.tsx index 88c28159a1..d801ef06b5 100644 --- a/webview-ui/src/components/settings/ApiOptions.tsx +++ b/webview-ui/src/components/settings/ApiOptions.tsx @@ -8,6 +8,7 @@ import { VSCodeTextField, } from "@vscode/webview-ui-toolkit/react" import { Fragment, memo, useCallback, useEffect, useMemo, useState } from "react" +import ThinkingBudgetSlider from "./ThinkingBudgetSlider" import { useEvent, useInterval } from "react-use" import { ApiConfiguration, @@ -1186,6 +1187,10 @@ const ApiOptions = ({ showModelOptions, apiErrorMessage, modelIdErrorMessage, is {selectedProvider === "xai" && createDropdown(xaiModels)} + {selectedProvider === "anthropic" && selectedModelId === "claude-3-7-sonnet-20250219" && ( + + )} + void +} + +// Constants +const MIN_VALID_TOKENS = 1024 +const MAX_PERCENTAGE = 0.8 + +const ThinkingBudgetSlider = ({ apiConfiguration, setApiConfiguration }: ThinkingBudgetSliderProps) => { + // Calculate max tokens based on model + const maxTokens = anthropicModels["claude-3-7-sonnet-20250219"].maxTokens + const maxSliderValue = Math.floor(maxTokens * MAX_PERCENTAGE) + const currentValue = apiConfiguration?.thinkingBudgetTokens || 0 + + // Style constants for reasoning level display + const labelStyle: React.CSSProperties = { color: "var(--vscode-editor-foreground)" } + const valueStyle: React.CSSProperties = { + color: "white", + backgroundColor: "var(--vscode-button-background)", + padding: "2px 6px", + borderRadius: "4px", + fontWeight: "bold", + } + + // Handlers + const handleChange = (value: number) => { + if (!apiConfiguration) return + setApiConfiguration({ + ...apiConfiguration, + thinkingBudgetTokens: value, + }) + } + + const handleChangeEnd = (value: number) => { + if (!apiConfiguration) return + const validValue = getValidValue(value) + + setApiConfiguration({ + ...apiConfiguration, + thinkingBudgetTokens: validValue, + }) + + vscode.postMessage({ + type: "updateThinkingBudgetTokens", + number: validValue, + }) + } + + // Utility functions + const getValidValue = (value: number): number => (value === 0 ? 0 : Math.max(MIN_VALID_TOKENS, value)) + + const getReasoningLevel = (value: number, min: number, max: number): JSX.Element => { + let levelText: string + + if (value === 0) { + levelText = "Off" + } else { + const percentage = (value - min) / (max - min) + levelText = percentage <= 1 / 3 ? "Low" : percentage <= 2 / 3 ? "Medium" : "High" + } + + return ( + + Reasoning: {levelText} + + ) + } + + return ( + + ) +} + +export default memo(ThinkingBudgetSlider)