OpenAI Responses Agent Completeness

This commit is contained in:
Giles Odigwe
2025-09-11 10:46:31 -07:00
Unverified
parent 3c0926b670
commit a69a2fe592
4 changed files with 2316 additions and 2284 deletions
@@ -14,6 +14,9 @@ from agent_framework import (
ChatResponse,
ChatResponseUpdate,
HostedCodeInterpreterTool,
HostedFileSearchTool,
HostedMCPTool,
HostedVectorStoreContent,
TextContent,
ai_function,
)
@@ -45,6 +48,29 @@ async def get_weather(location: Annotated[str, "The location as a city name"]) -
return f"The weather in {location} is sunny and 72°F."
async def create_vector_store(client: AzureResponsesClient) -> tuple[str, HostedVectorStoreContent]:
"""Create a vector store with sample documents for testing."""
file = await client.client.files.create(
file=("todays_weather.txt", b"The weather today is sunny with a high of 75F."), purpose="assistants"
)
vector_store = await client.client.vector_stores.create(
name="knowledge_base",
expires_after={"anchor": "last_active_at", "days": 1},
)
result = await client.client.vector_stores.files.create_and_poll(vector_store_id=vector_store.id, file_id=file.id)
if result.last_error is not None:
raise Exception(f"Vector store file processing failed with status: {result.last_error.message}")
return file.id, HostedVectorStoreContent(vector_store_id=vector_store.id)
async def delete_vector_store(client: AzureResponsesClient, file_id: str, vector_store_id: str) -> None:
"""Delete the vector store after tests."""
await client.client.vector_stores.delete(vector_store_id=vector_store_id)
await client.client.files.delete(file_id=file_id)
def test_init(azure_openai_unit_test_env: dict[str, str]) -> None:
# Test successful initialization
azure_responses_client = AzureResponsesClient()
@@ -459,3 +485,138 @@ async def test_azure_responses_client_agent_level_tool_persistence():
assert second_response.text is not None
# Should use the agent-level weather tool again
assert any(term in second_response.text.lower() for term in ["miami", "sunny", "72"])
@skip_if_azure_integration_tests_disabled
async def test_azure_responses_client_agent_chat_options_run_level() -> None:
"""Integration test for comprehensive ChatOptions parameter coverage with Azure Response Agent."""
async with ChatAgent(
chat_client=AzureResponsesClient(),
instructions="You are a helpful assistant.",
) as agent:
response = await agent.run(
"Provide a brief, helpful response.",
max_tokens=100,
temperature=0.7,
top_p=0.9,
seed=123,
user="comprehensive-test-user",
tools=[get_weather],
tool_choice="auto",
)
assert isinstance(response, AgentRunResponse)
assert response.text is not None
assert len(response.text) > 0
@skip_if_azure_integration_tests_disabled
async def test_azure_responses_client_agent_chat_options_agent_level() -> None:
"""Integration test for comprehensive ChatOptions parameter coverage with Azure Response Agent."""
async with ChatAgent(
chat_client=AzureResponsesClient(),
instructions="You are a helpful assistant.",
max_tokens=100,
temperature=0.7,
top_p=0.9,
seed=123,
user="comprehensive-test-user",
tools=[get_weather],
tool_choice="auto",
) as agent:
response = await agent.run(
"Provide a brief, helpful response.",
)
assert isinstance(response, AgentRunResponse)
assert response.text is not None
assert len(response.text) > 0
@skip_if_azure_integration_tests_disabled
async def test_azure_responses_client_agent_hosted_mcp_tool() -> None:
"""Integration test for HostedMCPTool with Azure Response Agent using Microsoft Learn MCP."""
# Use the same MCP server as the Foundry example
mcp_tool = HostedMCPTool(
name="Microsoft Learn MCP",
url="https://learn.microsoft.com/api/mcp",
description="A Microsoft Learn MCP server for documentation questions",
approval_mode="never_require",
)
async with ChatAgent(
chat_client=AzureResponsesClient(),
instructions="You are a helpful assistant that can help with microsoft documentation questions.",
tools=[mcp_tool],
) as agent:
# Use the same query as the Foundry example
response = await agent.run(
"How to create an Azure storage account using az cli?",
max_tokens=200,
)
assert isinstance(response, AgentRunResponse)
assert response.text is not None
assert len(response.text) > 0
# Should contain Azure-related content since it's asking about Azure CLI
assert any(term in response.text.lower() for term in ["azure", "storage", "account", "cli"])
@skip_if_azure_integration_tests_disabled
async def test_azure_responses_client_file_search() -> None:
"""Test Azure responses client with file search tool."""
azure_responses_client = AzureResponsesClient()
assert isinstance(azure_responses_client, ChatClientProtocol)
file_id, vector_store = await create_vector_store(azure_responses_client)
# Test that the client will use the web search tool
response = await azure_responses_client.get_response(
messages=[
ChatMessage(
role="user",
text="What is the weather today? Do a file search to find the answer.",
)
],
tools=[HostedFileSearchTool(inputs=vector_store)],
tool_choice="auto",
)
await delete_vector_store(azure_responses_client, file_id, vector_store.vector_store_id)
assert "sunny" in response.text.lower()
assert "75" in response.text
@skip_if_azure_integration_tests_disabled
async def test_azure_responses_client_file_search_streaming() -> None:
"""Test Azure responses client with file search tool and streaming."""
azure_responses_client = AzureResponsesClient()
assert isinstance(azure_responses_client, ChatClientProtocol)
file_id, vector_store = await create_vector_store(azure_responses_client)
# Test that the client will use the web search tool
response = azure_responses_client.get_streaming_response(
messages=[
ChatMessage(
role="user",
text="What is the weather today? Do a file search to find the answer.",
)
],
tools=[HostedFileSearchTool(inputs=vector_store)],
tool_choice="auto",
)
assert response is not None
full_message: str = ""
async for chunk in response:
assert chunk is not None
assert isinstance(chunk, ChatResponseUpdate)
for content in chunk.contents:
if isinstance(content, TextContent) and content.text:
full_message += content.text
await delete_vector_store(azure_responses_client, file_id, vector_store.vector_store_id)
assert "sunny" in full_message.lower()
assert "75" in full_message
@@ -1,10 +1,10 @@
# Copyright (c) Microsoft. All rights reserved.
import sys
from collections.abc import AsyncIterable, Callable, Mapping, MutableMapping, MutableSequence, Sequence
from collections.abc import AsyncIterable, Mapping, MutableMapping, MutableSequence, Sequence
from datetime import datetime
from itertools import chain
from typing import TYPE_CHECKING, Any, Literal, TypeVar
from typing import TYPE_CHECKING, Any, TypeVar
from openai import AsyncOpenAI, BadRequestError
from openai.types.responses.file_search_tool_param import FileSearchToolParam
@@ -68,14 +68,12 @@ from ._exceptions import OpenAIContentFilterException
from ._shared import OpenAIBase, OpenAIConfigMixin, OpenAISettings, prepare_function_call_results
if sys.version_info >= (3, 12):
from typing import override # type: ignore # pragma: no cover
pass # type: ignore # pragma: no cover
else:
from typing_extensions import override # type: ignore[import] # pragma: no cover
pass # type: ignore[import] # pragma: no cover
if TYPE_CHECKING:
from openai.types.responses.response_includable import ResponseIncludable
from .._types import ChatToolMode
pass
logger = get_logger("agent_framework.openai")
@@ -90,192 +88,6 @@ class OpenAIBaseResponsesClient(OpenAIBase, BaseChatClient):
FILE_SEARCH_MAX_RESULTS: int = 50
def _filter_options(self, **kwargs: Any) -> dict[str, Any]:
"""Filter options for the responses call."""
# The responses call does not support all the options that the chat completion call does.
# We filter out the unsupported options.
return {key: value for key, value in kwargs.items() if value is not None}
@override
async def get_response(
self,
messages: str | ChatMessage | list[str] | list[ChatMessage],
*,
include: list["ResponseIncludable"] | None = None,
instructions: str | None = None,
max_tokens: int | None = None,
parallel_tool_calls: bool | None = None,
model: str | None = None,
previous_response_id: str | None = None,
reasoning: dict[str, str] | None = None,
service_tier: str | None = None,
response_format: type[BaseModel] | None = None,
seed: int | None = None,
store: bool | None = None,
temperature: float | None = None,
tool_choice: "ChatToolMode" | Literal["auto", "required", "none"] | dict[str, Any] | None = "auto",
tools: ToolProtocol
| Callable[..., Any]
| MutableMapping[str, Any]
| list[ToolProtocol | Callable[..., Any] | MutableMapping[str, Any]]
| None = None,
top_p: float | None = None,
user: str | None = None,
truncation: str | None = None,
timeout: float | None = None,
additional_properties: dict[str, Any] | None = None,
**kwargs: Any,
) -> ChatResponse:
"""Get a response from the OpenAI API.
Args:
messages: the message or messages to send to the model
include: additional output data to include in the model response.
instructions: a system (or developer) message inserted into the model's context.
max_tokens: The maximum number of tokens to generate.
parallel_tool_calls: Whether to enable parallel tool calls.
model: The model to use for the agent.
previous_response_id: The ID of the previous response.
reasoning: The reasoning to use for the response.
service_tier: The service tier to use for the response.
response_format: The format of the response.
seed: The random seed to use for the response.
store: whether to store the response.
temperature: the sampling temperature to use.
tool_choice: the tool choice for the request.
tools: the tools to use for the request.
top_p: the nucleus sampling probability to use.
user: the user to associate with the request.
truncation: the truncation strategy to use.
timeout: the timeout for the request.
additional_properties: additional properties to include in the request.
kwargs: any additional keyword arguments,
will only be passed to functions that are called.
Returns:
A chat response from the model.
"""
additional_properties = additional_properties or {}
additional_properties.update(
self._filter_options(
include=include,
instructions=instructions,
parallel_tool_calls=parallel_tool_calls,
model=model,
previous_response_id=previous_response_id,
reasoning=reasoning,
service_tier=service_tier,
truncation=truncation,
timeout=timeout,
)
)
return await super().get_response(
messages=messages,
max_tokens=max_tokens,
response_format=response_format,
seed=seed,
store=store,
temperature=temperature,
tool_choice=tool_choice,
tools=tools, # type: ignore
top_p=top_p,
user=user,
additional_properties=additional_properties,
**kwargs,
)
@override
async def get_streaming_response(
self,
messages: str | ChatMessage | list[str] | list[ChatMessage],
*,
# TODO(peterychang): enable this option. background: bool | None = None,
include: list["ResponseIncludable"] | None = None,
instructions: str | None = None,
max_tokens: int | None = None,
parallel_tool_calls: bool | None = None,
model: str | None = None,
previous_response_id: str | None = None,
reasoning: dict[str, str] | None = None,
service_tier: str | None = None,
response_format: type[BaseModel] | None = None,
seed: int | None = None,
store: bool | None = None,
temperature: float | None = None,
tool_choice: "ChatToolMode" | Literal["auto", "required", "none"] | dict[str, Any] | None = "auto",
tools: ToolProtocol
| Callable[..., Any]
| MutableMapping[str, Any]
| list[ToolProtocol | Callable[..., Any] | MutableMapping[str, Any]]
| None = None,
top_p: float | None = None,
user: str | None = None,
truncation: str | None = None,
timeout: float | None = None,
additional_properties: dict[str, Any] | None = None,
**kwargs: Any,
) -> AsyncIterable[ChatResponseUpdate]:
"""Get a streaming response from the OpenAI API.
Args:
messages: the message or messages to send to the model
include: additional output data to include in the model response.
instructions: a system (or developer) message inserted into the model's context.
max_tokens: The maximum number of tokens to generate.
parallel_tool_calls: Whether to enable parallel tool calls.
model: The model to use for the agent.
previous_response_id: The ID of the previous response.
reasoning: The reasoning to use for the response.
service_tier: The service tier to use for the response.
response_format: The format of the response.
seed: The random seed to use for the response.
store: whether to store the response.
temperature: the sampling temperature to use.
tool_choice: the tool choice for the request.
tools: the tools to use for the request.
top_p: the nucleus sampling probability to use.
user: the user to associate with the request.
truncation: the truncation strategy to use.
timeout: the timeout for the request.
additional_properties: additional properties to include in the request.
kwargs: any additional keyword arguments,
will only be passed to functions that are called.
Returns:
A stream representing the response(s) from the LLM.
"""
additional_properties = additional_properties or {}
additional_properties.update(
self._filter_options(
include=include,
instructions=instructions,
parallel_tool_calls=parallel_tool_calls,
model=model,
previous_response_id=previous_response_id,
reasoning=reasoning,
service_tier=service_tier,
truncation=truncation,
timeout=timeout,
)
)
async for update in super().get_streaming_response(
messages=messages,
max_tokens=max_tokens,
response_format=response_format,
seed=seed,
store=store,
temperature=temperature,
tool_choice=tool_choice,
tools=tools, # type: ignore
top_p=top_p,
user=user,
additional_properties=additional_properties,
**kwargs,
):
yield update
# region Inner Methods
async def _inner_get_response(
@@ -474,18 +286,33 @@ class OpenAIBaseResponsesClient(OpenAIBase, BaseChatClient):
return response_tools
def _prepare_options(self, messages: MutableSequence[ChatMessage], chat_options: ChatOptions) -> dict[str, Any]:
"""Take ChatOptions and create the specific options for Responses."""
options_dict = chat_options.to_provider_settings(exclude={"response_format"})
"""Take ChatOptions and create the specific options for Responses API."""
options_dict: dict[str, Any] = {}
if chat_options.max_tokens is not None:
options_dict["max_output_tokens"] = chat_options.max_tokens
if chat_options.temperature is not None:
options_dict["temperature"] = chat_options.temperature
if chat_options.top_p is not None:
options_dict["top_p"] = chat_options.top_p
if chat_options.user is not None:
options_dict["user"] = chat_options.user
# messages
request_input = self._prepare_chat_messages_for_request(messages)
if not request_input:
raise ServiceInvalidRequestError("Messages are required for chat completions")
options_dict["input"] = request_input
# tools
if chat_options.tools is None:
options_dict.pop("parallel_tool_calls", None)
else:
options_dict["tools"] = self._tools_to_response_tools(chat_options.tools)
# other settings
if "store" not in options_dict:
options_dict["store"] = False
@@ -181,37 +181,6 @@ def test_serialize_with_org_id(openai_unit_test_env: dict[str, str]) -> None:
assert "User-Agent" not in dumped_settings["default_headers"]
def test_filter_options_method(openai_unit_test_env: dict[str, str]) -> None:
"""Test that the _filter_options method filters out None values correctly."""
client = OpenAIResponsesClient()
# Test with a mix of None and non-None values
filtered = client._filter_options( # type: ignore
include=["usage"],
instructions="Test instruction",
max_tokens=None,
temperature=0.7,
seed=None,
model="test-model",
store=True,
top_p=None,
)
# Should only contain non-None values
expected = {
"include": ["usage"],
"instructions": "Test instruction",
"temperature": 0.7,
"model": "test-model",
"store": True,
}
assert filtered == expected
assert "max_tokens" not in filtered
assert "seed" not in filtered
assert "top_p" not in filtered
def test_get_response_with_invalid_input() -> None:
"""Test get_response with invalid inputs to trigger exception handling."""
@@ -972,7 +941,7 @@ async def test_openai_responses_client_response_tools() -> None:
@skip_if_openai_integration_tests_disabled
async def test_openai_responses_client_streaming() -> None:
"""Test Azure OpenAI chat completion responses."""
"""Test OpenAI chat completion responses."""
openai_responses_client = OpenAIResponsesClient()
assert isinstance(openai_responses_client, ChatClientProtocol)
@@ -1156,7 +1125,6 @@ async def test_openai_responses_client_web_search_streaming() -> None:
@skip_if_openai_integration_tests_disabled
@pytest.mark.skip(reason="OpenAI file search functionality is currently broken - tracked in GitHub issue")
async def test_openai_responses_client_file_search() -> None:
openai_responses_client = OpenAIResponsesClient()
@@ -1181,7 +1149,6 @@ async def test_openai_responses_client_file_search() -> None:
@skip_if_openai_integration_tests_disabled
@pytest.mark.skip(reason="OpenAI file search functionality is currently broken - tracked in GitHub issue")
async def test_openai_responses_client_streaming_file_search() -> None:
openai_responses_client = OpenAIResponsesClient()
@@ -1422,6 +1389,81 @@ async def test_openai_responses_client_run_level_tool_isolation():
assert call_count == 1
@skip_if_openai_integration_tests_disabled
async def test_openai_responses_client_agent_chat_options_run_level() -> None:
"""Integration test for comprehensive ChatOptions parameter coverage with OpenAI Response Agent."""
async with ChatAgent(
chat_client=OpenAIResponsesClient(),
instructions="You are a helpful assistant.",
) as agent:
response = await agent.run(
"Provide a brief, helpful response.",
max_tokens=100,
temperature=0.7,
top_p=0.9,
seed=123,
user="comprehensive-test-user",
tools=[get_weather],
tool_choice="auto",
)
assert isinstance(response, AgentRunResponse)
assert response.text is not None
assert len(response.text) > 0
@skip_if_openai_integration_tests_disabled
async def test_openai_responses_client_agent_chat_options_agent_level() -> None:
"""Integration test for comprehensive ChatOptions parameter coverage with OpenAI Response Agent."""
async with ChatAgent(
chat_client=OpenAIResponsesClient(),
instructions="You are a helpful assistant.",
max_tokens=100,
temperature=0.7,
top_p=0.9,
seed=123,
user="comprehensive-test-user",
tools=[get_weather],
tool_choice="auto",
) as agent:
response = await agent.run(
"Provide a brief, helpful response.",
)
assert isinstance(response, AgentRunResponse)
assert response.text is not None
assert len(response.text) > 0
@skip_if_openai_integration_tests_disabled
async def test_openai_responses_client_agent_hosted_mcp_tool() -> None:
"""Integration test for HostedMCPTool with OpenAI Response Agent using Microsoft Learn MCP."""
# Use the same MCP server as the Foundry example
mcp_tool = HostedMCPTool(
name="Microsoft Learn MCP",
url="https://learn.microsoft.com/api/mcp",
description="A Microsoft Learn MCP server for documentation questions",
approval_mode="never_require",
)
async with ChatAgent(
chat_client=OpenAIResponsesClient(),
instructions="You are a helpful assistant that can help with microsoft documentation questions.",
tools=[mcp_tool],
) as agent:
# Use the same query as the Foundry example
response = await agent.run(
"How to create an Azure storage account using az cli?",
max_tokens=200,
)
assert isinstance(response, AgentRunResponse)
assert response.text is not None
assert len(response.text) > 0
# Should contain Azure-related content since it's asking about Azure CLI
assert any(term in response.text.lower() for term in ["azure", "storage", "account", "cli"])
def test_service_response_exception_includes_original_error_details() -> None:
"""Test that ServiceResponseException messages include original error details in the new format."""
client = OpenAIResponsesClient(ai_model_id="test-model", api_key="test-key")