diff --git a/litellm-rust/crates/inference-messages/src/types.rs b/litellm-rust/crates/inference-messages/src/types.rs index ae346259559..ece17b3380c 100644 --- a/litellm-rust/crates/inference-messages/src/types.rs +++ b/litellm-rust/crates/inference-messages/src/types.rs @@ -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, diff --git a/litellm-rust/crates/llms/src/anthropic/common_utils.rs b/litellm-rust/crates/llms/src/anthropic/common_utils.rs index 13653f438f7..0854820358b 100644 --- a/litellm-rust/crates/llms/src/anthropic/common_utils.rs +++ b/litellm-rust/crates/llms/src/anthropic/common_utils.rs @@ -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, diff --git a/litellm-rust/crates/llms/src/anthropic/messages/thinking.rs b/litellm-rust/crates/llms/src/anthropic/messages/thinking.rs index ae3488a21f9..0ddbc915154 100644 --- a/litellm-rust/crates/llms/src/anthropic/messages/thinking.rs +++ b/litellm-rust/crates/llms/src/anthropic/messages/thinking.rs @@ -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, + ) { + 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())] diff --git a/litellm-rust/crates/llms/src/base_llm/messages/context.rs b/litellm-rust/crates/llms/src/base_llm/messages/context.rs index b514e975aa4..3d8b0f41d8c 100644 --- a/litellm-rust/crates/llms/src/base_llm/messages/context.rs +++ b/litellm-rust/crates/llms/src/base_llm/messages/context.rs @@ -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, diff --git a/litellm/llms/anthropic/chat/transformation.py b/litellm/llms/anthropic/chat/transformation.py index e0d313175b7..48bccc7c001 100644 --- a/litellm/llms/anthropic/chat/transformation.py +++ b/litellm/llms/anthropic/chat/transformation.py @@ -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( diff --git a/litellm/llms/anthropic/messages/native/transformation.py b/litellm/llms/anthropic/messages/native/transformation.py index 5281ad99620..aa0693d6f03 100644 --- a/litellm/llms/anthropic/messages/native/transformation.py +++ b/litellm/llms/anthropic/messages/native/transformation.py @@ -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( diff --git a/litellm/llms/bedrock/chat/converse_transformation.py b/litellm/llms/bedrock/chat/converse_transformation.py index 12c5031628b..5c82a8d6bc4 100644 --- a/litellm/llms/bedrock/chat/converse_transformation.py +++ b/litellm/llms/bedrock/chat/converse_transformation.py @@ -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( diff --git a/litellm/llms/databricks/chat/transformation.py b/litellm/llms/databricks/chat/transformation.py index a173cdb50f2..7917a74247e 100644 --- a/litellm/llms/databricks/chat/transformation.py +++ b/litellm/llms/databricks/chat/transformation.py @@ -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) diff --git a/litellm/rust_bridge/host/model_capabilities.py b/litellm/rust_bridge/host/model_capabilities.py index cbf59dfb97b..39eb0b08aa2 100644 --- a/litellm/rust_bridge/host/model_capabilities.py +++ b/litellm/rust_bridge/host/model_capabilities.py @@ -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 diff --git a/tests/unit/llms/anthropic/messages/native/test_anthropic_messages_transformation.py b/tests/unit/llms/anthropic/messages/native/test_anthropic_messages_transformation.py index 54b29ca6aa7..8f86888728f 100644 --- a/tests/unit/llms/anthropic/messages/native/test_anthropic_messages_transformation.py +++ b/tests/unit/llms/anthropic/messages/native/test_anthropic_messages_transformation.py @@ -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 {}) diff --git a/tests/unit/llms/anthropic/test_anthropic_reasoning_effort.py b/tests/unit/llms/anthropic/test_anthropic_reasoning_effort.py index 573f586de1e..1b066e49e3b 100644 --- a/tests/unit/llms/anthropic/test_anthropic_reasoning_effort.py +++ b/tests/unit/llms/anthropic/test_anthropic_reasoning_effort.py @@ -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.""" diff --git a/tests/unit/llms/bedrock/chat/test_converse_transformation.py b/tests/unit/llms/bedrock/chat/test_converse_transformation.py index 4dcf312737d..23ac5baf1ba 100644 --- a/tests/unit/llms/bedrock/chat/test_converse_transformation.py +++ b/tests/unit/llms/bedrock/chat/test_converse_transformation.py @@ -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: diff --git a/tests/unit/llms/databricks/chat/test_databricks_chat_transformation.py b/tests/unit/llms/databricks/chat/test_databricks_chat_transformation.py index e04dd35f851..053d60ef0e1 100644 --- a/tests/unit/llms/databricks/chat/test_databricks_chat_transformation.py +++ b/tests/unit/llms/databricks/chat/test_databricks_chat_transformation.py @@ -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 diff --git a/tests/unit/rust_bridge/host/test_model_capabilities.py b/tests/unit/rust_bridge/host/test_model_capabilities.py index 4d73a29d078..dffa1b2cde3 100644 --- a/tests/unit/rust_bridge/host/test_model_capabilities.py +++ b/tests/unit/rust_bridge/host/test_model_capabilities.py @@ -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