From 3835306c8319382c0b186caa7c1dae796ddeb8ab Mon Sep 17 00:00:00 2001 From: mateo-berri <277851410+mateo-berri@users.noreply.github.com> Date: Sat, 2 May 2026 00:04:57 -0700 Subject: [PATCH] fix(anthropic,bedrock): omit thinking/output_config when reasoning_effort="none" Setting reasoning_effort="none" on Anthropic chat models (direct, Bedrock Invoke, Bedrock Converse, Vertex AI Anthropic, Azure AI Anthropic) crashed LiteLLM with: litellm.APIConnectionError: 'NoneType' object has no attribute 'get' Both the Anthropic chat transformation and Bedrock Converse called ``AnthropicConfig._map_reasoning_effort`` and assigned the ``None`` it returns for ``"none"`` directly to ``optional_params["thinking"]``. Downstream ``is_thinking_enabled`` then did ``optional_params["thinking"].get("type")`` and crashed. Pop ``thinking`` (and on Claude 4.6/4.7, ``output_config``) instead of assigning ``None``, restoring the documented contract that ``reasoning_effort="none"`` means "do not enable thinking". This also prevents downstream Anthropic 400s ("thinking: Input should be an object", "output_config.effort: Input should be ...") if the bug were ever masked. Verified end-to-end against the live Anthropic API and Bedrock Converse on claude-opus-4-{5,6,7} and claude-sonnet-4-6, plus Bedrock Invoke for Claude 4.5/4.6. Vertex AI Anthropic and Azure AI Anthropic inherit the fixed ``map_openai_params`` from ``AnthropicConfig`` and need no further changes. --- litellm/llms/anthropic/chat/transformation.py | 37 +++++++++++-------- .../bedrock/chat/converse_transformation.py | 6 ++- .../test_anthropic_chat_transformation.py | 32 +++++++++++++--- .../chat/test_converse_transformation.py | 22 +++++++++++ 4 files changed, 74 insertions(+), 23 deletions(-) diff --git a/litellm/llms/anthropic/chat/transformation.py b/litellm/llms/anthropic/chat/transformation.py index 61ddf801a25..35624c93b37 100644 --- a/litellm/llms/anthropic/chat/transformation.py +++ b/litellm/llms/anthropic/chat/transformation.py @@ -1088,24 +1088,29 @@ class AnthropicConfig(AnthropicModelInfo, BaseConfig): elif param == "thinking": optional_params["thinking"] = value elif param == "reasoning_effort" and isinstance(value, str): - optional_params["thinking"] = AnthropicConfig._map_reasoning_effort( + mapped_thinking = AnthropicConfig._map_reasoning_effort( reasoning_effort=value, model=model ) - # For Claude 4.6+ models, effort is controlled via output_config, - # not thinking budget_tokens. Map reasoning_effort to output_config. - if AnthropicConfig._is_claude_4_6_model( - model - ) or AnthropicConfig._is_claude_4_7_model(model): - effort_map = { - "low": "low", - "minimal": "low", - "medium": "medium", - "high": "high", - "xhigh": "xhigh", - "max": "max", - } - mapped_effort = effort_map.get(value, value) - optional_params["output_config"] = {"effort": mapped_effort} + if mapped_thinking is None: + optional_params.pop("thinking", None) + optional_params.pop("output_config", None) + else: + optional_params["thinking"] = mapped_thinking + # For Claude 4.6+ models, effort is controlled via output_config, + # not thinking budget_tokens. Map reasoning_effort to output_config. + if AnthropicConfig._is_claude_4_6_model( + model + ) or AnthropicConfig._is_claude_4_7_model(model): + effort_map = { + "low": "low", + "minimal": "low", + "medium": "medium", + "high": "high", + "xhigh": "xhigh", + "max": "max", + } + mapped_effort = effort_map.get(value, value) + optional_params["output_config"] = {"effort": mapped_effort} elif param == "web_search_options" and isinstance(value, dict): hosted_web_search_tool = self.map_web_search_tool( cast(OpenAIWebSearchOptions, value) diff --git a/litellm/llms/bedrock/chat/converse_transformation.py b/litellm/llms/bedrock/chat/converse_transformation.py index 61a7d4c08db..8b8500ac060 100644 --- a/litellm/llms/bedrock/chat/converse_transformation.py +++ b/litellm/llms/bedrock/chat/converse_transformation.py @@ -449,9 +449,13 @@ class AmazonConverseConfig(BaseConfig): optional_params.update(reasoning_config) else: # Anthropic and other models: convert to thinking parameter - optional_params["thinking"] = AnthropicConfig._map_reasoning_effort( + mapped_thinking = AnthropicConfig._map_reasoning_effort( reasoning_effort=reasoning_effort, model=model ) + if mapped_thinking is None: + optional_params.pop("thinking", None) + else: + optional_params["thinking"] = mapped_thinking @staticmethod def _clamp_thinking_budget_tokens(optional_params: dict) -> None: diff --git a/tests/test_litellm/llms/anthropic/chat/test_anthropic_chat_transformation.py b/tests/test_litellm/llms/anthropic/chat/test_anthropic_chat_transformation.py index e8da50f0ec7..cbb9b21d620 100644 --- a/tests/test_litellm/llms/anthropic/chat/test_anthropic_chat_transformation.py +++ b/tests/test_litellm/llms/anthropic/chat/test_anthropic_chat_transformation.py @@ -1687,9 +1687,7 @@ def test_max_effort_rejected_for_opus_45(): messages = [{"role": "user", "content": "Test"}] - with pytest.raises( - ValueError, match="effort='max' is not supported by this model" - ): + with pytest.raises(ValueError, match="effort='max' is not supported by this model"): optional_params = {"output_config": {"effort": "max"}} config.transform_request( model="claude-opus-4-5-20251101", @@ -2251,9 +2249,7 @@ def test_max_effort_rejected_for_sonnet_46(): config = AnthropicConfig() messages = [{"role": "user", "content": "Test"}] - with pytest.raises( - ValueError, match="effort='max' is not supported by this model" - ): + with pytest.raises(ValueError, match="effort='max' is not supported by this model"): config.transform_request( model="claude-sonnet-4-6-20260219", messages=messages, @@ -2315,6 +2311,30 @@ def test_effort_beta_header_not_injected_for_46_models(): assert result is False, f"is_effort_used should return False for {model}" +@pytest.mark.parametrize( + "model", + [ + "claude-opus-4-5-20251101", + "claude-opus-4-6-20250514", + "claude-sonnet-4-6-20260219", + "claude-opus-4-7", + ], +) +def test_reasoning_effort_none_omits_thinking_and_output_config(model): + """reasoning_effort="none" must omit thinking and output_config from the request.""" + config = AnthropicConfig() + + result = config.map_openai_params( + non_default_params={"reasoning_effort": "none"}, + optional_params={}, + model=model, + drop_params=False, + ) + + assert "thinking" not in result + assert "output_config" not in result + + def test_effort_beta_header_still_injected_for_older_models(): """ Test that is_effort_used still returns True for pre-4.6 models diff --git a/tests/test_litellm/llms/bedrock/chat/test_converse_transformation.py b/tests/test_litellm/llms/bedrock/chat/test_converse_transformation.py index 589bcb4fc0e..5bb51ac619c 100644 --- a/tests/test_litellm/llms/bedrock/chat/test_converse_transformation.py +++ b/tests/test_litellm/llms/bedrock/chat/test_converse_transformation.py @@ -288,6 +288,28 @@ def test_reasoning_with_forced_tool_choice_switches_to_auto(): assert optional_params["tool_choice"] == {"auto": {}} +@pytest.mark.parametrize( + "model", + [ + "bedrock/converse/us.anthropic.claude-opus-4-5-20251101-v1:0", + "bedrock/converse/us.anthropic.claude-opus-4-6-v1", + "bedrock/converse/us.anthropic.claude-opus-4-7", + ], +) +def test_reasoning_effort_none_omits_thinking_for_anthropic_converse(model): + """reasoning_effort="none" must omit thinking from the Bedrock Converse request.""" + config = AmazonConverseConfig() + + optional_params = config.map_openai_params( + non_default_params={"reasoning_effort": "none"}, + optional_params={}, + model=model, + drop_params=False, + ) + + assert "thinking" not in optional_params + + def test_get_supported_openai_params(): config = AmazonConverseConfig() supported_params = config.get_supported_openai_params(