fix(anthropic): upgrade legacy thinking to adaptive on adaptive-only models for chat and Bedrock Converse

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
Devin AI 2026-09-01 18:45:26 +00:00
parent 77813ae906
commit 2063c29f5d
6 changed files with 154 additions and 47 deletions

View file

@ -1544,6 +1544,9 @@ class AnthropicConfig(AnthropicModelInfo, BaseConfig):
optional_params.pop("thinking", None)
else:
optional_params["thinking"] = value
AnthropicModelInfo.translate_legacy_thinking_for_adaptive_model(
model=model, optional_params=optional_params, custom_llm_provider=self._resolved_provider
)
elif param == "reasoning_effort":
# Accept both string ("low") and dict ({"effort": "low",
# "summary": "concise"}). The Responses->Chat parser keeps the

View file

@ -13,7 +13,12 @@ import httpx
from pydantic import BaseModel, ConfigDict, TypeAdapter, ValidationError
import litellm
from litellm.constants import DEFAULT_MODEL_CREATED_AT_TIME
from litellm.constants import (
DEFAULT_MODEL_CREATED_AT_TIME,
DEFAULT_REASONING_EFFORT_HIGH_THINKING_BUDGET,
DEFAULT_REASONING_EFFORT_MEDIUM_THINKING_BUDGET,
DEFAULT_REASONING_EFFORT_XHIGH_THINKING_BUDGET,
)
from litellm.litellm_core_utils.prompt_templates.common_utils import (
get_file_ids_from_messages,
)
@ -490,6 +495,46 @@ class AnthropicModelInfo(BaseLLMModelInfo):
)
optional_params.pop("thinking", None)
@staticmethod
def translate_legacy_thinking_for_adaptive_model(
model: str,
optional_params: MutableMapping[str, object], # mutable-ok: in-place out-param, as in maybe_drop_disabled_thinking
custom_llm_provider: str,
) -> None:
"""Translate legacy ``thinking.type=enabled`` to adaptive for the
adaptive-thinking models that reject it (4.7+ and the 5 families).
Models flagged ``supports_legacy_thinking`` (the 4.6 family) accept the
legacy shape natively, so it is forwarded verbatim and the caller's
``budget_tokens`` cap keeps applying. Caller-provided
``output_config.effort`` is never overridden.
"""
if not AnthropicModelInfo._is_adaptive_thinking_model(model, custom_llm_provider):
return
if AnthropicModelInfo._supports_legacy_thinking(model, custom_llm_provider):
return
thinking: Final = optional_params.get("thinking")
if not isinstance(thinking, dict) or thinking.get("type") != "enabled":
return
budget: Final = int(thinking.get("budget_tokens") or 0)
if budget >= DEFAULT_REASONING_EFFORT_XHIGH_THINKING_BUDGET and (
AnthropicModelInfo._supports_model_capability(model, "supports_xhigh_reasoning_effort", custom_llm_provider)
):
effort = "xhigh"
elif budget >= DEFAULT_REASONING_EFFORT_HIGH_THINKING_BUDGET:
effort = "high"
elif budget >= DEFAULT_REASONING_EFFORT_MEDIUM_THINKING_BUDGET:
effort = "medium"
else:
effort = "low"
optional_params["thinking"] = {"type": "adaptive"}
existing_output_config = optional_params.get("output_config")
if not isinstance(existing_output_config, dict):
existing_output_config = {}
existing_output_config.setdefault("effort", effort)
optional_params["output_config"] = existing_output_config
def is_effort_used(
self,
optional_params: dict | None,

View file

@ -3,11 +3,6 @@ from typing import Any, Final
import httpx
from litellm.constants import (
DEFAULT_REASONING_EFFORT_HIGH_THINKING_BUDGET,
DEFAULT_REASONING_EFFORT_MEDIUM_THINKING_BUDGET,
DEFAULT_REASONING_EFFORT_XHIGH_THINKING_BUDGET,
)
from litellm.exceptions import AuthenticationError
from litellm.litellm_core_utils.litellm_logging import Logging as LiteLLMLoggingObj
from litellm.litellm_core_utils.litellm_logging import verbose_logger
@ -400,46 +395,6 @@ class AnthropicMessagesConfig(BaseAnthropicMessagesConfig):
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, custom_llm_provider: str
) -> None:
"""Translate legacy ``thinking.type=enabled`` to adaptive for the
adaptive-thinking models that reject it (4.7+ and the 5 families).
Models flagged ``supports_legacy_thinking`` (the 4.6 family) accept the
legacy shape natively, so it is forwarded verbatim and the caller's
``budget_tokens`` cap keeps applying. Caller-provided
``output_config.effort`` is never overridden.
"""
from litellm.llms.anthropic.chat.transformation import AnthropicConfig
if not AnthropicModelInfo._is_adaptive_thinking_model(model, custom_llm_provider):
return
if AnthropicModelInfo._supports_legacy_thinking(model, custom_llm_provider):
return
thinking: Final = optional_params.get("thinking")
if not isinstance(thinking, dict) or thinking.get("type") != "enabled":
return
budget: Final = int(thinking.get("budget_tokens") or 0)
if budget >= DEFAULT_REASONING_EFFORT_XHIGH_THINKING_BUDGET and (
AnthropicConfig._supports_effort_level(model, "xhigh", custom_llm_provider)
):
effort = "xhigh"
elif budget >= DEFAULT_REASONING_EFFORT_HIGH_THINKING_BUDGET:
effort = "high"
elif budget >= DEFAULT_REASONING_EFFORT_MEDIUM_THINKING_BUDGET:
effort = "medium"
else:
effort = "low"
optional_params["thinking"] = {"type": "adaptive"}
existing_output_config = optional_params.get("output_config")
if not isinstance(existing_output_config, dict):
existing_output_config = {}
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: int | None, custom_llm_provider: str
@ -606,7 +561,7 @@ class AnthropicMessagesConfig(BaseAnthropicMessagesConfig):
custom_llm_provider=self._resolved_provider,
)
self._translate_legacy_thinking_for_adaptive_model(
AnthropicModelInfo.translate_legacy_thinking_for_adaptive_model(
model=model,
optional_params=anthropic_messages_optional_request_params,
custom_llm_provider=self._resolved_provider,

View file

@ -934,6 +934,9 @@ class AmazonConverseConfig(BaseConfig):
litellm.verbose_logger.warning(DROP_UNSUPPORTED_ADAPTIVE_THINKING_WARNING, model)
else:
optional_params["thinking"] = value
AnthropicModelInfo.translate_legacy_thinking_for_adaptive_model(
model=model, optional_params=optional_params, custom_llm_provider="bedrock"
)
elif param == "reasoning_effort" and isinstance(value, str):
self._handle_reasoning_effort_parameter(
model=model, reasoning_effort=value, optional_params=optional_params

View file

@ -3127,6 +3127,31 @@ def test_reasoning_effort_accepts_dict_shape_for_non_adaptive_model(
)
@pytest.mark.parametrize(
"model,budget_tokens,expected",
[
("claude-opus-4-8", 4096, ({"type": "adaptive"}, {"effort": "high"})),
("claude-opus-4-7", 24000, ({"type": "adaptive"}, {"effort": "xhigh"})),
("claude-opus-4-6", 4096, ({"type": "enabled", "budget_tokens": 4096}, None)),
("claude-sonnet-4-5-20250929", 4096, ({"type": "enabled", "budget_tokens": 4096}, None)),
],
)
def test_legacy_thinking_translated_to_adaptive_on_adaptive_only_models(model, budget_tokens, expected):
"""Adaptive-only models reject thinking={type: enabled} with a 400, so the
legacy shape must be upgraded to adaptive + output_config.effort on
/chat/completions too, while models that accept it keep the caller's budget."""
config = AnthropicConfig()
result = config.map_openai_params(
non_default_params={"thinking": {"type": "enabled", "budget_tokens": budget_tokens}, "max_tokens": 64000},
optional_params={},
model=model,
drop_params=False,
)
assert (result["thinking"], result.get("output_config")) == expected
@pytest.mark.parametrize(
"bad_value",
[

View file

@ -6269,6 +6269,82 @@ def test_adaptive_thinking_passes_through_on_46_plus_converse(model):
assert optional_params.get("thinking") == {"type": "adaptive"}
@pytest.mark.parametrize(
"model,budget_tokens,expected_effort",
[
("anthropic.claude-opus-4-8", 4096, "high"),
("us.anthropic.claude-opus-4-8", 2000, "low"),
("global.anthropic.claude-opus-4-8", 12000, "xhigh"),
("us.anthropic.claude-opus-4-7", 3000, "medium"),
("anthropic.claude-fable-5", 4096, "high"),
],
)
def test_legacy_thinking_translated_to_adaptive_on_adaptive_only_converse(model, budget_tokens, expected_effort):
"""Adaptive-only models (4.7+, 5 families) reject thinking={type: enabled}
with a 400 on Bedrock Converse, so the legacy shape from callers like Claude
Code must be upgraded to thinking={type: adaptive} + output_config.effort
derived from budget_tokens, matching the /v1/messages passthrough."""
config = AmazonConverseConfig()
optional_params = config.map_openai_params(
non_default_params={"thinking": {"type": "enabled", "budget_tokens": budget_tokens}, "max_tokens": 64000},
optional_params={},
model=model,
drop_params=False,
)
request = config.transform_request(
model=model,
messages=[{"role": "user", "content": "hi"}],
optional_params=optional_params,
litellm_params={},
headers={},
)
assert request["additionalModelRequestFields"]["thinking"] == {"type": "adaptive"}
assert request["additionalModelRequestFields"]["output_config"] == {"effort": expected_effort}
def test_legacy_thinking_translation_keeps_caller_output_config_effort_converse():
config = AmazonConverseConfig()
optional_params = config.map_openai_params(
non_default_params={
"output_config": {"effort": "low"},
"thinking": {"type": "enabled", "budget_tokens": 12000},
"max_tokens": 64000,
},
optional_params={},
model="anthropic.claude-opus-4-8",
drop_params=False,
)
assert optional_params["thinking"] == {"type": "adaptive"}
assert optional_params["output_config"] == {"effort": "low"}
@pytest.mark.parametrize(
"model",
[
"us.anthropic.claude-opus-4-6",
"anthropic.claude-3-5-sonnet-20241022-v2:0",
],
)
def test_legacy_thinking_forwarded_verbatim_when_model_accepts_it_converse(model):
"""The 4.6 family and pre-adaptive models accept thinking={type: enabled}
natively, so the caller's budget_tokens cap must keep applying."""
config = AmazonConverseConfig()
optional_params = config.map_openai_params(
non_default_params={"thinking": {"type": "enabled", "budget_tokens": 4096}, "max_tokens": 8192},
optional_params={},
model=model,
drop_params=False,
)
assert optional_params["thinking"] == {"type": "enabled", "budget_tokens": 4096}
assert "output_config" not in optional_params
def test_adaptive_thinking_dropped_when_max_tokens_too_small_converse():
"""When max_tokens can't fit even the minimum thinking budget, the raw
adaptive block must be dropped entirely rather than translated, so the