mirror of
https://github.com/microsoft/agent-framework.git
synced 2026-06-16 21:04:09 +08:00
Python: Unify tool results as Content items with rich content support (#4331)
* feat(python): allow @tool functions to return rich content (images, audio) Add support for tool functions to return Content objects that the model can perceive natively. Closes #4272 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Anthropic logging + mypy fix * Address PR review: fix MCP ordering, fold helper into from_function_result, fix Chat client - Preserve original content order in MCP tool results instead of text-first - Move _build_function_result logic into Content.from_function_result() - Chat Completions: inject user message for rich items (API only supports string tool content) - Update tests for ordering and new from_function_result behavior Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Use native Responses API multi-part output, warn+omit for Chat client - Responses client: put rich items directly in function_call_output's output field as list (native API support) instead of user message injection - Chat client: warn and omit rich items (API doesn't support multi-part tool results), matching Ollama/Bedrock pattern - Unify test image: use sample_image.jpg across all integration tests - Add Azure OpenAI Responses integration test - Assert model describes house image to verify perception Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Fix lint: remove print statement, wrap long line Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Address review feedback: bug fixes, single-pass MCP, unit tests - Add isinstance guard in from_function_result for non-Content lists - Fix Anthropic empty tool_content fallback to string result - Fix Content(type='text', text=None) edge case in parse_result - Rewrite MCP _parse_tool_result_from_mcp as single-pass (no index counters) - Add Anthropic unit tests: data image, uri image, unsupported media, all-unsupported - Add OpenAI Chat unit test: rich items warning and omission - Add OpenAI Responses unit tests: function_result with/without items - Add test_types tests: only-rich-items list, non-Content list fallback Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Fix pyright errors: add type ignore comments for Any list iteration Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Fix mypy/pyright: ensure ToolExecutionException receives str Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Fix lint: remove duplicate test_prepare_options_excludes_conversation_id Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * refactor: unify all tool results into Content items * addressed copilot comments * pyright fix * small fix * comments * fix: address Copilot review - warnings, blob safety, dedup - Add warning logs when rich content is dropped in Claude agent and MCP server handlers (matching Chat/Bedrock/Ollama pattern) - Defensive blob URI construction: wrap plain base64 in data: prefix - Simplify Chat client _prepare_content_for_openai to use content.result - Simplify Responses client text-only path, remove redundant nesting - Add test for plain base64 blob without data: prefix Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Fix token double-counting in compaction and address review comments - Exclude items from _serialize_content() to prevent double-counting tokens when items mirrors result in function_result content - Add rich content warning in GitHub Copilot agent tool handler - Replace raw Content debug log with concise item count/type summary - Update stale test comments about FunctionTool.invoke return type Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
committed by
GitHub
Unverified
parent
b6a1315386
commit
5e33deff45
@@ -142,7 +142,9 @@ def test_serialize_with_org_id(openai_unit_test_env: dict[str, str]) -> None:
|
||||
assert "User-Agent" not in dumped_settings.get("default_headers", {})
|
||||
|
||||
|
||||
async def test_content_filter_exception_handling(openai_unit_test_env: dict[str, str]) -> None:
|
||||
async def test_content_filter_exception_handling(
|
||||
openai_unit_test_env: dict[str, str],
|
||||
) -> None:
|
||||
"""Test that content filter errors are properly handled."""
|
||||
client = OpenAIChatClient()
|
||||
messages = [Message(role="user", text="test message")]
|
||||
@@ -150,7 +152,9 @@ async def test_content_filter_exception_handling(openai_unit_test_env: dict[str,
|
||||
# Create a mock BadRequestError with content_filter code
|
||||
mock_response = MagicMock()
|
||||
mock_error = BadRequestError(
|
||||
message="Content filter error", response=mock_response, body={"error": {"code": "content_filter"}}
|
||||
message="Content filter error",
|
||||
response=mock_response,
|
||||
body={"error": {"code": "content_filter"}},
|
||||
)
|
||||
mock_error.code = "content_filter"
|
||||
|
||||
@@ -184,7 +188,9 @@ def test_unsupported_tool_handling(openai_unit_test_env: dict[str, str]) -> None
|
||||
assert result["tools"] == [dict_tool]
|
||||
|
||||
|
||||
def test_prepare_tools_with_single_function_tool(openai_unit_test_env: dict[str, str]) -> None:
|
||||
def test_prepare_tools_with_single_function_tool(
|
||||
openai_unit_test_env: dict[str, str],
|
||||
) -> None:
|
||||
"""Test that a single FunctionTool is accepted for tool preparation."""
|
||||
client = OpenAIChatClient()
|
||||
|
||||
@@ -241,12 +247,17 @@ async def test_exception_message_includes_original_error_details() -> None:
|
||||
assert original_error_message in exception_message
|
||||
|
||||
|
||||
def test_chat_response_content_order_text_before_tool_calls(openai_unit_test_env: dict[str, str]):
|
||||
def test_chat_response_content_order_text_before_tool_calls(
|
||||
openai_unit_test_env: dict[str, str],
|
||||
):
|
||||
"""Test that text content appears before tool calls in ChatResponse contents."""
|
||||
# Import locally to avoid break other tests when the import changes
|
||||
from openai.types.chat.chat_completion import ChatCompletion, Choice
|
||||
from openai.types.chat.chat_completion_message import ChatCompletionMessage
|
||||
from openai.types.chat.chat_completion_message_tool_call import ChatCompletionMessageToolCall, Function
|
||||
from openai.types.chat.chat_completion_message_tool_call import (
|
||||
ChatCompletionMessageToolCall,
|
||||
Function,
|
||||
)
|
||||
|
||||
# Create a mock OpenAI response with both text and tool calls
|
||||
mock_response = ChatCompletion(
|
||||
@@ -296,9 +307,10 @@ def test_function_result_falsy_values_handling(openai_unit_test_env: dict[str, s
|
||||
"""
|
||||
client = OpenAIChatClient()
|
||||
|
||||
# Test with empty list serialized as JSON string (as FunctionTool.invoke would produce)
|
||||
# Test with empty list serialized as JSON string (pre-serialized result passed to from_function_result)
|
||||
message_with_empty_list = Message(
|
||||
role="tool", contents=[Content.from_function_result(call_id="call-123", result="[]")]
|
||||
role="tool",
|
||||
contents=[Content.from_function_result(call_id="call-123", result="[]")],
|
||||
)
|
||||
|
||||
openai_messages = client._prepare_message_for_openai(message_with_empty_list)
|
||||
@@ -307,16 +319,18 @@ def test_function_result_falsy_values_handling(openai_unit_test_env: dict[str, s
|
||||
|
||||
# Test with empty string (falsy but not None)
|
||||
message_with_empty_string = Message(
|
||||
role="tool", contents=[Content.from_function_result(call_id="call-456", result="")]
|
||||
role="tool",
|
||||
contents=[Content.from_function_result(call_id="call-456", result="")],
|
||||
)
|
||||
|
||||
openai_messages = client._prepare_message_for_openai(message_with_empty_string)
|
||||
assert len(openai_messages) == 1
|
||||
assert openai_messages[0]["content"] == "" # Empty string should be preserved
|
||||
|
||||
# Test with False serialized as JSON string (as FunctionTool.invoke would produce)
|
||||
# Test with False serialized as JSON string (pre-serialized result passed to from_function_result)
|
||||
message_with_false = Message(
|
||||
role="tool", contents=[Content.from_function_result(call_id="call-789", result="false")]
|
||||
role="tool",
|
||||
contents=[Content.from_function_result(call_id="call-789", result="false")],
|
||||
)
|
||||
|
||||
openai_messages = client._prepare_message_for_openai(message_with_false)
|
||||
@@ -336,7 +350,11 @@ def test_function_result_exception_handling(openai_unit_test_env: dict[str, str]
|
||||
message_with_exception = Message(
|
||||
role="tool",
|
||||
contents=[
|
||||
Content.from_function_result(call_id="call-123", result="Error: Function failed.", exception=test_exception)
|
||||
Content.from_function_result(
|
||||
call_id="call-123",
|
||||
result="Error: Function failed.",
|
||||
exception=test_exception,
|
||||
)
|
||||
],
|
||||
)
|
||||
|
||||
@@ -346,16 +364,50 @@ def test_function_result_exception_handling(openai_unit_test_env: dict[str, str]
|
||||
assert openai_messages[0]["tool_call_id"] == "call-123"
|
||||
|
||||
|
||||
def test_function_result_with_rich_items_warns_and_omits(
|
||||
openai_unit_test_env: dict[str, str],
|
||||
) -> None:
|
||||
"""Test that function_result with items logs a warning and omits rich items."""
|
||||
|
||||
client = OpenAIChatClient()
|
||||
image_content = Content.from_data(data=b"image_bytes", media_type="image/png")
|
||||
message = Message(
|
||||
role="tool",
|
||||
contents=[
|
||||
Content.from_function_result(
|
||||
call_id="call_rich",
|
||||
result=[Content.from_text("Result text"), image_content],
|
||||
)
|
||||
],
|
||||
)
|
||||
|
||||
with patch("agent_framework.openai._chat_client.logger") as mock_logger:
|
||||
openai_messages = client._prepare_message_for_openai(message)
|
||||
|
||||
# Warning should be logged
|
||||
mock_logger.warning.assert_called_once()
|
||||
assert "does not support rich content" in mock_logger.warning.call_args[0][0]
|
||||
|
||||
# Tool message should still be emitted with text result
|
||||
assert len(openai_messages) == 1
|
||||
assert openai_messages[0]["role"] == "tool"
|
||||
assert openai_messages[0]["tool_call_id"] == "call_rich"
|
||||
assert openai_messages[0]["content"] == "Result text"
|
||||
|
||||
|
||||
def test_parse_result_string_passthrough():
|
||||
"""Test that string values are passed through directly without JSON encoding."""
|
||||
"""Test that string values are wrapped in Content."""
|
||||
from agent_framework import FunctionTool
|
||||
|
||||
result = FunctionTool.parse_result("simple string")
|
||||
assert result == "simple string"
|
||||
assert isinstance(result, str)
|
||||
assert isinstance(result, list)
|
||||
assert len(result) == 1
|
||||
assert result[0].text == "simple string"
|
||||
|
||||
|
||||
def test_prepare_content_for_openai_data_content_image(openai_unit_test_env: dict[str, str]) -> None:
|
||||
def test_prepare_content_for_openai_data_content_image(
|
||||
openai_unit_test_env: dict[str, str],
|
||||
) -> None:
|
||||
"""Test _prepare_content_for_openai converts DataContent with image media type to OpenAI format."""
|
||||
client = OpenAIChatClient()
|
||||
|
||||
@@ -397,7 +449,8 @@ def test_prepare_content_for_openai_data_content_image(openai_unit_test_env: dic
|
||||
|
||||
# Test DataContent with MP3 audio
|
||||
mp3_data_content = Content.from_uri(
|
||||
uri="data:audio/mp3;base64,//uQAAAAWGluZwAAAA8AAAACAAACcQ==", media_type="audio/mp3"
|
||||
uri="data:audio/mp3;base64,//uQAAAAWGluZwAAAA8AAAACAAACcQ==",
|
||||
media_type="audio/mp3",
|
||||
)
|
||||
|
||||
result = client._prepare_content_for_openai(mp3_data_content) # type: ignore
|
||||
@@ -409,7 +462,9 @@ def test_prepare_content_for_openai_data_content_image(openai_unit_test_env: dic
|
||||
assert result["input_audio"]["format"] == "mp3"
|
||||
|
||||
|
||||
def test_prepare_content_for_openai_document_file_mapping(openai_unit_test_env: dict[str, str]) -> None:
|
||||
def test_prepare_content_for_openai_document_file_mapping(
|
||||
openai_unit_test_env: dict[str, str],
|
||||
) -> None:
|
||||
"""Test _prepare_content_for_openai converts document files (PDF, DOCX, etc.) to OpenAI file format."""
|
||||
client = OpenAIChatClient()
|
||||
|
||||
@@ -515,7 +570,9 @@ 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:
|
||||
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()
|
||||
@@ -563,7 +620,9 @@ def test_parse_text_reasoning_content_from_response(openai_unit_test_env: dict[s
|
||||
assert parsed_details == mock_reasoning_details
|
||||
|
||||
|
||||
def test_parse_text_reasoning_content_from_streaming_chunk(openai_unit_test_env: dict[str, str]) -> None:
|
||||
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
|
||||
@@ -611,7 +670,9 @@ def test_parse_text_reasoning_content_from_streaming_chunk(openai_unit_test_env:
|
||||
assert parsed_details == mock_reasoning_details
|
||||
|
||||
|
||||
def test_prepare_message_with_text_reasoning_content(openai_unit_test_env: dict[str, str]) -> None:
|
||||
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()
|
||||
|
||||
@@ -643,7 +704,9 @@ def test_prepare_message_with_text_reasoning_content(openai_unit_test_env: dict[
|
||||
assert prepared[0]["content"] == "The answer is 42."
|
||||
|
||||
|
||||
def test_prepare_message_with_only_text_reasoning_content(openai_unit_test_env: dict[str, str]) -> None:
|
||||
def test_prepare_message_with_only_text_reasoning_content(
|
||||
openai_unit_test_env: dict[str, str],
|
||||
) -> None:
|
||||
"""Test that a message with only text_reasoning content does not raise IndexError.
|
||||
|
||||
Regression test for https://github.com/microsoft/agent-framework/issues/4384
|
||||
@@ -677,7 +740,9 @@ def test_prepare_message_with_only_text_reasoning_content(openai_unit_test_env:
|
||||
assert prepared[0]["content"] == ""
|
||||
|
||||
|
||||
def test_prepare_message_with_text_reasoning_before_text(openai_unit_test_env: dict[str, str]) -> None:
|
||||
def test_prepare_message_with_text_reasoning_before_text(
|
||||
openai_unit_test_env: dict[str, str],
|
||||
) -> None:
|
||||
"""Test that text_reasoning content appearing before text content is handled correctly.
|
||||
|
||||
Regression test for https://github.com/microsoft/agent-framework/issues/4384
|
||||
@@ -711,7 +776,9 @@ def test_prepare_message_with_text_reasoning_before_text(openai_unit_test_env: d
|
||||
assert prepared[0]["content"] == "The answer is 42."
|
||||
|
||||
|
||||
def test_prepare_message_with_text_reasoning_before_function_call(openai_unit_test_env: dict[str, str]) -> None:
|
||||
def test_prepare_message_with_text_reasoning_before_function_call(
|
||||
openai_unit_test_env: dict[str, str],
|
||||
) -> None:
|
||||
"""Test that text_reasoning content appearing before a function call is handled correctly.
|
||||
|
||||
Regression test for https://github.com/microsoft/agent-framework/issues/4384
|
||||
@@ -747,7 +814,9 @@ def test_prepare_message_with_text_reasoning_before_function_call(openai_unit_te
|
||||
assert prepared[0]["role"] == "assistant"
|
||||
|
||||
|
||||
def test_function_approval_content_is_skipped_in_preparation(openai_unit_test_env: dict[str, str]) -> None:
|
||||
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()
|
||||
|
||||
@@ -793,7 +862,9 @@ def test_function_approval_content_is_skipped_in_preparation(openai_unit_test_en
|
||||
assert prepared_mixed[0]["content"] == "I need approval for this action."
|
||||
|
||||
|
||||
def test_usage_content_in_streaming_response(openai_unit_test_env: dict[str, str]) -> None:
|
||||
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
|
||||
@@ -829,13 +900,19 @@ def test_usage_content_in_streaming_response(openai_unit_test_env: dict[str, str
|
||||
assert usage_content.usage_details["total_token_count"] == 150
|
||||
|
||||
|
||||
def test_streaming_chunk_with_usage_and_text(openai_unit_test_env: dict[str, str]) -> None:
|
||||
def test_streaming_chunk_with_usage_and_text(
|
||||
openai_unit_test_env: dict[str, str],
|
||||
) -> None:
|
||||
"""Test that text content is not lost when usage data is in the same chunk.
|
||||
|
||||
Some providers (e.g. Gemini) include both usage and text content in the
|
||||
same streaming chunk. See https://github.com/microsoft/agent-framework/issues/3434
|
||||
"""
|
||||
from openai.types.chat.chat_completion_chunk import ChatCompletionChunk, Choice, ChoiceDelta
|
||||
from openai.types.chat.chat_completion_chunk import (
|
||||
ChatCompletionChunk,
|
||||
Choice,
|
||||
ChoiceDelta,
|
||||
)
|
||||
from openai.types.completion_usage import CompletionUsage
|
||||
|
||||
client = OpenAIChatClient()
|
||||
@@ -923,7 +1000,9 @@ def test_prepare_options_without_messages(openai_unit_test_env: dict[str, str])
|
||||
client._prepare_options([], {})
|
||||
|
||||
|
||||
def test_prepare_tools_with_web_search_no_location(openai_unit_test_env: dict[str, str]) -> None:
|
||||
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()
|
||||
|
||||
@@ -937,7 +1016,9 @@ def test_prepare_tools_with_web_search_no_location(openai_unit_test_env: dict[st
|
||||
assert result["web_search_options"] == {}
|
||||
|
||||
|
||||
def test_prepare_options_with_instructions(openai_unit_test_env: dict[str, str]) -> None:
|
||||
def test_prepare_options_with_instructions(
|
||||
openai_unit_test_env: dict[str, str],
|
||||
) -> None:
|
||||
"""Test that instructions are prepended as system message."""
|
||||
client = OpenAIChatClient()
|
||||
|
||||
@@ -969,7 +1050,9 @@ def test_prepare_message_with_author_name(openai_unit_test_env: dict[str, str])
|
||||
assert prepared[0]["name"] == "TestUser"
|
||||
|
||||
|
||||
def test_prepare_message_with_tool_result_author_name(openai_unit_test_env: dict[str, str]) -> None:
|
||||
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()
|
||||
|
||||
@@ -987,7 +1070,9 @@ def test_prepare_message_with_tool_result_author_name(openai_unit_test_env: dict
|
||||
assert "name" not in prepared[0]
|
||||
|
||||
|
||||
def test_prepare_system_message_content_is_string(openai_unit_test_env: dict[str, str]) -> None:
|
||||
def test_prepare_system_message_content_is_string(
|
||||
openai_unit_test_env: dict[str, str],
|
||||
) -> None:
|
||||
"""Test that system message content is a plain string, not a list.
|
||||
|
||||
Some OpenAI-compatible endpoints (e.g. NVIDIA NIM) reject system messages
|
||||
@@ -1005,7 +1090,9 @@ def test_prepare_system_message_content_is_string(openai_unit_test_env: dict[str
|
||||
assert prepared[0]["content"] == "You are a helpful assistant."
|
||||
|
||||
|
||||
def test_prepare_developer_message_content_is_string(openai_unit_test_env: dict[str, str]) -> None:
|
||||
def test_prepare_developer_message_content_is_string(
|
||||
openai_unit_test_env: dict[str, str],
|
||||
) -> None:
|
||||
"""Test that developer message content is a plain string, not a list."""
|
||||
client = OpenAIChatClient()
|
||||
|
||||
@@ -1019,7 +1106,9 @@ def test_prepare_developer_message_content_is_string(openai_unit_test_env: dict[
|
||||
assert prepared[0]["content"] == "Follow these rules."
|
||||
|
||||
|
||||
def test_prepare_system_message_multiple_text_contents_joined(openai_unit_test_env: dict[str, str]) -> None:
|
||||
def test_prepare_system_message_multiple_text_contents_joined(
|
||||
openai_unit_test_env: dict[str, str],
|
||||
) -> None:
|
||||
"""Test that system messages with multiple text contents are joined into a single string."""
|
||||
client = OpenAIChatClient()
|
||||
|
||||
@@ -1039,7 +1128,9 @@ def test_prepare_system_message_multiple_text_contents_joined(openai_unit_test_e
|
||||
assert prepared[0]["content"] == "You are a helpful assistant.\nBe concise."
|
||||
|
||||
|
||||
def test_prepare_user_message_text_content_is_string(openai_unit_test_env: dict[str, str]) -> None:
|
||||
def test_prepare_user_message_text_content_is_string(
|
||||
openai_unit_test_env: dict[str, str],
|
||||
) -> None:
|
||||
"""Test that text-only user message content is flattened to a plain string.
|
||||
|
||||
Some OpenAI-compatible endpoints (e.g. Foundry Local) cannot deserialize
|
||||
@@ -1057,7 +1148,9 @@ def test_prepare_user_message_text_content_is_string(openai_unit_test_env: dict[
|
||||
assert prepared[0]["content"] == "Hello"
|
||||
|
||||
|
||||
def test_prepare_user_message_multimodal_content_remains_list(openai_unit_test_env: dict[str, str]) -> None:
|
||||
def test_prepare_user_message_multimodal_content_remains_list(
|
||||
openai_unit_test_env: dict[str, str],
|
||||
) -> None:
|
||||
"""Test that multimodal user message content remains a list."""
|
||||
client = OpenAIChatClient()
|
||||
|
||||
@@ -1076,7 +1169,9 @@ def test_prepare_user_message_multimodal_content_remains_list(openai_unit_test_e
|
||||
assert has_list_content
|
||||
|
||||
|
||||
def test_prepare_assistant_message_text_content_is_string(openai_unit_test_env: dict[str, str]) -> None:
|
||||
def test_prepare_assistant_message_text_content_is_string(
|
||||
openai_unit_test_env: dict[str, str],
|
||||
) -> None:
|
||||
"""Test that text-only assistant message content is flattened to a plain string."""
|
||||
client = OpenAIChatClient()
|
||||
|
||||
@@ -1090,7 +1185,9 @@ def test_prepare_assistant_message_text_content_is_string(openai_unit_test_env:
|
||||
assert prepared[0]["content"] == "Sure, I can help."
|
||||
|
||||
|
||||
def test_tool_choice_required_with_function_name(openai_unit_test_env: dict[str, str]) -> None:
|
||||
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()
|
||||
|
||||
@@ -1125,7 +1222,9 @@ def test_response_format_dict_passthrough(openai_unit_test_env: dict[str, str])
|
||||
assert prepared_options["response_format"] == custom_format
|
||||
|
||||
|
||||
def test_multiple_function_calls_in_single_message(openai_unit_test_env: dict[str, str]) -> None:
|
||||
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()
|
||||
|
||||
@@ -1148,7 +1247,9 @@ def test_multiple_function_calls_in_single_message(openai_unit_test_env: dict[st
|
||||
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:
|
||||
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()
|
||||
|
||||
@@ -1176,7 +1277,9 @@ def test_prepare_options_excludes_conversation_id(openai_unit_test_env: dict[str
|
||||
assert prepared_options["temperature"] == 0.7
|
||||
|
||||
|
||||
async def test_streaming_exception_handling(openai_unit_test_env: dict[str, str]) -> None:
|
||||
async def test_streaming_exception_handling(
|
||||
openai_unit_test_env: dict[str, str],
|
||||
) -> None:
|
||||
"""Test that streaming errors are properly handled."""
|
||||
client = OpenAIChatClient()
|
||||
messages = [Message(role="user", text="test")]
|
||||
@@ -1220,7 +1323,12 @@ class OutputStruct(BaseModel):
|
||||
param("allow_multiple_tool_calls", True, False, id="allow_multiple_tool_calls"),
|
||||
# OpenAIChatOptions - just verify they don't fail
|
||||
param("logit_bias", {"50256": -1}, False, id="logit_bias"),
|
||||
param("prediction", {"type": "content", "content": "hello world"}, False, id="prediction"),
|
||||
param(
|
||||
"prediction",
|
||||
{"type": "content", "content": "hello world"},
|
||||
False,
|
||||
id="prediction",
|
||||
),
|
||||
# Complex options requiring output validation
|
||||
param("tools", [get_weather], True, id="tools_function"),
|
||||
param("tool_choice", "auto", True, id="tool_choice_auto"),
|
||||
@@ -1249,7 +1357,12 @@ class OutputStruct(BaseModel):
|
||||
"temperature_c": {"type": "number"},
|
||||
"advisory": {"type": "string"},
|
||||
},
|
||||
"required": ["location", "conditions", "temperature_c", "advisory"],
|
||||
"required": [
|
||||
"location",
|
||||
"conditions",
|
||||
"temperature_c",
|
||||
"advisory",
|
||||
],
|
||||
"additionalProperties": False,
|
||||
},
|
||||
},
|
||||
@@ -1383,7 +1496,12 @@ async def test_integration_web_search() -> None:
|
||||
}
|
||||
)
|
||||
content = {
|
||||
"messages": [Message(role="user", text="What is the current weather? Do not ask for my current location.")],
|
||||
"messages": [
|
||||
Message(
|
||||
role="user",
|
||||
text="What is the current weather? Do not ask for my current location.",
|
||||
)
|
||||
],
|
||||
"options": {
|
||||
"tool_choice": "auto",
|
||||
"tools": [web_search_tool_with_location],
|
||||
|
||||
@@ -4,6 +4,7 @@ import base64
|
||||
import json
|
||||
import os
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Annotated, Any
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
@@ -36,7 +37,10 @@ from agent_framework import (
|
||||
SupportsChatGetResponse,
|
||||
tool,
|
||||
)
|
||||
from agent_framework.exceptions import ChatClientException, ChatClientInvalidRequestException
|
||||
from agent_framework.exceptions import (
|
||||
ChatClientException,
|
||||
ChatClientInvalidRequestException,
|
||||
)
|
||||
from agent_framework.openai import OpenAIResponsesClient
|
||||
from agent_framework.openai._exceptions import OpenAIContentFilterException
|
||||
from agent_framework.openai._responses_client import OPENAI_LOCAL_SHELL_CALL_ITEM_ID_KEY
|
||||
@@ -1313,7 +1317,10 @@ def test_prepare_messages_for_openai_full_conversation_with_reasoning() -> None:
|
||||
),
|
||||
],
|
||||
),
|
||||
Message(role="assistant", contents=[Content.from_text(text="I found hotels for you")]),
|
||||
Message(
|
||||
role="assistant",
|
||||
contents=[Content.from_text(text="I found hotels for you")],
|
||||
),
|
||||
]
|
||||
|
||||
result = client._prepare_messages_for_openai(messages)
|
||||
@@ -1422,10 +1429,16 @@ def test_response_format_with_conflicting_definitions() -> None:
|
||||
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": {}}}
|
||||
response_format = {
|
||||
"type": "json_schema",
|
||||
"format": {"type": "json_schema", "name": "Test", "schema": {}},
|
||||
}
|
||||
text_config = {"format": {"type": "json_object"}}
|
||||
|
||||
with pytest.raises(ChatClientInvalidRequestException, match="Conflicting response_format definitions"):
|
||||
with pytest.raises(
|
||||
ChatClientInvalidRequestException,
|
||||
match="Conflicting response_format definitions",
|
||||
):
|
||||
client._prepare_response_and_text_format(response_format=response_format, text_config=text_config)
|
||||
|
||||
|
||||
@@ -1457,7 +1470,13 @@ 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"}}}
|
||||
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)
|
||||
|
||||
@@ -1487,7 +1506,11 @@ def test_response_format_json_schema_with_strict() -> None:
|
||||
|
||||
response_format = {
|
||||
"type": "json_schema",
|
||||
"json_schema": {"name": "StrictSchema", "schema": {"type": "object"}, "strict": True},
|
||||
"json_schema": {
|
||||
"name": "StrictSchema",
|
||||
"schema": {"type": "object"},
|
||||
"strict": True,
|
||||
},
|
||||
}
|
||||
|
||||
_, text_config = client._prepare_response_and_text_format(response_format=response_format, text_config=None)
|
||||
@@ -1521,7 +1544,10 @@ def test_response_format_json_schema_missing_schema() -> None:
|
||||
|
||||
response_format = {"type": "json_schema", "json_schema": {"name": "NoSchema"}}
|
||||
|
||||
with pytest.raises(ChatClientInvalidRequestException, match="json_schema response_format requires a schema"):
|
||||
with pytest.raises(
|
||||
ChatClientInvalidRequestException,
|
||||
match="json_schema response_format requires a schema",
|
||||
):
|
||||
client._prepare_response_and_text_format(response_format=response_format, text_config=None)
|
||||
|
||||
|
||||
@@ -1541,7 +1567,10 @@ def test_response_format_invalid_type() -> None:
|
||||
|
||||
response_format = "invalid" # Not a Pydantic model or mapping
|
||||
|
||||
with pytest.raises(ChatClientInvalidRequestException, match="response_format must be a Pydantic model or mapping"):
|
||||
with pytest.raises(
|
||||
ChatClientInvalidRequestException,
|
||||
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
|
||||
|
||||
|
||||
@@ -2198,7 +2227,9 @@ async def test_get_response_streaming_with_response_format() -> None:
|
||||
|
||||
async def run_streaming():
|
||||
async for _ in client.get_response(
|
||||
stream=True, messages=messages, options={"response_format": OutputStruct}
|
||||
stream=True,
|
||||
messages=messages,
|
||||
options={"response_format": OutputStruct},
|
||||
):
|
||||
pass
|
||||
|
||||
@@ -2262,6 +2293,45 @@ def test_prepare_content_for_openai_unsupported_content() -> None:
|
||||
assert result == {}
|
||||
|
||||
|
||||
def test_prepare_content_for_openai_function_result_with_rich_items() -> None:
|
||||
"""Test _prepare_content_for_openai with function_result containing rich items."""
|
||||
client = OpenAIResponsesClient(model_id="test-model", api_key="test-key")
|
||||
|
||||
image_content = Content.from_data(data=b"image_bytes", media_type="image/png")
|
||||
content = Content.from_function_result(
|
||||
call_id="call_rich",
|
||||
result=[Content.from_text("Result text"), image_content],
|
||||
)
|
||||
|
||||
result = client._prepare_content_for_openai("user", content, {}) # type: ignore
|
||||
|
||||
assert result["type"] == "function_call_output"
|
||||
assert result["call_id"] == "call_rich"
|
||||
# Output should be a list with text and image parts
|
||||
output = result["output"]
|
||||
assert isinstance(output, list)
|
||||
assert len(output) == 2
|
||||
assert output[0]["type"] == "input_text"
|
||||
assert output[0]["text"] == "Result text"
|
||||
assert output[1]["type"] == "input_image"
|
||||
|
||||
|
||||
def test_prepare_content_for_openai_function_result_without_items() -> None:
|
||||
"""Test _prepare_content_for_openai with plain string function_result."""
|
||||
client = OpenAIResponsesClient(model_id="test-model", api_key="test-key")
|
||||
|
||||
content = Content.from_function_result(
|
||||
call_id="call_plain",
|
||||
result="Simple result",
|
||||
)
|
||||
|
||||
result = client._prepare_content_for_openai("user", content, {}) # type: ignore
|
||||
|
||||
assert result["type"] == "function_call_output"
|
||||
assert result["call_id"] == "call_plain"
|
||||
assert result["output"] == "Simple result"
|
||||
|
||||
|
||||
def test_parse_chunk_from_openai_code_interpreter() -> None:
|
||||
"""Test _parse_chunk_from_openai with code_interpreter_call."""
|
||||
client = OpenAIResponsesClient(model_id="test-model", api_key="test-key")
|
||||
@@ -2778,7 +2848,10 @@ async def test_instructions_sent_first_turn_then_skipped_for_continuation() -> N
|
||||
|
||||
await client.get_response(
|
||||
messages=[Message(role="user", text="Tell me a joke")],
|
||||
options={"instructions": "Reply in uppercase.", "conversation_id": "resp_123"},
|
||||
options={
|
||||
"instructions": "Reply in uppercase.",
|
||||
"conversation_id": "resp_123",
|
||||
},
|
||||
)
|
||||
|
||||
second_input_messages = mock_create.call_args.kwargs["input"]
|
||||
@@ -2788,7 +2861,9 @@ async def test_instructions_sent_first_turn_then_skipped_for_continuation() -> N
|
||||
|
||||
|
||||
@pytest.mark.parametrize("conversation_id", ["resp_456", "conv_abc123"])
|
||||
async def test_instructions_not_repeated_for_continuation_ids(conversation_id: str) -> None:
|
||||
async def test_instructions_not_repeated_for_continuation_ids(
|
||||
conversation_id: str,
|
||||
) -> None:
|
||||
client = OpenAIResponsesClient(model_id="test-model", api_key="test-key")
|
||||
mock_response = _create_mock_responses_text_response(response_id="resp_456")
|
||||
|
||||
@@ -2889,7 +2964,12 @@ def test_with_callable_api_key() -> None:
|
||||
"temperature_c": {"type": "number"},
|
||||
"advisory": {"type": "string"},
|
||||
},
|
||||
"required": ["location", "conditions", "temperature_c", "advisory"],
|
||||
"required": [
|
||||
"location",
|
||||
"conditions",
|
||||
"temperature_c",
|
||||
"advisory",
|
||||
],
|
||||
"additionalProperties": False,
|
||||
},
|
||||
},
|
||||
@@ -3014,7 +3094,12 @@ async def test_integration_web_search() -> None:
|
||||
user_location={"country": "US", "city": "Seattle"},
|
||||
)
|
||||
content = {
|
||||
"messages": [Message(role="user", text="What is the current weather? Do not ask for my current location.")],
|
||||
"messages": [
|
||||
Message(
|
||||
role="user",
|
||||
text="What is the current weather? Do not ask for my current location.",
|
||||
)
|
||||
],
|
||||
"options": {
|
||||
"tool_choice": "auto",
|
||||
"tools": [web_search_tool_with_location],
|
||||
@@ -3105,7 +3190,42 @@ async def test_integration_streaming_file_search() -> None:
|
||||
assert "75" in full_message
|
||||
|
||||
|
||||
# region Background Response / ContinuationToken Tests
|
||||
@pytest.mark.flaky
|
||||
@pytest.mark.integration
|
||||
@skip_if_openai_integration_tests_disabled
|
||||
async def test_integration_tool_rich_content_image() -> None:
|
||||
"""Integration test: a tool returns an image and the model describes it."""
|
||||
image_path = Path(__file__).parent.parent / "assets" / "sample_image.jpg"
|
||||
image_bytes = image_path.read_bytes()
|
||||
|
||||
@tool(approval_mode="never_require")
|
||||
def get_test_image() -> Content:
|
||||
"""Return a test image for analysis."""
|
||||
return Content.from_data(data=image_bytes, media_type="image/jpeg")
|
||||
|
||||
client = OpenAIResponsesClient()
|
||||
client.function_invocation_configuration["max_iterations"] = 2
|
||||
|
||||
for streaming in [False, True]:
|
||||
messages = [
|
||||
Message(
|
||||
role="user",
|
||||
text="Call the get_test_image tool and describe what you see.",
|
||||
)
|
||||
]
|
||||
options: dict[str, Any] = {"tools": [get_test_image], "tool_choice": "auto"}
|
||||
|
||||
if streaming:
|
||||
response = await client.get_response(messages=messages, stream=True, options=options).get_final_response()
|
||||
else:
|
||||
response = await client.get_response(messages=messages, options=options)
|
||||
|
||||
assert response is not None
|
||||
assert isinstance(response, ChatResponse)
|
||||
assert response.text is not None
|
||||
assert len(response.text) > 0
|
||||
# sample_image.jpg contains a photo of a house; the model should mention it.
|
||||
assert "house" in response.text.lower(), f"Model did not describe the house image. Response: {response.text}"
|
||||
|
||||
|
||||
def test_continuation_token_json_serializable() -> None:
|
||||
|
||||
Reference in New Issue
Block a user