fix(adapter): map output_config.effort to reasoning_effort (#25079)

Anthropic's adaptive thinking (thinking.type="adaptive") and
output_config.effort were silently dropped when translating to
OpenAI format, resulting in no reasoning_effort on the outgoing
request.

Adapter changes (format translation):
- adapters/transformation.py: add "adaptive" branch to
  translate_anthropic_thinking_to_reasoning_effort(); pass through
  output_config.effort as-is in _translate_thinking_to_openai();
  add "output_config" to translatable_anthropic_params
- adapters/handler.py: extract output_config from extra_kwargs into
  request_data so it reaches the translation layer
- responses_adapters/transformation.py: add "adaptive" branch and
  output_config param to translate_thinking_to_reasoning()

Handler changes (model-aware normalization):
- utils.py: add normalize_reasoning_effort_value() that uses
  get_model_info() to map "max" → "xhigh"/"high" and
  "minimal" → "minimal"/"low" based on model capabilities
- adapters/handler.py: call normalization before responses routing
- responses_adapters/handler.py: call normalization after translation

Relates to BerriAI/litellm#25079
This commit is contained in:
Vigilans 2026-04-13 17:39:15 +08:00
parent 74c1161015
commit e50677bbb6
5 changed files with 151 additions and 10 deletions

View file

@ -106,6 +106,44 @@ class LiteLLMMessagesToCompletionTransformationHandler:
updated_reasoning_effort["summary"] = effective_summary
completion_kwargs["reasoning_effort"] = updated_reasoning_effort
@staticmethod
def _normalize_reasoning_effort(
completion_kwargs: Dict[str, Any],
) -> None:
"""
Normalize reasoning_effort values based on target model capabilities.
Handles both string ("max") and dict ({"effort": "max", "summary": ...})
formats. Uses model registry to check supports_xhigh/supports_minimal.
"""
from litellm.llms.anthropic.experimental_pass_through.utils import (
normalize_reasoning_effort_value,
)
reasoning_effort = completion_kwargs.get("reasoning_effort")
if reasoning_effort is None:
return
model = cast(str, completion_kwargs.get("model", ""))
custom_llm_provider = completion_kwargs.get("custom_llm_provider")
if isinstance(reasoning_effort, str):
normalized = normalize_reasoning_effort_value(
reasoning_effort, model=model, custom_llm_provider=custom_llm_provider
)
if normalized != reasoning_effort:
completion_kwargs["reasoning_effort"] = normalized
elif isinstance(reasoning_effort, dict) and "effort" in reasoning_effort:
effort = reasoning_effort["effort"]
normalized = normalize_reasoning_effort_value(
effort, model=model, custom_llm_provider=custom_llm_provider
)
if normalized != effort:
completion_kwargs["reasoning_effort"] = {
**reasoning_effort,
"effort": normalized,
}
@staticmethod
def _prepare_completion_kwargs(
*,
@ -163,6 +201,12 @@ class LiteLLMMessagesToCompletionTransformationHandler:
if output_format:
request_data["output_format"] = output_format
# Extract output_config from extra_kwargs so the translator can use it
# (e.g. output_config.effort for adaptive thinking → reasoning_effort)
extra_kwargs = extra_kwargs or {}
if "output_config" in extra_kwargs:
request_data["output_config"] = extra_kwargs["output_config"]
(
openai_request,
tool_name_mapping,
@ -202,6 +246,14 @@ class LiteLLMMessagesToCompletionTransformationHandler:
):
completion_kwargs[key] = value
# Normalize reasoning_effort based on model capabilities
# (e.g. "max" → "xhigh"/"high", "minimal" → "low" if unsupported)
# Must run BEFORE _route_openai_thinking, which prepends "responses/"
# to the model name and would break get_model_info() lookups.
LiteLLMMessagesToCompletionTransformationHandler._normalize_reasoning_effort(
completion_kwargs
)
LiteLLMMessagesToCompletionTransformationHandler._route_openai_thinking_to_responses_api_if_needed(
completion_kwargs,
thinking=thinking,

View file

@ -317,6 +317,7 @@ class LiteLLMAnthropicMessagesAdapter:
"tools",
"thinking",
"output_format",
"output_config",
]
def _is_web_search_tool(self, tool: Dict[str, Any]) -> bool:
@ -694,6 +695,11 @@ class LiteLLMAnthropicMessagesAdapter:
return "low"
else:
return "minimal"
elif thinking_type == "adaptive":
# Adaptive thinking: effort is controlled by output_config.effort,
# not budget_tokens. Return a default; caller should override with
# output_config.effort when available.
return "medium"
return None
@ -1041,6 +1047,12 @@ class LiteLLMAnthropicMessagesAdapter:
if not reasoning_effort:
return
# For adaptive thinking, override with output_config.effort if available
if isinstance(thinking, dict) and thinking.get("type") == "adaptive":
output_config = anthropic_message_request.get("output_config")
if isinstance(output_config, dict) and output_config.get("effort"):
reasoning_effort = output_config["effort"]
summary = thinking.get("summary") if isinstance(thinking, dict) else None
auto_summary = is_reasoning_auto_summary_enabled()
if summary:

View file

@ -72,6 +72,19 @@ def _build_responses_kwargs(
anthropic_request = AnthropicMessagesRequest(**request_data) # type: ignore[typeddict-item]
responses_kwargs = _ADAPTER.translate_request(anthropic_request)
# Normalize reasoning effort based on model capabilities
# (e.g. "max" → "xhigh"/"high", "minimal" → "low" if unsupported)
reasoning = responses_kwargs.get("reasoning")
if isinstance(reasoning, dict) and "effort" in reasoning:
from litellm.llms.anthropic.experimental_pass_through.utils import (
normalize_reasoning_effort_value,
)
effort = reasoning["effort"]
normalized = normalize_reasoning_effort_value(effort, model=model)
if normalized != effort:
responses_kwargs["reasoning"] = {**reasoning, "effort": normalized}
if stream:
responses_kwargs["stream"] = True

View file

@ -251,25 +251,41 @@ class LiteLLMAnthropicToResponsesAPIAdapter:
@staticmethod
def translate_thinking_to_reasoning(
thinking: Dict[str, Any]
thinking: Dict[str, Any],
output_config: Optional[Dict[str, Any]] = None,
) -> Optional[Dict[str, Any]]:
"""
Convert Anthropic thinking param to Responses API reasoning param.
thinking.budget_tokens maps to reasoning effort:
>= 10000 -> high, >= 5000 -> medium, >= 2000 -> low, < 2000 -> minimal
For adaptive thinking, uses output_config.effort if available,
otherwise defaults to medium.
"""
if not isinstance(thinking, dict) or thinking.get("type") != "enabled":
if not isinstance(thinking, dict):
return None
budget = thinking.get("budget_tokens", 0)
if budget >= 10000:
effort = "high"
elif budget >= 5000:
thinking_type = thinking.get("type")
if thinking_type == "adaptive":
# Use output_config.effort if available
effort = "medium"
elif budget >= 2000:
effort = "low"
if isinstance(output_config, dict) and output_config.get("effort"):
effort = output_config["effort"]
elif thinking_type == "enabled":
budget = thinking.get("budget_tokens", 0)
if budget >= 10000:
effort = "high"
elif budget >= 5000:
effort = "medium"
elif budget >= 2000:
effort = "low"
else:
effort = "minimal"
else:
effort = "minimal"
return None
auto_summary = is_reasoning_auto_summary_enabled()
result: Dict[str, Any] = {"effort": effort}
summary = thinking.get("summary")
@ -346,7 +362,11 @@ class LiteLLMAnthropicToResponsesAPIAdapter:
# thinking -> reasoning
thinking = anthropic_request.get("thinking")
if isinstance(thinking, dict):
reasoning = self.translate_thinking_to_reasoning(thinking)
output_config = anthropic_request.get("output_config")
reasoning = self.translate_thinking_to_reasoning(
thinking,
output_config=cast(Optional[Dict[str, Any]], output_config),
)
if reasoning:
responses_kwargs["reasoning"] = reasoning

View file

@ -1,4 +1,5 @@
import os
from typing import Optional
import litellm
@ -9,3 +10,46 @@ def is_reasoning_auto_summary_enabled() -> bool:
litellm.reasoning_auto_summary
or os.getenv("LITELLM_REASONING_AUTO_SUMMARY", "false").lower() == "true"
)
def normalize_reasoning_effort_value(
effort: str,
model: str,
custom_llm_provider: Optional[str] = None,
) -> str:
"""
Normalize a reasoning effort value based on model capabilities.
Degradation chains:
- "max" max / xhigh / high
- "xhigh" xhigh / high
- "minimal" minimal / low
- other values pass through unchanged
"""
if effort not in ("max", "xhigh", "minimal"):
return effort
from litellm.utils import get_model_info
try:
model_info = get_model_info(
model=model, custom_llm_provider=custom_llm_provider
)
except Exception:
model_info = {}
if effort == "max":
if model_info.get("supports_max_reasoning_effort"):
return "max"
if model_info.get("supports_xhigh_reasoning_effort"):
return "xhigh"
return "high"
elif effort == "xhigh":
if model_info.get("supports_xhigh_reasoning_effort"):
return "xhigh"
return "high"
elif effort == "minimal":
if model_info.get("supports_minimal_reasoning_effort"):
return "minimal"
return "low"
return "medium"