fix(anthropic): disable optional default thinking for reasoning effort none (#50541)

This commit is contained in:
ryan-crabbe-berri 2026-11-07 12:51:37 -08:00 • committed by GitHub
parent 8d9ddb349e
commit 02d610f1a9
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
14 changed files with 158 additions and 17 deletions

View file

@ -144,6 +144,7 @@ mod tests {
supports_reasoning: true,
supports_adaptive_thinking: true,
thinking_always_on: false,
thinking_on_by_default: false,
supports_legacy_thinking: false,
supports_output_config: true,
supports_sampling_params: false,

View file

@ -1913,6 +1913,7 @@ mod tests {
supports_reasoning: false,
supports_adaptive_thinking: false,
thinking_always_on: false,
thinking_on_by_default: false,
supports_legacy_thinking: false,
supports_output_config: false,
supports_sampling_params: true,

View file

@ -2,8 +2,8 @@ use litellm_llms_types::{
formats::{
chat_completions::ReasoningEffort,
messages::{
AdaptiveThinking, EffortLevel, MessagesOptionalParams, MessagesRequest, OutputConfig,
ThinkingConfig, ThinkingDisplay,
AdaptiveThinking, DisabledThinking, EffortLevel, MessagesOptionalParams,
MessagesRequest, OutputConfig, ThinkingConfig, ThinkingDisplay,
},
},
recognized::Recognized,
@ -172,7 +172,11 @@ fn translate_reasoning_effort(
) else {
return Ok(MessagesRequest {
params: MessagesOptionalParams {
thinking: None,
thinking: (context.capabilities.thinking_on_by_default
&& !context.capabilities.thinking_always_on)
.then(|| {
Recognized::Known(ThinkingConfig::Disabled(DisabledThinking::default()))
}),
output_config: None,
..request.params
},
@ -748,6 +752,33 @@ mod tests {
);
}
#[rstest]
#[case::default_thinking(true, false, Some(serde_json::json!({"type": "disabled"}))) ]
#[case::legacy(false, false, None)]
#[case::always_on(true, true, None)]
fn none_disables_only_optional_default_thinking(
#[case] thinking_on_by_default: bool,
#[case] thinking_always_on: bool,
#[case] thinking: Option<Value>,
) {
let capabilities = MessagesModelCapabilities {
thinking_on_by_default,
thinking_always_on,
..opus_4_7()
};
let expected = match thinking {
Some(thinking) => serde_json::json!({"max_tokens": 1024, "thinking": thinking}),
None => serde_json::json!({"max_tokens": 1024}),
};
assert_eq!(
translate(
capabilities,
serde_json::json!({"max_tokens": 1024, "reasoning_effort": "none"})
),
Ok(request(expected))
);
}
#[rstest]
#[case::adaptive_model(opus_4_7())]
#[case::effort_capable_model(opus_4_5())]

View file

@ -32,6 +32,8 @@ pub struct MessagesModelCapabilities {
#[serde(default)]
pub thinking_always_on: bool,
#[serde(default)]
pub thinking_on_by_default: bool,
#[serde(default)]
pub supports_legacy_thinking: bool,
#[serde(default)]
pub supports_output_config: bool,
@ -55,6 +57,7 @@ impl Default for MessagesModelCapabilities {
supports_reasoning: false,
supports_adaptive_thinking: false,
thinking_always_on: false,
thinking_on_by_default: false,
supports_legacy_thinking: false,
supports_output_config: false,
supports_sampling_params: true,

View file

@ -1718,7 +1718,13 @@ class AnthropicConfig(AnthropicModelInfo, BaseConfig):
llm_provider: str = "anthropic",
) -> AnthropicThinkingParam | None:
"""Capability probes read the cost map under ``custom_llm_provider``; ``llm_provider`` only tags raised exceptions."""
if reasoning_effort is None or reasoning_effort == "none":
if reasoning_effort is None:
return None
if reasoning_effort == "none":
if AnthropicModelInfo.thinks_by_default(
model, custom_llm_provider
) and not AnthropicModelInfo._is_always_on_thinking_model(model, custom_llm_provider):
return AnthropicThinkingParam(type="disabled")
return None
if AnthropicConfig._is_adaptive_thinking_model(model, custom_llm_provider):
# without display, Anthropic defaults adaptive thinking to
@ -2126,12 +2132,14 @@ class AnthropicConfig(AnthropicModelInfo, BaseConfig):
custom_llm_provider=self._resolved_provider,
llm_provider=self._resolved_provider,
)
if mapped_thinking is None:
if mapped_thinking is None or mapped_thinking.get("type") == "disabled":
optional_params.pop("thinking", None)
optional_params.pop("output_config", None)
else:
if mapped_thinking is not None:
optional_params["thinking"] = mapped_thinking
if AnthropicConfig._is_adaptive_thinking_model(model, self._resolved_provider):
if mapped_thinking.get("type") != "disabled" and AnthropicConfig._is_adaptive_thinking_model(
model, self._resolved_provider
):
mapped_effort = REASONING_EFFORT_TO_OUTPUT_CONFIG_EFFORT.get(effort_value)
if mapped_effort is None:
AnthropicConfig._raise_invalid_reasoning_effort(

View file

@ -471,7 +471,7 @@ class AnthropicMessagesConfig(BaseAnthropicMessagesConfig):
"""Map OpenAI-style ``reasoning_effort`` to native Anthropic params.
Caller-supplied ``thinking`` / ``output_config`` win over the alias.
``effort='none'`` clears both. Invalid efforts raise a 400. A mapped
``effort='none'`` disables optional default thinking and clears effort. Invalid efforts raise a 400. A mapped
thinking budget is capped below ``max_tokens`` and dropped when even
the minimum budget cannot fit.
"""
@ -485,6 +485,10 @@ class AnthropicMessagesConfig(BaseAnthropicMessagesConfig):
if not isinstance(reasoning_effort, str):
return
if reasoning_effort == "none":
optional_params.pop("thinking", None)
optional_params.pop("output_config", None)
try:
mapped_thinking: Final = AnthropicConfig.map_reasoning_effort(
reasoning_effort=reasoning_effort,
@ -495,8 +499,6 @@ class AnthropicMessagesConfig(BaseAnthropicMessagesConfig):
raise AnthropicError(message=str(e.message), status_code=400)
if mapped_thinking is None:
optional_params.pop("thinking", None)
optional_params.pop("output_config", None)
return
fitted_thinking: Final = AnthropicConfig.cap_thinking_budget_to_max_tokens(mapped_thinking, max_tokens)
@ -505,7 +507,9 @@ class AnthropicMessagesConfig(BaseAnthropicMessagesConfig):
return
optional_params.setdefault("thinking", fitted_thinking)
if AnthropicModelInfo.is_adaptive_thinking_model(model, custom_llm_provider):
if fitted_thinking.get("type") != "disabled" and AnthropicModelInfo.is_adaptive_thinking_model(
model, custom_llm_provider
):
mapped_effort: Final = REASONING_EFFORT_TO_OUTPUT_CONFIG_EFFORT.get(reasoning_effort)
if mapped_effort is None:
raise AnthropicError(

View file

@ -583,12 +583,14 @@ class AmazonConverseConfig(BaseConfig):
custom_llm_provider="bedrock",
llm_provider="bedrock_converse",
)
if mapped_thinking is None:
if mapped_thinking is None or mapped_thinking.get("type") == "disabled":
optional_params.pop("thinking", None)
optional_params.pop("output_config", None)
else:
if mapped_thinking is not None:
optional_params["thinking"] = mapped_thinking
if AnthropicConfig.is_adaptive_thinking_model(model, "bedrock"):
if mapped_thinking.get("type") != "disabled" and AnthropicConfig.is_adaptive_thinking_model(
model, "bedrock"
):
mapped_effort = REASONING_EFFORT_TO_OUTPUT_CONFIG_EFFORT.get(reasoning_effort)
if mapped_effort is None:
AnthropicConfig.raise_invalid_reasoning_effort(

View file

@ -629,12 +629,14 @@ class DatabricksConfig(DatabricksBase, OpenAILikeChatConfig, AnthropicConfig):
custom_llm_provider="databricks",
llm_provider="databricks",
)
if mapped_thinking is None:
if mapped_thinking is None or mapped_thinking.get("type") == "disabled":
optional_params.pop("thinking", None)
optional_params.pop("output_config", None)
else:
if mapped_thinking is not None:
optional_params["thinking"] = mapped_thinking
if AnthropicConfig._is_adaptive_thinking_model(model, "databricks"):
if mapped_thinking.get("type") != "disabled" and AnthropicConfig._is_adaptive_thinking_model(
model, "databricks"
):
mapped_effort: str | None = None
if isinstance(reasoning_effort_value, str):
mapped_effort = REASONING_EFFORT_TO_OUTPUT_CONFIG_EFFORT.get(reasoning_effort_value)

View file

@ -27,6 +27,7 @@ def anthropic_model_capabilities(model: str, custom_llm_provider: str | None) ->
"supports_reasoning": supports("supports_reasoning"),
"supports_adaptive_thinking": supports("supports_adaptive_thinking"),
"thinking_always_on": supports("thinking_always_on"),
"thinking_on_by_default": AnthropicModelInfo.thinks_by_default(model, provider),
"supports_legacy_thinking": supports("supports_legacy_thinking"),
"supports_output_config": supports("supports_output_config"),
"supports_sampling_params": AnthropicModelInfo._supports_sampling_params(resolved_model), # pyright: ignore[reportPrivateUsage] # same gate the handler applies

View file

@ -721,3 +721,17 @@ def test_a_first_party_messages_request_never_fetches_the_remote_beta_headers_co
assert request["messages"][1] == {"role": "system", "content": "be terse"}
assert not remote_config.called
@pytest.mark.parametrize(
"model, expected",
[
("claude-sonnet-5", {"type": "disabled"}),
("claude-haiku-4-5", None),
("claude-sonnet-5-5", None),
],
)
def test_none_disables_only_optional_default_thinking(model: str, expected: dict[str, str] | None) -> None:
params: Final[dict[str, object]] = {"reasoning_effort": "none", "output_config": {"effort": "high"}}
AnthropicMessagesConfig._translate_reasoning_effort_to_anthropic(model, params, 1024, "anthropic")
assert params == ({"thinking": expected} if expected is not None else {})

View file

@ -5,11 +5,42 @@ Verifies that reasoning_effort=None returns None for all models,
including Claude Opus 4.6.
"""
from typing import Final
import pytest
from litellm.exceptions import BadRequestError
from litellm.llms.anthropic.chat.transformation import AnthropicConfig
@pytest.mark.parametrize(
"model, expected",
[
("claude-sonnet-5", {"type": "disabled"}),
("claude-opus-5", {"type": "disabled"}),
("claude-haiku-5-5", {"type": "disabled"}),
("claude-haiku-4-5", None),
("claude-sonnet-5-5", None),
("claude-opus-5-5", None),
("claude-fable-5", None),
("claude-mythos-5", None),
],
)
def test_none_disables_only_optional_default_thinking(model: str, expected: dict[str, str] | None) -> None:
mapped: Final = AnthropicConfig().map_openai_params({"reasoning_effort": "none"}, {}, model, False)
assert mapped.get("thinking") == expected
assert "output_config" not in mapped
@pytest.mark.parametrize("effort", [None, "bogus"])
def test_default_thinking_preserves_absent_and_invalid_effort(effort: str | None) -> None:
if effort is None:
assert AnthropicConfig.map_reasoning_effort(effort, "claude-sonnet-5", "anthropic") is None
else:
with pytest.raises(BadRequestError, match="Invalid reasoning_effort"):
AnthropicConfig().map_openai_params({"reasoning_effort": effort}, {}, "claude-sonnet-5", False)
class TestMapReasoningEffort:
def test_none_returns_none_for_opus_4_6(self):
"""reasoning_effort=None should return None for Opus 4.6, not adaptive."""

View file

@ -12203,6 +12203,21 @@ def test_converse_claude_request_over_the_cache_control_cap_drops_the_oldest_hin
assert messages == messages_before
@pytest.mark.parametrize(
"model, expected",
[
("us.anthropic.claude-sonnet-5", {"type": "disabled"}),
("us.anthropic.claude-haiku-4-5", None),
("us.anthropic.claude-sonnet-5-5", None),
],
)
def test_none_disables_only_optional_default_thinking(model: str, expected: dict[str, str] | None) -> None:
params: Final[dict[str, object]] = {}
AmazonConverseConfig()._handle_reasoning_effort_parameter(model, "none", params)
assert params.get("thinking") == expected
assert "output_config" not in params
def test_converse_claude_tool_message_with_several_marked_blocks_counts_as_one_cache_point_against_the_cap(
monkeypatch: pytest.MonkeyPatch, local_model_cost_map: None
) -> None:

View file

@ -3220,3 +3220,18 @@ def test_databricks_claude_cache_control_cap_keeps_room_for_a_top_level_cache_co
assert "cache_control" not in json.dumps(body["messages"][0])
assert json.dumps(body["messages"]).count('"cache_control"') == 3
@pytest.mark.parametrize(
"model, expected",
[
("databricks-claude-sonnet-5", {"type": "disabled"}),
("databricks-claude-haiku-4-5", None),
("databricks-claude-sonnet-5-5", None),
],
)
def test_none_disables_only_optional_default_thinking(model: str, expected: dict[str, str] | None) -> None:
params: Final = _map_reasoning_effort(model, "none")
assert params.get("thinking") == expected
assert "output_config" not in params
assert "reasoning_effort" not in params

View file

@ -73,3 +73,16 @@ def test_capability_source_observes_runtime_registration_changes(monkeypatch: py
assert before["supports_output_config"] is True
assert after["supports_reasoning"] is False
assert after["supports_output_config"] is False
@pytest.mark.parametrize(
"model, expected",
[
("claude-sonnet-5", True),
("claude-haiku-4-5", False),
("claude-sonnet-5-5", True),
],
)
def test_default_thinking_capability_is_projected(model: str, expected: bool) -> None:
capabilities: Final = anthropic_model_capabilities(model, "anthropic")
assert capabilities["thinking_on_by_default"] is expected