Python: [Breaking] Simplified Content types to a single class with classmethod constructors. (#3252)

* ported Content to a new model

* fixed linting

* fixes

* fixed data format handling

* fix for 3.10 mypy

* fix

* fix int test
This commit is contained in:
Eduard van Valkenburg
2026-01-20 22:09:39 +00:00
committed by GitHub
parent 73761aa4a3
commit 83e6229c11
132 changed files with 3949 additions and 4741 deletions
@@ -2,6 +2,7 @@
import ast
import json
import os
import re
import sys
from collections.abc import AsyncIterable, Callable, Mapping, MutableMapping, MutableSequence, Sequence
@@ -9,6 +10,8 @@ from typing import Any, ClassVar, Generic, TypedDict
from agent_framework import (
AGENT_FRAMEWORK_USER_AGENT,
AIFunction,
Annotation,
BaseChatClient,
ChatAgent,
ChatMessage,
@@ -16,23 +19,16 @@ from agent_framework import (
ChatOptions,
ChatResponse,
ChatResponseUpdate,
CitationAnnotation,
Contents,
Content,
ContextProvider,
DataContent,
FunctionApprovalRequestContent,
FunctionApprovalResponseContent,
FunctionCallContent,
FunctionResultContent,
HostedFileContent,
HostedCodeInterpreterTool,
HostedFileSearchTool,
HostedMCPTool,
HostedWebSearchTool,
Middleware,
Role,
TextContent,
TextSpanRegion,
ToolProtocol,
UriContent,
UsageContent,
UsageDetails,
get_logger,
prepare_function_call_results,
@@ -50,9 +46,14 @@ from azure.ai.agents.models import (
AgentStreamEvent,
AsyncAgentEventHandler,
AsyncAgentRunStream,
BingCustomSearchTool,
BingGroundingTool,
CodeInterpreterToolDefinition,
FileSearchTool,
FunctionName,
FunctionToolDefinition,
ListSortOrder,
McpTool,
MessageDeltaChunk,
MessageDeltaTextContent,
MessageDeltaTextFileCitationAnnotation,
@@ -422,7 +423,7 @@ class AzureAIAgentClient(BaseChatClient[TAzureAIAgentOptions], Generic[TAzureAIA
self,
agent_id: str,
run_options: dict[str, Any],
required_action_results: list[FunctionResultContent | FunctionApprovalResponseContent] | None,
required_action_results: list[Content] | None,
) -> tuple[AsyncAgentRunStream[AsyncAgentEventHandler[Any]] | AsyncAgentEventHandler[Any], str]:
"""Create the agent stream for processing.
@@ -506,9 +507,9 @@ class AzureAIAgentClient(BaseChatClient[TAzureAIAgentOptions], Generic[TAzureAIA
def _extract_url_citations(
self, message_delta_chunk: MessageDeltaChunk, azure_search_tool_calls: list[dict[str, Any]]
) -> list[CitationAnnotation]:
) -> list[Annotation]:
"""Extract URL citations from MessageDeltaChunk."""
url_citations: list[CitationAnnotation] = []
url_citations: list[Annotation] = []
# Process each content item in the delta to find citations
for content in message_delta_chunk.delta.content:
@@ -520,6 +521,7 @@ class AzureAIAgentClient(BaseChatClient[TAzureAIAgentOptions], Generic[TAzureAIA
if annotation.start_index and annotation.end_index:
annotated_regions = [
TextSpanRegion(
type="text_span",
start_index=annotation.start_index,
end_index=annotation.end_index,
)
@@ -530,11 +532,12 @@ class AzureAIAgentClient(BaseChatClient[TAzureAIAgentOptions], Generic[TAzureAIA
annotation.url_citation.url, azure_search_tool_calls
)
# Create CitationAnnotation with real URL
citation = CitationAnnotation(
title=getattr(annotation.url_citation, "title", None),
# Create Annotation with real URL
citation = Annotation(
type="citation",
title=annotation.url_citation.title, # type: ignore[typeddict-item]
url=real_url,
snippet=None,
snippet=None, # type: ignore[typeddict-item]
annotated_regions=annotated_regions,
raw_representation=annotation,
)
@@ -542,7 +545,7 @@ class AzureAIAgentClient(BaseChatClient[TAzureAIAgentOptions], Generic[TAzureAIA
return url_citations
def _extract_file_path_contents(self, message_delta_chunk: MessageDeltaChunk) -> list[HostedFileContent]:
def _extract_file_path_contents(self, message_delta_chunk: MessageDeltaChunk) -> list[Content]:
"""Extract file references from MessageDeltaChunk annotations.
Code interpreter generates files that are referenced via file path or file citation
@@ -559,7 +562,7 @@ class AzureAIAgentClient(BaseChatClient[TAzureAIAgentOptions], Generic[TAzureAIA
Returns:
List of HostedFileContent objects for any files referenced in annotations
"""
file_contents: list[HostedFileContent] = []
file_contents: list[Content] = []
for content in message_delta_chunk.delta.content:
if isinstance(content, MessageDeltaTextContent) and content.text and content.text.annotations:
@@ -570,14 +573,14 @@ class AzureAIAgentClient(BaseChatClient[TAzureAIAgentOptions], Generic[TAzureAIA
if file_path is not None:
file_id = getattr(file_path, "file_id", None)
if file_id:
file_contents.append(HostedFileContent(file_id=file_id))
file_contents.append(Content.from_hosted_file(file_id=file_id))
elif isinstance(annotation, MessageDeltaTextFileCitationAnnotation):
# Extract file_id from the file_citation annotation
file_citation = getattr(annotation, "file_citation", None)
if file_citation is not None:
file_id = getattr(file_citation, "file_id", None)
if file_id:
file_contents.append(HostedFileContent(file_id=file_id))
file_contents.append(Content.from_hosted_file(file_id=file_id))
return file_contents
@@ -644,9 +647,9 @@ class AzureAIAgentClient(BaseChatClient[TAzureAIAgentOptions], Generic[TAzureAIA
file_contents = self._extract_file_path_contents(event_data)
# Create contents with citations if any exist
citation_content: list[Contents] = []
citation_content: list[Content] = []
if event_data.text or url_citations:
text_content_obj = TextContent(text=event_data.text or "")
text_content_obj = Content.from_text(text=event_data.text or "")
if url_citations:
text_content_obj.annotations = url_citations
citation_content.append(text_content_obj)
@@ -722,7 +725,7 @@ class AzureAIAgentClient(BaseChatClient[TAzureAIAgentOptions], Generic[TAzureAIA
self._capture_azure_search_tool_calls(event_data, azure_search_tool_calls)
if event_data.usage:
usage_content = UsageContent(
usage_content = Content.from_usage(
UsageDetails(
input_token_count=event_data.usage.prompt_tokens,
output_token_count=event_data.usage.completion_tokens,
@@ -757,19 +760,21 @@ class AzureAIAgentClient(BaseChatClient[TAzureAIAgentOptions], Generic[TAzureAIA
tool_call.code_interpreter,
RunStepDeltaCodeInterpreterDetailItemObject,
):
code_contents: list[Contents] = []
code_contents: list[Content] = []
if tool_call.code_interpreter.input is not None:
logger.debug(f"Code Interpreter Input: {tool_call.code_interpreter.input}")
if tool_call.code_interpreter.outputs is not None:
for output in tool_call.code_interpreter.outputs:
if isinstance(output, RunStepDeltaCodeInterpreterLogOutput) and output.logs:
code_contents.append(TextContent(text=output.logs))
code_contents.append(Content.from_text(text=output.logs))
if (
isinstance(output, RunStepDeltaCodeInterpreterImageOutput)
and output.image is not None
and output.image.file_id is not None
):
code_contents.append(HostedFileContent(file_id=output.image.file_id))
code_contents.append(
Content.from_hosted_file(file_id=output.image.file_id)
)
yield ChatResponseUpdate(
role=Role.ASSISTANT,
contents=code_contents,
@@ -822,12 +827,12 @@ class AzureAIAgentClient(BaseChatClient[TAzureAIAgentOptions], Generic[TAzureAIA
except Exception as ex:
logger.debug(f"Failed to capture Azure AI Search tool call: {ex}")
def _parse_function_calls_from_azure_ai(self, event_data: ThreadRun, response_id: str | None) -> list[Contents]:
def _parse_function_calls_from_azure_ai(self, event_data: ThreadRun, response_id: str | None) -> list[Content]:
"""Parse function call contents from an Azure AI tool action event."""
if isinstance(event_data, ThreadRun) and event_data.required_action is not None:
if isinstance(event_data.required_action, SubmitToolOutputsAction):
return [
FunctionCallContent(
Content.from_function_call(
call_id=f'["{response_id}", "{tool.id}"]',
name=tool.function.name,
arguments=tool.function.arguments,
@@ -837,9 +842,9 @@ class AzureAIAgentClient(BaseChatClient[TAzureAIAgentOptions], Generic[TAzureAIA
]
if isinstance(event_data.required_action, SubmitToolApprovalAction):
return [
FunctionApprovalRequestContent(
Content.from_function_approval_request(
id=f'["{response_id}", "{tool.id}"]',
function_call=FunctionCallContent(
function_call=Content.from_function_call(
call_id=f'["{response_id}", "{tool.id}"]',
name=tool.name,
arguments=tool.arguments,
@@ -875,7 +880,7 @@ class AzureAIAgentClient(BaseChatClient[TAzureAIAgentOptions], Generic[TAzureAIA
messages: MutableSequence[ChatMessage],
options: Mapping[str, Any],
**kwargs: Any,
) -> tuple[dict[str, Any], list[FunctionResultContent | FunctionApprovalResponseContent] | None]:
) -> tuple[dict[str, Any], list[Content] | None]:
agent_definition = await self._load_agent_definition_if_needed()
# Build run_options from options dict, excluding specific keys
@@ -1052,7 +1057,7 @@ class AzureAIAgentClient(BaseChatClient[TAzureAIAgentOptions], Generic[TAzureAIA
) -> tuple[
list[ThreadMessageOptions] | None,
list[str],
list[FunctionResultContent | FunctionApprovalResponseContent] | None,
list[Content] | None,
]:
"""Prepare messages for Azure AI Agents API.
@@ -1064,28 +1069,34 @@ class AzureAIAgentClient(BaseChatClient[TAzureAIAgentOptions], Generic[TAzureAIA
Tuple of (additional_messages, instructions, required_action_results)
"""
instructions: list[str] = []
required_action_results: list[FunctionResultContent | FunctionApprovalResponseContent] | None = None
required_action_results: list[Content] | None = None
additional_messages: list[ThreadMessageOptions] | None = None
for chat_message in messages:
if chat_message.role.value in ["system", "developer"]:
for text_content in [content for content in chat_message.contents if isinstance(content, TextContent)]:
instructions.append(text_content.text)
for text_content in [content for content in chat_message.contents if content.type == "text"]:
instructions.append(text_content.text) # type: ignore[arg-type]
continue
message_contents: list[MessageInputContentBlock] = []
for content in chat_message.contents:
if isinstance(content, TextContent):
message_contents.append(MessageInputTextBlock(text=content.text))
elif isinstance(content, (DataContent, UriContent)) and content.has_top_level_media_type("image"):
message_contents.append(MessageInputImageUrlBlock(image_url=MessageImageUrlParam(url=content.uri)))
elif isinstance(content, (FunctionResultContent, FunctionApprovalResponseContent)):
if required_action_results is None:
required_action_results = []
required_action_results.append(content)
elif isinstance(content.raw_representation, MessageInputContentBlock):
message_contents.append(content.raw_representation)
match content.type:
case "text":
message_contents.append(MessageInputTextBlock(text=content.text)) # type: ignore[arg-type]
case "data" | "uri":
if content.has_top_level_media_type("image"):
message_contents.append(
MessageInputImageUrlBlock(image_url=MessageImageUrlParam(url=content.uri)) # type: ignore[arg-type]
)
# Only images are supported. Other media types are ignored.
case "function_result" | "function_approval_response":
if required_action_results is None:
required_action_results = []
required_action_results.append(content)
case _:
if isinstance(content.raw_representation, MessageInputContentBlock):
message_contents.append(content.raw_representation)
if message_contents:
if additional_messages is None:
@@ -1099,9 +1110,85 @@ class AzureAIAgentClient(BaseChatClient[TAzureAIAgentOptions], Generic[TAzureAIA
return additional_messages, instructions, required_action_results
async def _prepare_tools_for_azure_ai(
self, tools: Sequence["ToolProtocol | MutableMapping[str, Any]"], run_options: dict[str, Any] | None = None
) -> list[ToolDefinition | dict[str, Any]]:
"""Prepare tool definitions for the Azure AI Agents API."""
tool_definitions: list[ToolDefinition | dict[str, Any]] = []
for tool in tools:
match tool:
case AIFunction():
tool_definitions.append(tool.to_json_schema_spec()) # type: ignore[reportUnknownArgumentType]
case HostedWebSearchTool():
additional_props = tool.additional_properties or {}
config_args: dict[str, Any] = {}
if count := additional_props.get("count"):
config_args["count"] = count
if freshness := additional_props.get("freshness"):
config_args["freshness"] = freshness
if market := additional_props.get("market"):
config_args["market"] = market
if set_lang := additional_props.get("set_lang"):
config_args["set_lang"] = set_lang
# Bing Grounding
connection_id = additional_props.get("connection_id") or os.getenv("BING_CONNECTION_ID")
# Custom Bing Search
custom_connection_id = additional_props.get("custom_connection_id") or os.getenv(
"BING_CUSTOM_CONNECTION_ID"
)
custom_instance_name = additional_props.get("custom_instance_name") or os.getenv(
"BING_CUSTOM_INSTANCE_NAME"
)
bing_search: BingGroundingTool | BingCustomSearchTool | None = None
if (connection_id) and not custom_connection_id and not custom_instance_name:
if connection_id:
conn_id = connection_id
else:
raise ServiceInitializationError("Parameter connection_id is not provided.")
bing_search = BingGroundingTool(connection_id=conn_id, **config_args)
if custom_connection_id and custom_instance_name:
bing_search = BingCustomSearchTool(
connection_id=custom_connection_id,
instance_name=custom_instance_name,
**config_args,
)
if not bing_search:
raise ServiceInitializationError(
"Bing search tool requires either 'connection_id' for Bing Grounding "
"or both 'custom_connection_id' and 'custom_instance_name' for Custom Bing Search. "
"These can be provided via additional_properties or environment variables: "
"'BING_CONNECTION_ID', 'BING_CUSTOM_CONNECTION_ID', "
"'BING_CUSTOM_INSTANCE_NAME'"
)
tool_definitions.extend(bing_search.definitions)
case HostedCodeInterpreterTool():
tool_definitions.append(CodeInterpreterToolDefinition())
case HostedMCPTool():
mcp_tool = McpTool(
server_label=tool.name.replace(" ", "_"),
server_url=str(tool.url),
allowed_tools=list(tool.allowed_tools) if tool.allowed_tools else [],
)
tool_definitions.extend(mcp_tool.definitions)
case HostedFileSearchTool():
vector_stores = [inp for inp in tool.inputs or [] if inp.type == "hosted_vector_store"]
if vector_stores:
file_search = FileSearchTool(vector_store_ids=[vs.vector_store_id for vs in vector_stores]) # type: ignore[misc]
tool_definitions.extend(file_search.definitions)
# Set tool_resources for file search to work properly with Azure AI
if run_options is not None and "tool_resources" not in run_options:
run_options["tool_resources"] = file_search.resources
case ToolDefinition():
tool_definitions.append(tool)
case dict():
tool_definitions.append(tool)
case _:
raise ServiceInitializationError(f"Unsupported tool type: {type(tool)}")
return tool_definitions
def _prepare_tool_outputs_for_azure_ai(
self,
required_action_results: list[FunctionResultContent | FunctionApprovalResponseContent] | None,
required_action_results: list[Content] | None,
) -> tuple[str | None, list[ToolOutput] | None, list[ToolApproval] | None]:
"""Prepare function results and approvals for submission to the Azure AI API."""
run_id: str | None = None
@@ -1115,9 +1202,7 @@ class AzureAIAgentClient(BaseChatClient[TAzureAIAgentOptions], Generic[TAzureAIA
# We need to extract the run ID and ensure that the Output/Approval we send back to Azure
# is only the call ID.
run_and_call_ids: list[str] = (
json.loads(content.call_id)
if isinstance(content, FunctionResultContent)
else json.loads(content.id)
json.loads(content.call_id) if content.type == "function_result" else json.loads(content.id) # type: ignore[arg-type]
)
if (
@@ -1132,16 +1217,16 @@ class AzureAIAgentClient(BaseChatClient[TAzureAIAgentOptions], Generic[TAzureAIA
run_id = run_and_call_ids[0]
call_id = run_and_call_ids[1]
if isinstance(content, FunctionResultContent):
if content.type == "function_result":
if tool_outputs is None:
tool_outputs = []
tool_outputs.append(
ToolOutput(tool_call_id=call_id, output=prepare_function_call_results(content.result))
)
elif isinstance(content, FunctionApprovalResponseContent):
elif content.type == "function_approval_response":
if tool_approvals is None:
tool_approvals = []
tool_approvals.append(ToolApproval(tool_call_id=call_id, approve=content.approved))
tool_approvals.append(ToolApproval(tool_call_id=call_id, approve=content.approved)) # type: ignore[arg-type]
return run_id, tool_outputs, tool_approvals
@@ -12,7 +12,6 @@ from agent_framework import (
ContextProvider,
HostedMCPTool,
Middleware,
TextContent,
ToolProtocol,
get_logger,
use_chat_middleware,
@@ -477,8 +476,8 @@ class AzureAIClient(OpenAIBaseResponsesClient[TAzureAIClientOptions], Generic[TA
# System/developer messages are turned into instructions, since there is no such message roles in Azure AI.
for message in messages:
if message.role.value in ["system", "developer"]:
for text_content in [content for content in message.contents if isinstance(content, TextContent)]:
instructions_list.append(text_content.text)
for text_content in [content for content in message.contents if content.type == "text"]:
instructions_list.append(text_content.text) # type: ignore[arg-type]
else:
result.append(message)
@@ -6,12 +6,10 @@ from typing import Any, ClassVar, Literal, cast
from agent_framework import (
AIFunction,
Contents,
Content,
HostedCodeInterpreterTool,
HostedFileContent,
HostedFileSearchTool,
HostedMCPTool,
HostedVectorStoreContent,
HostedWebSearchTool,
ToolProtocol,
get_logger,
@@ -189,9 +187,9 @@ def to_azure_ai_agent_tools(
)
tool_definitions.extend(mcp_tool.definitions)
case HostedFileSearchTool():
vector_stores = [inp for inp in tool.inputs or [] if isinstance(inp, HostedVectorStoreContent)]
vector_stores = [inp for inp in tool.inputs or [] if inp.type == "hosted_vector_store"]
if vector_stores:
file_search = AgentsFileSearchTool(vector_store_ids=[vs.vector_store_id for vs in vector_stores])
file_search = AgentsFileSearchTool(vector_store_ids=[vs.vector_store_id for vs in vector_stores]) # type: ignore[misc]
tool_definitions.extend(file_search.definitions)
# Set tool_resources for file search to work properly with Azure AI
if run_options is not None and "tool_resources" not in run_options:
@@ -247,7 +245,7 @@ def _convert_dict_tool(tool: dict[str, Any]) -> ToolProtocol | dict[str, Any] |
if tool_type == "file_search":
file_search_config = tool.get("file_search", {})
vector_store_ids = file_search_config.get("vector_store_ids", [])
inputs = [HostedVectorStoreContent(vector_store_id=vs_id) for vs_id in vector_store_ids]
inputs = [Content.from_hosted_vector_store(vector_store_id=vs_id) for vs_id in vector_store_ids]
return HostedFileSearchTool(inputs=inputs if inputs else None) # type: ignore
if tool_type == "bing_grounding":
@@ -287,7 +285,7 @@ def _convert_sdk_tool(tool: ToolDefinition) -> ToolProtocol | dict[str, Any] | N
if tool_type == "file_search":
file_search_config = getattr(tool, "file_search", None)
vector_store_ids = getattr(file_search_config, "vector_store_ids", []) if file_search_config else []
inputs = [HostedVectorStoreContent(vector_store_id=vs_id) for vs_id in vector_store_ids]
inputs = [Content.from_hosted_vector_store(vector_store_id=vs_id) for vs_id in vector_store_ids]
return HostedFileSearchTool(inputs=inputs if inputs else None) # type: ignore
if tool_type == "bing_grounding":
@@ -372,18 +370,18 @@ def from_azure_ai_tools(tools: Sequence[Tool | dict[str, Any]] | None) -> list[T
elif tool_type == "code_interpreter":
ci_tool = cast(CodeInterpreterTool, tool_dict)
container = ci_tool.get("container", {})
ci_inputs: list[Contents] = []
ci_inputs: list[Content] = []
if "file_ids" in container:
for file_id in container["file_ids"]:
ci_inputs.append(HostedFileContent(file_id=file_id))
ci_inputs.append(Content.from_hosted_file(file_id=file_id))
agent_tools.append(HostedCodeInterpreterTool(inputs=ci_inputs if ci_inputs else None)) # type: ignore
elif tool_type == "file_search":
fs_tool = cast(ProjectsFileSearchTool, tool_dict)
fs_inputs: list[Contents] = []
fs_inputs: list[Content] = []
if "vector_store_ids" in fs_tool:
for vs_id in fs_tool["vector_store_ids"]:
fs_inputs.append(HostedVectorStoreContent(vector_store_id=vs_id))
fs_inputs.append(Content.from_hosted_vector_store(vector_store_id=vs_id))
agent_tools.append(
HostedFileSearchTool(
@@ -433,8 +431,8 @@ def to_azure_ai_tools(
file_ids: list[str] = []
if tool.inputs:
for tool_input in tool.inputs:
if isinstance(tool_input, HostedFileContent):
file_ids.append(tool_input.file_id)
if tool_input.type == "hosted_file":
file_ids.append(tool_input.file_id) # type: ignore[misc, arg-type]
container = CodeInterpreterToolAuto(file_ids=file_ids if file_ids else None)
ci_tool: CodeInterpreterTool = CodeInterpreterTool(container=container)
azure_tools.append(ci_tool)
@@ -453,11 +451,14 @@ def to_azure_ai_tools(
if not tool.inputs:
raise ValueError("HostedFileSearchTool requires inputs to be specified.")
vector_store_ids: list[str] = [
inp.vector_store_id for inp in tool.inputs if isinstance(inp, HostedVectorStoreContent)
inp.vector_store_id # type: ignore[misc]
for inp in tool.inputs
if inp.type == "hosted_vector_store"
]
if not vector_store_ids:
raise ValueError(
"HostedFileSearchTool requires inputs to be of type `HostedVectorStoreContent`."
"HostedFileSearchTool requires inputs to be of type `Content` with "
"type 'hosted_vector_store'."
)
fs_tool: ProjectsFileSearchTool = ProjectsFileSearchTool(vector_store_ids=vector_store_ids)
if tool.max_results:
@@ -7,10 +7,10 @@ from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from agent_framework import (
ChatAgent,
Content,
HostedCodeInterpreterTool,
HostedFileSearchTool,
HostedMCPTool,
HostedVectorStoreContent,
HostedWebSearchTool,
ai_function,
)
@@ -509,7 +509,7 @@ def test_to_azure_ai_agent_tools_code_interpreter() -> None:
def test_to_azure_ai_agent_tools_file_search() -> None:
"""Test converting HostedFileSearchTool with vector stores."""
tool = HostedFileSearchTool(inputs=[HostedVectorStoreContent(vector_store_id="vs-123")])
tool = HostedFileSearchTool(inputs=[Content.from_hosted_vector_store(vector_store_id="vs-123")])
run_options: dict[str, Any] = {}
result = to_azure_ai_agent_tools([tool], run_options)
@@ -17,19 +17,12 @@ from agent_framework import (
ChatOptions,
ChatResponse,
ChatResponseUpdate,
CitationAnnotation,
FunctionApprovalRequestContent,
FunctionApprovalResponseContent,
FunctionCallContent,
FunctionResultContent,
Content,
HostedCodeInterpreterTool,
HostedFileContent,
HostedFileSearchTool,
HostedMCPTool,
HostedVectorStoreContent,
HostedWebSearchTool,
Role,
TextContent,
UriContent,
)
from agent_framework._serialization import SerializationMixin
from agent_framework.exceptions import ServiceInitializationError
@@ -368,7 +361,7 @@ async def test_azure_ai_chat_client_prepare_options_with_image_content(mock_agen
# Mock get_agent
mock_agents_client.get_agent = AsyncMock(return_value=None)
image_content = UriContent(uri="https://example.com/image.jpg", media_type="image/jpeg")
image_content = Content.from_uri(uri="https://example.com/image.jpg", media_type="image/jpeg")
messages = [ChatMessage(role=Role.USER, contents=[image_content])]
run_options, _ = await chat_client._prepare_options(messages, {}) # type: ignore
@@ -551,7 +544,7 @@ def test_azure_ai_chat_client_parse_function_calls_from_azure_ai_basic(mock_agen
result = chat_client._parse_function_calls_from_azure_ai(mock_event_data, "response_123") # type: ignore
assert len(result) == 1
assert isinstance(result[0], FunctionCallContent)
assert result[0].type == "function_call"
assert result[0].name == "get_weather"
assert result[0].call_id == '["response_123", "call_123"]'
@@ -728,6 +721,121 @@ async def test_azure_ai_chat_client_prepare_options_mcp_with_headers(mock_agents
assert mcp_resource["headers"] == headers
async def test_azure_ai_chat_client_prepare_tools_for_azure_ai_web_search_bing_grounding(
mock_agents_client: MagicMock,
) -> None:
"""Test _prepare_tools_for_azure_ai with HostedWebSearchTool using Bing Grounding."""
chat_client = create_test_azure_ai_chat_client(mock_agents_client, agent_id="test-agent")
web_search_tool = HostedWebSearchTool(
additional_properties={
"connection_id": "test-connection-id",
"count": 5,
"freshness": "Day",
"market": "en-US",
"set_lang": "en",
}
)
# Mock BingGroundingTool
with patch("agent_framework_azure_ai._chat_client.BingGroundingTool") as mock_bing_grounding:
mock_bing_tool = MagicMock()
mock_bing_tool.definitions = [{"type": "bing_grounding"}]
mock_bing_grounding.return_value = mock_bing_tool
result = await chat_client._prepare_tools_for_azure_ai([web_search_tool]) # type: ignore
assert len(result) == 1
assert result[0] == {"type": "bing_grounding"}
call_args = mock_bing_grounding.call_args[1]
assert call_args["count"] == 5
assert call_args["freshness"] == "Day"
assert call_args["market"] == "en-US"
assert call_args["set_lang"] == "en"
assert "connection_id" in call_args
async def test_azure_ai_chat_client_prepare_tools_for_azure_ai_web_search_bing_grounding_with_connection_id(
mock_agents_client: MagicMock,
) -> None:
"""Test _prepare_tools_... with HostedWebSearchTool using Bing Grounding with connection_id (no HTTP call)."""
chat_client = create_test_azure_ai_chat_client(mock_agents_client, agent_id="test-agent")
web_search_tool = HostedWebSearchTool(
additional_properties={
"connection_id": "direct-connection-id",
"count": 3,
}
)
# Mock BingGroundingTool
with patch("agent_framework_azure_ai._chat_client.BingGroundingTool") as mock_bing_grounding:
mock_bing_tool = MagicMock()
mock_bing_tool.definitions = [{"type": "bing_grounding"}]
mock_bing_grounding.return_value = mock_bing_tool
result = await chat_client._prepare_tools_for_azure_ai([web_search_tool]) # type: ignore
assert len(result) == 1
assert result[0] == {"type": "bing_grounding"}
mock_bing_grounding.assert_called_once_with(connection_id="direct-connection-id", count=3)
async def test_azure_ai_chat_client_prepare_tools_for_azure_ai_web_search_custom_bing(
mock_agents_client: MagicMock,
) -> None:
"""Test _prepare_tools_for_azure_ai with HostedWebSearchTool using Custom Bing Search."""
chat_client = create_test_azure_ai_chat_client(mock_agents_client, agent_id="test-agent")
web_search_tool = HostedWebSearchTool(
additional_properties={
"custom_connection_id": "custom-connection-id",
"custom_instance_name": "custom-instance",
"count": 10,
}
)
# Mock BingCustomSearchTool
with patch("agent_framework_azure_ai._chat_client.BingCustomSearchTool") as mock_custom_bing:
mock_custom_tool = MagicMock()
mock_custom_tool.definitions = [{"type": "bing_custom_search"}]
mock_custom_bing.return_value = mock_custom_tool
result = await chat_client._prepare_tools_for_azure_ai([web_search_tool]) # type: ignore
assert len(result) == 1
assert result[0] == {"type": "bing_custom_search"}
async def test_azure_ai_chat_client_prepare_tools_for_azure_ai_file_search_with_vector_stores(
mock_agents_client: MagicMock,
) -> None:
"""Test _prepare_tools_for_azure_ai with HostedFileSearchTool using vector stores."""
chat_client = create_test_azure_ai_chat_client(mock_agents_client, agent_id="test-agent")
vector_store_input = Content.from_hosted_vector_store(vector_store_id="vs-123")
file_search_tool = HostedFileSearchTool(inputs=[vector_store_input])
# Mock FileSearchTool
with patch("agent_framework_azure_ai._chat_client.FileSearchTool") as mock_file_search:
mock_file_tool = MagicMock()
mock_file_tool.definitions = [{"type": "file_search"}]
mock_file_tool.resources = {"vector_store_ids": ["vs-123"]}
mock_file_search.return_value = mock_file_tool
run_options = {}
result = await chat_client._prepare_tools_for_azure_ai([file_search_tool], run_options) # type: ignore
assert len(result) == 1
assert result[0] == {"type": "file_search"}
assert run_options["tool_resources"] == {"vector_store_ids": ["vs-123"]}
mock_file_search.assert_called_once_with(vector_store_ids=["vs-123"])
async def test_azure_ai_chat_client_create_agent_stream_submit_tool_approvals(
mock_agents_client: MagicMock,
) -> None:
@@ -741,9 +849,9 @@ async def test_azure_ai_chat_client_create_agent_stream_submit_tool_approvals(
chat_client._get_active_thread_run = AsyncMock(return_value=mock_thread_run) # type: ignore
# Mock required action results with approval response that matches run ID
approval_response = FunctionApprovalResponseContent(
approval_response = Content.from_function_approval_response(
id='["test-run-id", "test-call-id"]',
function_call=FunctionCallContent(
function_call=Content.from_function_call(
call_id='["test-run-id", "test-call-id"]', name="test_function", arguments="{}"
),
approved=True,
@@ -839,7 +947,7 @@ async def test_azure_ai_chat_client_prepare_tool_outputs_for_azure_ai_function_r
chat_client = create_test_azure_ai_chat_client(mock_agents_client, agent_id="test-agent")
# Test with simple result
function_result = FunctionResultContent(call_id='["run_123", "call_456"]', result="Simple result")
function_result = Content.from_function_result(call_id='["run_123", "call_456"]', result="Simple result")
run_id, tool_outputs, tool_approvals = chat_client._prepare_tool_outputs_for_azure_ai([function_result]) # type: ignore
@@ -857,7 +965,7 @@ async def test_azure_ai_chat_client_convert_required_action_invalid_call_id(mock
chat_client = create_test_azure_ai_chat_client(mock_agents_client, agent_id="test-agent")
# Invalid call_id format - should raise JSONDecodeError
function_result = FunctionResultContent(call_id="invalid_json", result="result")
function_result = Content.from_function_result(call_id="invalid_json", result="result")
with pytest.raises(json.JSONDecodeError):
chat_client._prepare_tool_outputs_for_azure_ai([function_result]) # type: ignore
@@ -870,7 +978,7 @@ async def test_azure_ai_chat_client_convert_required_action_invalid_structure(
chat_client = create_test_azure_ai_chat_client(mock_agents_client, agent_id="test-agent")
# Valid JSON but invalid structure (missing second element)
function_result = FunctionResultContent(call_id='["run_123"]', result="result")
function_result = Content.from_function_result(call_id='["run_123"]', result="result")
run_id, tool_outputs, tool_approvals = chat_client._prepare_tool_outputs_for_azure_ai([function_result]) # type: ignore
@@ -894,7 +1002,7 @@ async def test_azure_ai_chat_client_convert_required_action_serde_model_results(
# Test with BaseModel result
mock_result = MockResult(name="test", value=42)
function_result = FunctionResultContent(call_id='["run_123", "call_456"]', result=mock_result)
function_result = Content.from_function_result(call_id='["run_123", "call_456"]', result=mock_result)
run_id, tool_outputs, tool_approvals = chat_client._prepare_tool_outputs_for_azure_ai([function_result]) # type: ignore
@@ -922,7 +1030,7 @@ async def test_azure_ai_chat_client_convert_required_action_multiple_results(
# Test with multiple results - mix of BaseModel and regular objects
mock_basemodel = MockResult(data="model_data")
results_list = [mock_basemodel, {"key": "value"}, "string_result"]
function_result = FunctionResultContent(call_id='["run_123", "call_456"]', result=results_list)
function_result = Content.from_function_result(call_id='["run_123", "call_456"]', result=results_list)
run_id, tool_outputs, tool_approvals = chat_client._prepare_tool_outputs_for_azure_ai([function_result]) # type: ignore
@@ -948,9 +1056,11 @@ async def test_azure_ai_chat_client_convert_required_action_approval_response(
chat_client = create_test_azure_ai_chat_client(mock_agents_client, agent_id="test-agent")
# Test with approval response - need to provide required fields
approval_response = FunctionApprovalResponseContent(
approval_response = Content.from_function_approval_response(
id='["run_123", "call_456"]',
function_call=FunctionCallContent(call_id='["run_123", "call_456"]', name="test_function", arguments="{}"),
function_call=Content.from_function_call(
call_id='["run_123", "call_456"]', name="test_function", arguments="{}"
),
approved=True,
)
@@ -985,7 +1095,7 @@ async def test_azure_ai_chat_client_parse_function_calls_from_azure_ai_approval_
result = chat_client._parse_function_calls_from_azure_ai(mock_event_data, "response_123") # type: ignore
assert len(result) == 1
assert isinstance(result[0], FunctionApprovalRequestContent)
assert result[0].type == "function_approval_request"
assert result[0].id == '["response_123", "approval_call_123"]'
assert result[0].function_call.name == "approve_action"
assert result[0].function_call.call_id == '["response_123", "approval_call_123"]'
@@ -1064,7 +1174,7 @@ async def test_azure_ai_chat_client_create_agent_stream_submit_tool_outputs(
chat_client._get_active_thread_run = AsyncMock(return_value=mock_thread_run) # type: ignore
# Mock required action results with matching run ID
function_result = FunctionResultContent(call_id='["test-run-id", "test-call-id"]', result="test result")
function_result = Content.from_function_result(call_id='["test-run-id", "test-call-id"]', result="test result")
# Mock submit_tool_outputs_stream
mock_handler = MagicMock()
@@ -1115,14 +1225,13 @@ def test_azure_ai_chat_client_extract_url_citations_with_citations(mock_agents_c
# Verify results
assert len(citations) == 1
citation = citations[0]
assert isinstance(citation, CitationAnnotation)
assert citation.url == "https://example.com/test"
assert citation.title == "Test Title"
assert citation.snippet is None
assert citation.annotated_regions is not None
assert len(citation.annotated_regions) == 1
assert citation.annotated_regions[0].start_index == 10
assert citation.annotated_regions[0].end_index == 20
assert citation["url"] == "https://example.com/test"
assert citation["title"] == "Test Title"
assert citation["snippet"] is None
assert citation["annotated_regions"] is not None
assert len(citation["annotated_regions"]) == 1
assert citation["annotated_regions"][0]["start_index"] == 10
assert citation["annotated_regions"][0]["end_index"] == 20
def test_azure_ai_chat_client_extract_file_path_contents_with_file_path_annotation(
@@ -1158,7 +1267,7 @@ def test_azure_ai_chat_client_extract_file_path_contents_with_file_path_annotati
# Verify results
assert len(file_contents) == 1
assert isinstance(file_contents[0], HostedFileContent)
assert file_contents[0].type == "hosted_file"
assert file_contents[0].file_id == "assistant-test-file-123"
@@ -1195,7 +1304,7 @@ def test_azure_ai_chat_client_extract_file_path_contents_with_file_citation_anno
# Verify results
assert len(file_contents) == 1
assert isinstance(file_contents[0], HostedFileContent)
assert file_contents[0].type == "hosted_file"
assert file_contents[0].file_id == "cfile_test-citation-456"
@@ -1305,7 +1414,7 @@ async def test_azure_ai_chat_client_streaming() -> None:
assert chunk is not None
assert isinstance(chunk, ChatResponseUpdate)
for content in chunk.contents:
if isinstance(content, TextContent) and content.text:
if content.type == "text" and content.text:
full_message += content.text
assert any(word in full_message.lower() for word in ["sunny", "25"])
@@ -1331,7 +1440,7 @@ async def test_azure_ai_chat_client_streaming_tools() -> None:
assert chunk is not None
assert isinstance(chunk, ChatResponseUpdate)
for content in chunk.contents:
if isinstance(content, TextContent) and content.text:
if content.type == "text" and content.text:
full_message += content.text
assert any(word in full_message.lower() for word in ["sunny", "25"])
@@ -1476,7 +1585,9 @@ async def test_azure_ai_chat_client_agent_file_search():
)
# 2. Create file search tool with uploaded resources
file_search_tool = HostedFileSearchTool(inputs=[HostedVectorStoreContent(vector_store_id=vector_store.id)])
file_search_tool = HostedFileSearchTool(
inputs=[Content.from_hosted_vector_store(vector_store_id=vector_store.id)]
)
async with ChatAgent(
chat_client=client,
@@ -1795,7 +1906,7 @@ def test_azure_ai_chat_client_extract_url_citations_with_azure_search_enhanced_u
# Verify real URL was used
assert len(citations) == 1
citation = citations[0]
assert citation.url == "https://real-example.com/doc2" # doc_1 maps to index 1
assert citation["url"] == "https://real-example.com/doc2" # doc_1 maps to index 1
def test_azure_ai_chat_client_init_with_auto_created_agents_client(
@@ -16,14 +16,12 @@ from agent_framework import (
ChatMessage,
ChatOptions,
ChatResponse,
Content,
HostedCodeInterpreterTool,
HostedFileContent,
HostedFileSearchTool,
HostedMCPTool,
HostedVectorStoreContent,
HostedWebSearchTool,
Role,
TextContent,
)
from agent_framework.exceptions import ServiceInitializationError
from azure.ai.projects.aio import AIProjectClient
@@ -298,9 +296,9 @@ async def test_prepare_messages_for_azure_ai_with_system_messages(
client = create_test_azure_ai_client(mock_project_client)
messages = [
ChatMessage(role=Role.SYSTEM, contents=[TextContent(text="You are a helpful assistant.")]),
ChatMessage(role=Role.USER, contents=[TextContent(text="Hello")]),
ChatMessage(role=Role.ASSISTANT, contents=[TextContent(text="System response")]),
ChatMessage(role=Role.SYSTEM, contents=[Content.from_text(text="You are a helpful assistant.")]),
ChatMessage(role=Role.USER, contents=[Content.from_text(text="Hello")]),
ChatMessage(role=Role.ASSISTANT, contents=[Content.from_text(text="System response")]),
]
result_messages, instructions = client._prepare_messages_for_azure_ai(messages) # type: ignore
@@ -318,8 +316,8 @@ async def test_prepare_messages_for_azure_ai_no_system_messages(
client = create_test_azure_ai_client(mock_project_client)
messages = [
ChatMessage(role=Role.USER, contents=[TextContent(text="Hello")]),
ChatMessage(role=Role.ASSISTANT, contents=[TextContent(text="Hi there!")]),
ChatMessage(role=Role.USER, contents=[Content.from_text(text="Hello")]),
ChatMessage(role=Role.ASSISTANT, contents=[Content.from_text(text="Hi there!")]),
]
result_messages, instructions = client._prepare_messages_for_azure_ai(messages) # type: ignore
@@ -419,7 +417,7 @@ async def test_prepare_options_basic(mock_project_client: MagicMock) -> None:
"""Test prepare_options basic functionality."""
client = create_test_azure_ai_client(mock_project_client, agent_name="test-agent", agent_version="1.0")
messages = [ChatMessage(role=Role.USER, contents=[TextContent(text="Hello")])]
messages = [ChatMessage(role=Role.USER, contents=[Content.from_text(text="Hello")])]
with (
patch.object(client.__class__.__bases__[0], "_prepare_options", return_value={"model": "test-model"}),
@@ -453,7 +451,7 @@ async def test_prepare_options_with_application_endpoint(
agent_version="1",
)
messages = [ChatMessage(role=Role.USER, contents=[TextContent(text="Hello")])]
messages = [ChatMessage(role=Role.USER, contents=[Content.from_text(text="Hello")])]
with (
patch.object(client.__class__.__bases__[0], "_prepare_options", return_value={"model": "test-model"}),
@@ -492,7 +490,7 @@ async def test_prepare_options_with_application_project_client(
agent_version="1",
)
messages = [ChatMessage(role=Role.USER, contents=[TextContent(text="Hello")])]
messages = [ChatMessage(role=Role.USER, contents=[Content.from_text(text="Hello")])]
with (
patch.object(client.__class__.__bases__[0], "_prepare_options", return_value={"model": "test-model"}),
@@ -848,7 +846,7 @@ async def test_prepare_options_excludes_response_format(
"""Test that prepare_options excludes response_format, text, and text_format from final run options."""
client = create_test_azure_ai_client(mock_project_client, agent_name="test-agent", agent_version="1.0")
messages = [ChatMessage(role=Role.USER, contents=[TextContent(text="Hello")])]
messages = [ChatMessage(role=Role.USER, contents=[Content.from_text(text="Hello")])]
chat_options: ChatOptions = {}
with (
@@ -992,7 +990,7 @@ def test_from_azure_ai_tools() -> None:
tool_input = parsed_tools[0].inputs[0]
assert tool_input and isinstance(tool_input, HostedFileContent) and tool_input.file_id == "file-1"
assert tool_input and tool_input.type == "hosted_file" and tool_input.file_id == "file-1"
# Test File Search tool
fs_tool = FileSearchTool(vector_store_ids=["vs-1"], max_num_results=5)
@@ -1004,7 +1002,7 @@ def test_from_azure_ai_tools() -> None:
tool_input = parsed_tools[0].inputs[0]
assert tool_input and isinstance(tool_input, HostedVectorStoreContent) and tool_input.vector_store_id == "vs-1"
assert tool_input and tool_input.type == "hosted_vector_store" and tool_input.vector_store_id == "vs-1"
assert parsed_tools[0].max_results == 5
# Test Web Search tool