Python: [BREAKING] Replace Hosted*Tool classes with tool methods (#3634)

* Replace Hosted*Tool classes with client static factory methods

* fixed failing test

* mypy fix

* mypy fix 2

* declarative mypy fix

* addressed comments

* ToolProtocol removal

* fixed test

* agents mypy fix

* fix failing tests

* mypy fix

* addressed comments

* fixed tests

* addressed comments + added factory method overrides for azureai v2 client

* mypy fix

* added kwargs to azureai tool methods

* fixed in test

* _sessions fix

* test fix
This commit is contained in:
Giles Odigwe
2026-02-11 00:04:27 +00:00
committed by GitHub
parent d249473a6d
commit 7a88af0aef
133 changed files with 3018 additions and 2650 deletions
@@ -8,9 +8,9 @@ import pytest
from openai.types.beta.assistant import Assistant
from pydantic import BaseModel, Field
from agent_framework import Agent, HostedCodeInterpreterTool, HostedFileSearchTool, normalize_tools, tool
from agent_framework import Agent, normalize_tools, tool
from agent_framework.exceptions import ServiceInitializationError
from agent_framework.openai import OpenAIAssistantProvider
from agent_framework.openai import OpenAIAssistantProvider, OpenAIAssistantsClient
from agent_framework.openai._shared import from_assistant_tools, to_assistant_tools
# region Test Helpers
@@ -269,7 +269,7 @@ class TestOpenAIAssistantProviderCreateAgent:
await provider.create_agent(
name="CodeAgent",
model="gpt-4",
tools=[HostedCodeInterpreterTool()],
tools=[OpenAIAssistantsClient.get_code_interpreter_tool()],
)
call_kwargs = mock_async_openai.beta.assistants.create.call_args.kwargs
@@ -282,7 +282,7 @@ class TestOpenAIAssistantProviderCreateAgent:
await provider.create_agent(
name="SearchAgent",
model="gpt-4",
tools=[HostedFileSearchTool()],
tools=[OpenAIAssistantsClient.get_file_search_tool()],
)
call_kwargs = mock_async_openai.beta.assistants.create.call_args.kwargs
@@ -295,7 +295,7 @@ class TestOpenAIAssistantProviderCreateAgent:
await provider.create_agent(
name="SearchAgent",
model="gpt-4",
tools=[HostedFileSearchTool(max_results=10)],
tools=[OpenAIAssistantsClient.get_file_search_tool(max_num_results=10)],
)
call_kwargs = mock_async_openai.beta.assistants.create.call_args.kwargs
@@ -309,7 +309,11 @@ class TestOpenAIAssistantProviderCreateAgent:
await provider.create_agent(
name="MultiToolAgent",
model="gpt-4",
tools=[get_weather, HostedCodeInterpreterTool(), HostedFileSearchTool()],
tools=[
get_weather,
OpenAIAssistantsClient.get_code_interpreter_tool(),
OpenAIAssistantsClient.get_file_search_tool(),
],
)
call_kwargs = mock_async_openai.beta.assistants.create.call_args.kwargs
@@ -564,22 +568,22 @@ class TestToolConversion:
assert api_tools[0]["function"]["name"] == "get_weather"
def test_to_assistant_tools_code_interpreter(self) -> None:
"""Test HostedCodeInterpreterTool conversion."""
api_tools = to_assistant_tools([HostedCodeInterpreterTool()])
"""Test code_interpreter tool dict conversion."""
api_tools = to_assistant_tools([OpenAIAssistantsClient.get_code_interpreter_tool()])
assert len(api_tools) == 1
assert api_tools[0] == {"type": "code_interpreter"}
def test_to_assistant_tools_file_search(self) -> None:
"""Test HostedFileSearchTool conversion."""
api_tools = to_assistant_tools([HostedFileSearchTool()])
"""Test file_search tool dict conversion."""
api_tools = to_assistant_tools([OpenAIAssistantsClient.get_file_search_tool()])
assert len(api_tools) == 1
assert api_tools[0]["type"] == "file_search"
def test_to_assistant_tools_file_search_with_max_results(self) -> None:
"""Test HostedFileSearchTool with max_results conversion."""
api_tools = to_assistant_tools([HostedFileSearchTool(max_results=5)])
"""Test file_search tool with max_results conversion."""
api_tools = to_assistant_tools([OpenAIAssistantsClient.get_file_search_tool(max_num_results=5)])
assert api_tools[0]["file_search"]["max_num_results"] == 5
@@ -605,7 +609,7 @@ class TestToolConversion:
tools = from_assistant_tools(assistant_tools)
assert len(tools) == 1
assert isinstance(tools[0], HostedCodeInterpreterTool)
assert tools[0] == {"type": "code_interpreter"}
def test_from_assistant_tools_file_search(self) -> None:
"""Test converting file_search tool from OpenAI format."""
@@ -614,7 +618,7 @@ class TestToolConversion:
tools = from_assistant_tools(assistant_tools)
assert len(tools) == 1
assert isinstance(tools[0], HostedFileSearchTool)
assert tools[0] == {"type": "file_search"}
def test_from_assistant_tools_function_skipped(self) -> None:
"""Test that function tools are skipped (no implementations)."""
@@ -707,7 +711,7 @@ class TestToolMerging:
merged = provider._merge_tools(assistant_tools, None) # type: ignore[reportPrivateUsage]
assert len(merged) == 1
assert isinstance(merged[0], HostedCodeInterpreterTool)
assert merged[0] == {"type": "code_interpreter"}
def test_merge_file_search(self, mock_async_openai: MagicMock) -> None:
"""Test merging file search tool."""
@@ -717,7 +721,7 @@ class TestToolMerging:
merged = provider._merge_tools(assistant_tools, None) # type: ignore[reportPrivateUsage]
assert len(merged) == 1
assert isinstance(merged[0], HostedFileSearchTool)
assert merged[0] == {"type": "file_search"}
def test_merge_with_user_tools(self, mock_async_openai: MagicMock) -> None:
"""Test merging hosted and user tools."""
@@ -727,7 +731,7 @@ class TestToolMerging:
merged = provider._merge_tools(assistant_tools, [get_weather]) # type: ignore[reportPrivateUsage]
assert len(merged) == 2
assert isinstance(merged[0], HostedCodeInterpreterTool)
assert merged[0] == {"type": "code_interpreter"}
def test_merge_multiple_hosted_tools(self, mock_async_openai: MagicMock) -> None:
"""Test merging multiple hosted tools."""
@@ -18,8 +18,6 @@ from agent_framework import (
ChatResponse,
ChatResponseUpdate,
Content,
HostedCodeInterpreterTool,
HostedFileSearchTool,
Message,
SupportsChatGetResponse,
tool,
@@ -736,11 +734,11 @@ def test_prepare_options_with_tool_tool(mock_async_openai: MagicMock) -> None:
def test_prepare_options_with_code_interpreter(mock_async_openai: MagicMock) -> None:
"""Test _prepare_options with HostedCodeInterpreterTool."""
"""Test _prepare_options with code interpreter tool."""
client = create_test_openai_assistants_client(mock_async_openai)
# Create a real HostedCodeInterpreterTool
code_tool = HostedCodeInterpreterTool()
# Create a code interpreter tool dict
code_tool = OpenAIAssistantsClient.get_code_interpreter_tool()
options = {
"tools": [code_tool],
@@ -831,12 +829,12 @@ def test_prepare_options_required_function(mock_async_openai: MagicMock) -> None
def test_prepare_options_with_file_search_tool(mock_async_openai: MagicMock) -> None:
"""Test _prepare_options with HostedFileSearchTool."""
"""Test _prepare_options with file_search tool."""
client = create_test_openai_assistants_client(mock_async_openai)
# Create a HostedFileSearchTool with max_results
file_search_tool = HostedFileSearchTool(max_results=10)
# Create a file_search tool with max_results
file_search_tool = OpenAIAssistantsClient.get_file_search_tool(max_num_results=10)
options = {
"tools": [file_search_tool],
@@ -851,7 +849,7 @@ def test_prepare_options_with_file_search_tool(mock_async_openai: MagicMock) ->
# Check file search tool was set correctly
assert "tools" in run_options
assert len(run_options["tools"]) == 1
expected_tool = {"type": "file_search", "max_num_results": 10}
expected_tool = {"type": "file_search", "file_search": {"max_num_results": 10}}
assert run_options["tools"][0] == expected_tool
assert run_options["tool_choice"] == "auto"
@@ -1182,7 +1180,7 @@ async def test_file_search() -> None:
response = await openai_assistants_client.get_response(
messages=messages,
options={
"tools": [HostedFileSearchTool()],
"tools": [OpenAIAssistantsClient.get_file_search_tool()],
"tool_resources": {"file_search": {"vector_store_ids": [vector_store.vector_store_id]}},
},
)
@@ -1209,7 +1207,7 @@ async def test_file_search_streaming() -> None:
stream=True,
messages=messages,
options={
"tools": [HostedFileSearchTool()],
"tools": [OpenAIAssistantsClient.get_file_search_tool()],
"tool_resources": {"file_search": {"vector_store_ids": [vector_store.vector_store_id]}},
},
)
@@ -1346,7 +1344,7 @@ async def test_openai_assistants_agent_code_interpreter():
async with Agent(
client=OpenAIAssistantsClient(model_id=INTEGRATION_TEST_MODEL),
instructions="You are a helpful assistant that can write and execute Python code.",
tools=[HostedCodeInterpreterTool()],
tools=[OpenAIAssistantsClient.get_code_interpreter_tool()],
) as agent:
# Request code execution
response = await agent.run("Write Python code to calculate the factorial of 5 and show the result.")
@@ -15,10 +15,8 @@ from pytest import param
from agent_framework import (
ChatResponse,
Content,
HostedWebSearchTool,
Message,
SupportsChatGetResponse,
ToolProtocol,
prepare_function_call_results,
tool,
)
@@ -172,18 +170,22 @@ async def test_content_filter_exception_handling(openai_unit_test_env: dict[str,
def test_unsupported_tool_handling(openai_unit_test_env: dict[str, str]) -> None:
"""Test that unsupported tool types are handled correctly."""
"""Test that unsupported tool types are passed through unchanged."""
client = OpenAIChatClient()
# Create a mock ToolProtocol that's not a FunctionTool
unsupported_tool = MagicMock(spec=ToolProtocol)
unsupported_tool.__class__.__name__ = "UnsupportedAITool"
# Create a random object that's not a FunctionTool, dict, or callable
# This simulates an unsupported tool type that gets passed through
class UnsupportedTool:
pass
# This should ignore the unsupported ToolProtocol and return empty list
unsupported_tool = UnsupportedTool()
# Unsupported tools are passed through for the API to handle/reject
result = client._prepare_tools_for_openai([unsupported_tool]) # type: ignore
assert result == {}
assert "tools" in result
assert len(result["tools"]) == 1
# Also test with a non-ToolProtocol that should be converted to dict
# Also test with a dict-based tool that should be passed through
dict_tool = {"type": "function", "name": "test"}
result = client._prepare_tools_for_openai([dict_tool]) # type: ignore
assert result["tools"] == [dict_tool]
@@ -770,8 +772,8 @@ def test_prepare_tools_with_web_search_no_location(openai_unit_test_env: dict[st
"""Test preparing web search tool without user location."""
client = OpenAIChatClient()
# Web search tool without additional_properties
web_search_tool = HostedWebSearchTool()
# Web search tool using static method
web_search_tool = OpenAIChatClient.get_web_search_tool()
result = client._prepare_tools_for_openai([web_search_tool])
@@ -1071,11 +1073,13 @@ async def test_integration_web_search() -> None:
client = OpenAIChatClient(model_id="gpt-4o-search-preview")
for streaming in [False, True]:
# Use static method for web search tool
web_search_tool = OpenAIChatClient.get_web_search_tool()
content = {
"messages": "Who are the main characters of Kpop Demon Hunters? Do a web search to find the answer.",
"options": {
"tool_choice": "auto",
"tools": [HostedWebSearchTool()],
"tools": [web_search_tool],
},
}
if streaming:
@@ -1090,17 +1094,19 @@ async def test_integration_web_search() -> None:
assert "Zoey" in response.text
# Test that the client will use the web search tool with location
additional_properties = {
"user_location": {
"country": "US",
"city": "Seattle",
web_search_tool_with_location = OpenAIChatClient.get_web_search_tool(
web_search_options={
"user_location": {
"type": "approximate",
"approximate": {"country": "US", "city": "Seattle"},
},
}
}
)
content = {
"messages": "What is the current weather? Do not ask for my current location.",
"options": {
"tool_choice": "auto",
"tools": [HostedWebSearchTool(additional_properties=additional_properties)],
"tools": [web_search_tool_with_location],
},
}
if streaming:
@@ -31,11 +31,6 @@ from agent_framework import (
ChatResponse,
ChatResponseUpdate,
Content,
HostedCodeInterpreterTool,
HostedFileSearchTool,
HostedImageGenerationTool,
HostedMCPTool,
HostedWebSearchTool,
Message,
SupportsChatGetResponse,
tool,
@@ -236,19 +231,18 @@ async def test_get_response_with_all_parameters() -> None:
)
@pytest.mark.asyncio
async def test_web_search_tool_with_location() -> None:
"""Test HostedWebSearchTool with location parameters."""
"""Test web search tool with location parameters."""
client = OpenAIResponsesClient(model_id="test-model", api_key="test-key")
# Test web search tool with location
web_search_tool = HostedWebSearchTool(
additional_properties={
"user_location": {
"country": "US",
"city": "Seattle",
"region": "WA",
"timezone": "America/Los_Angeles",
}
# Test web search tool with location using static method
web_search_tool = OpenAIResponsesClient.get_web_search_tool(
user_location={
"city": "Seattle",
"country": "US",
"region": "WA",
"timezone": "America/Los_Angeles",
}
)
@@ -260,38 +254,21 @@ async def test_web_search_tool_with_location() -> None:
)
async def test_file_search_tool_with_invalid_inputs() -> None:
"""Test HostedFileSearchTool with invalid vector store inputs."""
client = OpenAIResponsesClient(model_id="test-model", api_key="test-key")
# Test with invalid inputs type (should trigger ValueError)
file_search_tool = HostedFileSearchTool(inputs=[Content.from_hosted_file(file_id="invalid")])
# Should raise an error due to invalid inputs
with pytest.raises(ValueError, match="HostedFileSearchTool requires inputs to be of type"):
await client.get_response(
messages=[Message(role="user", text="Search files")],
options={"tools": [file_search_tool]},
)
async def test_code_interpreter_tool_variations() -> None:
"""Test HostedCodeInterpreterTool with and without file inputs."""
client = OpenAIResponsesClient(model_id="test-model", api_key="test-key")
# Test code interpreter without files
code_tool_empty = HostedCodeInterpreterTool()
# Test code interpreter using static method
code_tool = OpenAIResponsesClient.get_code_interpreter_tool()
with pytest.raises(ServiceResponseException):
await client.get_response(
messages=[Message(role="user", text="Run some code")],
options={"tools": [code_tool_empty]},
messages=[Message("user", ["Run some code"])],
options={"tools": [code_tool]},
)
# Test code interpreter with files
code_tool_with_files = HostedCodeInterpreterTool(
inputs=[Content.from_hosted_file(file_id="file1"), Content.from_hosted_file(file_id="file2")]
)
# Test code interpreter with files using static method
code_tool_with_files = OpenAIResponsesClient.get_code_interpreter_tool(file_ids=["file1", "file2"])
with pytest.raises(ServiceResponseException):
await client.get_response(
@@ -319,18 +296,20 @@ async def test_content_filter_exception() -> None:
assert "content error" in str(exc_info.value)
@pytest.mark.asyncio
async def test_hosted_file_search_tool_validation() -> None:
"""Test get_response HostedFileSearchTool validation."""
client = OpenAIResponsesClient(model_id="test-model", api_key="test-key")
# Test HostedFileSearchTool without inputs (should raise ValueError)
empty_file_search_tool = HostedFileSearchTool()
# Test file search tool with vector store IDs
file_search_tool = OpenAIResponsesClient.get_file_search_tool(vector_store_ids=["vs_123"])
with pytest.raises((ValueError, ServiceInvalidRequestError)):
# Test using file search tool - may raise various exceptions depending on API response
with pytest.raises((ValueError, ServiceInvalidRequestError, ServiceResponseException)):
await client.get_response(
messages=[Message(role="user", text="Test")],
options={"tools": [empty_file_search_tool]},
messages=[Message("user", ["Test"])],
options={"tools": [file_search_tool]},
)
@@ -1074,18 +1053,17 @@ def test_streaming_chunk_with_usage_only() -> None:
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."""
def test_prepare_tools_for_openai_with_mcp() -> None:
"""Test that MCP tool dict is converted to the correct response tool dict."""
client = OpenAIResponsesClient(model_id="test-model", api_key="test-key")
tool = HostedMCPTool(
name="My MCP",
# Use static method to create MCP tool
tool = OpenAIResponsesClient.get_mcp_tool(
name="My_MCP",
url="https://mcp.example",
description="An MCP server",
approval_mode={"always_require_approval": ["tool_a", "tool_b"]},
allowed_tools={"tool_a", "tool_b"},
allowed_tools=["tool_a", "tool_b"],
headers={"X-Test": "yes"},
additional_properties={"custom": "value"},
approval_mode={"always_require_approval": ["tool_a", "tool_b"]},
)
resp_tools = client._prepare_tools_for_openai([tool])
@@ -1097,7 +1075,6 @@ def test_prepare_tools_for_openai_with_hosted_mcp() -> None:
assert mcp["server_label"] == "My_MCP"
# server_url may be normalized to include a trailing slash by the client
assert str(mcp["server_url"]).rstrip("/") == "https://mcp.example"
assert mcp["server_description"] == "An MCP server"
assert mcp["headers"]["X-Test"] == "yes"
assert set(mcp["allowed_tools"]) == {"tool_a", "tool_b"}
# approval mapping created from approval_mode dict
@@ -1258,13 +1235,15 @@ def test_prepare_tools_for_openai_with_raw_image_generation_minimal() -> None:
assert len(image_tool) == 1
def test_prepare_tools_for_openai_with_hosted_image_generation() -> None:
"""Test HostedImageGenerationTool conversion."""
def test_prepare_tools_for_openai_with_image_generation_options() -> None:
"""Test image generation tool conversion with options."""
client = OpenAIResponsesClient(model_id="test-model", api_key="test-key")
tool = HostedImageGenerationTool(
description="Generate images",
options={"output_format": "png", "size": "512x512"},
additional_properties={"quality": "high"},
# Use static method to create image generation tool
tool = OpenAIResponsesClient.get_image_generation_tool(
output_format="png",
size="512x512",
quality="high",
)
resp_tools = client._prepare_tools_for_openai([tool])
@@ -2324,11 +2303,13 @@ async def test_integration_web_search() -> None:
client = OpenAIResponsesClient(model_id="gpt-5")
for streaming in [False, True]:
# Use static method for web search tool
web_search_tool = OpenAIResponsesClient.get_web_search_tool()
content = {
"messages": "Who are the main characters of Kpop Demon Hunters? Do a web search to find the answer.",
"options": {
"tool_choice": "auto",
"tools": [HostedWebSearchTool()],
"tools": [web_search_tool],
},
}
if streaming:
@@ -2343,17 +2324,14 @@ async def test_integration_web_search() -> None:
assert "Zoey" in response.text
# Test that the client will use the web search tool with location
additional_properties = {
"user_location": {
"country": "US",
"city": "Seattle",
}
}
web_search_tool_with_location = OpenAIResponsesClient.get_web_search_tool(
user_location={"country": "US", "city": "Seattle"},
)
content = {
"messages": "What is the current weather? Do not ask for my current location.",
"options": {
"tool_choice": "auto",
"tools": [HostedWebSearchTool(additional_properties=additional_properties)],
"tools": [web_search_tool_with_location],
},
}
if streaming:
@@ -2375,7 +2353,9 @@ async def test_integration_file_search() -> None:
assert isinstance(openai_responses_client, SupportsChatGetResponse)
file_id, vector_store = await create_vector_store(openai_responses_client)
# Test that the client will use the web search tool
# Use static method for file search tool
file_search_tool = OpenAIResponsesClient.get_file_search_tool(vector_store_ids=[vector_store.vector_store_id])
# Test that the client will use the file search tool
response = await openai_responses_client.get_response(
messages=[
Message(
@@ -2385,7 +2365,7 @@ async def test_integration_file_search() -> None:
],
options={
"tool_choice": "auto",
"tools": [HostedFileSearchTool(inputs=vector_store)],
"tools": [file_search_tool],
},
)
@@ -2406,9 +2386,10 @@ async def test_integration_streaming_file_search() -> None:
assert isinstance(openai_responses_client, SupportsChatGetResponse)
file_id, vector_store = await create_vector_store(openai_responses_client)
# Use static method for file search tool
file_search_tool = OpenAIResponsesClient.get_file_search_tool(vector_store_ids=[vector_store.vector_store_id])
# Test that the client will use the web search tool
response = openai_responses_client.get_response(
stream=True,
response = openai_responses_client.get_streaming_response(
messages=[
Message(
role="user",
@@ -2417,7 +2398,7 @@ async def test_integration_streaming_file_search() -> None:
],
options={
"tool_choice": "auto",
"tools": [HostedFileSearchTool(inputs=vector_store)],
"tools": [file_search_tool],
},
)