From 71a951691ab07776a1e5294e399d70c59851b5b1 Mon Sep 17 00:00:00 2001 From: mateo-berri <277851410+mateo-berri@users.noreply.github.com> Date: Sat, 29 Aug 2026 15:24:05 -0700 Subject: [PATCH 1/2] fix(anthropic): cap reasoning_effort thinking budget below max_tokens on /v1/messages A deployment carrying reasoning_effort in its litellm_params on the /v1/messages passthrough mapped the effort to a legacy thinking block whose budget_tokens was forwarded as is, so any request whose max_tokens sat at or below that budget was rejected upstream with a 400. The mapped budget now runs through the same cap the adaptive-to-legacy branch and the chat path already use: it is clamped to max_tokens - 1, and dropped with a warning when even the minimum budget cannot fit. The cap helper becomes public since three call sites outside AnthropicConfig use it. --- litellm/llms/anthropic/chat/transformation.py | 4 +- .../messages/transformation.py | 23 +++++++++-- .../bedrock/chat/converse_transformation.py | 2 +- .../test_anthropic_messages_effort.py | 40 +++++++++++++++++++ .../test_reasoning_effort_translation.py | 4 +- ..._like_anthropic_messages_transformation.py | 3 +- 6 files changed, 66 insertions(+), 10 deletions(-) diff --git a/litellm/llms/anthropic/chat/transformation.py b/litellm/llms/anthropic/chat/transformation.py index 47116a8f8fb..e1387a9068c 100644 --- a/litellm/llms/anthropic/chat/transformation.py +++ b/litellm/llms/anthropic/chat/transformation.py @@ -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 ) diff --git a/litellm/llms/anthropic/experimental_pass_through/messages/transformation.py b/litellm/llms/anthropic/experimental_pass_through/messages/transformation.py index ebd514c2605..3d62b8b4784 100644 --- a/litellm/llms/anthropic/experimental_pass_through/messages/transformation.py +++ b/litellm/llms/anthropic/experimental_pass_through/messages/transformation.py @@ -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, ) diff --git a/litellm/llms/bedrock/chat/converse_transformation.py b/litellm/llms/bedrock/chat/converse_transformation.py index d22b225b0bd..db9c8a5cedd 100644 --- a/litellm/llms/bedrock/chat/converse_transformation.py +++ b/litellm/llms/bedrock/chat/converse_transformation.py @@ -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 ) diff --git a/tests/test_litellm/llms/anthropic/experimental_pass_through/messages/test_anthropic_messages_effort.py b/tests/test_litellm/llms/anthropic/experimental_pass_through/messages/test_anthropic_messages_effort.py index e9d4d625421..daaa110e7b9 100644 --- a/tests/test_litellm/llms/anthropic/experimental_pass_through/messages/test_anthropic_messages_effort.py +++ b/tests/test_litellm/llms/anthropic/experimental_pass_through/messages/test_anthropic_messages_effort.py @@ -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 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 index c9170efd18a..7e2fa356685 100644 --- 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 @@ -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", ""]) diff --git a/tests/test_litellm/llms/openai_like/messages/test_openai_like_anthropic_messages_transformation.py b/tests/test_litellm/llms/openai_like/messages/test_openai_like_anthropic_messages_transformation.py index 33e677b000e..9a6a039a470 100644 --- a/tests/test_litellm/llms/openai_like/messages/test_openai_like_anthropic_messages_transformation.py +++ b/tests/test_litellm/llms/openai_like/messages/test_openai_like_anthropic_messages_transformation.py @@ -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): From 36c036cd2dab3ab4bb5f2d3ee825a7a33974e147 Mon Sep 17 00:00:00 2001 From: mateo-berri <277851410+mateo-berri@users.noreply.github.com> Date: Sat, 29 Aug 2026 16:28:49 -0700 Subject: [PATCH 2/2] test(reasoning-effort-grid): expect capped thinking on messages-route budget models --- .../reasoning_effort_grid/grid_spec.py | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/tests/llm_translation/reasoning_effort_grid/grid_spec.py b/tests/llm_translation/reasoning_effort_grid/grid_spec.py index 4fa77f38940..485f78ed8c7 100644 --- a/tests/llm_translation/reasoning_effort_grid/grid_spec.py +++ b/tests/llm_translation/reasoning_effort_grid/grid_spec.py @@ -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