diff --git a/litellm/responses/litellm_completion_transformation/streaming_iterator.py b/litellm/responses/litellm_completion_transformation/streaming_iterator.py index 6f2d5bc185..5f9fa8525c 100644 --- a/litellm/responses/litellm_completion_transformation/streaming_iterator.py +++ b/litellm/responses/litellm_completion_transformation/streaming_iterator.py @@ -8,6 +8,7 @@ from litellm.responses.litellm_completion_transformation.transformation import ( from litellm.responses.streaming_iterator import ResponsesAPIStreamingIterator from litellm.types.llms.openai import ( OutputTextDeltaEvent, + ReasoningSummaryTextDeltaEvent, ResponseCompletedEvent, ResponseInputParam, ResponsesAPIOptionalRequestParams, @@ -115,15 +116,34 @@ class LiteLLMCompletionStreamingIterator(ResponsesAPIStreamingIterator): """ Transform a chat completion chunk to a response API chunk. - This currently only handles emitting the OutputTextDeltaEvent, which is used by other tools using the responses API. + This currently handles emitting the OutputTextDeltaEvent, which is used by other tools using the responses API + and the ReasoningSummaryTextDeltaEvent, which is used by the responses API to emit reasoning content. """ - return OutputTextDeltaEvent( - type=ResponsesAPIStreamEvents.OUTPUT_TEXT_DELTA, - item_id=chunk.id, - output_index=0, - content_index=0, - delta=self._get_delta_string_from_streaming_choices(chunk.choices), - ) + if ( + chunk.choices + and hasattr(chunk.choices[0].delta, "reasoning_content") + and chunk.choices[0].delta.reasoning_content + ): + reasoning_content = chunk.choices[0].delta.reasoning_content + + return ReasoningSummaryTextDeltaEvent( + type=ResponsesAPIStreamEvents.REASONING_SUMMARY_TEXT_DELTA, + item_id=f"{chunk.id}_reasoning", + output_index=0, + delta=reasoning_content, + ) + else: + delta_content = self._get_delta_string_from_streaming_choices(chunk.choices) + if delta_content: + return OutputTextDeltaEvent( + type=ResponsesAPIStreamEvents.OUTPUT_TEXT_DELTA, + item_id=chunk.id, + output_index=0, + content_index=0, + delta=delta_content, + ) + + return None def _get_delta_string_from_streaming_choices( self, choices: List[StreamingChoices] diff --git a/litellm/responses/litellm_completion_transformation/transformation.py b/litellm/responses/litellm_completion_transformation/transformation.py index 3ca2f73354..0aec463c7e 100644 --- a/litellm/responses/litellm_completion_transformation/transformation.py +++ b/litellm/responses/litellm_completion_transformation/transformation.py @@ -599,8 +599,59 @@ class LiteLLMCompletionResponsesConfig: responses_output: List[ Union[GenericResponseOutputItem, OutputFunctionToolCall] ] = [] + + responses_output.extend( + LiteLLMCompletionResponsesConfig._extract_reasoning_output_items( + chat_completion_response, choices + ) + ) + responses_output.extend( + LiteLLMCompletionResponsesConfig._extract_message_output_items( + chat_completion_response, choices + ) + ) + responses_output.extend( + LiteLLMCompletionResponsesConfig.transform_chat_completion_tools_to_responses_tools( + chat_completion_response=chat_completion_response + ) + ) + return responses_output + + @staticmethod + def _extract_reasoning_output_items( + chat_completion_response: ModelResponse, + choices: List[Choices], + ) -> List[GenericResponseOutputItem]: for choice in choices: - responses_output.append( + if hasattr(choice, "message") and choice.message: + message = choice.message + if hasattr(message, "reasoning_content") and message.reasoning_content: + # Only check the first choice for reasoning content + return [ + GenericResponseOutputItem( + type="reasoning", + id=f"{chat_completion_response.id}_reasoning", + status=choice.finish_reason, + role="assistant", + content=[ + OutputText( + type="output_text", + text=message.reasoning_content, + annotations=[], + ) + ], + ) + ] + return [] + + @staticmethod + def _extract_message_output_items( + chat_completion_response: ModelResponse, + choices: List[Choices], + ) -> List[GenericResponseOutputItem]: + message_output_items = [] + for choice in choices: + message_output_items.append( GenericResponseOutputItem( type="message", id=chat_completion_response.id, @@ -613,12 +664,7 @@ class LiteLLMCompletionResponsesConfig: ], ) ) - - tool_calls = LiteLLMCompletionResponsesConfig.transform_chat_completion_tools_to_responses_tools( - chat_completion_response=chat_completion_response - ) - responses_output.extend(tool_calls) - return responses_output + return message_output_items @staticmethod def _transform_responses_api_outputs_to_chat_completion_messages( @@ -712,6 +758,7 @@ class LiteLLMCompletionResponsesConfig: return response_output_annotations + @staticmethod def _transform_chat_completion_usage_to_responses_usage( chat_completion_response: Union[ModelResponse, Usage], diff --git a/litellm/types/llms/openai.py b/litellm/types/llms/openai.py index 65dacd199b..9a9a426743 100644 --- a/litellm/types/llms/openai.py +++ b/litellm/types/llms/openai.py @@ -1052,8 +1052,9 @@ class ResponsesAPIStreamEvents(str, Enum): RESPONSE_FAILED = "response.failed" RESPONSE_INCOMPLETE = "response.incomplete" - # Part added + # Reasoning summary events RESPONSE_PART_ADDED = "response.reasoning_summary_part.added" + REASONING_SUMMARY_TEXT_DELTA = "response.reasoning_summary_text.delta" # Output item events OUTPUT_ITEM_ADDED = "response.output_item.added" @@ -1116,6 +1117,20 @@ class ResponseIncompleteEvent(BaseLiteLLMOpenAIResponseObject): response: ResponsesAPIResponse +class ResponsePartAddedEvent(BaseLiteLLMOpenAIResponseObject): + type: Literal[ResponsesAPIStreamEvents.RESPONSE_PART_ADDED] + item_id: str + output_index: int + part: dict + + +class ReasoningSummaryTextDeltaEvent(BaseLiteLLMOpenAIResponseObject): + type: Literal[ResponsesAPIStreamEvents.REASONING_SUMMARY_TEXT_DELTA] + item_id: str + output_index: int + delta: str + + class OutputItemAddedEvent(BaseLiteLLMOpenAIResponseObject): type: Literal[ResponsesAPIStreamEvents.OUTPUT_ITEM_ADDED] output_index: int @@ -1256,6 +1271,8 @@ ResponsesAPIStreamingResponse = Annotated[ ResponseCompletedEvent, ResponseFailedEvent, ResponseIncompleteEvent, + ResponsePartAddedEvent, + ReasoningSummaryTextDeltaEvent, OutputItemAddedEvent, OutputItemDoneEvent, ContentPartAddedEvent, 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 77ecb48382..021d2f30bd 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 @@ -1,11 +1,6 @@ -import base64 -import json import os import sys -import pytest -from fastapi.testclient import TestClient - sys.path.insert( 0, os.path.abspath("../../..") ) # Adds the parent directory to the system path @@ -13,6 +8,7 @@ sys.path.insert( from litellm.responses.litellm_completion_transformation.transformation import ( LiteLLMCompletionResponsesConfig, ) +from litellm.types.utils import ModelResponse, Choices, Message class TestLiteLLMCompletionResponsesConfig: @@ -119,3 +115,147 @@ class TestLiteLLMCompletionResponsesConfig: assert result == expected assert "extra_field" not in result["file"] assert "another_field" not in result["file"] + + def test_transform_chat_completion_response_with_reasoning_content(self): + """Test that reasoning content is preserved in the full transformation pipeline""" + # Setup + chat_completion_response = ModelResponse( + id="test-response-id", + created=1234567890, + model="test-model", + object="chat.completion", + choices=[ + Choices( + finish_reason="stop", + index=0, + message=Message( + content="The answer is 42.", + role="assistant", + reasoning_content="Let me think about this step by step. The question asks for the meaning of life, and according to The Hitchhiker's Guide to the Galaxy, the answer is 42.", + ), + ) + ], + ) + + # Execute + responses_api_response = LiteLLMCompletionResponsesConfig.transform_chat_completion_response_to_responses_api_response( + request_input="What is the meaning of life?", + responses_api_request={}, + chat_completion_response=chat_completion_response, + ) + + # Assert + assert hasattr(responses_api_response, "output") + assert ( + len(responses_api_response.output) >= 2 + ) + + reasoning_items = [ + item for item in responses_api_response.output if item.type == "reasoning" + ] + assert len(reasoning_items) == 1, "Should have exactly one reasoning item" + + reasoning_item = reasoning_items[0] + assert reasoning_item.id == "test-response-id_reasoning" + assert reasoning_item.status == "stop" + assert reasoning_item.role == "assistant" + assert len(reasoning_item.content) == 1 + assert reasoning_item.content[0].type == "output_text" + assert "step by step" in reasoning_item.content[0].text + assert "42" in reasoning_item.content[0].text + + message_items = [ + item for item in responses_api_response.output if item.type == "message" + ] + assert len(message_items) == 1, "Should have exactly one message item" + + message_item = message_items[0] + assert message_item.content[0].text == "The answer is 42." + + def test_transform_chat_completion_response_without_reasoning_content(self): + """Test that transformation works normally when no reasoning content is present""" + # Setup + chat_completion_response = ModelResponse( + id="test-response-id", + created=1234567890, + model="test-model", + object="chat.completion", + choices=[ + Choices( + finish_reason="stop", + index=0, + message=Message( + content="Just a regular answer.", + role="assistant", + ), + ) + ], + ) + + # Execute + responses_api_response = LiteLLMCompletionResponsesConfig.transform_chat_completion_response_to_responses_api_response( + request_input="A simple question?", + responses_api_request={}, + chat_completion_response=chat_completion_response, + ) + + # Assert + reasoning_items = [ + item for item in responses_api_response.output if item.type == "reasoning" + ] + assert len(reasoning_items) == 0, "Should have no reasoning items" + + message_items = [ + item for item in responses_api_response.output if item.type == "message" + ] + assert len(message_items) == 1, "Should have exactly one message item" + assert message_items[0].content[0].text == "Just a regular answer." + + def test_transform_chat_completion_response_multiple_choices_with_reasoning(self): + """Test that only reasoning from first choice is included when multiple choices exist""" + # Setup + chat_completion_response = ModelResponse( + id="test-response-id", + created=1234567890, + model="test-model", + object="chat.completion", + choices=[ + Choices( + finish_reason="stop", + index=0, + message=Message( + content="First answer.", + role="assistant", + reasoning_content="First reasoning process.", + ), + ), + Choices( + finish_reason="stop", + index=1, + message=Message( + content="Second answer.", + role="assistant", + reasoning_content="Second reasoning process.", + ), + ), + ], + ) + + # Execute + responses_api_response = LiteLLMCompletionResponsesConfig.transform_chat_completion_response_to_responses_api_response( + request_input="A question with multiple answers?", + responses_api_request={}, + chat_completion_response=chat_completion_response, + ) + + # Assert + reasoning_items = [ + item for item in responses_api_response.output if item.type == "reasoning" + ] + assert len(reasoning_items) == 1, "Should have exactly one reasoning item" + assert reasoning_items[0].content[0].text == "First reasoning process." + + message_items = [ + item for item in responses_api_response.output if item.type == "message" + ] + assert len(message_items) == 2, "Should have two message items" diff --git a/tests/test_litellm/responses/litellm_completion_transformation/test_reasoning_content_transformation.py b/tests/test_litellm/responses/litellm_completion_transformation/test_reasoning_content_transformation.py new file mode 100644 index 0000000000..20bd56ce41 --- /dev/null +++ b/tests/test_litellm/responses/litellm_completion_transformation/test_reasoning_content_transformation.py @@ -0,0 +1,255 @@ +""" +Test reasoning content preservation in Responses API transformation +""" + +from unittest.mock import AsyncMock + +from litellm.types.utils import ModelResponseStream, StreamingChoices, Delta +from litellm.responses.litellm_completion_transformation.streaming_iterator import ( + LiteLLMCompletionStreamingIterator, +) +from litellm.responses.litellm_completion_transformation.transformation import ( + LiteLLMCompletionResponsesConfig, +) +from litellm.types.utils import ModelResponse, Choices, Message + + +class TestReasoningContentStreaming: + """Test reasoning content preservation during streaming""" + + def test_reasoning_content_in_delta(self): + """Test that reasoning content is preserved in streaming deltas""" + # Setup + chunk = ModelResponseStream( + id="test-id", + created=1234567890, + model="test-model", + object="chat.completion.chunk", + choices=[ + StreamingChoices( + finish_reason=None, + index=0, + delta=Delta( + content="", + role="assistant", + reasoning_content="Let me think about this problem...", + ), + ) + ], + ) + + mock_stream = AsyncMock() + + iterator = LiteLLMCompletionStreamingIterator( + litellm_custom_stream_wrapper=mock_stream, + request_input="Test input", + responses_api_request={}, + ) + + # Execute + transformed_chunk = ( + iterator._transform_chat_completion_chunk_to_response_api_chunk(chunk) + ) + + # Assert + assert transformed_chunk.delta == "Let me think about this problem..." + assert transformed_chunk.type == "response.reasoning_summary_text.delta" + + def test_mixed_content_and_reasoning(self): + """Test handling of both content and reasoning content""" + # Setup + chunk = ModelResponseStream( + id="test-id", + created=1234567890, + model="test-model", + object="chat.completion.chunk", + choices=[ + StreamingChoices( + finish_reason=None, + index=0, + delta=Delta( + content="Here is the answer", + role="assistant", + reasoning_content="First, let me analyze...", + ), + ) + ], + ) + + mock_stream = AsyncMock() + iterator = LiteLLMCompletionStreamingIterator( + litellm_custom_stream_wrapper=mock_stream, + request_input="Test input", + responses_api_request={}, + ) + + # Execute + transformed_chunk = ( + iterator._transform_chat_completion_chunk_to_response_api_chunk(chunk) + ) + + # Assert + assert transformed_chunk.delta == "First, let me analyze..." + assert transformed_chunk.type == "response.reasoning_summary_text.delta" + + def test_no_reasoning_content(self): + """Test handling when no reasoning content is present""" + # Setup + chunk = ModelResponseStream( + id="test-id", + created=1234567890, + model="test-model", + object="chat.completion.chunk", + choices=[ + StreamingChoices( + finish_reason=None, + index=0, + delta=Delta( + content="Regular content only", + role="assistant", + ), + ) + ], + ) + + mock_stream = AsyncMock() + iterator = LiteLLMCompletionStreamingIterator( + litellm_custom_stream_wrapper=mock_stream, + request_input="Test input", + responses_api_request={}, + ) + + # Execute + transformed_chunk = ( + iterator._transform_chat_completion_chunk_to_response_api_chunk(chunk) + ) + + # Assert + assert transformed_chunk.delta == "Regular content only" + assert transformed_chunk.type == "response.output_text.delta" + + +class TestReasoningContentFinalResponse: + """Test reasoning content preservation in final response transformation""" + + def test_reasoning_content_in_final_response(self): + """Test that reasoning content is included in final response""" + # Setup + response = ModelResponse( + id="test-id", + created=1234567890, + model="test-model", + object="chat.completion", + choices=[ + Choices( + finish_reason="stop", + index=0, + message=Message( + content="Here is my answer", + role="assistant", + reasoning_content="Let me think step by step about this problem...", + ), + ) + ], + ) + + # Execute + responses_api_response = LiteLLMCompletionResponsesConfig.transform_chat_completion_response_to_responses_api_response( + request_input="Test input", + responses_api_request={}, + chat_completion_response=response, + ) + + # Assert + assert hasattr(responses_api_response, "output") + assert len(responses_api_response.output) > 0 + + reasoning_items = [ + item for item in responses_api_response.output if item.type == "reasoning" + ] + assert len(reasoning_items) > 0, "No reasoning item found in output" + + reasoning_item = reasoning_items[0] + assert ( + reasoning_item.content[0].text + == "Let me think step by step about this problem..." + ) + + def test_no_reasoning_content_in_response(self): + """Test handling when no reasoning content in response""" + # Setup + response = ModelResponse( + id="test-id", + created=1234567890, + model="test-model", + object="chat.completion", + choices=[ + Choices( + finish_reason="stop", + index=0, + message=Message( + content="Simple answer", + role="assistant", + ), + ) + ], + ) + + # Execute + responses_api_response = LiteLLMCompletionResponsesConfig.transform_chat_completion_response_to_responses_api_response( + request_input="Test input", + responses_api_request={}, + chat_completion_response=response, + ) + + # Assert + reasoning_items = [ + item for item in responses_api_response.output if item.type == "reasoning" + ] + assert ( + len(reasoning_items) == 0 + ), "Should have no reasoning items when no reasoning content present" + + def test_multiple_choices_with_reasoning(self): + """Test handling multiple choices, first with reasoning content""" + # Setup + response = ModelResponse( + id="test-id", + created=1234567890, + model="test-model", + object="chat.completion", + choices=[ + Choices( + finish_reason="stop", + index=0, + message=Message( + content="First answer", + role="assistant", + reasoning_content="Reasoning for first answer", + ), + ), + Choices( + finish_reason="stop", + index=1, + message=Message( + content="Second answer", + role="assistant", + reasoning_content="Reasoning for second answer", + ), + ), + ], + ) + + # Execute + responses_api_response = LiteLLMCompletionResponsesConfig.transform_chat_completion_response_to_responses_api_response( + request_input="Test input", + responses_api_request={}, + chat_completion_response=response, + ) + + # Assert + reasoning_items = [ + item for item in responses_api_response.output if item.type == "reasoning" + ] + assert len(reasoning_items) == 1, "Should have exactly one reasoning item" + assert reasoning_items[0].content[0].text == "Reasoning for first answer"