Merge pull request #39159 from BerriAI/litellm_bedrock_converse_legacy_thinking_adaptive

fix(anthropic): upgrade legacy thinking to adaptive on adaptive-only Claude models for chat, Bedrock Converse, Invoke, Vertex AI, and Databricks
This commit is contained in:
Mateo Wang 2026-09-02 16:35:03 -07:00 committed by GitHub
commit 22cc97fe0a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
14 changed files with 289 additions and 53 deletions

View file

@ -1565,6 +1565,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,
)
@ -534,6 +539,51 @@ 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 like the sibling helpers
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
effort: Final = AnthropicModelInfo._legacy_budget_to_effort(
model=model,
budget_tokens=int(thinking.get("budget_tokens") or 0),
custom_llm_provider=custom_llm_provider,
)
existing_output_config: Final = optional_params.get("output_config")
optional_params["thinking"] = {"type": "adaptive"}
optional_params["output_config"] = {
"effort": effort,
**(existing_output_config if isinstance(existing_output_config, dict) else MappingProxyType({})),
}
@staticmethod
def _legacy_budget_to_effort(model: str, budget_tokens: int, custom_llm_provider: str) -> str:
if budget_tokens >= DEFAULT_REASONING_EFFORT_XHIGH_THINKING_BUDGET and (
AnthropicModelInfo._supports_model_capability(model, "supports_xhigh_reasoning_effort", custom_llm_provider)
):
return "xhigh"
if budget_tokens >= DEFAULT_REASONING_EFFORT_HIGH_THINKING_BUDGET:
return "high"
if budget_tokens >= DEFAULT_REASONING_EFFORT_MEDIUM_THINKING_BUDGET:
return "medium"
return "low"
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

@ -17,13 +17,14 @@ def _promote_extra_body_to_optional_params(optional_params: dict) -> None:
``output_config`` get auto-routed into ``extra_body`` by
``add_provider_specific_params_to_optional_params``. For the AzureAnthropic
route those keys must reach the request body and be validated, so promote
them. ``setdefault`` keeps explicit top-level values authoritative.
them. The caller's values overwrite mapped top-level duplicates, matching
the native ``anthropic`` provider, where the same passthrough lands on
top-level ``optional_params`` after mapping.
"""
extra_body: Final = optional_params.get("extra_body")
if not isinstance(extra_body, dict) or not extra_body:
return
for k, v in extra_body.items():
optional_params.setdefault(k, v)
optional_params.update(extra_body)
optional_params.pop("extra_body", None)

View file

@ -943,6 +943,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

@ -107,6 +107,10 @@ class AmazonAnthropicClaudeConfig(AmazonInvokeConfig, AnthropicConfig):
# Restore original model name
model = original_model
AnthropicModelInfo.translate_legacy_thinking_for_adaptive_model(
model=original_model, optional_params=optional_params, custom_llm_provider="bedrock"
)
# The stub model hides the original model from the parent's forced-tool-use backstop
response_format_tool_choice: Final = optional_params.get("tool_choice")
if (

View file

@ -330,6 +330,10 @@ class DatabricksConfig(DatabricksBase, OpenAILikeChatConfig, AnthropicConfig):
) -> dict:
is_thinking_enabled: Final = self.is_thinking_enabled(non_default_params)
mapped_params: Final = super().map_openai_params(non_default_params, optional_params, model, drop_params)
if "claude" in model:
AnthropicConfig.translate_legacy_thinking_for_adaptive_model(
model=model, optional_params=mapped_params, custom_llm_provider="databricks"
)
if "tools" in mapped_params:
mapped_params["tools"] = self._map_openai_to_dbrx_tool(model=model, tools=mapped_params["tools"])
if "max_completion_tokens" in non_default_params and replace_max_completion_tokens_with_max_tokens:

View file

@ -177,6 +177,10 @@ class VertexAIAnthropicConfig(AnthropicConfig):
# Restore original model name for any other processing
model = original_model
AnthropicModelInfo.translate_legacy_thinking_for_adaptive_model(
model=original_model, optional_params=optional_params, custom_llm_provider="vertex_ai"
)
return optional_params
def transform_response(

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

@ -362,8 +362,8 @@ class TestAzureAnthropicConfig:
)
assert "xhigh" in str(exc_info.value)
def test_extra_body_promotion_does_not_clobber_top_level(self):
"""Top-level ``optional_params`` wins over duplicates in ``extra_body``."""
def test_extra_body_promotion_overrides_mapped_top_level(self):
"""The caller's ``extra_body`` wins over a mapped top-level duplicate, like the native ``anthropic`` passthrough."""
config = AzureAnthropicConfig()
messages = [{"role": "user", "content": "Hello"}]
@ -383,7 +383,31 @@ class TestAzureAnthropicConfig:
headers=headers,
)
assert result["output_config"] == {"effort": "low"}
assert result["output_config"] == {"effort": "high"}
def test_legacy_thinking_upgrade_keeps_caller_effort_from_extra_body(self, local_model_cost_map):
config = AzureAnthropicConfig()
mapped = config.map_openai_params(
non_default_params={"thinking": {"type": "enabled", "budget_tokens": 1024}, "max_tokens": 100},
optional_params={},
model="claude-opus-4-8",
drop_params=False,
)
assert mapped["thinking"] == {"type": "adaptive"}
assert mapped["output_config"] == {"effort": "low"}
result = config.transform_request(
model="claude-opus-4-8",
messages=[{"role": "user", "content": "Hello"}],
optional_params={**mapped, "extra_body": {"output_config": {"effort": "high"}}},
litellm_params={"api_key": "test-key"},
headers={"api-key": "test-key", "anthropic-version": "2023-06-01"},
)
assert result["thinking"] == {"type": "adaptive"}
assert result["output_config"] == {"effort": "high"}
assert "extra_body" not in result
def test_context_management_mixed_edits_beta_headers(self):
"""Test that context_management with both compact and other edits adds both beta headers"""

