Merge pull request #38836 from BerriAI/litellm_fix_messages_effort_budget_cap

fix(anthropic): cap reasoning_effort thinking budget below max_tokens on /v1/messages
This commit is contained in:
Mateo Wang 2026-08-29 16:45:18 -07:00 committed by GitHub
commit 1f5e76155b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 79 additions and 12 deletions

View file

@ -1268,7 +1268,7 @@ class AnthropicConfig(AnthropicModelInfo, BaseConfig):
)
@staticmethod
def _cap_thinking_budget_to_max_tokens(
def cap_thinking_budget_to_max_tokens(
thinking: AnthropicThinkingParam, max_tokens: int | None
) -> AnthropicThinkingParam | None:
"""Cap a legacy ``thinking.budget_tokens`` below ``max_tokens`` (Anthropic
@ -1530,7 +1530,7 @@ class AnthropicConfig(AnthropicModelInfo, BaseConfig):
llm_provider=self._resolved_provider,
)
capped_thinking = (
AnthropicConfig._cap_thinking_budget_to_max_tokens(legacy_thinking, max_tokens)
AnthropicConfig.cap_thinking_budget_to_max_tokens(legacy_thinking, max_tokens)
if legacy_thinking is not None
else None
)

View file

@ -40,6 +40,11 @@ DROP_UNSUPPORTED_ADAPTIVE_EFFORT_WARNING: Final = (
"minimum thinking budget."
)
DROP_UNFITTING_REASONING_EFFORT_WARNING: Final = (
"Dropping `thinking` mapped from reasoning_effort=%s for model=%s: max_tokens=%s "
"is too small to fit the minimum thinking budget."
)
class AnthropicMessagesConfig(BaseAnthropicMessagesConfig):
@property
@ -335,11 +340,15 @@ class AnthropicMessagesConfig(BaseAnthropicMessagesConfig):
return headers, api_base
@staticmethod
def _translate_reasoning_effort_to_anthropic(model: str, optional_params: dict, custom_llm_provider: str) -> None:
def _translate_reasoning_effort_to_anthropic(
model: str, optional_params: dict, max_tokens: int | None, custom_llm_provider: str
) -> None:
"""Map OpenAI-style ``reasoning_effort`` to native Anthropic params.
Caller-supplied ``thinking`` / ``output_config`` win over the alias.
``effort='none'`` clears both. Invalid efforts raise a 400.
``effort='none'`` clears both. Invalid efforts raise a 400. A mapped
thinking budget is capped below ``max_tokens`` and dropped when even
the minimum budget cannot fit.
"""
from litellm.exceptions import BadRequestError as _BadRequestError
from litellm.llms.anthropic.chat.transformation import (
@ -365,7 +374,12 @@ class AnthropicMessagesConfig(BaseAnthropicMessagesConfig):
optional_params.pop("output_config", None)
return
optional_params.setdefault("thinking", mapped_thinking)
fitted_thinking: Final = AnthropicConfig.cap_thinking_budget_to_max_tokens(mapped_thinking, max_tokens)
if fitted_thinking is None:
verbose_logger.warning(DROP_UNFITTING_REASONING_EFFORT_WARNING, reasoning_effort, model, max_tokens)
return
optional_params.setdefault("thinking", fitted_thinking)
if AnthropicModelInfo._is_adaptive_thinking_model(model, custom_llm_provider):
mapped_effort: Final = REASONING_EFFORT_TO_OUTPUT_CONFIG_EFFORT.get(reasoning_effort)
if mapped_effort is None:
@ -510,7 +524,7 @@ class AnthropicMessagesConfig(BaseAnthropicMessagesConfig):
except _BadRequestError as e:
raise AnthropicError(message=str(e.message), status_code=400)
capped_thinking: Final = (
AnthropicConfig._cap_thinking_budget_to_max_tokens(legacy_thinking, max_tokens)
AnthropicConfig.cap_thinking_budget_to_max_tokens(legacy_thinking, max_tokens)
if legacy_thinking is not None
else None
)
@ -582,6 +596,7 @@ class AnthropicMessagesConfig(BaseAnthropicMessagesConfig):
self._translate_reasoning_effort_to_anthropic(
model=model,
optional_params=anthropic_messages_optional_request_params,
max_tokens=max_tokens,
custom_llm_provider=self._resolved_provider,
)

View file

@ -924,7 +924,7 @@ class AmazonConverseConfig(BaseConfig):
custom_llm_provider="bedrock",
)
capped = (
AnthropicConfig._cap_thinking_budget_to_max_tokens(legacy_thinking, max_tokens)
AnthropicConfig.cap_thinking_budget_to_max_tokens(legacy_thinking, max_tokens)
if legacy_thinking is not None
else None
)

View file

@ -103,7 +103,7 @@ def _bedrock_clamps_effort(model: "ModelEntry", effort: str) -> bool:
return _EFFORT_RANK[effort] > _EFFORT_RANK[model.bedrock_effort_ceiling]
def expected(model: ModelEntry, effort: str) -> CellExpectation:
def expected(route_name: str, model: ModelEntry, effort: str) -> CellExpectation:
if effort in ("__omit__", "none"):
if model.mode == "budget":
return CellExpectation(
@ -117,6 +117,15 @@ def expected(model: ModelEntry, effort: str) -> CellExpectation:
if effort in ("xhigh", "max"):
cap = f"supports_{effort}_reasoning_effort"
if cap not in model.caps and not _bedrock_clamps_effort(model, effort):
if model.mode == "budget" and route_name == "bedrock_invoke_messages":
# the /v1/messages path caps the mapped budget below max_tokens
# (LIT-6498), so oversized tiers succeed there instead of 400ing
return CellExpectation(
status=200,
thinking_type="enabled",
thinking_budget_tokens=BUDGET_MODE_MAX_TOKENS - 1,
max_tokens=BUDGET_MODE_MAX_TOKENS,
)
return CellExpectation(status=400, thinking_type=OMIT)
if model.mode == "adaptive":
@ -441,5 +450,7 @@ def all_cells() -> List[Tuple[str, ModelEntry, str, CellExpectation]]:
for route in ROUTES:
for model in route.models:
for effort in EFFORTS:
cells.append((route.name, model, effort, expected(model, effort)))
cells.append(
(route.name, model, effort, expected(route.name, model, effort))
)
return cells

View file

@ -10,6 +10,10 @@ from litellm.llms.anthropic.common_utils import AnthropicError
from litellm.llms.anthropic.experimental_pass_through.messages.transformation import (
AnthropicMessagesConfig,
)
from litellm.llms.openai_like.json_loader import SimpleProviderConfig
from litellm.llms.openai_like.messages.transformation import (
JSONProviderAnthropicMessagesConfig,
)
def _claude_code_payload(effort="medium", max_tokens=8192, **output_config_extra):
@ -294,3 +298,39 @@ def test_non_adaptive_request_without_effort_is_untouched():
assert "thinking" not in result
assert "output_config" not in result
def test_reasoning_effort_budget_capped_below_max_tokens():
result = _transform("claude-haiku-4-5", {"max_tokens": 4000, "reasoning_effort": "xhigh"})
assert result["thinking"] == {"type": "enabled", "budget_tokens": 3999}
assert result["max_tokens"] == 4000
def test_reasoning_effort_thinking_dropped_when_min_budget_cannot_fit():
result = _transform("claude-haiku-4-5", {"max_tokens": 1024, "reasoning_effort": "xhigh"})
assert "thinking" not in result
assert result["max_tokens"] == 1024
def test_reasoning_effort_budget_capped_for_openai_like_messages_upstream():
provider = SimpleProviderConfig(
"meta",
{
"base_url": "https://api.meta.ai/v1",
"api_key_env": "META_API_KEY",
"supported_endpoints": ["/v1/messages"],
},
)
result = JSONProviderAnthropicMessagesConfig(provider).transform_anthropic_messages_request(
model="muse-spark-1.2",
messages=[{"role": "user", "content": "Hello"}],
anthropic_messages_optional_request_params={"max_tokens": 4000, "reasoning_effort": "xhigh"},
litellm_params={},
headers={},
)
assert result["thinking"] == {"type": "enabled", "budget_tokens": 3999}
assert result["max_tokens"] == 4000

View file

@ -70,7 +70,7 @@ def test_reasoning_effort_none_clears_thinking_and_output_config():
def test_reasoning_effort_on_non_adaptive_model_uses_thinking_budget():
config = AnthropicMessagesConfig()
optional_params = {"max_tokens": 1024, "reasoning_effort": "high"}
optional_params = {"max_tokens": 8192, "reasoning_effort": "high"}
result = config.transform_anthropic_messages_request(
model="claude-opus-4-5",
@ -86,7 +86,7 @@ def test_reasoning_effort_on_non_adaptive_model_uses_thinking_budget():
assert isinstance(thinking, dict)
assert thinking.get("type") == "enabled"
assert isinstance(thinking.get("budget_tokens"), int)
assert thinking["budget_tokens"] >= 1024
assert 1024 <= thinking["budget_tokens"] < result["max_tokens"]
@pytest.mark.parametrize("bad_effort", ["invalid", "disabled", ""])

View file

@ -254,7 +254,7 @@ def test_request_maps_reasoning_effort_to_thinking(config):
model="claude-sonnet-4-20250514",
messages=[{"role": "user", "content": "hi"}],
anthropic_messages_optional_request_params={
"max_tokens": 1024,
"max_tokens": 8192,
"reasoning_effort": "medium",
},
litellm_params=GenericLiteLLMParams(),
@ -264,6 +264,7 @@ def test_request_maps_reasoning_effort_to_thinking(config):
assert "reasoning_effort" not in payload
assert isinstance(payload.get("thinking"), dict)
assert payload["thinking"].get("type") == "enabled"
assert payload["thinking"]["budget_tokens"] < payload["max_tokens"]
def test_passthrough_disables_anthropic_beta_filtering(config):