From f79fbfa92e6bd71aed0998f6707056c0b5a441f2 Mon Sep 17 00:00:00 2001 From: Christian Glessner Date: Fri, 12 Sep 2025 19:07:49 +0700 Subject: [PATCH] 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 --- .../openai/_responses_client.py | 40 +++-- .../openai/test_openai_responses_client.py | 145 ++++++++++++++++++ .../openai_responses_client_reasoning.py | 8 +- 3 files changed, 177 insertions(+), 16 deletions(-) diff --git a/python/packages/main/agent_framework/openai/_responses_client.py b/python/packages/main/agent_framework/openai/_responses_client.py index 9bc646c5f5..77cf54edf2 100644 --- a/python/packages/main/agent_framework/openai/_responses_client.py +++ b/python/packages/main/agent_framework/openai/_responses_client.py @@ -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( diff --git a/python/packages/main/tests/openai/test_openai_responses_client.py b/python/packages/main/tests/openai/test_openai_responses_client.py index 4f2846a981..2c7bd668c7 100644 --- a/python/packages/main/tests/openai/test_openai_responses_client.py +++ b/python/packages/main/tests/openai/test_openai_responses_client.py @@ -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) diff --git a/python/samples/getting_started/agents/openai_responses_client/openai_responses_client_reasoning.py b/python/samples/getting_started/agents/openai_responses_client/openai_responses_client_reasoning.py index fcc07c6efa..f9dfb34f16 100644 --- a/python/samples/getting_started/agents/openai_responses_client/openai_responses_client_reasoning.py +++ b/python/samples/getting_started/agents/openai_responses_client/openai_responses_client_reasoning.py @@ -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: