Responses API - Add reasoning content support for non-OpenAI providers (#12055)
Add reasoning content support when Responses API falls back to completions API, enabling reasoning content for all LLM providers (Anthropic, Vertex AI, etc.) since OpenAI is currently the only native Responses API provider. * Add ReasoningSummaryTextDeltaEvent for streaming reasoning deltas * Update streaming iterator to detect and transform reasoning content * Extract reasoning content as separate output items in transformations * Support reasoning content alongside regular message content Closes https://github.com/BerriAI/litellm/issues/11302
This commit is contained in:
parent
98ba5c8ffe
commit
ac11bfabcb
@ -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]
|
||||
|
||||
@ -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],
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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"
|
||||
Loading…
Reference in New Issue
Block a user