Skip to content
Draft
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
6 changes: 3 additions & 3 deletions src/api/providers/__tests__/openrouter.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
})
Expand Down
112 changes: 112 additions & 0 deletions src/shared/__tests__/api.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
7 changes: 7 additions & 0 deletions src/shared/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down
49 changes: 45 additions & 4 deletions webview-ui/src/components/settings/ThinkingBudget.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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.
Expand All @@ -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
Expand All @@ -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.
Expand Down Expand Up @@ -238,7 +277,7 @@ export const ThinkingBudget = ({ apiConfiguration, setApiConfigurationField, mod
</Checkbox>
</div>
)}
{(isReasoningBudgetRequired || enableReasoningEffort) && (
{isReasoningBudgetRequired || enableReasoningEffort ? (
<>
{renderMaxTokensSlider(
8192,
Expand All @@ -263,6 +302,8 @@ export const ThinkingBudget = ({ apiConfiguration, setApiConfigurationField, mod
</div>
</div>
</>
) : (
maxOutputTokensControl
)}
</>
) : isReasoningEffortSupported ? (
Expand Down
Loading
Loading