Compare commits

...

1 Commits

Author SHA1 Message Date
Igor Tceglevskii dce27bb10d thinking budget update 2025-09-07 21:10:53 -07:00
6 changed files with 316 additions and 9 deletions
+5
View File
@@ -0,0 +1,5 @@
---
"claude-dev": patch
---
Added automatic thinking budget validation that clamps values to model limits when switching models.
+6 -5
View File
@@ -37,6 +37,7 @@ import { VsCodeLmHandler } from "./providers/vscode-lm"
import { XAIHandler } from "./providers/xai"
import { ZAiHandler } from "./providers/zai"
import { ApiStream, ApiStreamUsageChunk } from "./transform/stream"
import { clampThinkingBudget } from "./utils/thinkingBudgetValidation"
export type CommonApiHandlerOptions = {
onRetryAttempt?: ApiConfiguration["onRetryAttempt"]
@@ -63,7 +64,7 @@ export interface SingleCompletionHandler {
completePrompt(prompt: string): Promise<string>
}
function createHandlerForProvider(
export function createHandlerForProvider(
apiProvider: string | undefined,
options: Omit<ApiConfiguration, "apiProvider">,
mode: Mode,
@@ -400,12 +401,12 @@ export function buildApiHandler(configuration: ApiConfiguration, mode: Mode): Ap
const handler = createHandlerForProvider(apiProvider, options, mode)
const modelInfo = handler.getModel().info
if (modelInfo.maxTokens && thinkingBudgetTokens > modelInfo.maxTokens) {
const clippedValue = modelInfo.maxTokens - 1
const clampedValue = clampThinkingBudget(thinkingBudgetTokens, modelInfo)
if (clampedValue !== thinkingBudgetTokens) {
if (mode === "plan") {
options.planModeThinkingBudgetTokens = clippedValue
options.planModeThinkingBudgetTokens = clampedValue
} else {
options.actModeThinkingBudgetTokens = clippedValue
options.actModeThinkingBudgetTokens = clampedValue
}
} else {
return handler // don't rebuild unless its necessary
@@ -0,0 +1,222 @@
import { ModelInfo } from "@shared/api"
import { expect } from "chai"
import { describe, it } from "mocha"
import { clampThinkingBudget, getMaxThinkingBudgetForModel } from "../thinkingBudgetValidation"
describe("thinkingBudgetValidation", () => {
describe("getMaxThinkingBudgetForModel", () => {
it("should return thinkingConfig.maxBudget when available", () => {
const modelInfo: ModelInfo = {
maxTokens: 8192,
contextWindow: 200000,
supportsImages: true,
supportsPromptCache: true,
thinkingConfig: {
maxBudget: 32767,
},
}
expect(getMaxThinkingBudgetForModel(modelInfo)).to.equal(32767)
})
it("should return maxTokens - 1 when thinkingConfig.maxBudget is not available", () => {
const modelInfo: ModelInfo = {
maxTokens: 8192,
contextWindow: 200000,
supportsImages: true,
supportsPromptCache: true,
}
expect(getMaxThinkingBudgetForModel(modelInfo)).to.equal(8191)
})
it("should prefer thinkingConfig.maxBudget over maxTokens when both are available", () => {
const modelInfo: ModelInfo = {
maxTokens: 8192,
contextWindow: 200000,
supportsImages: true,
supportsPromptCache: true,
thinkingConfig: {
maxBudget: 5000,
},
}
expect(getMaxThinkingBudgetForModel(modelInfo)).to.equal(5000)
})
it("should return undefined when neither thinkingConfig.maxBudget nor maxTokens are available", () => {
const modelInfo: ModelInfo = {
contextWindow: 200000,
supportsImages: true,
supportsPromptCache: true,
}
expect(getMaxThinkingBudgetForModel(modelInfo)).to.be.undefined
})
it("should return undefined when maxTokens is 0", () => {
const modelInfo: ModelInfo = {
maxTokens: 0,
contextWindow: 200000,
supportsImages: true,
supportsPromptCache: true,
}
expect(getMaxThinkingBudgetForModel(modelInfo)).to.be.undefined
})
})
describe("clampThinkingBudget", () => {
it("should return original value when it is below the limit", () => {
const modelInfo: ModelInfo = {
maxTokens: 8192,
contextWindow: 200000,
supportsImages: true,
supportsPromptCache: true,
}
expect(clampThinkingBudget(5000, modelInfo)).to.equal(5000)
})
it("should return original value when it equals the limit", () => {
const modelInfo: ModelInfo = {
maxTokens: 8192,
contextWindow: 200000,
supportsImages: true,
supportsPromptCache: true,
}
expect(clampThinkingBudget(8191, modelInfo)).to.equal(8191)
})
it("should return clamped value when it exceeds the limit", () => {
const modelInfo: ModelInfo = {
maxTokens: 4096,
contextWindow: 200000,
supportsImages: true,
supportsPromptCache: true,
}
expect(clampThinkingBudget(10000, modelInfo)).to.equal(4095)
})
it("should use thinkingConfig.maxBudget for clamping when available", () => {
const modelInfo: ModelInfo = {
maxTokens: 8192,
contextWindow: 200000,
supportsImages: true,
supportsPromptCache: true,
thinkingConfig: {
maxBudget: 5000,
},
}
expect(clampThinkingBudget(7000, modelInfo)).to.equal(5000)
})
it("should return original value when model has no limits", () => {
const modelInfo: ModelInfo = {
contextWindow: 200000,
supportsImages: true,
supportsPromptCache: true,
}
expect(clampThinkingBudget(10000, modelInfo)).to.equal(10000)
})
it("should handle edge case with very small maxTokens", () => {
const modelInfo: ModelInfo = {
maxTokens: 1,
contextWindow: 200000,
supportsImages: true,
supportsPromptCache: true,
}
expect(clampThinkingBudget(1000, modelInfo)).to.equal(0)
})
it("should handle zero thinking budget value", () => {
const modelInfo: ModelInfo = {
maxTokens: 8192,
contextWindow: 200000,
supportsImages: true,
supportsPromptCache: true,
}
expect(clampThinkingBudget(0, modelInfo)).to.equal(0)
})
it("should handle negative thinking budget value", () => {
const modelInfo: ModelInfo = {
maxTokens: 8192,
contextWindow: 200000,
supportsImages: true,
supportsPromptCache: true,
}
expect(clampThinkingBudget(-100, modelInfo)).to.equal(-100)
})
})
describe("real-world model scenarios", () => {
it("should handle Anthropic Claude model (uses maxTokens)", () => {
const claudeModel: ModelInfo = {
maxTokens: 8192,
contextWindow: 200000,
supportsImages: true,
supportsPromptCache: true,
inputPrice: 3.0,
outputPrice: 15.0,
}
expect(getMaxThinkingBudgetForModel(claudeModel)).to.equal(8191)
expect(clampThinkingBudget(10000, claudeModel)).to.equal(8191)
expect(clampThinkingBudget(5000, claudeModel)).to.equal(5000)
})
it("should handle Gemini model (uses thinkingConfig.maxBudget)", () => {
const geminiModel: ModelInfo = {
maxTokens: 65536,
contextWindow: 1048576,
supportsImages: true,
supportsPromptCache: true,
thinkingConfig: {
maxBudget: 32767,
},
inputPrice: 2.5,
outputPrice: 15,
}
expect(getMaxThinkingBudgetForModel(geminiModel)).to.equal(32767)
expect(clampThinkingBudget(50000, geminiModel)).to.equal(32767)
expect(clampThinkingBudget(20000, geminiModel)).to.equal(20000)
})
it("should handle model switching scenario (high to low limit)", () => {
const highLimitModel: ModelInfo = {
maxTokens: 65536,
contextWindow: 1048576,
supportsImages: true,
supportsPromptCache: true,
thinkingConfig: {
maxBudget: 32767,
},
}
const lowLimitModel: ModelInfo = {
maxTokens: 4096,
contextWindow: 200000,
supportsImages: true,
supportsPromptCache: true,
}
const originalBudget = 25000
// Original budget is valid for high limit model
expect(clampThinkingBudget(originalBudget, highLimitModel)).to.equal(25000)
// Same budget gets clamped for low limit model
expect(clampThinkingBudget(originalBudget, lowLimitModel)).to.equal(4095)
})
})
})
@@ -0,0 +1,23 @@
import { ModelInfo } from "@shared/api"
export function getMaxThinkingBudgetForModel(modelInfo: ModelInfo): number | undefined {
// Prefer explicit thinkingConfig.maxBudget if available
if (modelInfo.thinkingConfig?.maxBudget) {
return modelInfo.thinkingConfig.maxBudget
}
// Fallback to maxTokens - 1 (as current buildApiHandler does)
if (modelInfo.maxTokens) {
return modelInfo.maxTokens - 1
}
return undefined
}
export function clampThinkingBudget(value: number, modelInfo: ModelInfo): number {
const maxBudget = getMaxThinkingBudgetForModel(modelInfo)
if (maxBudget !== undefined && value > maxBudget) {
return maxBudget
}
return value
}
@@ -1,9 +1,52 @@
import { buildApiHandler } from "@core/api"
import { buildApiHandler, createHandlerForProvider } from "@core/api"
import { clampThinkingBudget } from "@core/api/utils/thinkingBudgetValidation"
import { ApiConfiguration } from "@shared/api"
import { Empty } from "@shared/proto/cline/common"
import { UpdateApiConfigurationRequest } from "@shared/proto/cline/models"
import { convertProtoToApiConfiguration } from "@shared/proto-conversions/models/api-configuration-conversion"
import type { Controller } from "../index"
/**
* Validates and clamps thinking budget tokens to ensure they don't exceed model limits
* @param config The API configuration to validate
* @returns The validated configuration with clamped thinking budget values
*/
function validateAndClampThinkingBudgets(config: ApiConfiguration): ApiConfiguration {
try {
let configChanged = false
const validatedConfig = { ...config }
// Validate plan mode thinking budget
if (validatedConfig.planModeThinkingBudgetTokens && validatedConfig.planModeThinkingBudgetTokens > 0) {
const planHandler = createHandlerForProvider(validatedConfig.planModeApiProvider, validatedConfig, "plan")
const planModelInfo = planHandler.getModel().info
const clampedPlanValue = clampThinkingBudget(validatedConfig.planModeThinkingBudgetTokens, planModelInfo)
if (clampedPlanValue !== validatedConfig.planModeThinkingBudgetTokens) {
validatedConfig.planModeThinkingBudgetTokens = clampedPlanValue
configChanged = true
}
}
// Validate act mode thinking budget
if (validatedConfig.actModeThinkingBudgetTokens && validatedConfig.actModeThinkingBudgetTokens > 0) {
const actHandler = createHandlerForProvider(validatedConfig.actModeApiProvider, validatedConfig, "act")
const actModelInfo = actHandler.getModel().info
const clampedActValue = clampThinkingBudget(validatedConfig.actModeThinkingBudgetTokens, actModelInfo)
if (clampedActValue !== validatedConfig.actModeThinkingBudgetTokens) {
validatedConfig.actModeThinkingBudgetTokens = clampedActValue
configChanged = true
}
}
return validatedConfig
} catch (error) {
console.error("[APICONFIG: validateAndClampThinkingBudgets] Error validating thinking budgets:", error)
return config // Return original config if validation fails
}
}
/**
* Updates API configuration
* @param controller The controller instance
@@ -23,13 +66,16 @@ export async function updateApiConfigurationProto(
// Convert proto ApiConfiguration to application ApiConfiguration
const appApiConfiguration = convertProtoToApiConfiguration(request.apiConfiguration)
// Validate and clamp thinking budget tokens before persisting
const validatedConfig = validateAndClampThinkingBudgets(appApiConfiguration)
// Update the API configuration in storage
controller.stateManager.setApiConfiguration(appApiConfiguration)
controller.stateManager.setApiConfiguration(validatedConfig)
// Update the task's API handler if there's an active task
if (controller.task) {
const currentMode = await controller.getCurrentMode()
controller.task.api = buildApiHandler({ ...appApiConfiguration, ulid: controller.task.ulid }, currentMode)
controller.task.api = buildApiHandler({ ...validatedConfig, ulid: controller.task.ulid }, currentMode)
}
// Post updated state to webview
@@ -1,7 +1,7 @@
import { anthropicModels, geminiDefaultModelId, geminiModels } from "@shared/api"
import { Mode } from "@shared/storage/types"
import { VSCodeCheckbox } from "@vscode/webview-ui-toolkit/react"
import { memo, useCallback, useMemo, useState } from "react"
import { memo, useCallback, useEffect, useMemo, useState } from "react"
import styled from "styled-components"
import { useExtensionState } from "@/context/ExtensionStateContext"
import { getModeSpecificFields } from "./utils/providerUtils"
@@ -115,6 +115,16 @@ const ThinkingBudgetSlider = ({ maxBudget, currentMode }: ThinkingBudgetSliderPr
// Add local state for the slider value
const [localValue, setLocalValue] = useState(modeFields.thinkingBudgetTokens || 0)
// Sync local state with backend values when they change
useEffect(() => {
const backendValue = modeFields.thinkingBudgetTokens || 0
if (backendValue !== localValue) {
setLocalValue(backendValue)
setIsEnabled(backendValue > 0)
}
}, [modeFields.thinkingBudgetTokens, currentMode])
const handleSliderChange = useCallback((event: React.ChangeEvent<HTMLInputElement>) => {
const value = parseInt(event.target.value, 10)
setLocalValue(value)