fix(anthropic): translate reasoning_effort on /v1/messages route

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
This commit is contained in:
Claude 2026-05-03 08:28:28 +00:00 committed by mateo-berri
parent ec022fd1bb
commit e400de4654
5 changed files with 477 additions and 1 deletions

View file

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

View file

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

View file

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

View file

@ -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=<tier>``,
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"
)

View file

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