diff --git a/python/packages/core/tests/openai/test_openai_assistants_client.py b/python/packages/core/tests/openai/test_openai_assistants_client.py index 424c1cc044..f16bc2b1f0 100644 --- a/python/packages/core/tests/openai/test_openai_assistants_client.py +++ b/python/packages/core/tests/openai/test_openai_assistants_client.py @@ -19,11 +19,13 @@ ChatMessage, ChatResponse, ChatResponseUpdate, + CodeInterpreterToolCallContent, FunctionCallContent, FunctionResultContent, HostedCodeInterpreterTool, HostedFileSearchTool, HostedVectorStoreContent, + MCPServerToolCallContent, Role, TextContent, UriContent, @@ -614,6 +616,81 @@ def test_parse_function_calls_from_assistants_basic(mock_async_openai: MagicMock assert contents[0].arguments == {"location": "Seattle"} +def test_parse_run_step_with_code_interpreter_tool_call(mock_async_openai: MagicMock) -> None: + """Test _parse_run_step_tool_call with code_interpreter type creates CodeInterpreterToolCallContent.""" + client = create_test_openai_assistants_client( + mock_async_openai, + model_id="test-model", + assistant_id="test-assistant", + ) + + # Mock a run with required_action containing code_interpreter tool call + mock_run = MagicMock() + mock_run.id = "run_123" + mock_run.status = "requires_action" + + mock_tool_call = MagicMock() + mock_tool_call.id = "call_code_123" + mock_tool_call.type = "code_interpreter" + mock_code_interpreter = MagicMock() + mock_code_interpreter.input = "print('Hello, World!')" + mock_tool_call.code_interpreter = mock_code_interpreter + + mock_required_action = MagicMock() + mock_required_action.submit_tool_outputs = MagicMock() + mock_required_action.submit_tool_outputs.tool_calls = [mock_tool_call] + mock_run.required_action = mock_required_action + + # Parse the run step + contents = client._parse_function_calls_from_assistants(mock_run, "response_123") + + # Should have CodeInterpreterToolCallContent + assert len(contents) == 1 + assert isinstance(contents[0], CodeInterpreterToolCallContent) + assert contents[0].call_id == '["response_123", "call_code_123"]' + assert contents[0].inputs is not None + assert len(contents[0].inputs) == 1 + assert isinstance(contents[0].inputs[0], TextContent) + assert contents[0].inputs[0].text == "print('Hello, World!')" + + +def test_parse_run_step_with_mcp_tool_call(mock_async_openai: MagicMock) -> None: + """Test _parse_run_step_tool_call with mcp type creates MCPServerToolCallContent.""" + client = create_test_openai_assistants_client( + mock_async_openai, + model_id="test-model", + assistant_id="test-assistant", + ) + + # Mock a run with required_action containing mcp tool call + mock_run = MagicMock() + mock_run.id = "run_456" + mock_run.status = "requires_action" + + mock_tool_call = MagicMock() + mock_tool_call.id = "call_mcp_456" + mock_tool_call.type = "mcp" + mock_tool_call.name = "fetch_data" + mock_tool_call.server_label = "DataServer" + mock_tool_call.args = {"key": "value"} + + mock_required_action = MagicMock() + mock_required_action.submit_tool_outputs = MagicMock() + mock_required_action.submit_tool_outputs.tool_calls = [mock_tool_call] + mock_run.required_action = mock_required_action + + # Parse the run step + contents = client._parse_function_calls_from_assistants(mock_run, "response_456") + + # Should have MCPServerToolCallContent + assert len(contents) == 1 + assert isinstance(contents[0], MCPServerToolCallContent) + assert contents[0].call_id == '["response_456", "call_mcp_456"]' + assert contents[0].tool_name == "fetch_data" + assert contents[0].server_name == "DataServer" + assert contents[0].arguments == {"key": "value"} + + def test_prepare_options_basic(mock_async_openai: MagicMock) -> None: """Test _prepare_options with basic chat options.""" chat_client = create_test_openai_assistants_client(mock_async_openai) diff --git a/python/packages/core/tests/openai/test_openai_chat_client.py b/python/packages/core/tests/openai/test_openai_chat_client.py index 1f1d624345..dcc6a70aca 100644 --- a/python/packages/core/tests/openai/test_openai_chat_client.py +++ b/python/packages/core/tests/openai/test_openai_chat_client.py @@ -1,5 +1,6 @@ # Copyright (c) Microsoft. All rights reserved. +import asyncio import json import os from typing import Any @@ -7,6 +8,8 @@ import pytest from openai import BadRequestError +from openai.types.chat.chat_completion import ChatCompletion, Choice +from openai.types.chat.chat_completion_message import ChatCompletionMessage from pydantic import BaseModel from pytest import param @@ -15,9 +18,15 @@ ChatMessage, ChatResponse, DataContent, + FunctionApprovalRequestContent, + FunctionApprovalResponseContent, + FunctionCallContent, FunctionResultContent, HostedWebSearchTool, + TextReasoningContent, ToolProtocol, + UsageContent, + UsageDetails, ai_function, prepare_function_call_results, ) @@ -492,6 +501,436 @@ def test_prepare_content_for_openai_document_file_mapping(openai_unit_test_env: assert "filename" not in result["file"] # None filename should be omitted +def test_parse_text_reasoning_content_from_response(openai_unit_test_env: dict[str, str]) -> None: + """Test that TextReasoningContent is correctly parsed from OpenAI response with reasoning_details.""" + + client = OpenAIChatClient() + + # Mock response with reasoning_details + mock_reasoning_details = { + "effort": "high", + "summary": "Analyzed the problem carefully", + "content": [{"type": "reasoning_text", "text": "Step-by-step thinking..."}], + } + + mock_response = ChatCompletion( + id="test-response", + object="chat.completion", + created=1234567890, + model="gpt-5", + choices=[ + Choice( + index=0, + message=ChatCompletionMessage( + role="assistant", + content="The answer is 42.", + reasoning_details=mock_reasoning_details, + ), + finish_reason="stop", + ) + ], + ) + + response = client._parse_response_from_openai(mock_response, {}) + + # Should have both text and reasoning content + assert len(response.messages) == 1 + message = response.messages[0] + assert len(message.contents) == 2 + + # First should be text content + assert message.contents[0].type == "text" + assert message.contents[0].text == "The answer is 42." + + # Second should be reasoning content with protected_data + assert isinstance(message.contents[1], TextReasoningContent) + assert message.contents[1].protected_data is not None + parsed_details = json.loads(message.contents[1].protected_data) + assert parsed_details == mock_reasoning_details + + +def test_parse_text_reasoning_content_from_streaming_chunk(openai_unit_test_env: dict[str, str]) -> None: + """Test that TextReasoningContent is correctly parsed from streaming OpenAI chunk with reasoning_details.""" + from openai.types.chat.chat_completion_chunk import ChatCompletionChunk + from openai.types.chat.chat_completion_chunk import Choice as ChunkChoice + from openai.types.chat.chat_completion_chunk import ChoiceDelta as ChunkChoiceDelta + + client = OpenAIChatClient() + + # Mock streaming chunk with reasoning_details + mock_reasoning_details = { + "type": "reasoning", + "content": "Analyzing the question...", + } + + mock_chunk = ChatCompletionChunk( + id="test-chunk", + object="chat.completion.chunk", + created=1234567890, + model="gpt-5", + choices=[ + ChunkChoice( + index=0, + delta=ChunkChoiceDelta( + role="assistant", + content="Partial answer", + reasoning_details=mock_reasoning_details, + ), + finish_reason=None, + ) + ], + ) + + update = client._parse_response_update_from_openai(mock_chunk) + + # Should have both text and reasoning content + assert len(update.contents) == 2 + + # First should be text content + assert update.contents[0].type == "text" + assert update.contents[0].text == "Partial answer" + + # Second should be reasoning content + assert isinstance(update.contents[1], TextReasoningContent) + assert update.contents[1].protected_data is not None + parsed_details = json.loads(update.contents[1].protected_data) + assert parsed_details == mock_reasoning_details + + +def test_prepare_message_with_text_reasoning_content(openai_unit_test_env: dict[str, str]) -> None: + """Test that TextReasoningContent with protected_data is correctly prepared for OpenAI.""" + from agent_framework import TextContent + + client = OpenAIChatClient() + + # Create message with TextReasoningContent that has protected_data + # TextReasoningContent is meant to be added to an existing message, so include text content first + mock_reasoning_data = { + "effort": "medium", + "summary": "Quick analysis", + } + + reasoning_content = TextReasoningContent(text=None, protected_data=json.dumps(mock_reasoning_data)) + + # Message must have other content first for reasoning to attach to + message = ChatMessage( + role="assistant", + contents=[ + TextContent(text="The answer is 42."), + reasoning_content, + ], + ) + + prepared = client._prepare_message_for_openai(message) + + # Should have one message with reasoning_details attached + assert len(prepared) == 1 + assert "reasoning_details" in prepared[0] + assert prepared[0]["reasoning_details"] == mock_reasoning_data + # Should also have the text content + assert prepared[0]["content"][0]["type"] == "text" + assert prepared[0]["content"][0]["text"] == "The answer is 42." + + +def test_function_approval_content_is_skipped_in_preparation(openai_unit_test_env: dict[str, str]) -> None: + """Test that FunctionApprovalRequestContent and FunctionApprovalResponseContent are skipped.""" + client = OpenAIChatClient() + + # Create approval request + function_call = FunctionCallContent( + call_id="call_123", + name="dangerous_action", + arguments='{"confirm": true}', + ) + + approval_request = FunctionApprovalRequestContent( + id="approval_001", + function_call=function_call, + ) + + # Create approval response + approval_response = FunctionApprovalResponseContent( + approved=False, + id="approval_001", + function_call=function_call, + ) + + # Test that approval request is skipped + message_with_request = ChatMessage(role="assistant", contents=[approval_request]) + prepared_request = client._prepare_message_for_openai(message_with_request) + assert len(prepared_request) == 0 # Should be empty - approval content is skipped + + # Test that approval response is skipped + message_with_response = ChatMessage(role="user", contents=[approval_response]) + prepared_response = client._prepare_message_for_openai(message_with_response) + assert len(prepared_response) == 0 # Should be empty - approval content is skipped + + # Test with mixed content - approval should be skipped, text should remain + from agent_framework import TextContent + + mixed_message = ChatMessage( + role="assistant", + contents=[ + TextContent(text="I need approval for this action."), + approval_request, + ], + ) + prepared_mixed = client._prepare_message_for_openai(mixed_message) + assert len(prepared_mixed) == 1 # Only text content should remain + assert prepared_mixed[0]["content"][0]["type"] == "text" + assert prepared_mixed[0]["content"][0]["text"] == "I need approval for this action." + + +def test_usage_content_in_streaming_response(openai_unit_test_env: dict[str, str]) -> None: + """Test that UsageContent is correctly parsed from streaming response with usage data.""" + from openai.types.chat.chat_completion_chunk import ChatCompletionChunk + from openai.types.completion_usage import CompletionUsage + + client = OpenAIChatClient() + + # Mock streaming chunk with usage data (typically last chunk) + mock_usage = CompletionUsage( + prompt_tokens=100, + completion_tokens=50, + total_tokens=150, + ) + + mock_chunk = ChatCompletionChunk( + id="test-chunk", + object="chat.completion.chunk", + created=1234567890, + model="gpt-4o", + choices=[], # Empty choices when sending usage + usage=mock_usage, + ) + + update = client._parse_response_update_from_openai(mock_chunk) + + # Should have UsageContent + assert len(update.contents) == 1 + assert isinstance(update.contents[0], UsageContent) + + usage_content = update.contents[0] + assert isinstance(usage_content.details, UsageDetails) + assert usage_content.details.input_token_count == 100 + assert usage_content.details.output_token_count == 50 + assert usage_content.details.total_token_count == 150 + + +def test_parse_text_with_refusal(openai_unit_test_env: dict[str, str]) -> None: + """Test that refusal content is parsed correctly.""" + from openai.types.chat.chat_completion import ChatCompletion, Choice + from openai.types.chat.chat_completion_message import ChatCompletionMessage + + client = OpenAIChatClient() + + # Mock response with refusal + mock_response = ChatCompletion( + id="test-response", + object="chat.completion", + created=1234567890, + model="gpt-4o", + choices=[ + Choice( + index=0, + message=ChatCompletionMessage( + role="assistant", + content=None, + refusal="I cannot provide that information.", + ), + finish_reason="stop", + ) + ], + ) + + response = client._parse_response_from_openai(mock_response, {}) + + # Should have text content with refusal message + assert len(response.messages) == 1 + message = response.messages[0] + assert len(message.contents) == 1 + assert message.contents[0].type == "text" + assert message.contents[0].text == "I cannot provide that information." + + +def test_prepare_options_without_model_id(openai_unit_test_env: dict[str, str]) -> None: + """Test that prepare_options raises error when model_id is not set.""" + client = OpenAIChatClient() + client.model_id = None # Remove model_id + + messages = [ChatMessage(role="user", text="test")] + + with pytest.raises(ValueError, match="model_id must be a non-empty string"): + client._prepare_options(messages, {}) + + +def test_prepare_options_without_messages(openai_unit_test_env: dict[str, str]) -> None: + """Test that prepare_options raises error when messages are missing.""" + from agent_framework.exceptions import ServiceInvalidRequestError + + client = OpenAIChatClient() + + with pytest.raises(ServiceInvalidRequestError, match="Messages are required"): + client._prepare_options([], {}) + + +def test_prepare_tools_with_web_search_no_location(openai_unit_test_env: dict[str, str]) -> None: + """Test preparing web search tool without user location.""" + client = OpenAIChatClient() + + # Web search tool without additional_properties + web_search_tool = HostedWebSearchTool() + + result = client._prepare_tools_for_openai([web_search_tool]) + + # Should have empty web_search_options (no location) + assert "web_search_options" in result + assert result["web_search_options"] == {} + + +def test_prepare_options_with_instructions(openai_unit_test_env: dict[str, str]) -> None: + """Test that instructions are prepended as system message.""" + client = OpenAIChatClient() + + messages = [ChatMessage(role="user", text="Hello")] + options = {"instructions": "You are a helpful assistant."} + + prepared_options = client._prepare_options(messages, options) + + # Should have messages with system message prepended + assert "messages" in prepared_options + assert len(prepared_options["messages"]) == 2 + assert prepared_options["messages"][0]["role"] == "system" + assert prepared_options["messages"][0]["content"][0]["text"] == "You are a helpful assistant." + + +def test_prepare_message_with_author_name(openai_unit_test_env: dict[str, str]) -> None: + """Test that author_name is included in prepared message.""" + from agent_framework import TextContent + + client = OpenAIChatClient() + + message = ChatMessage( + role="user", + author_name="TestUser", + contents=[TextContent(text="Hello")], + ) + + prepared = client._prepare_message_for_openai(message) + + assert len(prepared) == 1 + assert prepared[0]["name"] == "TestUser" + + +def test_prepare_message_with_tool_result_author_name(openai_unit_test_env: dict[str, str]) -> None: + """Test that author_name is not included for TOOL role messages.""" + client = OpenAIChatClient() + + # Tool messages should not have 'name' field (it's for function name instead) + message = ChatMessage( + role="tool", + author_name="ShouldNotAppear", + contents=[FunctionResultContent(call_id="call_123", result="result")], + ) + + prepared = client._prepare_message_for_openai(message) + + assert len(prepared) == 1 + # Should not have 'name' field for tool messages + assert "name" not in prepared[0] + + +def test_tool_choice_required_with_function_name(openai_unit_test_env: dict[str, str]) -> None: + """Test that tool_choice with required mode and function name is correctly prepared.""" + client = OpenAIChatClient() + + messages = [ChatMessage(role="user", text="test")] + options = { + "tools": [get_weather], + "tool_choice": {"mode": "required", "required_function_name": "get_weather"}, + } + + prepared_options = client._prepare_options(messages, options) + + # Should format tool_choice correctly + assert "tool_choice" in prepared_options + assert prepared_options["tool_choice"]["type"] == "function" + assert prepared_options["tool_choice"]["function"]["name"] == "get_weather" + + +def test_response_format_dict_passthrough(openai_unit_test_env: dict[str, str]) -> None: + """Test that response_format as dict is passed through directly.""" + client = OpenAIChatClient() + + messages = [ChatMessage(role="user", text="test")] + custom_format = { + "type": "json_schema", + "json_schema": {"name": "Test", "schema": {"type": "object"}}, + } + options = {"response_format": custom_format} + + prepared_options = client._prepare_options(messages, options) + + # Should pass through the dict directly + assert prepared_options["response_format"] == custom_format + + +def test_multiple_function_calls_in_single_message(openai_unit_test_env: dict[str, str]) -> None: + """Test that multiple function calls in a message are correctly prepared.""" + client = OpenAIChatClient() + + # Create message with multiple function calls + message = ChatMessage( + role="assistant", + contents=[ + FunctionCallContent(call_id="call_1", name="func_1", arguments='{"a": 1}'), + FunctionCallContent(call_id="call_2", name="func_2", arguments='{"b": 2}'), + ], + ) + + prepared = client._prepare_message_for_openai(message) + + # Should have one message with multiple tool_calls + assert len(prepared) == 1 + assert "tool_calls" in prepared[0] + assert len(prepared[0]["tool_calls"]) == 2 + assert prepared[0]["tool_calls"][0]["id"] == "call_1" + assert prepared[0]["tool_calls"][1]["id"] == "call_2" + + +def test_prepare_options_removes_parallel_tool_calls_when_no_tools(openai_unit_test_env: dict[str, str]) -> None: + """Test that parallel_tool_calls is removed when no tools are present.""" + client = OpenAIChatClient() + + messages = [ChatMessage(role="user", text="test")] + options = {"allow_multiple_tool_calls": True} + + prepared_options = client._prepare_options(messages, options) + + # Should not have parallel_tool_calls when no tools + assert "parallel_tool_calls" not in prepared_options + + +def test_streaming_exception_handling(openai_unit_test_env: dict[str, str]) -> None: + """Test that streaming errors are properly handled.""" + client = OpenAIChatClient() + messages = [ChatMessage(role="user", text="test")] + + # Create a mock error during streaming + mock_error = Exception("Streaming error") + + with ( + patch.object(client.client.chat.completions, "create", side_effect=mock_error), + pytest.raises(ServiceResponseException), + ): + + async def consume_stream(): + async for _ in client._inner_get_streaming_response(messages=messages, options={}): # type: ignore + pass + + asyncio.run(consume_stream()) + + # region Integration Tests diff --git a/python/packages/core/tests/openai/test_openai_responses_client.py b/python/packages/core/tests/openai/test_openai_responses_client.py index c91297d7df..318d9e79d5 100644 --- a/python/packages/core/tests/openai/test_openai_responses_client.py +++ b/python/packages/core/tests/openai/test_openai_responses_client.py @@ -36,6 +36,7 @@ CodeInterpreterToolCallContent, CodeInterpreterToolResultContent, DataContent, + ErrorContent, FunctionApprovalRequestContent, FunctionApprovalResponseContent, FunctionCallContent, @@ -49,10 +50,14 @@ HostedWebSearchTool, ImageGenerationToolCallContent, ImageGenerationToolResultContent, + MCPServerToolCallContent, + MCPServerToolResultContent, Role, TextContent, TextReasoningContent, UriContent, + UsageContent, + UsageDetails, ai_function, ) from agent_framework.exceptions import ( @@ -655,6 +660,453 @@ def test_response_content_creation_with_function_call() -> None: assert function_call.arguments == '{"location": "Seattle"}' +def test_prepare_content_for_openai_function_approval_response() -> None: + """Test _prepare_content_for_openai with FunctionApprovalResponseContent.""" + client = OpenAIResponsesClient(model_id="test-model", api_key="test-key") + + # Test approved response + function_call = FunctionCallContent( + call_id="call_123", + name="send_email", + arguments='{"to": "user@example.com"}', + ) + approval_response = FunctionApprovalResponseContent( + approved=True, + id="approval_001", + function_call=function_call, + ) + + result = client._prepare_content_for_openai(Role.ASSISTANT, approval_response, {}) + + assert result["type"] == "mcp_approval_response" + assert result["approval_request_id"] == "approval_001" + assert result["approve"] is True + + +def test_prepare_content_for_openai_error_content() -> None: + """Test _prepare_content_for_openai with ErrorContent.""" + client = OpenAIResponsesClient(model_id="test-model", api_key="test-key") + + error_content = ErrorContent( + message="Operation failed", + error_code="ERR_123", + details="Invalid parameter", + ) + + result = client._prepare_content_for_openai(Role.ASSISTANT, error_content, {}) + + # ErrorContent should return empty dict (logged but not sent) + assert result == {} + + +def test_prepare_content_for_openai_usage_content() -> None: + """Test _prepare_content_for_openai with UsageContent.""" + client = OpenAIResponsesClient(model_id="test-model", api_key="test-key") + + usage_content = UsageContent( + details=UsageDetails( + input_token_count=100, + output_token_count=50, + total_token_count=150, + ) + ) + + result = client._prepare_content_for_openai(Role.ASSISTANT, usage_content, {}) + + # UsageContent should return empty dict (logged but not sent) + assert result == {} + + +def test_prepare_content_for_openai_hosted_vector_store_content() -> None: + """Test _prepare_content_for_openai with HostedVectorStoreContent.""" + client = OpenAIResponsesClient(model_id="test-model", api_key="test-key") + + vector_store_content = HostedVectorStoreContent( + vector_store_id="vs_123", + ) + + result = client._prepare_content_for_openai(Role.ASSISTANT, vector_store_content, {}) + + # HostedVectorStoreContent should return empty dict (logged but not sent) + assert result == {} + + +def test_parse_response_from_openai_with_mcp_server_tool_result() -> None: + """Test _parse_response_from_openai with MCP server tool result.""" + client = OpenAIResponsesClient(model_id="test-model", api_key="test-key") + + mock_response = MagicMock() + mock_response.output_parsed = None + mock_response.metadata = {} + mock_response.usage = None + mock_response.id = "resp-id" + mock_response.model = "test-model" + mock_response.created_at = 1000000000 + + # Mock MCP call item with result + mock_mcp_item = MagicMock() + mock_mcp_item.type = "mcp_call" + mock_mcp_item.id = "mcp_call_123" + mock_mcp_item.name = "get_data" + mock_mcp_item.arguments = {"key": "value"} + mock_mcp_item.server_label = "TestServer" + mock_mcp_item.result = [{"content": [{"type": "text", "text": "MCP result"}]}] + + mock_response.output = [mock_mcp_item] + + response = client._parse_response_from_openai(mock_response, options={}) # type: ignore + + # Should have both call and result content + assert len(response.messages[0].contents) == 2 + call_content, result_content = response.messages[0].contents + + assert isinstance(call_content, MCPServerToolCallContent) + assert call_content.call_id == "mcp_call_123" + assert call_content.tool_name == "get_data" + assert call_content.server_name == "TestServer" + + assert isinstance(result_content, MCPServerToolResultContent) + assert result_content.call_id == "mcp_call_123" + assert result_content.output is not None + + +def test_parse_chunk_from_openai_with_mcp_call_result() -> None: + """Test _parse_chunk_from_openai with MCP call output.""" + client = OpenAIResponsesClient(model_id="test-model", api_key="test-key") + + # Mock event with MCP call that has output + mock_event = MagicMock() + mock_event.type = "response.output_item.added" + + mock_item = MagicMock() + mock_item.type = "mcp_call" + mock_item.id = "mcp_call_456" + mock_item.call_id = "call_456" + mock_item.name = "fetch_resource" + mock_item.server_label = "ResourceServer" + mock_item.arguments = {"resource_id": "123"} + # Use proper content structure that _parse_content can handle + mock_item.result = [{"type": "text", "text": "test result"}] + + mock_event.item = mock_item + mock_event.output_index = 0 + + function_call_ids: dict[int, tuple[str, str]] = {} + + update = client._parse_chunk_from_openai(mock_event, options={}, function_call_ids=function_call_ids) + + # Should have both call and result in contents + assert len(update.contents) == 2 + call_content, result_content = update.contents + + assert isinstance(call_content, MCPServerToolCallContent) + assert call_content.call_id in ["mcp_call_456", "call_456"] + assert call_content.tool_name == "fetch_resource" + + assert isinstance(result_content, MCPServerToolResultContent) + assert result_content.call_id in ["mcp_call_456", "call_456"] + # Verify the output was parsed + assert result_content.output is not None + + +def test_prepare_message_for_openai_with_function_approval_response() -> None: + """Test _prepare_message_for_openai with FunctionApprovalResponseContent in messages.""" + client = OpenAIResponsesClient(model_id="test-model", api_key="test-key") + + function_call = FunctionCallContent( + call_id="call_789", + name="execute_command", + arguments='{"command": "ls"}', + ) + + approval_response = FunctionApprovalResponseContent( + approved=True, + id="approval_003", + function_call=function_call, + ) + + message = ChatMessage(role="user", contents=[approval_response]) + call_id_to_id: dict[str, str] = {} + + result = client._prepare_message_for_openai(message, call_id_to_id) + + # FunctionApprovalResponseContent is added directly, not nested in args with role + assert len(result) == 1 + prepared_message = result[0] + assert prepared_message["type"] == "mcp_approval_response" + assert prepared_message["approval_request_id"] == "approval_003" + assert prepared_message["approve"] is True + + +def test_chat_message_with_error_content() -> None: + """Test that ErrorContent in messages is handled properly.""" + client = OpenAIResponsesClient(model_id="test-model", api_key="test-key") + + error_content = ErrorContent( + message="Test error", + error_code="TEST_ERR", + ) + + message = ChatMessage(role="assistant", contents=[error_content]) + call_id_to_id: dict[str, str] = {} + + result = client._prepare_message_for_openai(message, call_id_to_id) + + # Message should be prepared with empty content list since ErrorContent returns {} + assert len(result) == 1 + prepared_message = result[0] + assert prepared_message["role"] == "assistant" + # Content should be a list with empty dict since ErrorContent returns {} + assert prepared_message.get("content") == [{}] + + +def test_chat_message_with_usage_content() -> None: + """Test that UsageContent in messages is handled properly.""" + client = OpenAIResponsesClient(model_id="test-model", api_key="test-key") + + usage_content = UsageContent( + details=UsageDetails( + input_token_count=200, + output_token_count=100, + total_token_count=300, + ) + ) + + message = ChatMessage(role="assistant", contents=[usage_content]) + call_id_to_id: dict[str, str] = {} + + result = client._prepare_message_for_openai(message, call_id_to_id) + + # Message should be prepared with empty content list since UsageContent returns {} + assert len(result) == 1 + prepared_message = result[0] + assert prepared_message["role"] == "assistant" + # Content should be a list with empty dict since UsageContent returns {} + assert prepared_message.get("content") == [{}] + + +def test_hosted_file_content_preparation() -> None: + """Test _prepare_content_for_openai with HostedFileContent.""" + client = OpenAIResponsesClient(model_id="test-model", api_key="test-key") + + hosted_file = HostedFileContent( + file_id="file_abc123", + media_type="application/pdf", + name="document.pdf", + ) + + result = client._prepare_content_for_openai(Role.USER, hosted_file, {}) + + assert result["type"] == "input_file" + assert result["file_id"] == "file_abc123" + + +def test_function_approval_response_with_mcp_tool_call() -> None: + """Test FunctionApprovalResponseContent with MCPServerToolCallContent.""" + client = OpenAIResponsesClient(model_id="test-model", api_key="test-key") + + mcp_call = MCPServerToolCallContent( + call_id="mcp_call_999", + tool_name="sensitive_action", + server_name="SecureServer", + arguments={"action": "delete"}, + ) + + approval_response = FunctionApprovalResponseContent( + approved=False, + id="approval_mcp_001", + function_call=mcp_call, + ) + + result = client._prepare_content_for_openai(Role.ASSISTANT, approval_response, {}) + + assert result["type"] == "mcp_approval_response" + assert result["approval_request_id"] == "approval_mcp_001" + assert result["approve"] is False + + +def test_response_format_with_conflicting_definitions() -> None: + """Test that conflicting response_format definitions raise an error.""" + client = OpenAIResponsesClient(model_id="test-model", api_key="test-key") + + # Mock response_format and text_config that conflict + response_format = {"type": "json_schema", "format": {"type": "json_schema", "name": "Test", "schema": {}}} + text_config = {"format": {"type": "json_object"}} + + with pytest.raises(ServiceInvalidRequestError, match="Conflicting response_format definitions"): + client._prepare_response_and_text_format(response_format=response_format, text_config=text_config) + + +def test_response_format_json_object_type() -> None: + """Test response_format with json_object type.""" + client = OpenAIResponsesClient(model_id="test-model", api_key="test-key") + + response_format = {"type": "json_object"} + + _, text_config = client._prepare_response_and_text_format(response_format=response_format, text_config=None) + + assert text_config is not None + assert text_config["format"]["type"] == "json_object" + + +def test_response_format_text_type() -> None: + """Test response_format with text type.""" + client = OpenAIResponsesClient(model_id="test-model", api_key="test-key") + + response_format = {"type": "text"} + + _, text_config = client._prepare_response_and_text_format(response_format=response_format, text_config=None) + + assert text_config is not None + assert text_config["format"]["type"] == "text" + + +def test_response_format_with_format_key() -> None: + """Test response_format that already has a format key.""" + client = OpenAIResponsesClient(model_id="test-model", api_key="test-key") + + response_format = {"format": {"type": "json_schema", "name": "MySchema", "schema": {"type": "object"}}} + + _, text_config = client._prepare_response_and_text_format(response_format=response_format, text_config=None) + + assert text_config is not None + assert text_config["format"]["type"] == "json_schema" + assert text_config["format"]["name"] == "MySchema" + + +def test_response_format_json_schema_no_name_uses_title() -> None: + """Test json_schema response_format without name uses title from schema.""" + client = OpenAIResponsesClient(model_id="test-model", api_key="test-key") + + response_format = { + "type": "json_schema", + "json_schema": {"schema": {"title": "MyTitle", "type": "object", "properties": {}}}, + } + + _, text_config = client._prepare_response_and_text_format(response_format=response_format, text_config=None) + + assert text_config is not None + assert text_config["format"]["name"] == "MyTitle" + + +def test_response_format_json_schema_with_strict() -> None: + """Test json_schema response_format with strict mode.""" + client = OpenAIResponsesClient(model_id="test-model", api_key="test-key") + + response_format = { + "type": "json_schema", + "json_schema": {"name": "StrictSchema", "schema": {"type": "object"}, "strict": True}, + } + + _, text_config = client._prepare_response_and_text_format(response_format=response_format, text_config=None) + + assert text_config is not None + assert text_config["format"]["strict"] is True + + +def test_response_format_json_schema_with_description() -> None: + """Test json_schema response_format with description.""" + client = OpenAIResponsesClient(model_id="test-model", api_key="test-key") + + response_format = { + "type": "json_schema", + "json_schema": { + "name": "DescribedSchema", + "schema": {"type": "object"}, + "description": "A test schema", + }, + } + + _, text_config = client._prepare_response_and_text_format(response_format=response_format, text_config=None) + + assert text_config is not None + assert text_config["format"]["description"] == "A test schema" + + +def test_response_format_json_schema_missing_schema() -> None: + """Test json_schema response_format without schema raises error.""" + client = OpenAIResponsesClient(model_id="test-model", api_key="test-key") + + response_format = {"type": "json_schema", "json_schema": {"name": "NoSchema"}} + + with pytest.raises(ServiceInvalidRequestError, match="json_schema response_format requires a schema"): + client._prepare_response_and_text_format(response_format=response_format, text_config=None) + + +def test_response_format_unsupported_type() -> None: + """Test unsupported response_format type raises error.""" + client = OpenAIResponsesClient(model_id="test-model", api_key="test-key") + + response_format = {"type": "unsupported_format"} + + with pytest.raises(ServiceInvalidRequestError, match="Unsupported response_format"): + client._prepare_response_and_text_format(response_format=response_format, text_config=None) + + +def test_response_format_invalid_type() -> None: + """Test invalid response_format type raises error.""" + client = OpenAIResponsesClient(model_id="test-model", api_key="test-key") + + response_format = "invalid" # Not a Pydantic model or mapping + + with pytest.raises(ServiceInvalidRequestError, match="response_format must be a Pydantic model or mapping"): + client._prepare_response_and_text_format(response_format=response_format, text_config=None) # type: ignore + + +def test_parse_response_with_store_false() -> None: + """Test _get_conversation_id returns None when store is False.""" + client = OpenAIResponsesClient(model_id="test-model", api_key="test-key") + + mock_response = MagicMock() + mock_response.id = "resp_123" + mock_response.conversation = MagicMock() + mock_response.conversation.id = "conv_456" + + conversation_id = client._get_conversation_id(mock_response, store=False) + + assert conversation_id is None + + +def test_parse_response_uses_response_id_when_no_conversation() -> None: + """Test _get_conversation_id returns response ID when no conversation exists.""" + client = OpenAIResponsesClient(model_id="test-model", api_key="test-key") + + mock_response = MagicMock() + mock_response.id = "resp_789" + mock_response.conversation = None + + conversation_id = client._get_conversation_id(mock_response, store=True) + + assert conversation_id == "resp_789" + + +def test_streaming_chunk_with_usage_only() -> None: + """Test streaming chunk that only contains usage info.""" + client = OpenAIResponsesClient(model_id="test-model", api_key="test-key") + chat_options = ChatOptions() + function_call_ids: dict[int, tuple[str, str]] = {} + + mock_event = MagicMock() + mock_event.type = "response.completed" + mock_event.response = MagicMock() + mock_event.response.id = "resp_usage" + mock_event.response.model = "test-model" + mock_event.response.conversation = None + mock_event.response.usage = MagicMock() + mock_event.response.usage.input_tokens = 50 + mock_event.response.usage.output_tokens = 25 + mock_event.response.usage.total_tokens = 75 + mock_event.response.usage.input_tokens_details = None + mock_event.response.usage.output_tokens_details = None + + update = client._parse_chunk_from_openai(mock_event, chat_options, function_call_ids) + + # Should have usage content + assert len(update.contents) == 1 + assert isinstance(update.contents[0], UsageContent) + assert update.contents[0].details.total_token_count == 75 + + def test_prepare_tools_for_openai_with_hosted_mcp() -> None: """Test that HostedMCPTool is converted to the correct response tool dict.""" client = OpenAIResponsesClient(model_id="test-model", api_key="test-key")