mirror of
https://github.com/microsoft/agent-framework.git
synced 2026-06-16 21:04:09 +08:00
Python: Added tests for OpenAI content types + Unit test improvement (#3259)
* added tests for content types+ unit test improvement * small fixes * small fix
This commit is contained in:
committed by
GitHub
Unverified
parent
50c2539f3a
commit
e229dfa7e5
@@ -609,6 +609,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 contents[0].type == "code_interpreter_tool_call"
|
||||
assert contents[0].call_id == '["response_123", "call_code_123"]'
|
||||
assert contents[0].inputs is not None
|
||||
assert len(contents[0].inputs) == 1
|
||||
assert contents[0].inputs[0].type == "text"
|
||||
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 contents[0].type == "mcp_server_tool_call"
|
||||
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)
|
||||
|
||||
@@ -7,6 +7,8 @@ from unittest.mock import MagicMock, patch
|
||||
|
||||
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
|
||||
|
||||
@@ -497,6 +499,430 @@ 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 message.contents[1].type == "text_reasoning"
|
||||
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 update.contents[1].type == "text_reasoning"
|
||||
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."""
|
||||
client = OpenAIChatClient()
|
||||
|
||||
# Create message with text_reasoning content that has protected_data
|
||||
# text_reasoning is meant to be added to an existing message, so include text content first
|
||||
mock_reasoning_data = {
|
||||
"effort": "medium",
|
||||
"summary": "Quick analysis",
|
||||
}
|
||||
|
||||
reasoning_content = Content.from_text_reasoning(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=[
|
||||
Content.from_text(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 function approval request and response content are skipped."""
|
||||
client = OpenAIChatClient()
|
||||
|
||||
# Create approval request
|
||||
function_call = Content.from_function_call(
|
||||
call_id="call_123",
|
||||
name="dangerous_action",
|
||||
arguments='{"confirm": true}',
|
||||
)
|
||||
|
||||
approval_request = Content.from_function_approval_request(
|
||||
id="approval_001",
|
||||
function_call=function_call,
|
||||
)
|
||||
|
||||
# Create approval response
|
||||
approval_response = Content.from_function_approval_response(
|
||||
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
|
||||
mixed_message = ChatMessage(
|
||||
role="assistant",
|
||||
contents=[
|
||||
Content.from_text(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 usage content
|
||||
assert len(update.contents) == 1
|
||||
assert update.contents[0].type == "usage"
|
||||
|
||||
usage_content = update.contents[0]
|
||||
assert isinstance(usage_content.usage_details, dict)
|
||||
assert usage_content.usage_details["input_token_count"] == 100
|
||||
assert usage_content.usage_details["output_token_count"] == 50
|
||||
assert usage_content.usage_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."""
|
||||
client = OpenAIChatClient()
|
||||
|
||||
message = ChatMessage(
|
||||
role="user",
|
||||
author_name="TestUser",
|
||||
contents=[Content.from_text(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=[Content.from_function_result(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=[
|
||||
Content.from_function_call(call_id="call_1", name="func_1", arguments='{"a": 1}'),
|
||||
Content.from_function_call(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
|
||||
|
||||
|
||||
async 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
|
||||
|
||||
await consume_stream()
|
||||
|
||||
|
||||
# region Integration Tests
|
||||
|
||||
|
||||
|
||||
@@ -642,6 +642,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 function approval response content."""
|
||||
client = OpenAIResponsesClient(model_id="test-model", api_key="test-key")
|
||||
|
||||
# Test approved response
|
||||
function_call = Content.from_function_call(
|
||||
call_id="call_123",
|
||||
name="send_email",
|
||||
arguments='{"to": "user@example.com"}',
|
||||
)
|
||||
approval_response = Content.from_function_approval_response(
|
||||
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 error content."""
|
||||
client = OpenAIResponsesClient(model_id="test-model", api_key="test-key")
|
||||
|
||||
error_content = Content.from_error(
|
||||
message="Operation failed",
|
||||
error_code="ERR_123",
|
||||
error_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 usage content."""
|
||||
client = OpenAIResponsesClient(model_id="test-model", api_key="test-key")
|
||||
|
||||
usage_content = Content.from_usage(
|
||||
usage_details={
|
||||
"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 hosted vector store content."""
|
||||
client = OpenAIResponsesClient(model_id="test-model", api_key="test-key")
|
||||
|
||||
vector_store_content = Content.from_hosted_vector_store(
|
||||
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 call_content.type == "mcp_server_tool_call"
|
||||
assert call_content.call_id == "mcp_call_123"
|
||||
assert call_content.tool_name == "get_data"
|
||||
assert call_content.server_name == "TestServer"
|
||||
|
||||
assert result_content.type == "mcp_server_tool_result"
|
||||
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 call_content.type == "mcp_server_tool_call"
|
||||
assert call_content.call_id in ["mcp_call_456", "call_456"]
|
||||
assert call_content.tool_name == "fetch_resource"
|
||||
|
||||
assert result_content.type == "mcp_server_tool_result"
|
||||
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 function approval response content in messages."""
|
||||
client = OpenAIResponsesClient(model_id="test-model", api_key="test-key")
|
||||
|
||||
function_call = Content.from_function_call(
|
||||
call_id="call_789",
|
||||
name="execute_command",
|
||||
arguments='{"command": "ls"}',
|
||||
)
|
||||
|
||||
approval_response = Content.from_function_approval_response(
|
||||
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 error content in messages is handled properly."""
|
||||
client = OpenAIResponsesClient(model_id="test-model", api_key="test-key")
|
||||
|
||||
error_content = Content.from_error(
|
||||
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 usage content in messages is handled properly."""
|
||||
client = OpenAIResponsesClient(model_id="test-model", api_key="test-key")
|
||||
|
||||
usage_content = Content.from_usage(
|
||||
usage_details={
|
||||
"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 hosted file content."""
|
||||
client = OpenAIResponsesClient(model_id="test-model", api_key="test-key")
|
||||
|
||||
hosted_file = Content.from_hosted_file(
|
||||
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 function approval response content with MCP server tool call content."""
|
||||
client = OpenAIResponsesClient(model_id="test-model", api_key="test-key")
|
||||
|
||||
mcp_call = Content.from_mcp_server_tool_call(
|
||||
call_id="mcp_call_999",
|
||||
tool_name="sensitive_action",
|
||||
server_name="SecureServer",
|
||||
arguments={"action": "delete"},
|
||||
)
|
||||
|
||||
approval_response = Content.from_function_approval_response(
|
||||
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 update.contents[0].type == "usage"
|
||||
assert update.contents[0].usage_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")
|
||||
|
||||
Reference in New Issue
Block a user