From 1d5ed799314ee8aa28d4c92d7fe749f3379800b7 Mon Sep 17 00:00:00 2001 From: Joshua Garnett Date: Sun, 9 Aug 2026 13:49:23 -0400 Subject: [PATCH] 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. --- .../transformation.py | 87 ++++++++++++--- .../test_litellm_completion_responses.py | 102 ++++++++++++++++++ 2 files changed, 172 insertions(+), 17 deletions(-) diff --git a/litellm/responses/litellm_completion_transformation/transformation.py b/litellm/responses/litellm_completion_transformation/transformation.py index cc594f167c7..bd4108a0d27 100644 --- a/litellm/responses/litellm_completion_transformation/transformation.py +++ b/litellm/responses/litellm_completion_transformation/transformation.py @@ -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( diff --git a/tests/test_litellm/responses/litellm_completion_transformation/test_litellm_completion_responses.py b/tests/test_litellm/responses/litellm_completion_transformation/test_litellm_completion_responses.py index 342ec4435a7..8e76ee315b7 100644 --- a/tests/test_litellm/responses/litellm_completion_transformation/test_litellm_completion_responses.py +++ b/tests/test_litellm/responses/litellm_completion_transformation/test_litellm_completion_responses.py @@ -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