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.
This commit is contained in:
mateo-berri 2026-05-02 00:04:57 -07:00
parent 934ecdca78
commit 3835306c83
4 changed files with 74 additions and 23 deletions

View file

@ -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)

View file

@ -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:

View file

@ -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

View file

@ -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(