View file

@ -671,3 +671,46 @@ def test_bedrock_chat_invoke_fable_5_1_response_format_avoids_forced_tool_choice
assert "output_format" not in result
assert "tools" in result
assert "tool_choice" not in result
@pytest.mark.parametrize("model", ["us.anthropic.claude-sonnet-5", "us.anthropic.claude-fable-5-1"])
def test_bedrock_chat_invoke_tool_based_response_format_still_upgrades_legacy_thinking(local_model_cost_map, model):
result = AmazonAnthropicClaudeConfig().map_openai_params(
non_default_params={
"response_format": {
"type": "json_schema",
"json_schema": {
"name": "test_schema",
"schema": {"type": "object", "properties": {"result": {"type": "string"}}},
},
},
"thinking": {"type": "enabled", "budget_tokens": 4096},
"max_tokens": 8192,
},
optional_params={},
model=model,
drop_params=False,
)
assert "tools" in result
assert result["thinking"] == {"type": "adaptive"}
assert result["output_config"] == {"effort": "high"}
def test_bedrock_chat_invoke_response_format_stub_still_upgrades_legacy_thinking(local_model_cost_map):
"""Regression: the tool-based ``response_format`` path swaps in a Claude 3 stub
model before the shared Anthropic mapping, which hid the adaptive-only model
from the legacy ``thinking`` upgrade and left ``type=enabled`` on the wire."""
result = AmazonAnthropicClaudeConfig().map_openai_params(
non_default_params={
"response_format": {"type": "json_object"},
"thinking": {"type": "enabled", "budget_tokens": 4096},
"max_tokens": 8192,
},
optional_params={},
model="us.anthropic.claude-fable-5-1",
drop_params=False,
)
assert result["thinking"] == {"type": "adaptive"}
assert result["output_config"] == {"effort": "high"}

View file

@ -6347,6 +6347,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

View file

@ -422,6 +422,27 @@ def test_databricks_config_probes_capabilities_under_databricks_namespace():
assert DatabricksConfig().custom_llm_provider == "databricks"
@pytest.mark.parametrize(
"model, expected_thinking, expected_output_config",
[
("databricks-claude-opus-4-8", {"type": "adaptive"}, {"effort": "high"}),
("databricks-claude-opus-4-6", {"type": "enabled", "budget_tokens": 4096}, None),
],
ids=["adaptive_only_upgrades_to_adaptive", "legacy_capable_forwards_verbatim"],
)
def test_map_openai_params_upgrades_legacy_thinking_on_adaptive_only_claude(
model, expected_thinking, expected_output_config
):
mapped = DatabricksConfig().map_openai_params(
non_default_params={"thinking": {"type": "enabled", "budget_tokens": 4096}},
optional_params={},
model=model,
drop_params=False,
)
assert mapped["thinking"] == expected_thinking
assert mapped.get("output_config") == expected_output_config
def _streaming_chunk(usage=None, choices=None):
base = {
"id": "chatcmpl-test",

View file

@ -752,3 +752,26 @@ def test_vertex_ai_fable_5_1_response_format_uses_native_output_format(local_mod
assert "output_format" in result_params
assert "tool_choice" not in result_params
assert "tools" not in result_params
def test_vertex_ai_anthropic_tool_based_response_format_still_upgrades_legacy_thinking(local_model_cost_map):
result_params = VertexAIAnthropicConfig().map_openai_params(
non_default_params={
"response_format": {
"type": "json_schema",
"json_schema": {
"name": "test_schema",
"schema": {"type": "object", "properties": {"result": {"type": "string"}}},
},
},
"thinking": {"type": "enabled", "budget_tokens": 4096},
"max_tokens": 8192,
},
optional_params={},
model="claude-opus-4-8",
drop_params=False,
)
assert "tools" in result_params
assert result_params["thinking"] == {"type": "adaptive"}
assert result_params["output_config"] == {"effort": "high"}