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