diff --git a/litellm/completion_extras/litellm_responses_transformation/handler.py b/litellm/completion_extras/litellm_responses_transformation/handler.py index e824d9bcfc1..c47bb046021 100644 --- a/litellm/completion_extras/litellm_responses_transformation/handler.py +++ b/litellm/completion_extras/litellm_responses_transformation/handler.py @@ -184,7 +184,7 @@ class ResponsesToCompletionBridgeHandler: custom_llm_provider: Final = validated_kwargs["custom_llm_provider"] from .transformation import serves_anthropic_messages - streams_reasoning_signatures: Final = serves_anthropic_messages(logging_obj) + serves_messages_client: Final = serves_anthropic_messages(logging_obj) if kwargs.get("stream") is True and "stream" not in optional_params: optional_params = {**optional_params, "stream": True} @@ -225,6 +225,7 @@ class ResponsesToCompletionBridgeHandler: encoding=validated_kwargs["encoding"], api_key=kwargs.get("api_key"), json_mode=kwargs.get("json_mode"), + keeps_every_reasoning_item=serves_messages_client, ) elif isinstance(result, ModelResponse): if not stream: @@ -250,6 +251,7 @@ class ResponsesToCompletionBridgeHandler: encoding=validated_kwargs["encoding"], api_key=kwargs.get("api_key"), json_mode=kwargs.get("json_mode"), + keeps_every_reasoning_item=serves_messages_client, ) else: if self._is_preformatted_cached_chat_stream(result): @@ -263,7 +265,7 @@ class ResponsesToCompletionBridgeHandler: structured_output=is_structured_output_request( cast("Mapping[str, object]", optional_params) # cast-ok: optional_params is an untyped dict ), - streams_reasoning_signatures=streams_reasoning_signatures, + streams_reasoning_signatures=serves_messages_client, ) streamwrapper: Final = CustomStreamWrapper( completion_stream=completion_stream, @@ -288,7 +290,7 @@ class ResponsesToCompletionBridgeHandler: custom_llm_provider: Final = validated_kwargs["custom_llm_provider"] from .transformation import serves_anthropic_messages - streams_reasoning_signatures: Final = serves_anthropic_messages(logging_obj) + serves_messages_client: Final = serves_anthropic_messages(logging_obj) if kwargs.get("stream") is True and "stream" not in optional_params: optional_params = {**optional_params, "stream": True} @@ -332,6 +334,7 @@ class ResponsesToCompletionBridgeHandler: encoding=validated_kwargs["encoding"], api_key=kwargs.get("api_key"), json_mode=kwargs.get("json_mode"), + keeps_every_reasoning_item=serves_messages_client, ) elif isinstance(result, ModelResponse): if not stream: @@ -357,6 +360,7 @@ class ResponsesToCompletionBridgeHandler: encoding=validated_kwargs["encoding"], api_key=kwargs.get("api_key"), json_mode=kwargs.get("json_mode"), + keeps_every_reasoning_item=serves_messages_client, ) else: if self._is_preformatted_cached_chat_stream(result): @@ -370,7 +374,7 @@ class ResponsesToCompletionBridgeHandler: structured_output=is_structured_output_request( cast("Mapping[str, object]", optional_params) # cast-ok: optional_params is an untyped dict ), - streams_reasoning_signatures=streams_reasoning_signatures, + streams_reasoning_signatures=serves_messages_client, ) streamwrapper: Final = CustomStreamWrapper( completion_stream=completion_stream, diff --git a/litellm/completion_extras/litellm_responses_transformation/transformation.py b/litellm/completion_extras/litellm_responses_transformation/transformation.py index cf8d845bf26..4c15f2cee4a 100644 --- a/litellm/completion_extras/litellm_responses_transformation/transformation.py +++ b/litellm/completion_extras/litellm_responses_transformation/transformation.py @@ -1482,6 +1482,7 @@ class LiteLLMResponsesTransformationHandler(CompletionTransformationBridge): encoding: object, api_key: str | None = None, json_mode: bool | None = None, + keeps_every_reasoning_item: bool = False, ) -> "ModelResponse": """Transform Responses API response to chat completion response""" from litellm.responses.utils import ResponseAPILoggingUtils @@ -1513,7 +1514,7 @@ class LiteLLMResponsesTransformationHandler(CompletionTransformationBridge): structured_output=is_structured_output_request( cast("Mapping[str, object]", optional_params) # cast-ok: optional_params is an untyped dict ), - keeps_every_reasoning_item=serves_anthropic_messages(logging_obj), + keeps_every_reasoning_item=keeps_every_reasoning_item, ) response_is_incomplete: Final = raw_response.status == "incomplete" or ( diff --git a/tests/unit/completion_extras/litellm_responses_transformation/test_completion_extras_litellm_responses_transformation_handler.py b/tests/unit/completion_extras/litellm_responses_transformation/test_completion_extras_litellm_responses_transformation_handler.py index c5d7ca96a21..0899c86cfb7 100644 --- a/tests/unit/completion_extras/litellm_responses_transformation/test_completion_extras_litellm_responses_transformation_handler.py +++ b/tests/unit/completion_extras/litellm_responses_transformation/test_completion_extras_litellm_responses_transformation_handler.py @@ -335,3 +335,101 @@ async def test_acompletion_keeps_provider_native_model_id_through_responses( handed_model = responses_call.call_args.kwargs["model"] assert _upstream_model_for(handed_model, custom_llm_provider) == expected_upstream_model + + +def _two_reasoning_tool_turn(): + from openai.types.responses import ResponseFunctionToolCall + from openai.types.responses.response_reasoning_item import ResponseReasoningItem + + from litellm.types.llms.openai import ResponsesAPIResponse + + def reasoning(item_id: str) -> ResponseReasoningItem: + return ResponseReasoningItem( + id=item_id, summary=[], type="reasoning", encrypted_content=f"gAAAA_{item_id}" + ) + + def call(index: int, city: str) -> ResponseFunctionToolCall: + return ResponseFunctionToolCall( + id=f"fc_{index}", + type="function_call", + status="completed", + arguments=f'{{"city": "{city}"}}', + call_id=f"call_{index}", + name="get_weather", + ) + + return ResponsesAPIResponse.model_construct( + id="resp_two_reasoning", + created_at=1, + error=None, + incomplete_details=None, + model="gpt-5.4-nano", + output=[reasoning("rs_first"), call(1, "Oslo"), reasoning("rs_second"), call(2, "Lyon")], + status="completed", + usage=None, + ) + + +class _CompletedStream: + def __init__(self, response): + self.completed_response = MagicMock(response=response) + self._hidden_params = {} + + def __iter__(self): + return iter(()) + + def __aiter__(self): + return self + + async def __anext__(self): + raise StopAsyncIteration + + +def _messages_client_kwargs(call_type: str): + messages = [{"role": "user", "content": "Weather in Oslo and Lyon?"}] + return { + "model": "gpt-5.4-nano", + "custom_llm_provider": "azure", + "messages": messages, + "optional_params": {}, + "litellm_params": {}, + "headers": {}, + "model_response": ModelResponse(), + "logging_obj": LiteLLMLogging( + litellm_call_id="test-call", + call_type=call_type, + model="gpt-5.4-nano", + messages=messages, + function_id="fn-id", + stream=False, + start_time=datetime.now(), + ), + } + + +@pytest.mark.parametrize("is_async", [True, False], ids=["acompletion", "completion"]) +@pytest.mark.parametrize("arrives_streamed", [False, True], ids=["response", "collected_stream"]) +@pytest.mark.parametrize( + ("call_type", "kept_ids"), + [ + pytest.param("anthropic_messages", ["rs_first", "rs_second"], id="messages_client"), + pytest.param("acompletion", ["rs_second"], id="chat_client"), + ], +) +@pytest.mark.asyncio +async def test_non_streamed_bridge_hands_messages_clients_every_reasoning_item( + is_async: bool, arrives_streamed: bool, call_type: str, kept_ids: list[str] +): + turn = _two_reasoning_tool_turn() + upstream = _CompletedStream(turn) if arrives_streamed else turn + bridge = ResponsesToCompletionBridgeHandler() + kwargs = _messages_client_kwargs(call_type) + + if is_async: + with patch("litellm.aresponses", new=AsyncMock(return_value=upstream)): + result = await bridge.acompletion(**kwargs) + else: + with patch("litellm.responses", return_value=upstream): + result = bridge.completion(**kwargs) + + assert [item["id"] for item in result.choices[0].message.reasoning_items] == kept_ids diff --git a/tests/unit/completion_extras/litellm_responses_transformation/test_completion_extras_litellm_responses_transformation_transformation.py b/tests/unit/completion_extras/litellm_responses_transformation/test_completion_extras_litellm_responses_transformation_transformation.py index cb4cbfc1c57..04505b717af 100644 --- a/tests/unit/completion_extras/litellm_responses_transformation/test_completion_extras_litellm_responses_transformation_transformation.py +++ b/tests/unit/completion_extras/litellm_responses_transformation/test_completion_extras_litellm_responses_transformation_transformation.py @@ -3571,16 +3571,15 @@ def test_transform_response_hands_encrypted_reasoning_to_messages_clients_only() @pytest.mark.parametrize( - ("call_type", "kept_ids"), + ("keeps_every_reasoning_item", "kept_ids"), [ - pytest.param("anthropic_messages", ["rs_first", "rs_second"], id="messages_client_keeps_every_item"), - pytest.param("acompletion", ["rs_second"], id="chat_client_keeps_the_last_item"), + pytest.param(True, ["rs_first", "rs_second"], id="messages_client_keeps_every_item"), + pytest.param(False, ["rs_second"], id="chat_client_keeps_the_last_item"), ], ) def test_non_streamed_tool_turn_hands_messages_clients_every_reasoning_item( - call_type: str, kept_ids: list[str] + keeps_every_reasoning_item: bool, kept_ids: list[str] ) -> None: - from types import SimpleNamespace from unittest.mock import Mock from litellm.types.llms.openai import ResponsesAPIResponse @@ -3623,12 +3622,13 @@ def test_non_streamed_tool_turn_hands_messages_clients_every_reasoning_item( choices=[], usage=Usage(completion_tokens=0, prompt_tokens=0, total_tokens=0), ), - logging_obj=SimpleNamespace(call_type=call_type), + logging_obj=Mock(), request_data={"model": "gpt-5.4-nano"}, messages=[{"role": "user", "content": "Oslo or Lyon?"}], optional_params={}, litellm_params={"custom_llm_provider": "azure"}, encoding=Mock(), + keeps_every_reasoning_item=keeps_every_reasoning_item, ) message: Final = result.choices[0].message