diff --git a/src/api/providers/__tests__/openrouter.spec.ts b/src/api/providers/__tests__/openrouter.spec.ts index b21d409d0a..82eacf62aa 100644 --- a/src/api/providers/__tests__/openrouter.spec.ts +++ b/src/api/providers/__tests__/openrouter.spec.ts @@ -154,9 +154,9 @@ describe("OpenRouterHandler", () => { }) const result = await handler.fetchModel() - // With the new clamping logic, 128000 tokens (64% of 200000 context window) - // gets clamped to 20% of context window: 200000 * 0.2 = 40000 - expect(result.maxTokens).toBe(40000) + // The user-configured max output tokens are honored for Anthropic contexts, + // capped at the model's advertised ceiling (128000). + expect(result.maxTokens).toBe(32_768) expect(result.reasoningBudget).toBeUndefined() expect(result.temperature).toBe(0) }) diff --git a/src/shared/__tests__/api.spec.ts b/src/shared/__tests__/api.spec.ts index f4fb8330bb..4ffc30bf4d 100644 --- a/src/shared/__tests__/api.spec.ts +++ b/src/shared/__tests__/api.spec.ts @@ -80,6 +80,118 @@ describe("getModelMaxOutputTokens", () => { expect(result).toBe(ANTHROPIC_DEFAULT_MAX_TOKENS) // Should be 8192, not 64_000 }) + test("should honor modelMaxTokens override for Anthropic hybrid models when reasoning is disabled", () => { + const anthropicModelId = "claude-sonnet-4-20250514" + const model: ModelInfo = { + contextWindow: 200_000, + supportsPromptCache: true, + supportsReasoningBudget: true, + maxTokens: 64_000, + } + + const settings: ProviderSettings = { + apiProvider: "anthropic", + enableReasoningEffort: false, + modelMaxTokens: 32_768, + } + + const result = getModelMaxOutputTokens({ modelId: anthropicModelId, model, settings }) + expect(result).toBe(32_768) + }) + + test("should cap modelMaxTokens override at model maxTokens for Anthropic hybrid models when reasoning is disabled", () => { + const anthropicModelId = "claude-sonnet-4-20250514" + const model: ModelInfo = { + contextWindow: 200_000, + supportsPromptCache: true, + supportsReasoningBudget: true, + maxTokens: 64_000, + } + + const settings: ProviderSettings = { + apiProvider: "anthropic", + enableReasoningEffort: false, + modelMaxTokens: 100_000, + } + + const result = getModelMaxOutputTokens({ modelId: anthropicModelId, model, settings }) + expect(result).toBe(64_000) + }) + + test("should ignore modelMaxTokens: 0 for Anthropic hybrid models and preserve the default 8K cap", () => { + const anthropicModelId = "claude-sonnet-4-20250514" + const model: ModelInfo = { + contextWindow: 200_000, + supportsPromptCache: true, + supportsReasoningBudget: true, + maxTokens: 64_000, + } + + const settings: ProviderSettings = { + apiProvider: "anthropic", + enableReasoningEffort: false, + modelMaxTokens: 0, + } + + const result = getModelMaxOutputTokens({ modelId: anthropicModelId, model, settings }) + expect(result).toBe(ANTHROPIC_DEFAULT_MAX_TOKENS) + }) + + test("should honor modelMaxTokens override for Anthropic hybrid models via OpenRouter when reasoning is disabled", () => { + const model: ModelInfo = { + contextWindow: 200_000, + supportsPromptCache: true, + supportsReasoningBudget: true, + maxTokens: 64_000, + } + + const settings: ProviderSettings = { + apiProvider: "openrouter", + enableReasoningEffort: false, + modelMaxTokens: 32_768, + } + + const result = getModelMaxOutputTokens({ + modelId: "anthropic/claude-sonnet-4-20250514", + model, + settings, + format: "openrouter", + }) + expect(result).toBe(32_768) + }) + + test("should honor modelMaxTokens override for non-reasoning Anthropic models", () => { + const model: ModelInfo = { + contextWindow: 200_000, + supportsPromptCache: true, + maxTokens: 8192, + } + + const settings: ProviderSettings = { + apiProvider: "anthropic", + modelMaxTokens: 4096, + } + + const result = getModelMaxOutputTokens({ modelId: "claude-3-5-sonnet-20241022", model, settings }) + expect(result).toBe(4096) + }) + + test("should cap modelMaxTokens override for non-reasoning Anthropic models at model maxTokens", () => { + const model: ModelInfo = { + contextWindow: 200_000, + supportsPromptCache: true, + maxTokens: 8192, + } + + const settings: ProviderSettings = { + apiProvider: "anthropic", + modelMaxTokens: 16_384, + } + + const result = getModelMaxOutputTokens({ modelId: "claude-3-5-sonnet-20241022", model, settings }) + expect(result).toBe(8192) + }) + test("should preserve Anthropic hybrid token handling when a model also supports binary reasoning", () => { const model: ModelInfo = { contextWindow: 1_000_000, diff --git a/src/shared/api.ts b/src/shared/api.ts index d5a579be9d..15bf39ab02 100644 --- a/src/shared/api.ts +++ b/src/shared/api.ts @@ -122,7 +122,14 @@ export const getModelMaxOutputTokens = ({ format === "anthropic" || (format === "openrouter" && modelId.startsWith("anthropic/")) + // For all Anthropic contexts, honor an explicit modelMaxTokens override (capped at the + // model's advertised ceiling) so the UI slider's persisted value is actually sent to the API. + if (isAnthropicContext && settings?.modelMaxTokens != null && settings.modelMaxTokens > 0) { + return model.maxTokens ? Math.min(settings.modelMaxTokens, model.maxTokens) : settings.modelMaxTokens + } + // For "Hybrid" reasoning models, discard the model's actual maxTokens for Anthropic contexts + // when the user has not configured an output token budget. if (model.supportsReasoningBudget && isAnthropicContext) { return ANTHROPIC_DEFAULT_MAX_TOKENS } diff --git a/webview-ui/src/components/settings/ThinkingBudget.tsx b/webview-ui/src/components/settings/ThinkingBudget.tsx index 9525ba7a95..53eb9473e8 100644 --- a/webview-ui/src/components/settings/ThinkingBudget.tsx +++ b/webview-ui/src/components/settings/ThinkingBudget.tsx @@ -71,10 +71,15 @@ export const ThinkingBudget = ({ apiConfiguration, setApiConfigurationField, mod const isReasoningBudgetSupported = !!modelInfo && modelInfo.supportsReasoningBudget const isReasoningBudgetRequired = !!modelInfo && modelInfo.requiredReasoningBudget const isReasoningEffortSupported = !!modelInfo && modelInfo.supportsReasoningEffort + const isAnthropicModel = !!selectedModelId && selectedModelId.includes("claude") // Models that advertise a user-configurable max output budget (e.g. Z.ai GLM) but do not // use the reasoning-budget slider. The reasoning-budget branch already renders its own // max-tokens control, so only surface this standalone slider when that branch is inactive. - const isMaxTokensConfigurable = !!modelInfo && modelInfo.supportsMaxTokens && !isReasoningBudgetSupported + // Anthropic models also get a configurable max output slider so users can override the + // conservative 8K default when reasoning is disabled. + const isMaxTokensConfigurable = + !!modelInfo && + ((modelInfo.supportsMaxTokens && !isReasoningBudgetSupported) || (isAnthropicModel && !!modelInfo.maxTokens)) // "disable" turns off reasoning entirely; "none" is a valid reasoning level. // Both display as "None" in the UI but behave differently. @@ -165,6 +170,29 @@ export const ThinkingBudget = ({ apiConfiguration, setApiConfigurationField, mod } }, [isReasoningBudgetSupported, customMaxThinkingTokens, modelMaxThinkingTokens, setApiConfigurationField]) + // Anthropic hybrid reasoning requires max output tokens to be at least 8192. If the user + // toggles reasoning on with a persisted value below that floor, raise it to keep the + // reasoning-budget slider range valid and the thinking budget within its own 1024 min. + useEffect(() => { + const ANTHROPIC_REASONING_MIN_OUTPUT_TOKENS = 8192 + if ( + isReasoningBudgetSupported && + isAnthropicModel && + enableReasoningEffort && + apiConfiguration.modelMaxTokens != null && + apiConfiguration.modelMaxTokens > 0 && + apiConfiguration.modelMaxTokens < ANTHROPIC_REASONING_MIN_OUTPUT_TOKENS + ) { + setApiConfigurationField("modelMaxTokens", ANTHROPIC_REASONING_MIN_OUTPUT_TOKENS, false) + } + }, [ + isReasoningBudgetSupported, + isAnthropicModel, + enableReasoningEffort, + apiConfiguration.modelMaxTokens, + setApiConfigurationField, + ]) + // Default max output budget for models that expose a standalone max-tokens slider. // When the user hasn't set an explicit `modelMaxTokens`, fall back to the same value // the runtime would use (the default output clamp) so behavior is unchanged. @@ -174,7 +202,10 @@ export const ThinkingBudget = ({ apiConfiguration, setApiConfigurationField, mod : undefined) ?? modelInfo?.maxTokens ?? DEFAULT_HYBRID_REASONING_MODEL_MAX_TOKENS - const standaloneMaxOutputTokens = apiConfiguration.modelMaxTokens ?? defaultMaxOutputTokens + const standaloneMaxOutputTokens = Math.min( + apiConfiguration.modelMaxTokens ?? defaultMaxOutputTokens, + modelInfo?.maxTokens ?? defaultMaxOutputTokens, + ) if (!modelInfo) { return null @@ -200,9 +231,17 @@ export const ThinkingBudget = ({ apiConfiguration, setApiConfigurationField, mod // Standalone max output tokens slider for models that advertise `supportsMaxTokens` // (e.g. Z.ai GLM) but do not surface the reasoning-budget control. + // Anthropic hybrid reasoning requires a minimum output budget of 8192, so keep the + // standalone slider aligned with the reasoning-on branch's floor for those models. + const maxOutputTokensMin = isAnthropicModel && isReasoningBudgetSupported ? 8192 : 1024 const maxOutputTokensControl = isMaxTokensConfigurable && modelInfo.maxTokens - ? renderMaxTokensSlider(1024, modelInfo.maxTokens, standaloneMaxOutputTokens, "max-output-tokens") + ? renderMaxTokensSlider( + maxOutputTokensMin, + modelInfo.maxTokens, + standaloneMaxOutputTokens, + "max-output-tokens", + ) : null // Models with supportsReasoningBinary (binary reasoning) show a simple on/off toggle. @@ -238,7 +277,7 @@ export const ThinkingBudget = ({ apiConfiguration, setApiConfigurationField, mod )} - {(isReasoningBudgetRequired || enableReasoningEffort) && ( + {isReasoningBudgetRequired || enableReasoningEffort ? ( <> {renderMaxTokensSlider( 8192, @@ -263,6 +302,8 @@ export const ThinkingBudget = ({ apiConfiguration, setApiConfigurationField, mod + ) : ( + maxOutputTokensControl )} ) : isReasoningEffortSupported ? ( diff --git a/webview-ui/src/components/settings/__tests__/ThinkingBudget.spec.tsx b/webview-ui/src/components/settings/__tests__/ThinkingBudget.spec.tsx index 5f9b74dfe0..5f15f3f838 100644 --- a/webview-ui/src/components/settings/__tests__/ThinkingBudget.spec.tsx +++ b/webview-ui/src/components/settings/__tests__/ThinkingBudget.spec.tsx @@ -82,6 +82,7 @@ describe("ThinkingBudget", () => { const { container } = render( { expect(screen.getByTestId("reasoning-effort")).toBeInTheDocument() }) }) + + describe("Anthropic max output tokens override", () => { + it("should show max output tokens slider for Anthropic hybrid models when reasoning is disabled", () => { + render( + , + ) + + expect(screen.getByTestId("max-output-tokens")).toBeInTheDocument() + expect(screen.queryByTestId("reasoning-budget")).not.toBeInTheDocument() + const slider = screen.getByTestId("max-output-tokens").querySelector("input[type='range']")! + expect(slider).toHaveValue("8192") + expect(slider.getAttribute("max")).toBe("64000") + }) + + it("should show reasoning budget slider when reasoning is enabled for Anthropic hybrid models", () => { + render( + , + ) + + expect(screen.queryByTestId("max-output-tokens")).not.toBeInTheDocument() + expect(screen.getByTestId("reasoning-budget")).toBeInTheDocument() + }) + + it("should show max output tokens slider for Anthropic binary reasoning models", () => { + render( + , + ) + + expect(screen.getByTestId("max-output-tokens")).toBeInTheDocument() + }) + + it("should show max output tokens slider for OpenRouter Anthropic models when reasoning is disabled", () => { + render( + , + ) + + expect(screen.getByTestId("max-output-tokens")).toBeInTheDocument() + const slider = screen.getByTestId("max-output-tokens").querySelector("input[type='range']")! + expect(slider).toHaveValue("8192") + expect(slider.getAttribute("max")).toBe("64000") + }) + + it("should show max output tokens slider for non-reasoning Anthropic models", () => { + render( + , + ) + + expect(screen.getByTestId("max-output-tokens")).toBeInTheDocument() + }) + + it("should use a minimum of 8192 for the Anthropic standalone max output slider", () => { + render( + , + ) + + const slider = screen.getByTestId("max-output-tokens").querySelector("input[type='range']")! + expect(slider.getAttribute("min")).toBe("8192") + }) + + it("should keep a minimum of 1024 for non-reasoning Anthropic models with small maxTokens", () => { + render( + , + ) + + const slider = screen.getByTestId("max-output-tokens").querySelector("input[type='range']")! + expect(slider.getAttribute("min")).toBe("1024") + expect(slider.getAttribute("max")).toBe("4096") + }) + + it("should clamp modelMaxTokens to 8192 when reasoning is enabled for an Anthropic hybrid model", () => { + const setApiConfigurationField = vi.fn() + + render( + , + ) + + expect(setApiConfigurationField).toHaveBeenCalledWith("modelMaxTokens", 8192, false) + }) + }) })