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