From e400de4654404749a64af7cbda985a47e8855fb7 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 3 May 2026 08:28:28 +0000 Subject: [PATCH] fix(anthropic): translate reasoning_effort on /v1/messages route MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes the remaining QA-sweep gap on PR #27074: Bedrock Invoke /v1/messages was silently ignoring ``reasoning_effort`` because the shared param filter only kept native Anthropic keys, so every effort tier collapsed to the same behavior on the wire (27/231 cells failing across opus-4-5 / opus-4-6 / sonnet-4-6). Map ``reasoning_effort`` to native Anthropic ``thinking`` / ``output_config.effort`` at the ``AnthropicMessagesConfig`` layer so all four /v1/messages routes (direct Anthropic, Azure AI, Vertex AI, Bedrock Invoke) inherit the same translation: - Add ``reasoning_effort`` to ``AnthropicMessagesRequestOptionalParams`` so the param filter in ``AnthropicMessagesRequestUtils.get_requested_anthropic_messages_optional_param`` no longer drops it before the transformation runs. - Add ``_translate_reasoning_effort_to_anthropic`` and call it from ``transform_anthropic_messages_request``. Mirrors ``AnthropicConfig.map_openai_params`` on the chat completion path (re-uses ``_map_reasoning_effort`` and ``REASONING_EFFORT_TO_OUTPUT_CONFIG_EFFORT``) so the two routes cannot drift. Pops ``reasoning_effort`` so it never reaches the wire. - Caller-supplied native ``thinking`` / ``output_config.effort`` always win — same precedence as ``_translate_legacy_thinking_for_adaptive_model``. - Garbage values (``""``, ``"disabled"``, ``"invalid"``) raise ``AnthropicError(status_code=400)`` instead of falling through and surfacing as 500s from the provider. - ``"none"`` clears thinking + output_config so callers can opt out per request. Also restores the non-adaptive-model test coverage on Bedrock Invoke /v1/messages that the previous commit lost when ``test_bedrock_messages_strips_output_config`` was renamed to the ``forwards`` variant on Opus 4.7. Adds a new test file ``test_reasoning_effort_translation.py`` covering the translation at the shared config level (adaptive + non-adaptive models, none, garbage, caller precedence) so all four /v1/messages routes are exercised by a single suite. Adds parametrized + behavioral tests on the Bedrock Invoke /v1/messages suite covering: minimal/low/medium/high/xhigh/max mapping for adaptive models, thinking-budget mapping for non-adaptive Opus 4.5, ``none`` clears both, garbage raises 400, explicit ``output_config`` wins. Refs: https://github.com/BerriAI/litellm/pull/27074 --- litellm/llms/anthropic/chat/transformation.py | 5 +- .../messages/transformation.py | 77 +++++++ litellm/types/llms/anthropic.py | 7 + .../test_reasoning_effort_translation.py | 184 ++++++++++++++++ .../test_anthropic_claude3_transformation.py | 205 ++++++++++++++++++ 5 files changed, 477 insertions(+), 1 deletion(-) create mode 100644 tests/test_litellm/llms/anthropic/experimental_pass_through/messages/test_reasoning_effort_translation.py diff --git a/litellm/llms/anthropic/chat/transformation.py b/litellm/llms/anthropic/chat/transformation.py index a1cff6b2e23..098a074892f 100644 --- a/litellm/llms/anthropic/chat/transformation.py +++ b/litellm/llms/anthropic/chat/transformation.py @@ -1580,7 +1580,10 @@ class AnthropicConfig(AnthropicModelInfo, BaseConfig): ) # ``max`` is for Opus 4.6+ output effort (not Sonnet 4.6, not Opus 4.5). # Accept known Opus 4.6/4.7 id patterns and/or ``supports_max_reasoning_effort`` - # in the model map (same pattern as ``xhigh`` below). + # in the model map (same pattern as ``xhigh`` below). The hardcoded + # patterns cover OpenRouter/GitHub Copilot/Vercel variants that don't + # carry the model-map flag yet — keep both checks until those provider + # entries are fully populated. if effort == "max" and not ( self._is_opus_4_6_model(model) or self._is_opus_4_7_model(model) diff --git a/litellm/llms/anthropic/experimental_pass_through/messages/transformation.py b/litellm/llms/anthropic/experimental_pass_through/messages/transformation.py index 7617ad52ab1..b737bf9aaff 100644 --- a/litellm/llms/anthropic/experimental_pass_through/messages/transformation.py +++ b/litellm/llms/anthropic/experimental_pass_through/messages/transformation.py @@ -47,6 +47,10 @@ class AnthropicMessagesConfig(BaseAnthropicMessagesConfig): "inference_geo", "speed", "output_config", + # OpenAI-style tier knob — translated to native ``thinking`` + + # ``output_config`` in ``transform_anthropic_messages_request`` + # and popped before the request is forwarded. + "reasoning_effort", # TODO: Add Anthropic `metadata` support # "metadata", ] @@ -166,6 +170,74 @@ class AnthropicMessagesConfig(BaseAnthropicMessagesConfig): return headers, api_base + @staticmethod + def _translate_reasoning_effort_to_anthropic( + model: str, optional_params: Dict + ) -> None: + """Map OpenAI-style ``reasoning_effort`` to native Anthropic params. + + The /v1/messages spec doesn't include ``reasoning_effort`` — without + this translation it gets silently dropped, leaving every adaptive + tier collapsed to the same behavior on Bedrock Invoke /v1/messages + (and on Anthropic / Azure AI / Vertex AI when callers pass it on + the messages route). Mirrors ``AnthropicConfig.map_openai_params`` + on the chat completion path so the two routes can't drift. + + - Pops ``reasoning_effort`` from ``optional_params`` so it never + reaches the wire. + - Caller-supplied ``thinking`` / ``output_config`` always win — we + don't override an explicit native value. + - Effort=``none`` clears thinking + output_config so callers can + opt out per request. + - Invalid efforts raise ``BadRequestError`` (clean 400) instead of + surfacing as 500s downstream. + """ + from litellm.llms.anthropic.chat.transformation import AnthropicConfig + + reasoning_effort = optional_params.pop("reasoning_effort", None) + if not isinstance(reasoning_effort, str): + return + + try: + mapped_thinking = AnthropicConfig._map_reasoning_effort( + reasoning_effort=reasoning_effort, model=model + ) + except ValueError as e: + raise AnthropicError(message=str(e), status_code=400) + + if mapped_thinking is None: + optional_params.pop("thinking", None) + optional_params.pop("output_config", None) + return + + optional_params.setdefault("thinking", mapped_thinking) + if AnthropicModelInfo._is_adaptive_thinking_model(model): + mapped_effort = ( + AnthropicConfig.REASONING_EFFORT_TO_OUTPUT_CONFIG_EFFORT.get( + reasoning_effort + ) + ) + # ``_map_reasoning_effort`` returns ``type=adaptive`` for any + # string on adaptive models without checking the value. The + # chat completion path validates the resolved effort downstream + # via ``_apply_output_config``; /v1/messages has no equivalent + # downstream check, so reject unmapped values here so callers + # see a clean 400 instead of a 500 from the provider. + if mapped_effort is None: + raise AnthropicError( + message=( + f"Invalid reasoning_effort: {reasoning_effort!r}. " + f"Must be one of: 'minimal', 'low', 'medium', 'high', " + f"'xhigh', 'max', 'none'" + ), + status_code=400, + ) + existing_output_config = optional_params.get("output_config") + if not isinstance(existing_output_config, dict): + existing_output_config = {} + existing_output_config.setdefault("effort", mapped_effort) + optional_params["output_config"] = existing_output_config + @staticmethod def _translate_legacy_thinking_for_adaptive_model( model: str, optional_params: Dict @@ -217,6 +289,11 @@ class AnthropicMessagesConfig(BaseAnthropicMessagesConfig): status_code=400, ) + self._translate_reasoning_effort_to_anthropic( + model=model, + optional_params=anthropic_messages_optional_request_params, + ) + self._translate_legacy_thinking_for_adaptive_model( model=model, optional_params=anthropic_messages_optional_request_params, diff --git a/litellm/types/llms/anthropic.py b/litellm/types/llms/anthropic.py index c376b8694af..6c57d3dc07a 100644 --- a/litellm/types/llms/anthropic.py +++ b/litellm/types/llms/anthropic.py @@ -393,6 +393,13 @@ class AnthropicMessagesRequestOptionalParams(TypedDict, total=False): AnthropicOutputConfig ] # Configuration for Claude's output behavior cache_control: Optional[Dict[str, Any]] # Automatic prompt caching + # OpenAI-style ``reasoning_effort`` is accepted on /v1/messages so callers + # can drive adaptive/extended thinking with a single tier-name knob (the + # same vocabulary as the chat completion path). The transformation layer + # maps it to native Anthropic ``thinking`` + ``output_config`` and pops + # this key before the request is forwarded — no provider receives + # ``reasoning_effort`` on the wire. + reasoning_effort: Optional[str] class AnthropicMessagesRequest(AnthropicMessagesRequestOptionalParams, total=False): diff --git a/tests/test_litellm/llms/anthropic/experimental_pass_through/messages/test_reasoning_effort_translation.py b/tests/test_litellm/llms/anthropic/experimental_pass_through/messages/test_reasoning_effort_translation.py new file mode 100644 index 00000000000..a2fcf1595bc --- /dev/null +++ b/tests/test_litellm/llms/anthropic/experimental_pass_through/messages/test_reasoning_effort_translation.py @@ -0,0 +1,184 @@ +""" +Tests for OpenAI-style ``reasoning_effort`` translation on the Anthropic +/v1/messages route. + +The /v1/messages spec doesn't include ``reasoning_effort`` — without +translation it gets silently dropped at the filter step, leaving every +adaptive tier collapsed to the same behavior on Bedrock Invoke /v1/messages +(and on Anthropic / Azure AI / Vertex AI when callers pass it on the +messages route). + +These tests pin the translation and validation behavior at the shared +``AnthropicMessagesConfig`` level so all four /v1/messages routes +(direct Anthropic, Azure AI, Vertex AI, Bedrock Invoke) inherit the +same mapping. +""" + +import pytest + +from litellm.llms.anthropic.common_utils import AnthropicError +from litellm.llms.anthropic.experimental_pass_through.messages.transformation import ( + AnthropicMessagesConfig, +) + + +@pytest.mark.parametrize( + "reasoning_effort,expected_effort", + [ + ("minimal", "low"), + ("low", "low"), + ("medium", "medium"), + ("high", "high"), + ("xhigh", "xhigh"), + ("max", "max"), + ], +) +def test_reasoning_effort_maps_to_output_config_for_adaptive_model( + reasoning_effort, expected_effort +): + """ + For Claude 4.6 / 4.7, ``reasoning_effort`` is mapped to + ``thinking={"type": "adaptive"}`` plus ``output_config.effort=``, + using the same mapping table as the chat completion path so the two + routes can't drift. + """ + config = AnthropicMessagesConfig() + optional_params = {"max_tokens": 1024, "reasoning_effort": reasoning_effort} + + result = config.transform_anthropic_messages_request( + model="claude-opus-4-7", + messages=[{"role": "user", "content": "Hello"}], + anthropic_messages_optional_request_params=optional_params, + litellm_params={}, + headers={}, + ) + + assert "reasoning_effort" not in result + assert result.get("thinking") == {"type": "adaptive"} + assert result.get("output_config") == {"effort": expected_effort} + + +def test_reasoning_effort_none_clears_thinking_and_output_config(): + """``reasoning_effort='none'`` opts out of extended thinking entirely.""" + config = AnthropicMessagesConfig() + optional_params = { + "max_tokens": 1024, + "reasoning_effort": "none", + "thinking": {"type": "adaptive"}, + "output_config": {"effort": "high"}, + } + + result = config.transform_anthropic_messages_request( + model="claude-opus-4-7", + messages=[{"role": "user", "content": "Hello"}], + anthropic_messages_optional_request_params=optional_params, + litellm_params={}, + headers={}, + ) + + assert "reasoning_effort" not in result + assert "thinking" not in result + assert "output_config" not in result + + +def test_reasoning_effort_on_non_adaptive_model_uses_thinking_budget(): + """ + Non-adaptive models (Opus 4.5 / earlier) take ``thinking.budget_tokens`` + rather than ``output_config.effort``. The translation falls back to + the budget mapping in that case. + """ + config = AnthropicMessagesConfig() + optional_params = {"max_tokens": 1024, "reasoning_effort": "high"} + + result = config.transform_anthropic_messages_request( + model="claude-opus-4-5", + messages=[{"role": "user", "content": "Hello"}], + anthropic_messages_optional_request_params=optional_params, + litellm_params={}, + headers={}, + ) + + assert "reasoning_effort" not in result + assert "output_config" not in result + thinking = result.get("thinking") + assert isinstance(thinking, dict) + assert thinking.get("type") == "enabled" + assert isinstance(thinking.get("budget_tokens"), int) + assert thinking["budget_tokens"] >= 1024 + + +@pytest.mark.parametrize("bad_effort", ["invalid", "disabled", ""]) +def test_invalid_reasoning_effort_raises_400(bad_effort): + """ + Garbage ``reasoning_effort`` values surface as a clean 400 instead of + silently passing through to the provider as an unknown + ``output_config.effort`` (which would 500). + """ + config = AnthropicMessagesConfig() + optional_params = {"max_tokens": 1024, "reasoning_effort": bad_effort} + + with pytest.raises(AnthropicError) as exc_info: + config.transform_anthropic_messages_request( + model="claude-opus-4-7", + messages=[{"role": "user", "content": "Hello"}], + anthropic_messages_optional_request_params=optional_params, + litellm_params={}, + headers={}, + ) + + assert exc_info.value.status_code == 400 + + +def test_explicit_output_config_wins_over_reasoning_effort(): + """ + Explicit native ``output_config.effort`` is never overridden by the + OpenAI alias. Same precedence as + ``_translate_legacy_thinking_for_adaptive_model``. + """ + config = AnthropicMessagesConfig() + optional_params = { + "max_tokens": 1024, + "reasoning_effort": "low", + "output_config": {"effort": "max"}, + } + + result = config.transform_anthropic_messages_request( + model="claude-opus-4-7", + messages=[{"role": "user", "content": "Hello"}], + anthropic_messages_optional_request_params=optional_params, + litellm_params={}, + headers={}, + ) + + assert "reasoning_effort" not in result + assert result.get("output_config") == {"effort": "max"} + + +def test_explicit_thinking_wins_over_reasoning_effort(): + """Explicit native ``thinking`` is never overridden by the alias.""" + config = AnthropicMessagesConfig() + optional_params = { + "max_tokens": 1024, + "reasoning_effort": "low", + "thinking": {"type": "enabled", "budget_tokens": 8000}, + } + + result = config.transform_anthropic_messages_request( + model="claude-opus-4-5", + messages=[{"role": "user", "content": "Hello"}], + anthropic_messages_optional_request_params=optional_params, + litellm_params={}, + headers={}, + ) + + assert "reasoning_effort" not in result + assert result.get("thinking") == {"type": "enabled", "budget_tokens": 8000} + + +def test_reasoning_effort_in_supported_params(): + """``reasoning_effort`` is advertised as a supported messages param so + callers and validation paths can introspect the schema.""" + config = AnthropicMessagesConfig() + assert "reasoning_effort" in config.get_supported_anthropic_messages_params( + "claude-opus-4-7" + ) diff --git a/tests/test_litellm/llms/bedrock/messages/invoke_transformations/test_anthropic_claude3_transformation.py b/tests/test_litellm/llms/bedrock/messages/invoke_transformations/test_anthropic_claude3_transformation.py index 725bc9503b2..1bc9093b620 100644 --- a/tests/test_litellm/llms/bedrock/messages/invoke_transformations/test_anthropic_claude3_transformation.py +++ b/tests/test_litellm/llms/bedrock/messages/invoke_transformations/test_anthropic_claude3_transformation.py @@ -662,6 +662,211 @@ def test_bedrock_messages_forwards_output_config_with_output_format(): assert "output_format" not in result +def test_bedrock_messages_forwards_output_config_for_non_adaptive_model(): + """ + ``output_config`` is forwarded for non-adaptive models too (e.g. haiku). + Bedrock will reject the unsupported key for those models — surfacing the + provider error is the correct behavior, since silently swallowing the + knob would hide caller bugs. + + Restores coverage previously asserted by + ``test_bedrock_messages_strips_output_config`` (renamed to the + ``forwards`` variant on Opus 4.7); the strip path no longer exists, + but the non-adaptive pass-through path needs its own explicit test + so a future regression that silently re-adds the strip can't sneak + through. + """ + from litellm.types.router import GenericLiteLLMParams + + cfg = AmazonAnthropicClaudeMessagesConfig() + messages = [{"role": "user", "content": [{"type": "text", "text": "Hello"}]}] + optional_params = { + "max_tokens": 4096, + "output_config": {"effort": "high"}, + } + + result = cfg.transform_anthropic_messages_request( + model="anthropic.claude-3-haiku-20240307-v1:0", + messages=messages, + anthropic_messages_optional_request_params=optional_params, + litellm_params=GenericLiteLLMParams(), + headers={}, + ) + + assert result.get("output_config") == {"effort": "high"} + assert result.get("max_tokens") == 4096 + + +@pytest.mark.parametrize( + "reasoning_effort,expected_effort", + [ + ("minimal", "low"), + ("low", "low"), + ("medium", "medium"), + ("high", "high"), + ("xhigh", "xhigh"), + ("max", "max"), + ], +) +def test_bedrock_messages_maps_reasoning_effort_for_adaptive_model( + reasoning_effort, expected_effort +): + """ + OpenAI-style ``reasoning_effort`` is mapped to native Anthropic + ``thinking`` + ``output_config.effort`` on the /v1/messages route so + callers can drive adaptive thinking with the same tier vocabulary as + the chat completion path. ``reasoning_effort`` itself is popped — the + /v1/messages spec doesn't define it and Bedrock rejects unknown + top-level fields. + + Closes the QA-sweep gap on PR #27074 where Bedrock Invoke /v1/messages + silently dropped ``reasoning_effort`` and every effort tier collapsed + to the same behavior. + """ + from litellm.types.router import GenericLiteLLMParams + + cfg = AmazonAnthropicClaudeMessagesConfig() + messages = [{"role": "user", "content": [{"type": "text", "text": "Hello"}]}] + optional_params = { + "max_tokens": 4096, + "reasoning_effort": reasoning_effort, + } + + result = cfg.transform_anthropic_messages_request( + model="anthropic.claude-opus-4-7", + messages=messages, + anthropic_messages_optional_request_params=optional_params, + litellm_params=GenericLiteLLMParams(), + headers={}, + ) + + assert "reasoning_effort" not in result + assert result.get("thinking") == {"type": "adaptive"} + assert result.get("output_config") == {"effort": expected_effort} + + +def test_bedrock_messages_reasoning_effort_on_non_adaptive_uses_thinking_budget(): + """ + For non-adaptive thinking models (e.g. Opus 4.5), ``reasoning_effort`` + is mapped to ``thinking.type=enabled`` with a budget_tokens value + instead of ``output_config.effort``. ``output_config`` is not set on + these models because they don't accept it. + + Mirrors ``AnthropicConfig._map_reasoning_effort`` behavior for the + non-adaptive branch on Opus 4.5 / earlier Claude 4 models, applied to + the /v1/messages route. + """ + from litellm.types.router import GenericLiteLLMParams + + cfg = AmazonAnthropicClaudeMessagesConfig() + messages = [{"role": "user", "content": [{"type": "text", "text": "Hello"}]}] + optional_params = { + "max_tokens": 4096, + "reasoning_effort": "medium", + } + + result = cfg.transform_anthropic_messages_request( + model="anthropic.claude-opus-4-5-20251101-v1:0", + messages=messages, + anthropic_messages_optional_request_params=optional_params, + litellm_params=GenericLiteLLMParams(), + headers={}, + ) + + assert "reasoning_effort" not in result + assert "output_config" not in result + thinking = result.get("thinking") + assert isinstance(thinking, dict) + assert thinking.get("type") == "enabled" + assert isinstance(thinking.get("budget_tokens"), int) + assert thinking["budget_tokens"] >= 1024 + + +def test_bedrock_messages_reasoning_effort_none_clears_thinking(): + """ + ``reasoning_effort='none'`` opts out — both ``thinking`` and + ``output_config`` are cleared so the request goes out without + extended thinking. Mirrors the chat completion path's behavior. + """ + from litellm.types.router import GenericLiteLLMParams + + cfg = AmazonAnthropicClaudeMessagesConfig() + messages = [{"role": "user", "content": [{"type": "text", "text": "Hello"}]}] + optional_params = { + "max_tokens": 4096, + "reasoning_effort": "none", + "output_config": {"effort": "high"}, + "thinking": {"type": "adaptive"}, + } + + result = cfg.transform_anthropic_messages_request( + model="anthropic.claude-opus-4-7", + messages=messages, + anthropic_messages_optional_request_params=optional_params, + litellm_params=GenericLiteLLMParams(), + headers={}, + ) + + assert "reasoning_effort" not in result + assert "thinking" not in result + assert "output_config" not in result + + +def test_bedrock_messages_invalid_reasoning_effort_raises_400(): + """ + Garbage ``reasoning_effort`` values (``invalid`` / ``disabled`` / ``""``) + surface as a clean 400 ``AnthropicError`` instead of silently passing + an invalid string through to Bedrock as ``output_config.effort``. + """ + from litellm.llms.anthropic.common_utils import AnthropicError + from litellm.types.router import GenericLiteLLMParams + + cfg = AmazonAnthropicClaudeMessagesConfig() + messages = [{"role": "user", "content": [{"type": "text", "text": "Hello"}]}] + + for bad_effort in ("invalid", "disabled", ""): + with pytest.raises(AnthropicError): + cfg.transform_anthropic_messages_request( + model="anthropic.claude-opus-4-7", + messages=messages, + anthropic_messages_optional_request_params={ + "max_tokens": 4096, + "reasoning_effort": bad_effort, + }, + litellm_params=GenericLiteLLMParams(), + headers={}, + ) + + +def test_bedrock_messages_explicit_output_config_wins_over_reasoning_effort(): + """ + Caller-supplied native ``output_config.effort`` wins over the OpenAI + ``reasoning_effort`` knob. Same precedence as + ``_translate_legacy_thinking_for_adaptive_model``: explicit native + Anthropic params are never overridden by the alias. + """ + from litellm.types.router import GenericLiteLLMParams + + cfg = AmazonAnthropicClaudeMessagesConfig() + messages = [{"role": "user", "content": [{"type": "text", "text": "Hello"}]}] + optional_params = { + "max_tokens": 4096, + "reasoning_effort": "low", + "output_config": {"effort": "max"}, + } + + result = cfg.transform_anthropic_messages_request( + model="anthropic.claude-opus-4-7", + messages=messages, + anthropic_messages_optional_request_params=optional_params, + litellm_params=GenericLiteLLMParams(), + headers={}, + ) + + assert "reasoning_effort" not in result + assert result.get("output_config") == {"effort": "max"} + + def test_bedrock_messages_strips_context_management(): """ Ensure context_management is stripped from the request before sending to