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:
Ryan Castner 2025-06-26 17:21:49 -04:00 committed by GitHub
parent 98ba5c8ffe
commit ac11bfabcb
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 500 additions and 21 deletions

View File

@ -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]

View File

@ -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],

View File

@ -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,

View File

@ -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"

View File

@ -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"