Skip to content
Merged
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import httpx

from litellm.constants import (
ANTHROPIC_MIN_THINKING_BUDGET_TOKENS,
DEFAULT_REASONING_EFFORT_HIGH_THINKING_BUDGET,
DEFAULT_REASONING_EFFORT_MEDIUM_THINKING_BUDGET,
DEFAULT_REASONING_EFFORT_XHIGH_THINKING_BUDGET,
Expand Down Expand Up @@ -32,6 +33,12 @@

DEFAULT_ANTHROPIC_API_VERSION = "2023-06-01"

DROP_UNSUPPORTED_ADAPTIVE_EFFORT_WARNING = (
"Dropping adaptive `thinking`/`output_config.effort` for model=%s: the model "
"does not support extended thinking, or max_tokens is too small to fit the "
"minimum thinking budget."
)


class AnthropicMessagesConfig(BaseAnthropicMessagesConfig):
def get_supported_anthropic_messages_params(self, model: str) -> list:
Expand Down Expand Up @@ -253,6 +260,111 @@ def _translate_legacy_thinking_for_adaptive_model(model: str, optional_params: D
existing_output_config.setdefault("effort", effort)
optional_params["output_config"] = existing_output_config

@staticmethod
def _translate_adaptive_effort_for_non_adaptive_model(
model: str, optional_params: Dict, max_tokens: Optional[int]
) -> None:
"""Translate the 4.6+ adaptive-thinking interface (``thinking.type=adaptive``
and/or ``output_config.effort``) down to what an older Anthropic model
supports. Clients like Claude Code send this interface unconditionally, so
without translation it reaches a pre-4.6 model and Anthropic rejects it with
"This model does not support the effort parameter".

The reshape is silent, matching how the messages path already strips
unsupported ``output_config`` for older models (bedrock invoke, issue
#22797): the goal is to keep the request working, not to fail it.

``thinking.type=adaptive`` and ``output_config.effort`` are independent
capabilities. Adaptive thinking needs ``supports_adaptive_thinking`` (4.6+);
``output_config.effort`` needs ``supports_output_config``, which some
non-adaptive models (e.g. Claude Opus 4.5) advertise on its own. So the two
are handled separately:

- Adaptive-thinking models (4.6+): both are native, left untouched.
- ``supports_output_config`` but non-adaptive (Opus 4.5): keep
``output_config.effort`` (native), only drop the unsupported adaptive
``thinking`` block. When adaptive thinking is being dropped and the
effort level itself isn't supported by the model (e.g. ``xhigh``/``max``
on Opus 4.5, which only accepts low/medium/high, while ``xhigh`` is
Claude Code's default), fall through to the legacy translation below
instead of forwarding a level Anthropic would reject. Effort-only
requests are always left untouched: provider subclasses own their level
normalization (bedrock clamps ``xhigh`` to the model's ceiling after
this base transform runs).
- Thinking-capable but neither (``supports_reasoning``, e.g. Haiku/Sonnet
4.5): map effort to legacy ``thinking={type: enabled, budget_tokens}`` via
``AnthropicConfig._map_reasoning_effort``, capped below ``max_tokens``
(Anthropic requires ``max_tokens > budget_tokens``) and dropped when
``max_tokens`` can't fit even the minimum budget.
- No reasoning support: ``thinking`` is dropped.

For the last two, only the consumed ``effort`` key is removed from
``output_config``; any residual (e.g. ``format``) is left for provider
subclasses to handle.
"""
from litellm.exceptions import BadRequestError as _BadRequestError
from litellm.llms.anthropic.chat.transformation import AnthropicConfig

if AnthropicConfig._is_adaptive_thinking_model(model):
return

output_config = optional_params.get("output_config")
thinking = optional_params.get("thinking")
effort = output_config.get("effort") if isinstance(output_config, dict) else None
adaptive_thinking = isinstance(thinking, dict) and thinking.get("type") == "adaptive"
if effort is None and not adaptive_thinking:
return

if AnthropicConfig._model_supports_effort_param(model) and (
not adaptive_thinking or AnthropicConfig._validate_effort_for_model(model, effort) is None
):
if adaptive_thinking:
optional_params.pop("thinking", None)
return

supports_thinking = AnthropicModelInfo._supports_model_capability(model, "supports_reasoning")
try:
legacy_thinking = (
AnthropicConfig._map_reasoning_effort(reasoning_effort=effort or "medium", model=model)
if supports_thinking
else None
)
except _BadRequestError as e:
raise AnthropicError(message=str(e.message), status_code=400)
capped_thinking = (
AnthropicMessagesConfig._cap_thinking_budget_to_max_tokens(legacy_thinking, max_tokens)
if legacy_thinking is not None
else None
)

if capped_thinking is not None:
optional_params["thinking"] = capped_thinking
else:
verbose_logger.warning(DROP_UNSUPPORTED_ADAPTIVE_EFFORT_WARNING, model)
optional_params.pop("thinking", None)

if isinstance(output_config, dict) and "effort" in output_config:
residual = {k: v for k, v in output_config.items() if k != "effort"}
if residual:
optional_params["output_config"] = residual
else:
optional_params.pop("output_config", None)

@staticmethod
def _cap_thinking_budget_to_max_tokens(thinking: Dict, max_tokens: Optional[int]) -> Optional[Dict]:
"""Cap a legacy ``thinking.budget_tokens`` below ``max_tokens`` (Anthropic
requires ``max_tokens > budget_tokens``). Returns the (possibly capped)
thinking dict, or ``None`` when ``max_tokens`` is too small to fit even the
minimum thinking budget and thinking should be dropped."""
budget = thinking.get("budget_tokens")
if max_tokens is None or not isinstance(budget, int):
return thinking
if max_tokens <= ANTHROPIC_MIN_THINKING_BUDGET_TOKENS:
return None
if budget < max_tokens:
return thinking
return {**thinking, "budget_tokens": max_tokens - 1}

def transform_anthropic_messages_request(
self,
model: str,
Expand Down Expand Up @@ -284,6 +396,12 @@ def transform_anthropic_messages_request(
optional_params=anthropic_messages_optional_request_params,
)

self._translate_adaptive_effort_for_non_adaptive_model(
model=model,
optional_params=anthropic_messages_optional_request_params,
max_tokens=max_tokens,
)

system_param = anthropic_messages_optional_request_params.get("system")
if self.should_strip_billing_metadata() and system_param is not None:
filtered_system = self._filter_billing_headers_from_system(system_param)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,189 @@
import pytest

from litellm.constants import (
DEFAULT_REASONING_EFFORT_HIGH_THINKING_BUDGET,
DEFAULT_REASONING_EFFORT_MEDIUM_THINKING_BUDGET,
DEFAULT_REASONING_EFFORT_XHIGH_THINKING_BUDGET,
)
from litellm.llms.anthropic.common_utils import AnthropicError
from litellm.llms.anthropic.experimental_pass_through.messages.transformation import (
AnthropicMessagesConfig,
)


def _claude_code_payload(effort="medium", max_tokens=8192, **output_config_extra):
"""The exact adaptive-thinking shape Claude Code (claude-cli) sends."""
output_config = {"effort": effort, **output_config_extra}
return {
"max_tokens": max_tokens,
"thinking": {"type": "adaptive"},
"output_config": output_config,
}


def _transform(model, params, litellm_params=None):
return AnthropicMessagesConfig().transform_anthropic_messages_request(
model=model,
messages=[{"role": "user", "content": "Hello"}],
anthropic_messages_optional_request_params=dict(params),
litellm_params=litellm_params or {},
headers={},
)


def test_effort_translated_to_legacy_thinking_for_haiku_4_5():
"""Core regression: Claude Code sends adaptive thinking + effort to Haiku 4.5
(thinking-capable, pre-4.6). Effort must be translated to legacy extended
thinking rather than forwarded raw (which Anthropic rejects with "This model
does not support the effort parameter")."""
result = _transform("claude-haiku-4-5", _claude_code_payload(effort="medium"))

assert result["thinking"] == {
"type": "enabled",
"budget_tokens": DEFAULT_REASONING_EFFORT_MEDIUM_THINKING_BUDGET,
}
assert "output_config" not in result


def test_effort_high_maps_to_high_budget_for_sonnet_4_5():
result = _transform("claude-sonnet-4-5", _claude_code_payload(effort="high"))

assert result["thinking"] == {
"type": "enabled",
"budget_tokens": DEFAULT_REASONING_EFFORT_HIGH_THINKING_BUDGET,
}
assert "output_config" not in result


def test_adaptive_effort_passes_through_untouched_for_4_6():
"""4.6+ natively supports the adaptive interface, so it must not be rewritten."""
result = _transform("claude-sonnet-4-6", _claude_code_payload(effort="high"))

assert result["thinking"] == {"type": "adaptive"}
assert result["output_config"] == {"effort": "high"}


def test_thinking_and_effort_dropped_for_non_reasoning_model():
"""A model with no reasoning support cannot take thinking or effort, so both are
silently dropped (no drop_params required) so the request still succeeds."""
result = _transform("claude-3-5-haiku-latest", _claude_code_payload(effort="medium"))

assert "thinking" not in result
assert "output_config" not in result


def test_residual_output_config_preserved_after_effort_translation():
"""output_config may carry `format` (structured outputs) alongside effort. Only
the consumed effort key is removed; the residual is left for provider subclasses
(bedrock/vertex) to handle, and effort is translated to legacy thinking."""
result = _transform(
"claude-haiku-4-5",
_claude_code_payload(effort="medium", format={"type": "json_schema"}),
)

assert result["thinking"] == {
"type": "enabled",
"budget_tokens": DEFAULT_REASONING_EFFORT_MEDIUM_THINKING_BUDGET,
}
assert result["output_config"] == {"format": {"type": "json_schema"}}


def test_opus_4_5_keeps_effort_but_drops_adaptive_thinking():
"""Regression: Opus 4.5 advertises supports_output_config (accepts
output_config.effort) but is NOT adaptive, so thinking:{type:adaptive} is
rejected by Anthropic. The effort must be kept and only the adaptive thinking
block dropped, rather than early-returning and forwarding adaptive thinking raw."""
result = _transform("claude-opus-4-5", _claude_code_payload(effort="medium"))

assert result["output_config"] == {"effort": "medium"}
assert "thinking" not in result


def test_opus_4_5_preserves_native_effort_without_adaptive_thinking():
"""A caller sending output_config.effort alone (no adaptive thinking) to Opus 4.5
must pass through untouched, since the model supports it natively."""
result = AnthropicMessagesConfig().transform_anthropic_messages_request(
model="claude-opus-4-5",
messages=[{"role": "user", "content": "Hello"}],
anthropic_messages_optional_request_params={
"max_tokens": 8192,
"output_config": {"effort": "high"},
},
litellm_params={},
headers={},
)

assert result["output_config"] == {"effort": "high"}
assert "thinking" not in result


def test_opus_4_5_unsupported_effort_level_translated_to_legacy_thinking():
"""Opus 4.5 accepts output_config.effort but only levels low/medium/high;
Claude Code defaults to xhigh on newer models, and forwarding that level raw
would be rejected with "effort='xhigh' is not supported by this model". An
unsupported level must fall through to the legacy translation (budget-based
thinking, effort stripped) instead of being preserved."""
result = _transform("claude-opus-4-5", _claude_code_payload(effort="xhigh", max_tokens=64000))

assert result["thinking"] == {
"type": "enabled",
"budget_tokens": DEFAULT_REASONING_EFFORT_XHIGH_THINKING_BUDGET,
}
assert "output_config" not in result


def test_opus_4_5_effort_only_unsupported_level_left_for_provider_normalization():
"""An effort-only request (no adaptive thinking) must pass through untouched even
when the level exceeds what the model supports: provider subclasses own their
level normalization (bedrock clamps xhigh to the model's ceiling after this base
transform runs), so consuming the effort here breaks that contract."""
result = _transform(
"claude-opus-4-5",
{"max_tokens": 4096, "output_config": {"effort": "xhigh"}},
)

assert result["output_config"] == {"effort": "xhigh"}
assert "thinking" not in result


def test_budget_capped_below_max_tokens():
"""Adaptive thinking carries no budget, so the translated legacy budget must be
capped below max_tokens (Anthropic requires max_tokens > budget_tokens). A
high-effort budget (4096) with max_tokens=3000 must be capped to 2999."""
result = _transform("claude-haiku-4-5", _claude_code_payload(effort="high", max_tokens=3000))

assert result["thinking"] == {"type": "enabled", "budget_tokens": 2999}


def test_thinking_dropped_when_max_tokens_too_small_for_min_budget():
"""When max_tokens can't fit even the minimum thinking budget, thinking is
silently dropped so the request still succeeds rather than being rejected."""
result = _transform("claude-haiku-4-5", _claude_code_payload(effort="medium", max_tokens=512))

assert "thinking" not in result
assert "output_config" not in result


def test_unrecognized_effort_raises_clean_400():
"""An unrecognized effort value (e.g. a future Anthropic tier) must surface as a
clean AnthropicError 400, matching _translate_reasoning_effort_to_anthropic,
rather than leaking litellm's internal BadRequestError."""
with pytest.raises(AnthropicError) as exc_info:
_transform("claude-haiku-4-5", _claude_code_payload(effort="turbo"))

assert exc_info.value.status_code == 400


def test_non_adaptive_request_without_effort_is_untouched():
"""A non-adaptive model receiving a request with no adaptive interface (no
effort, no adaptive thinking) must pass through untouched."""
result = AnthropicMessagesConfig().transform_anthropic_messages_request(
model="claude-haiku-4-5",
messages=[{"role": "user", "content": "Hello"}],
anthropic_messages_optional_request_params={"max_tokens": 1024},
litellm_params={},
headers={},
)

assert "thinking" not in result
assert "output_config" not in result
Loading