fix(responses): translate the reasoning object into a chat-completion reasoning effort

The Responses API takes reasoning as an object, {effort, summary}. Chat
Completions takes reasoning_effort as a string enum and has no equivalent of
summary, but the completion bridge forwarded the whole object whenever summary
was set, which agentic clients set on every request.

Bedrock Converse guards its mapping with isinstance(value, str) and has no else
branch, so the object fell through, thinking was never enabled, and the caller
was billed for a non-thinking turn with nothing in the response to explain it.

The object is still forwarded for the one caller that can consume it: a model
whose cost-map mode is responses, which litellm.completion bridges back onto the
Responses API and reassembles {effort, summary} there. That decision is delegated
to responses_api_bridge_check, the same check litellm.completion runs, rather
than a second copy of the rule that could drift from it. An object carrying no
effort now yields no reasoning_effort at all.
This commit is contained in:
Joshua Garnett 2026-08-09 13:49:23 -04:00 committed by ryan-crabbe-berri
parent d4a72e7372
commit 1d5ed79931
2 changed files with 172 additions and 17 deletions

View file

@ -309,6 +309,67 @@ class LiteLLMCompletionResponsesConfig:
)
return supported_params is not None and "web_search_options" not in supported_params
@staticmethod
def _completion_bridges_back_to_responses_api(
model: str,
custom_llm_provider: str | None,
tools: Sequence[ChatCompletionToolParam | OpenAIMcpServerTool] | None,
web_search_options: OpenAIWebSearchOptions | None,
reasoning_param: Reasoning,
) -> bool:
"""
Whether ``litellm.completion`` will route this model back onto the Responses API.
Delegates to the same check ``litellm.completion`` itself runs, so the two cannot
disagree about which models take the Responses-shaped params.
"""
from litellm.main import responses_api_bridge_check
try:
model_info, _ = responses_api_bridge_check(
model=model,
custom_llm_provider=custom_llm_provider or "",
web_search_options=web_search_options,
tools=tools,
reasoning_effort=reasoning_param,
reasoning_summary=reasoning_param.get("summary"),
)
except Exception as e: # noqa: BLE001 # a capability probe must never fail the request it probes for
verbose_logger.debug(f"responses bridge: reasoning effort mode check failed: {e}")
return False
return model_info.get("mode") == "responses"
@staticmethod
def _transform_reasoning_to_reasoning_effort(
reasoning_param: Reasoning | str | None,
model: str,
custom_llm_provider: str | None,
tools: Sequence[ChatCompletionToolParam | OpenAIMcpServerTool] | None = None,
web_search_options: OpenAIWebSearchOptions | None = None,
) -> Reasoning | str | None:
"""
Map the Responses ``reasoning`` param onto Chat Completions ``reasoning_effort``.
Chat Completions defines ``reasoning_effort`` as a string enum, and ``summary`` is a
Responses-only field with no Chat Completions equivalent. Sending the whole object to a
chat provider is rejected or silently discarded, which turns reasoning off. The object is
kept only when ``litellm.completion`` will bridge this model back onto the Responses API,
the one caller that can consume it.
"""
if not reasoning_param:
return None
if isinstance(reasoning_param, str):
return reasoning_param
if LiteLLMCompletionResponsesConfig._completion_bridges_back_to_responses_api(
model=model,
custom_llm_provider=custom_llm_provider,
tools=tools,
web_search_options=web_search_options,
reasoning_param=reasoning_param,
):
return reasoning_param
return reasoning_param.get("effort")
@staticmethod
def transform_responses_api_request_to_chat_completion_request(
model: str,
@ -339,23 +400,15 @@ class LiteLLMCompletionResponsesConfig:
if text_param:
response_format = LiteLLMCompletionResponsesConfig._transform_text_format_to_response_format(text_param)
# Extract reasoning_effort from reasoning parameter
reasoning_effort: Reasoning | str | None = None
reasoning_param: Final = responses_api_request.get("reasoning")
if reasoning_param:
if isinstance(reasoning_param, dict):
# reasoning can be {"effort": "low|medium|high", "summary": "detailed"}
# Keep the full dict when summary is set so the responses API bridge can
# forward it; otherwise use the effort string for chat completion (e.g. Gemini).
if "summary" in reasoning_param:
reasoning_effort = reasoning_param
elif "effort" in reasoning_param:
reasoning_effort = reasoning_param.get("effort")
else:
reasoning_effort = reasoning_param
elif isinstance(reasoning_param, str):
# reasoning could be a string directly
reasoning_effort = reasoning_param
reasoning_effort: Final[Reasoning | str | None] = (
LiteLLMCompletionResponsesConfig._transform_reasoning_to_reasoning_effort(
reasoning_param=responses_api_request.get("reasoning"),
model=model,
custom_llm_provider=custom_llm_provider,
tools=tools,
web_search_options=web_search_options,
)
)
litellm_completion_request: dict = {
"messages": LiteLLMCompletionResponsesConfig.transform_responses_api_input_to_messages(

View file

@ -2527,6 +2527,108 @@ class TestToolTransformation:
"type": "object",
}
@pytest.mark.parametrize(
"model, custom_llm_provider",
[
("bedrock/converse/global.anthropic.claude-sonnet-5", "bedrock_converse"),
("anthropic.claude-sonnet-4-5-20250929-v1:0", "bedrock"),
("claude-sonnet-5", "vertex_ai"),
("gemini-3.1-pro-preview", "vertex_ai"),
("moonshotai.kimi-k2-thinking", "bedrock_mantle"),
],
)
def test_reasoning_summary_still_yields_a_string_reasoning_effort(self, model, custom_llm_provider):
"""
A Responses request carrying ``reasoning.summary`` must still reach a chat provider as a
plain ``reasoning_effort`` string. ``summary`` is Responses-only, and forwarding the whole
object turns reasoning off: Bedrock Converse and Vertex silently discard a non-string
``reasoning_effort``, and Bedrock Mantle rejects the request outright.
"""
responses_api_request = {"reasoning": {"effort": "medium", "summary": "auto"}}
result = LiteLLMCompletionResponsesConfig.transform_responses_api_request_to_chat_completion_request(
model=model,
input="hi",
responses_api_request=responses_api_request,
custom_llm_provider=custom_llm_provider,
)
assert result["reasoning_effort"] == "medium"
def test_responses_mode_model_keeps_the_whole_reasoning_object(self):
"""
The one consumer of the object form is ``litellm.completion`` bridging a ``mode: responses``
model back onto the Responses API, which has no native Responses config of its own. That
path reassembles ``{effort, summary}``, so the object must survive for it.
"""
responses_api_request = {"reasoning": {"effort": "medium", "summary": "auto"}}
result = LiteLLMCompletionResponsesConfig.transform_responses_api_request_to_chat_completion_request(
model="gpt-5.4-pro",
input="hi",
responses_api_request=responses_api_request,
custom_llm_provider="azure_ai",
)
assert result["reasoning_effort"] == {"effort": "medium", "summary": "auto"}
@pytest.mark.parametrize(
"reasoning, expected",
[
({"effort": "high"}, "high"),
("low", "low"),
({"summary": "auto"}, None),
({}, None),
(None, None),
],
)
def test_reasoning_param_shapes_map_to_reasoning_effort(self, reasoning, expected):
"""
An object without ``effort`` carries nothing Chat Completions can use, so no
``reasoning_effort`` is sent at all (the bridge drops None-valued params).
"""
result = LiteLLMCompletionResponsesConfig.transform_responses_api_request_to_chat_completion_request(
model="anthropic.claude-sonnet-4-5-20250929-v1:0",
input="hi",
responses_api_request={"reasoning": reasoning},
custom_llm_provider="bedrock",
)
assert result.get("reasoning_effort") == expected
assert ("reasoning_effort" in result) is (expected is not None)
@pytest.mark.parametrize(
"model, expected_thinking",
[
("global.anthropic.claude-sonnet-5", {"type": "adaptive"}),
(
"anthropic.claude-sonnet-4-5-20250929-v1:0",
{"type": "enabled", "budget_tokens": 2048},
),
],
)
def test_reasoning_summary_still_enables_thinking_on_bedrock(self, model, expected_thinking):
"""
End to end through Bedrock Converse's own param mapping: the effort a Responses request asks
for must survive into ``thinking``, whether the model takes an adaptive effort or a legacy
token budget. Forwarding the object instead leaves ``thinking`` unset and the model never
reasons, which is the failure this guards.
"""
from litellm.llms.bedrock.chat.converse_transformation import AmazonConverseConfig
bridged = LiteLLMCompletionResponsesConfig.transform_responses_api_request_to_chat_completion_request(
model=model,
input="hi",
responses_api_request={"reasoning": {"effort": "medium", "summary": "auto"}},
custom_llm_provider="bedrock",
)
mapped = AmazonConverseConfig().map_openai_params(
{"reasoning_effort": bridged["reasoning_effort"]}, {}, model, True
)
assert mapped["thinking"] == expected_thinking
def test_bedrock_anthropic_responses_tools_yield_only_function_toolspec(self):
"""
End-to-end (no network) of the LIT-3858 acceptance criterion: the mixed tools array