mirror of
https://github.com/microsoft/agent-framework.git
synced 2026-06-16 21:04:09 +08:00
Python: Add OpenAI reasoning events support to Responses client (#698)
* feat: add OpenAI reasoning events support with comprehensive test coverage - Implement reasoning event handling in OpenAI Responses client * Add support for ResponseReasoningTextDeltaEvent * Add support for ResponseReasoningTextDoneEvent * Add support for ResponseReasoningSummaryTextDeltaEvent * Add support for ResponseReasoningSummaryTextDoneEvent * Map all reasoning events to TextReasoningContent objects * Preserve metadata across reasoning events - Add comprehensive test coverage (5 focused test functions) * test_streaming_reasoning_text_delta_event * test_streaming_reasoning_text_done_event * test_streaming_reasoning_summary_text_delta_event * test_streaming_reasoning_summary_text_done_event * test_streaming_reasoning_events_preserve_metadata - Add sample demonstrating reasoning functionality * Shows how to enable reasoning in chat options * Demonstrates accessing reasoning content from responses - Code quality improvements * Follow existing code patterns and style guidelines * Organize imports properly * Maintain backwards compatibility * All tests pass and quality checks succeed * fix: resolve type errors and cleanup unused imports after rebase - Add proper hasattr checks for optional attributes in union types - Remove unused OpenAI event type imports - Fix line length formatting issues - Ensure type safety when accessing content attributes
This commit is contained in:
committed by
GitHub
Unverified
parent
d54b0f0849
commit
f79fbfa92e
@@ -762,10 +762,10 @@ class OpenAIBaseResponsesClient(OpenAIBase, BaseChatClient):
|
||||
TextContent(text=message_content.refusal, raw_representation=message_content)
|
||||
)
|
||||
case "reasoning": # ResponseOutputReasoning
|
||||
if item.content:
|
||||
if hasattr(item, "content") and item.content:
|
||||
for index, reasoning_content in enumerate(item.content):
|
||||
additional_properties = None
|
||||
if item.summary and index < len(item.summary):
|
||||
if hasattr(item, "summary") and item.summary and index < len(item.summary):
|
||||
additional_properties = {"summary": item.summary[index]}
|
||||
contents.append(
|
||||
TextReasoningContent(
|
||||
@@ -775,7 +775,7 @@ class OpenAIBaseResponsesClient(OpenAIBase, BaseChatClient):
|
||||
)
|
||||
)
|
||||
case "code_interpreter_call": # ResponseOutputCodeInterpreterCall
|
||||
if item.outputs:
|
||||
if hasattr(item, "outputs") and item.outputs:
|
||||
for code_output in item.outputs:
|
||||
if code_output.type == "logs":
|
||||
contents.append(TextContent(text=code_output.logs, raw_representation=item))
|
||||
@@ -788,16 +788,16 @@ class OpenAIBaseResponsesClient(OpenAIBase, BaseChatClient):
|
||||
media_type="image",
|
||||
)
|
||||
)
|
||||
elif item.code:
|
||||
elif hasattr(item, "code") and item.code:
|
||||
# fallback if no output was returned is the code:
|
||||
contents.append(TextContent(text=item.code, raw_representation=item))
|
||||
case "function_call": # ResponseOutputFunctionCall
|
||||
contents.append(
|
||||
FunctionCallContent(
|
||||
call_id=item.call_id if item.call_id else "",
|
||||
name=item.name,
|
||||
arguments=item.arguments,
|
||||
additional_properties={"fc_id": item.id},
|
||||
call_id=item.call_id if hasattr(item, "call_id") and item.call_id else "",
|
||||
name=item.name if hasattr(item, "name") else "",
|
||||
arguments=item.arguments if hasattr(item, "arguments") else "",
|
||||
additional_properties={"fc_id": item.id} if hasattr(item, "id") else {},
|
||||
raw_representation=item,
|
||||
)
|
||||
)
|
||||
@@ -922,6 +922,18 @@ class OpenAIBaseResponsesClient(OpenAIBase, BaseChatClient):
|
||||
case "response.output_text.delta":
|
||||
contents.append(TextContent(text=event.delta, raw_representation=event))
|
||||
metadata.update(self._get_metadata_from_response(event))
|
||||
case "response.reasoning_text.delta":
|
||||
contents.append(TextReasoningContent(text=event.delta, raw_representation=event))
|
||||
metadata.update(self._get_metadata_from_response(event))
|
||||
case "response.reasoning_text.done":
|
||||
contents.append(TextReasoningContent(text=event.text, raw_representation=event))
|
||||
metadata.update(self._get_metadata_from_response(event))
|
||||
case "response.reasoning_summary_text.delta":
|
||||
contents.append(TextReasoningContent(text=event.delta, raw_representation=event))
|
||||
metadata.update(self._get_metadata_from_response(event))
|
||||
case "response.reasoning_summary_text.done":
|
||||
contents.append(TextReasoningContent(text=event.text, raw_representation=event))
|
||||
metadata.update(self._get_metadata_from_response(event))
|
||||
case "response.completed":
|
||||
conversation_id = event.response.id if chat_options.store is True else None
|
||||
model = event.response.model
|
||||
@@ -962,7 +974,7 @@ class OpenAIBaseResponsesClient(OpenAIBase, BaseChatClient):
|
||||
)
|
||||
)
|
||||
case "code_interpreter_call": # ResponseOutputCodeInterpreterCall
|
||||
if event_item.outputs:
|
||||
if hasattr(event_item, "outputs") and event_item.outputs:
|
||||
for code_output in event_item.outputs:
|
||||
if code_output.type == "logs":
|
||||
contents.append(TextContent(text=code_output.logs, raw_representation=event_item))
|
||||
@@ -975,14 +987,18 @@ class OpenAIBaseResponsesClient(OpenAIBase, BaseChatClient):
|
||||
media_type="image",
|
||||
)
|
||||
)
|
||||
elif event_item.code:
|
||||
elif hasattr(event_item, "code") and event_item.code:
|
||||
# fallback if no output was returned is the code:
|
||||
contents.append(TextContent(text=event_item.code, raw_representation=event_item))
|
||||
case "reasoning": # ResponseOutputReasoning
|
||||
if event_item.content:
|
||||
if hasattr(event_item, "content") and event_item.content:
|
||||
for index, reasoning_content in enumerate(event_item.content):
|
||||
additional_properties = None
|
||||
if event_item.summary and index < len(event_item.summary):
|
||||
if (
|
||||
hasattr(event_item, "summary")
|
||||
and event_item.summary
|
||||
and index < len(event_item.summary)
|
||||
):
|
||||
additional_properties = {"summary": event_item.summary[index]}
|
||||
contents.append(
|
||||
TextReasoningContent(
|
||||
|
||||
@@ -7,6 +7,11 @@ from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
from openai import BadRequestError
|
||||
from openai.types.responses.response_reasoning_summary_text_delta_event import ResponseReasoningSummaryTextDeltaEvent
|
||||
from openai.types.responses.response_reasoning_summary_text_done_event import ResponseReasoningSummaryTextDoneEvent
|
||||
from openai.types.responses.response_reasoning_text_delta_event import ResponseReasoningTextDeltaEvent
|
||||
from openai.types.responses.response_reasoning_text_done_event import ResponseReasoningTextDoneEvent
|
||||
from openai.types.responses.response_text_delta_event import ResponseTextDeltaEvent
|
||||
from pydantic import BaseModel
|
||||
|
||||
from agent_framework import (
|
||||
@@ -1445,3 +1450,143 @@ def test_service_response_exception_includes_original_error_details() -> None:
|
||||
exception_message = str(exc_info.value)
|
||||
assert "service failed to complete the prompt:" in exception_message
|
||||
assert original_error_message in exception_message
|
||||
|
||||
|
||||
def test_streaming_reasoning_text_delta_event() -> None:
|
||||
"""Test reasoning text delta event creates TextReasoningContent."""
|
||||
client = OpenAIResponsesClient(ai_model_id="test-model", api_key="test-key")
|
||||
chat_options = ChatOptions()
|
||||
function_call_ids: dict[int, tuple[str, str]] = {}
|
||||
|
||||
event = ResponseReasoningTextDeltaEvent(
|
||||
type="response.reasoning_text.delta",
|
||||
content_index=0,
|
||||
item_id="reasoning_123",
|
||||
output_index=0,
|
||||
sequence_number=1,
|
||||
delta="reasoning delta",
|
||||
)
|
||||
|
||||
with patch.object(client, "_get_metadata_from_response", return_value={}) as mock_metadata:
|
||||
response = client._create_streaming_response_content(event, chat_options, function_call_ids) # type: ignore
|
||||
|
||||
assert len(response.contents) == 1
|
||||
assert isinstance(response.contents[0], TextReasoningContent)
|
||||
assert response.contents[0].text == "reasoning delta"
|
||||
assert response.contents[0].raw_representation == event
|
||||
mock_metadata.assert_called_once_with(event)
|
||||
|
||||
|
||||
def test_streaming_reasoning_text_done_event() -> None:
|
||||
"""Test reasoning text done event creates TextReasoningContent with complete text."""
|
||||
client = OpenAIResponsesClient(ai_model_id="test-model", api_key="test-key")
|
||||
chat_options = ChatOptions()
|
||||
function_call_ids: dict[int, tuple[str, str]] = {}
|
||||
|
||||
event = ResponseReasoningTextDoneEvent(
|
||||
type="response.reasoning_text.done",
|
||||
content_index=0,
|
||||
item_id="reasoning_456",
|
||||
output_index=0,
|
||||
sequence_number=2,
|
||||
text="complete reasoning",
|
||||
)
|
||||
|
||||
with patch.object(client, "_get_metadata_from_response", return_value={"test": "data"}) as mock_metadata:
|
||||
response = client._create_streaming_response_content(event, chat_options, function_call_ids) # type: ignore
|
||||
|
||||
assert len(response.contents) == 1
|
||||
assert isinstance(response.contents[0], TextReasoningContent)
|
||||
assert response.contents[0].text == "complete reasoning"
|
||||
assert response.contents[0].raw_representation == event
|
||||
mock_metadata.assert_called_once_with(event)
|
||||
assert response.additional_properties == {"test": "data"}
|
||||
|
||||
|
||||
def test_streaming_reasoning_summary_text_delta_event() -> None:
|
||||
"""Test reasoning summary text delta event creates TextReasoningContent."""
|
||||
client = OpenAIResponsesClient(ai_model_id="test-model", api_key="test-key")
|
||||
chat_options = ChatOptions()
|
||||
function_call_ids: dict[int, tuple[str, str]] = {}
|
||||
|
||||
event = ResponseReasoningSummaryTextDeltaEvent(
|
||||
type="response.reasoning_summary_text.delta",
|
||||
item_id="summary_789",
|
||||
output_index=0,
|
||||
sequence_number=3,
|
||||
summary_index=0,
|
||||
delta="summary delta",
|
||||
)
|
||||
|
||||
with patch.object(client, "_get_metadata_from_response", return_value={}) as mock_metadata:
|
||||
response = client._create_streaming_response_content(event, chat_options, function_call_ids) # type: ignore
|
||||
|
||||
assert len(response.contents) == 1
|
||||
assert isinstance(response.contents[0], TextReasoningContent)
|
||||
assert response.contents[0].text == "summary delta"
|
||||
assert response.contents[0].raw_representation == event
|
||||
mock_metadata.assert_called_once_with(event)
|
||||
|
||||
|
||||
def test_streaming_reasoning_summary_text_done_event() -> None:
|
||||
"""Test reasoning summary text done event creates TextReasoningContent with complete text."""
|
||||
client = OpenAIResponsesClient(ai_model_id="test-model", api_key="test-key")
|
||||
chat_options = ChatOptions()
|
||||
function_call_ids: dict[int, tuple[str, str]] = {}
|
||||
|
||||
event = ResponseReasoningSummaryTextDoneEvent(
|
||||
type="response.reasoning_summary_text.done",
|
||||
item_id="summary_012",
|
||||
output_index=0,
|
||||
sequence_number=4,
|
||||
summary_index=0,
|
||||
text="complete summary",
|
||||
)
|
||||
|
||||
with patch.object(client, "_get_metadata_from_response", return_value={"custom": "meta"}) as mock_metadata:
|
||||
response = client._create_streaming_response_content(event, chat_options, function_call_ids) # type: ignore
|
||||
|
||||
assert len(response.contents) == 1
|
||||
assert isinstance(response.contents[0], TextReasoningContent)
|
||||
assert response.contents[0].text == "complete summary"
|
||||
assert response.contents[0].raw_representation == event
|
||||
mock_metadata.assert_called_once_with(event)
|
||||
assert response.additional_properties == {"custom": "meta"}
|
||||
|
||||
|
||||
def test_streaming_reasoning_events_preserve_metadata() -> None:
|
||||
"""Test that reasoning events preserve metadata like regular text events."""
|
||||
client = OpenAIResponsesClient(ai_model_id="test-model", api_key="test-key")
|
||||
chat_options = ChatOptions()
|
||||
function_call_ids: dict[int, tuple[str, str]] = {}
|
||||
|
||||
text_event = ResponseTextDeltaEvent(
|
||||
type="response.output_text.delta",
|
||||
content_index=0,
|
||||
item_id="text_item",
|
||||
output_index=0,
|
||||
sequence_number=1,
|
||||
logprobs=[],
|
||||
delta="text",
|
||||
)
|
||||
|
||||
reasoning_event = ResponseReasoningTextDeltaEvent(
|
||||
type="response.reasoning_text.delta",
|
||||
content_index=0,
|
||||
item_id="reasoning_item",
|
||||
output_index=0,
|
||||
sequence_number=2,
|
||||
delta="reasoning",
|
||||
)
|
||||
|
||||
with patch.object(client, "_get_metadata_from_response", return_value={"test": "metadata"}):
|
||||
text_response = client._create_streaming_response_content(text_event, chat_options, function_call_ids) # type: ignore
|
||||
reasoning_response = client._create_streaming_response_content(reasoning_event, chat_options, function_call_ids) # type: ignore
|
||||
|
||||
# Both should preserve metadata
|
||||
assert text_response.additional_properties == {"test": "metadata"}
|
||||
assert reasoning_response.additional_properties == {"test": "metadata"}
|
||||
|
||||
# Content types should be different
|
||||
assert isinstance(text_response.contents[0], TextContent)
|
||||
assert isinstance(reasoning_response.contents[0], TextReasoningContent)
|
||||
|
||||
+4
-4
@@ -10,12 +10,12 @@ async def reasoning_example() -> None:
|
||||
"""Example of reasoning response (get results as they are generated)."""
|
||||
print("=== Reasoning Example ===")
|
||||
|
||||
agent = OpenAIResponsesClient(ai_model_id="o4-mini").create_agent(
|
||||
agent = OpenAIResponsesClient(ai_model_id="gpt-5").create_agent(
|
||||
name="MathHelper",
|
||||
instructions="You are a personal math tutor. When asked a math question, "
|
||||
"write and run code using the python tool to answer the question.",
|
||||
tools=HostedCodeInterpreterTool(),
|
||||
reasoning={"effort": "medium"},
|
||||
reasoning={"effort": "high", "summary": "detailed"},
|
||||
)
|
||||
|
||||
query = "I need to solve the equation 3x + 11 = 14. Can you help me?"
|
||||
@@ -27,9 +27,9 @@ async def reasoning_example() -> None:
|
||||
for content in chunk.contents:
|
||||
if isinstance(content, TextReasoningContent):
|
||||
print(f"\033[97m{content.text}\033[0m", end="", flush=True)
|
||||
if isinstance(content, TextContent):
|
||||
elif isinstance(content, TextContent):
|
||||
print(content.text, end="", flush=True)
|
||||
if isinstance(content, UsageContent):
|
||||
elif isinstance(content, UsageContent):
|
||||
usage = content
|
||||
print("\n")
|
||||
if usage:
|
||||
|
||||
Reference in New Issue
Block a user