Python: OpenAI responses client (#239)

* Responses client WIP

* add responses class

* fix typing errors

* move test

* streaming responses, structured outputs

* tests

* Update python/packages/main/tests/openai/test_openai_responses_client.py

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* pr comments

* fix override import

* fix mypy

* add missing function override

* PR comments

* add docstrings

---------

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
This commit is contained in:
peterychang
2025-07-25 13:56:22 -04:00
committed by GitHub
Unverified
parent 233c557173
commit 3ee9dddfa2
7 changed files with 979 additions and 21 deletions
@@ -78,6 +78,7 @@ KNOWN_MEDIA_TYPES = [
__all__ = [
"AIContent",
"AIContents",
"AITool",
"AgentRunResponse",
"AgentRunResponseUpdate",
"ChatFinishReason",
@@ -3,4 +3,5 @@
from ._chat_client import * # noqa: F403
from ._exceptions import * # noqa: F403
from ._responses_client import * # noqa: F403
from ._shared import * # noqa: F403
@@ -4,7 +4,7 @@ import json
from collections.abc import AsyncIterable, Mapping, MutableSequence, Sequence
from datetime import datetime
from itertools import chain
from typing import Any, ClassVar, cast
from typing import Any, cast
from openai import AsyncOpenAI, AsyncStream
from openai.types import CompletionUsage
@@ -34,16 +34,16 @@ from ._shared import OpenAIConfigBase, OpenAIHandler, OpenAIModelTypes, OpenAISe
__all__ = ["OpenAIChatClient"]
# region OpenAIChatClientBase
# Implements agent_framework.ChatClient protocol, through ChatClientBase
@use_tool_calling
class OpenAIChatClientBase(OpenAIHandler, ChatClientBase):
"""OpenAI Chat completion class."""
MODEL_PROVIDER_NAME: ClassVar[str] = "openai"
SUPPORTS_FUNCTION_CALLING: ClassVar[bool] = True
# region Overriding base class methods
# most of the methods are overridden from the ChatCompletionClientBase class, otherwise it is mentioned
# most of the methods are overridden from the ChatClientBase class, otherwise it is mentioned
async def _inner_get_response(
self,
@@ -63,7 +63,6 @@ class OpenAIChatClientBase(OpenAIHandler, ChatClientBase):
self._create_chat_message_content(response, choice, response_metadata) for choice in response.choices
)
# @trace_streaming_chat_completion(MODEL_PROVIDER_NAME)
async def _inner_get_streaming_response(
self,
*,
@@ -79,6 +78,7 @@ class OpenAIChatClientBase(OpenAIHandler, ChatClientBase):
if not isinstance(response, AsyncStream):
raise ServiceInvalidResponseError("Expected an AsyncStream[ChatCompletionChunk] response.")
async for chunk in response:
assert isinstance(chunk, ChatCompletionChunk) # nosec # noqa: S101
if len(chunk.choices) == 0 and chunk.usage is None:
continue
@@ -269,6 +269,11 @@ class OpenAIChatClientBase(OpenAIHandler, ChatClientBase):
return content.model_dump(exclude_none=True)
# endregion
# region OpenAIChatClient
class OpenAIChatClient(OpenAIConfigBase, OpenAIChatClientBase):
"""OpenAI Chat completion class."""
@@ -301,21 +306,13 @@ class OpenAIChatClient(OpenAIConfigBase, OpenAIChatClientBase):
instruction_role (str | None): The role to use for 'instruction' messages, for example,
"""
try:
if api_key:
openai_settings = OpenAISettings(
api_key=SecretStr(api_key),
org_id=org_id,
chat_model_id=ai_model_id,
env_file_path=env_file_path,
env_file_encoding=env_file_encoding,
)
else:
openai_settings = OpenAISettings(
org_id=org_id,
chat_model_id=ai_model_id,
env_file_path=env_file_path,
env_file_encoding=env_file_encoding,
)
openai_settings = OpenAISettings(
api_key=SecretStr(api_key) if api_key else None,
org_id=org_id,
chat_model_id=ai_model_id,
env_file_path=env_file_path,
env_file_encoding=env_file_encoding,
)
except ValidationError as ex:
raise ServiceInitializationError("Failed to create OpenAI settings.", ex) from ex
@@ -345,3 +342,6 @@ class OpenAIChatClient(OpenAIConfigBase, OpenAIChatClientBase):
ai_model_id=settings["ai_model_id"],
default_headers=settings.get("default_headers"),
)
# endregion
@@ -0,0 +1,558 @@
# Copyright (c) Microsoft. All rights reserved.
import sys
from collections.abc import AsyncIterable, Callable, Mapping, MutableMapping, MutableSequence, Sequence
from datetime import datetime
from itertools import chain
from typing import Any, Literal
if sys.version_info >= (3, 12):
from typing import override # type: ignore
else:
from typing_extensions import override # type: ignore[import]
from openai import AsyncOpenAI, AsyncStream
from openai.types.responses.response import Response as OpenAIResponse
from openai.types.responses.response_completed_event import ResponseCompletedEvent
from openai.types.responses.response_content_part_added_event import ResponseContentPartAddedEvent
from openai.types.responses.response_function_tool_call import ResponseFunctionToolCall
from openai.types.responses.response_includable import ResponseIncludable
from openai.types.responses.response_output_item import ResponseOutputItem
from openai.types.responses.response_output_message import ResponseOutputMessage
from openai.types.responses.response_output_refusal import ResponseOutputRefusal
from openai.types.responses.response_output_text import ResponseOutputText
from openai.types.responses.response_stream_event import ResponseStreamEvent as OpenAIResponseStreamEvent
from openai.types.responses.response_text_delta_event import ResponseTextDeltaEvent
from openai.types.responses.response_usage import ResponseUsage
from pydantic import BaseModel, SecretStr, ValidationError
from .._clients import ChatClientBase, use_tool_calling
from .._types import (
AIContents,
AITool,
ChatMessage,
ChatOptions,
ChatResponse,
ChatResponseUpdate,
ChatRole,
ChatToolMode,
FunctionCallContent,
FunctionResultContent,
TextContent,
UsageDetails,
)
from ..exceptions import ServiceInitializationError, ServiceInvalidResponseError
from ._shared import OpenAIConfigBase, OpenAIHandler, OpenAIModelTypes, OpenAISettings
__all__ = ["OpenAIResponsesClient"]
# region ResponsesClient
@use_tool_calling
class OpenAIResponsesClient(OpenAIConfigBase, ChatClientBase, OpenAIHandler):
"""OpenAI Responses client class."""
def __init__(
self,
ai_model_id: str | None = None,
api_key: str | None = None,
org_id: str | None = None,
default_headers: Mapping[str, str] | None = None,
async_client: AsyncOpenAI | None = None,
env_file_path: str | None = None,
env_file_encoding: str | None = None,
instruction_role: str | None = None,
) -> None:
"""Initialize an OpenAIChatCompletion service.
Args:
ai_model_id (str): OpenAI model name, see
https://platform.openai.com/docs/models
api_key (str | None): The optional API key to use. If provided will override,
the env vars or .env file value.
org_id (str | None): The optional org ID to use. If provided will override,
the env vars or .env file value.
default_headers: The default headers mapping of string keys to
string values for HTTP requests. (Optional)
async_client (Optional[AsyncOpenAI]): An existing client to use. (Optional)
env_file_path (str | None): Use the environment settings file as a fallback
to environment variables. (Optional)
env_file_encoding (str | None): The encoding of the environment settings file. (Optional)
instruction_role (str | None): The role to use for 'instruction' messages, for example,
"""
try:
openai_settings = OpenAISettings(
api_key=SecretStr(api_key) if api_key else None,
org_id=org_id,
responses_model_id=ai_model_id,
env_file_path=env_file_path,
env_file_encoding=env_file_encoding,
)
except ValidationError as ex:
raise ServiceInitializationError("Failed to create OpenAI settings.", ex) from ex
if not async_client and not openai_settings.api_key:
raise ServiceInitializationError("The OpenAI API key is required.")
if not openai_settings.responses_model_id:
raise ServiceInitializationError("The OpenAI model ID is required.")
super().__init__(
ai_model_id=openai_settings.responses_model_id,
api_key=openai_settings.api_key.get_secret_value() if openai_settings.api_key else None,
org_id=openai_settings.org_id,
ai_model_type=OpenAIModelTypes.RESPONSE,
default_headers=default_headers,
client=async_client,
instruction_role=instruction_role,
)
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}
# The responses create call takes very different parameters than the chat completion call,
# so we override the get_response method to handle the specific parameters for responses.
@override
async def get_response(
self,
messages: str | ChatMessage | list[str] | list[ChatMessage],
*,
# TODO(peterychang): enable this option. background: bool | None = None,
include: list[ResponseIncludable] | None = None,
instruction: 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: AITool
| list[AITool]
| Callable[..., Any]
| list[Callable[..., Any]]
| MutableMapping[str, Any]
| list[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.
instruction: 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.
"""
filtered_options = self._filter_options(
background=False,
include=include,
instruction=instruction,
parallel_tool_calls=parallel_tool_calls,
previous_response_id=previous_response_id,
reasoning=reasoning,
service_tier=service_tier,
truncation=truncation,
timeout=timeout,
)
filtered_options.update(additional_properties or {})
chat_options = ChatOptions(
ai_model_id=model,
max_tokens=max_tokens,
response_format=response_format,
seed=seed,
store=store,
temperature=temperature,
top_p=top_p,
tool_choice=tool_choice,
tools=tools, # type: ignore
user=user,
additional_properties=filtered_options,
)
return await super().get_response(
messages=messages,
chat_options=chat_options,
**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,
instruction: 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: AITool
| list[AITool]
| Callable[..., Any]
| list[Callable[..., Any]]
| MutableMapping[str, Any]
| list[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.
instruction: 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.
"""
filtered_options = self._filter_options(
background=False,
include=include,
instruction=instruction,
parallel_tool_calls=parallel_tool_calls,
previous_response_id=previous_response_id,
reasoning=reasoning,
service_tier=service_tier,
truncation=truncation,
timeout=timeout,
)
filtered_options.update(additional_properties or {})
chat_options = ChatOptions(
ai_model_id=model,
max_tokens=max_tokens,
response_format=response_format,
seed=seed,
store=store,
temperature=temperature,
top_p=top_p,
tool_choice=tool_choice,
tools=tools, # type: ignore
user=user,
additional_properties=filtered_options,
)
async for update in super().get_streaming_response(
messages=messages,
chat_options=chat_options,
**kwargs,
):
yield update
def _chat_to_response_tool_spec(self, tools: list[AITool | MutableMapping[str, Any]]) -> list[dict[str, Any]]:
response_tools: list[dict[str, Any]] = []
for tool in tools:
if isinstance(tool, AITool):
# TODO(peterychang): Support AITools
continue
if "function" not in tool:
response_tools.append(tool if isinstance(tool, dict) else dict(tool))
parameters = {"additionalProperties": False}
parameters.update(tool.get("function", {}).get("parameters", {}))
response_tools.append({
"type": "function",
"name": tool.get("function", {}).get("name", ""),
"strict": True,
"description": tool.get("function", {}).get("description", None),
"parameters": parameters,
})
return response_tools
async def _inner_get_response(
self,
*,
messages: MutableSequence[ChatMessage],
chat_options: ChatOptions,
**kwargs: Any,
) -> ChatResponse:
chat_options.additional_properties["stream"] = False
if not chat_options.ai_model_id:
chat_options.ai_model_id = self.ai_model_id
if chat_options.tools:
chat_options.additional_properties.update({
"response_tools": self._chat_to_response_tool_spec(chat_options.tools)
})
response = await self._send_request(chat_options, messages=self._prepare_chat_history_for_request(messages))
assert isinstance(response, OpenAIResponse) # nosec # noqa: S101
return next(self._create_response_content(response, item) for item in response.output)
async def _inner_get_streaming_response(
self,
*,
messages: MutableSequence[ChatMessage],
chat_options: ChatOptions,
**kwargs: Any,
) -> AsyncIterable[ChatResponseUpdate]:
chat_options.additional_properties["stream"] = True
chat_options.ai_model_id = chat_options.ai_model_id or self.ai_model_id
if chat_options.tools:
chat_options.additional_properties.update({
"response_tools": self._chat_to_response_tool_spec(chat_options.tools)
})
response = await self._send_request(chat_options, messages=self._prepare_chat_history_for_request(messages))
if not isinstance(response, AsyncStream):
raise ServiceInvalidResponseError("Expected an AsyncStream[ResponseStreamEvent] response.")
async for chunk in response:
update = self._create_streaming_response_content(chunk) # type: ignore
if not update:
continue
yield update
def _create_response_content(self, response: OpenAIResponse, item: ResponseOutputItem) -> "ChatResponse":
"""Create a chat message content object from a choice."""
items: MutableSequence[ChatMessage] = []
metadata: dict[str, Any] = response.metadata or {}
# TODO(peterychang): Add support for other content types
if parsed_tool_calls := [tool for tool in self._get_tool_calls_from_response(response)]:
items.append(ChatMessage(role="assistant", contents=parsed_tool_calls))
if isinstance(item, ResponseOutputMessage):
for content in item.content:
# TODO(peterychang): Add annotations when available
if isinstance(content, ResponseOutputText):
items.append(ChatMessage(role=item.role, text=content.text))
metadata.update(self._get_metadata_from_response(content))
elif isinstance(content, ResponseOutputRefusal):
items.append(ChatMessage(role=item.role, text=content.refusal))
return ChatResponse(
response_id=response.id,
created_at=datetime.fromtimestamp(response.created_at).strftime("%Y-%m-%dT%H:%M:%S.%fZ"),
usage_details=self._usage_details_from_openai(response.usage) if response.usage else None,
messages=items,
model_id=self.ai_model_id,
additional_properties=metadata,
raw_representation=response,
)
def _create_streaming_response_content(
self,
event: OpenAIResponseStreamEvent,
) -> ChatResponseUpdate | None:
"""Create a streaming chat message content object from a choice."""
metadata: dict[str, Any] = {}
items: list[AIContents] = []
# TODO(peterychang): Add support for other content types
if isinstance(event, ResponseContentPartAddedEvent):
if isinstance(event.part, ResponseOutputText):
items.append(TextContent(text=event.part.text))
metadata.update(self._get_metadata_from_response(event.part))
elif isinstance(event.part, ResponseOutputRefusal):
items.append(TextContent(text=event.part.refusal))
elif isinstance(event, ResponseTextDeltaEvent):
items.append(TextContent(text=event.delta))
metadata.update(self._get_metadata_from_response(event))
elif isinstance(event, ResponseCompletedEvent):
# Tool calls are available in the completed event
if parsed_tool_calls := [tool for tool in self._get_tool_calls_from_response(event.response)]:
items.extend(parsed_tool_calls)
else:
return None
return ChatResponseUpdate(
contents=items,
role=ChatRole.ASSISTANT,
ai_model_id=self.ai_model_id,
additional_properties=metadata,
raw_representation=event,
)
def _get_tool_calls_from_response(self, response: OpenAIResponse) -> list[AIContents]:
resp: list[AIContents] = []
# TODO(peterychang): Support the other tool calls
for item in (i for i in response.output if isinstance(i, ResponseFunctionToolCall)):
fcc = FunctionCallContent(
call_id=item.id if item.id else "",
name=item.name,
arguments=item.arguments,
additional_properties={"call_id": item.call_id},
)
resp.append(fcc)
return resp
def _usage_details_from_openai(self, usage: ResponseUsage) -> UsageDetails | None:
return UsageDetails(
prompt_tokens=usage.input_tokens,
completion_tokens=usage.output_tokens,
total_tokens=usage.total_tokens,
)
def _openai_chat_message_parser(
self,
message: ChatMessage,
tool_id_to_call_id: dict[str, str],
) -> list[dict[str, Any]]:
"""Parse a chat message into the openai format."""
all_messages: list[dict[str, Any]] = []
args: dict[str, Any] = {
"role": message.role.value if isinstance(message.role, ChatRole) else message.role,
}
if message.additional_properties:
args["metadata"] = message.additional_properties
for content in message.contents:
match content:
case FunctionResultContent():
new_args: dict[str, Any] = {}
new_args.update(self._openai_content_parser(content, tool_id_to_call_id))
all_messages.append(new_args)
case FunctionCallContent():
function_call = self._openai_content_parser(content, tool_id_to_call_id)
all_messages.append(function_call) # type: ignore
case _:
if "content" not in args:
args["content"] = []
args["content"].append(self._openai_content_parser(content, tool_id_to_call_id)) # type: ignore
if "content" in args or "tool_calls" in args:
all_messages.append(args)
return all_messages
def _openai_content_parser(
self,
content: AIContents,
tool_id_to_call_id: dict[str, str],
) -> dict[str, Any]:
"""Parse contents into the openai format."""
match content:
case FunctionCallContent():
return {
"id": content.call_id,
"call_id": tool_id_to_call_id[content.call_id],
"type": "function_call",
"name": content.name,
"arguments": content.arguments,
}
case FunctionResultContent():
# call_id for the result needs to be the same as the call_id for the function call
return {
"call_id": tool_id_to_call_id[content.call_id],
"type": "function_call_output",
"output": content.result,
}
case TextContent():
return {
"type": "input_text",
"text": content.text,
}
# TODO(peterychang): We'll probably need to specialize the other content types as well
case _:
return content.model_dump(exclude_none=True)
def _prepare_chat_history_for_request(
self,
chat_messages: Sequence[ChatMessage],
role_key: str = "role",
content_key: str = "content",
) -> list[dict[str, Any]]:
"""Prepare the chat history for a request.
Allowing customization of the key names for role/author, and optionally overriding the role.
ChatRole.TOOL messages need to be formatted different than system/user/assistant messages:
They require a "tool_call_id" and (function) "name" key, and the "metadata" key should
be removed. The "encoding" key should also be removed.
Override this method to customize the formatting of the chat history for a request.
Args:
chat_messages: The chat history to prepare.
role_key: The key name for the role/author.
content_key: The key name for the content/message.
Returns:
prepared_chat_history (Any): The prepared chat history for a request.
"""
tool_id_to_call_id: dict[str, str] = {}
for message in chat_messages:
for content in message.contents:
if isinstance(content, FunctionCallContent):
assert content.additional_properties and "call_id" in content.additional_properties # nosec # noqa: S101
call_id = content.additional_properties["call_id"]
tool_id_to_call_id[content.call_id] = call_id
list_of_list = [self._openai_chat_message_parser(message, tool_id_to_call_id) for message in chat_messages]
# Flatten the list of lists into a single list
return list(chain.from_iterable(list_of_list))
def _get_metadata_from_response(self, output: Any) -> dict[str, Any]:
"""Get metadata from a chat choice."""
return {
"logprobs": getattr(output, "logprobs", None),
}
@classmethod
def from_dict(cls, settings: dict[str, Any]) -> "OpenAIResponsesClient":
"""Initialize an Open AI service from a dictionary of settings.
Args:
settings: A dictionary of settings for the service.
"""
return OpenAIResponsesClient(
ai_model_id=settings["ai_model_id"],
default_headers=settings.get("default_headers"),
api_key=settings.get("api_key"),
org_id=settings.get("org_id"),
)
# endregion
@@ -18,6 +18,8 @@ from openai.types import Completion
from openai.types.audio import Transcription
from openai.types.chat import ChatCompletion, ChatCompletionChunk
from openai.types.images_response import ImagesResponse
from openai.types.responses.response import Response
from openai.types.responses.response_stream_event import ResponseStreamEvent
from pydantic import BaseModel, ConfigDict, Field, SecretStr, validate_call
from pydantic.types import StringConstraints
@@ -38,6 +40,8 @@ RESPONSE_TYPE = Union[
AsyncStream[Completion],
list[Any],
ImagesResponse,
Response,
AsyncStream[ResponseStreamEvent],
Transcription,
_legacy_response.HttpxBinaryResponseContent,
]
@@ -138,6 +142,9 @@ class OpenAIHandler(AFBaseModel, ABC):
if self.ai_model_type == OpenAIModelTypes.TEXT_TO_SPEECH:
assert isinstance(options, TextToSpeechOptions) # nosec # noqa: S101
return await self._send_text_to_audio_request(options)
if self.ai_model_type == OpenAIModelTypes.RESPONSE:
assert isinstance(options, ChatOptions) # nosec # noqa: S101
return await self._send_response_request(options, messages)
raise NotImplementedError(f"Model type {self.ai_model_type} is not supported")
@@ -208,6 +215,46 @@ class OpenAIHandler(AFBaseModel, ABC):
ex,
) from ex
async def _send_response_request(
self,
chat_options: "ChatOptions",
messages: list[dict[str, Any]] | None = None,
) -> Response | AsyncStream[ResponseStreamEvent]:
try:
options_dict = chat_options.to_provider_settings()
if messages and "input" not in options_dict:
options_dict["input"] = messages
if "input" not in options_dict:
raise ServiceInvalidRequestError("Messages are required for chat completions")
if chat_options.tools is None:
options_dict.pop("parallel_tool_calls", None)
else:
options_dict["tools"] = options_dict["response_tools"]
options_dict.pop("response_tools", None)
if chat_options.response_format:
# create call does not support response_format, so we need to handle it via parse call
resp_format = options_dict.pop("response_format", None)
return await self.client.responses.parse(
**options_dict,
text_format=resp_format,
)
return await self.client.responses.create(**options_dict) # type: ignore
except BadRequestError as ex:
if ex.code == "content_filter":
raise OpenAIContentFilterException(
f"{type(self)} service encountered a content error",
ex,
) from ex
raise ServiceResponseException(
f"{type(self)} service failed to complete the prompt",
ex,
) from ex
except Exception as ex:
raise ServiceResponseException(
f"{type(self)} service failed to complete the prompt",
ex,
) from ex
def _handle_structured_outputs(self, chat_options: "ChatOptions", options_dict: dict[str, Any]) -> None:
if (
chat_options.response_format
@@ -0,0 +1,315 @@
# Copyright (c) Microsoft. All rights reserved.
import os
from typing import Annotated
import pytest
from pydantic import BaseModel
from agent_framework import ChatClient, ChatMessage, ChatResponse, ChatResponseUpdate, TextContent, ai_function
from agent_framework.exceptions import ServiceInitializationError, ServiceResponseException
from agent_framework.openai import OpenAIResponsesClient
skip_if_openai_integration_tests_disabled = pytest.mark.skipif(
os.getenv("RUN_INTEGRATION_TESTS", "false").lower() != "true"
or os.getenv("OPENAI_API_KEY", "") in ("", "test-dummy-key"),
reason="No real OPENAI_API_KEY provided; skipping integration tests."
if os.getenv("RUN_INTEGRATION_TESTS", "false").lower() == "true"
else "Integration tests are disabled.",
)
class OutputStruct(BaseModel):
"""A structured output for testing purposes."""
location: str
weather: str
@ai_function
async def get_weather(location: Annotated[str, "The location as a city name"]) -> str:
"""Get the current weather in a given location."""
# Implementation of the tool to get weather
return f"The current weather in {location} is sunny."
def test_init(openai_unit_test_env: dict[str, str]) -> None:
# Test successful initialization
openai_responses_client = OpenAIResponsesClient()
assert openai_responses_client.ai_model_id == openai_unit_test_env["OPENAI_RESPONSES_MODEL_ID"]
assert isinstance(openai_responses_client, ChatClient)
def test_init_validation_fail() -> None:
# Test successful initialization
with pytest.raises(ServiceInitializationError):
OpenAIResponsesClient(api_key="34523", ai_model_id={"test": "dict"}) # type: ignore
def test_init_ai_model_id_constructor(openai_unit_test_env: dict[str, str]) -> None:
# Test successful initialization
ai_model_id = "test_model_id"
openai_responses_client = OpenAIResponsesClient(ai_model_id=ai_model_id)
assert openai_responses_client.ai_model_id == ai_model_id
assert isinstance(openai_responses_client, ChatClient)
def test_init_with_default_header(openai_unit_test_env: dict[str, str]) -> None:
default_headers = {"X-Unit-Test": "test-guid"}
# Test successful initialization
openai_responses_client = OpenAIResponsesClient(
default_headers=default_headers,
)
assert openai_responses_client.ai_model_id == openai_unit_test_env["OPENAI_RESPONSES_MODEL_ID"]
assert isinstance(openai_responses_client, ChatClient)
# Assert that the default header we added is present in the client's default headers
for key, value in default_headers.items():
assert key in openai_responses_client.client.default_headers
assert openai_responses_client.client.default_headers[key] == value
@pytest.mark.parametrize("exclude_list", [["OPENAI_RESPONSES_MODEL_ID"]], indirect=True)
def test_init_with_empty_model_id(openai_unit_test_env: dict[str, str]) -> None:
with pytest.raises(ServiceInitializationError):
OpenAIResponsesClient(
env_file_path="test.env",
)
@pytest.mark.parametrize("exclude_list", [["OPENAI_API_KEY"]], indirect=True)
def test_init_with_empty_api_key(openai_unit_test_env: dict[str, str]) -> None:
ai_model_id = "test_model_id"
with pytest.raises(ServiceInitializationError):
OpenAIResponsesClient(
ai_model_id=ai_model_id,
env_file_path="test.env",
)
def test_serialize(openai_unit_test_env: dict[str, str]) -> None:
default_headers = {"X-Unit-Test": "test-guid"}
settings = {
"ai_model_id": openai_unit_test_env["OPENAI_RESPONSES_MODEL_ID"],
"api_key": openai_unit_test_env["OPENAI_API_KEY"],
"default_headers": default_headers,
}
openai_responses_client = OpenAIResponsesClient.from_dict(settings)
dumped_settings = openai_responses_client.to_dict()
assert dumped_settings["ai_model_id"] == openai_unit_test_env["OPENAI_RESPONSES_MODEL_ID"]
assert dumped_settings["api_key"] == openai_unit_test_env["OPENAI_API_KEY"]
# Assert that the default header we added is present in the dumped_settings default headers
for key, value in default_headers.items():
assert key in dumped_settings["default_headers"]
assert dumped_settings["default_headers"][key] == value
# Assert that the 'User-Agent' header is not present in the dumped_settings default headers
assert "User-Agent" not in dumped_settings["default_headers"]
def test_serialize_with_org_id(openai_unit_test_env: dict[str, str]) -> None:
settings = {
"ai_model_id": openai_unit_test_env["OPENAI_RESPONSES_MODEL_ID"],
"api_key": openai_unit_test_env["OPENAI_API_KEY"],
"org_id": openai_unit_test_env["OPENAI_ORG_ID"],
}
openai_responses_client = OpenAIResponsesClient.from_dict(settings)
dumped_settings = openai_responses_client.to_dict()
assert dumped_settings["ai_model_id"] == openai_unit_test_env["OPENAI_RESPONSES_MODEL_ID"]
assert dumped_settings["api_key"] == openai_unit_test_env["OPENAI_API_KEY"]
assert dumped_settings["org_id"] == openai_unit_test_env["OPENAI_ORG_ID"]
# Assert that the 'User-Agent' header is not present in the dumped_settings default headers
assert "User-Agent" not in dumped_settings["default_headers"]
@skip_if_openai_integration_tests_disabled
async def test_openai_responses_client_response() -> None:
"""Test OpenAI chat completion responses."""
openai_responses_client = OpenAIResponsesClient(ai_model_id="gpt-4.1-mini")
assert isinstance(openai_responses_client, ChatClient)
messages: list[ChatMessage] = []
messages.append(
ChatMessage(
role="user",
text="Emily and David, two passionate scientists, met during a research expedition to Antarctica. "
"Bonded by their love for the natural world and shared curiosity, they uncovered a "
"groundbreaking phenomenon in glaciology that could potentially reshape our understanding "
"of climate change.",
)
)
messages.append(ChatMessage(role="user", text="who are Emily and David?"))
# Test that the client can be used to get a response
response = await openai_responses_client.get_response(messages=messages)
assert response is not None
assert isinstance(response, ChatResponse)
assert "scientists" in response.text
messages.clear()
messages.append(ChatMessage(role="user", text="The weather in New York is sunny"))
messages.append(ChatMessage(role="user", text="What is the weather in New York?"))
# Test that the client can be used to get a response
response = await openai_responses_client.get_response(
messages=messages,
response_format=OutputStruct,
)
assert response is not None
assert isinstance(response, ChatResponse)
output = OutputStruct.model_validate_json(response.text)
assert output.location == "New York"
assert "sunny" in output.weather
@skip_if_openai_integration_tests_disabled
async def test_openai_responses_client_response_tools() -> None:
"""Test OpenAI chat completion responses."""
openai_responses_client = OpenAIResponsesClient(ai_model_id="gpt-4o-mini")
assert isinstance(openai_responses_client, ChatClient)
messages: list[ChatMessage] = []
messages.append(ChatMessage(role="user", text="What is the weather in New York?"))
# Test that the client can be used to get a response
response = await openai_responses_client.get_response(
messages=messages,
tools=[get_weather],
tool_choice="auto",
)
assert response is not None
assert isinstance(response, ChatResponse)
assert "sunny" in response.text
messages.clear()
messages.append(ChatMessage(role="user", text="What is the weather in Seattle?"))
# Test that the client can be used to get a response
response = await openai_responses_client.get_response(
messages=messages,
tools=[get_weather],
tool_choice="auto",
response_format=OutputStruct,
)
assert response is not None
assert isinstance(response, ChatResponse)
output = OutputStruct.model_validate_json(response.text)
assert "Seattle" in output.location
assert "sunny" in output.weather
@skip_if_openai_integration_tests_disabled
async def test_openai_responses_client_streaming() -> None:
"""Test Azure OpenAI chat completion responses."""
openai_responses_client = OpenAIResponsesClient(ai_model_id="gpt-4.1-mini")
assert isinstance(openai_responses_client, ChatClient)
messages: list[ChatMessage] = []
messages.append(
ChatMessage(
role="user",
text="Emily and David, two passionate scientists, met during a research expedition to Antarctica. "
"Bonded by their love for the natural world and shared curiosity, they uncovered a "
"groundbreaking phenomenon in glaciology that could potentially reshape our understanding "
"of climate change.",
)
)
messages.append(ChatMessage(role="user", text="who are Emily and David?"))
# Test that the client can be used to get a response
response = openai_responses_client.get_streaming_response(messages=messages)
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
assert "scientists" in full_message
messages.clear()
messages.append(ChatMessage(role="user", text="The weather in Seattle is sunny"))
messages.append(ChatMessage(role="user", text="What is the weather in Seattle?"))
# This is currently broken. See https://github.com/openai/openai-python/issues/2305
with pytest.raises(ServiceResponseException):
response = openai_responses_client.get_streaming_response(
messages=messages,
response_format=OutputStruct,
)
full_message = ""
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
output = OutputStruct.model_validate_json(full_message)
assert "Seattle" in output.location
assert "sunny" in output.weather
@skip_if_openai_integration_tests_disabled
async def test_openai_responses_client_streaming_tools() -> None:
"""Test OpenAI chat completion responses."""
openai_responses_client = OpenAIResponsesClient(ai_model_id="gpt-4o-mini")
assert isinstance(openai_responses_client, ChatClient)
messages: list[ChatMessage] = [ChatMessage(role="user", text="What is the weather in Seattle?")]
# Test that the client can be used to get a response
response = openai_responses_client.get_streaming_response(
messages=messages,
tools=[get_weather],
tool_choice="auto",
)
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
assert "sunny" in full_message
messages.clear()
messages.append(ChatMessage(role="user", text="What is the weather in Seattle?"))
# This is currently broken. See https://github.com/openai/openai-python/issues/2305
with pytest.raises(ServiceResponseException):
response = openai_responses_client.get_streaming_response(
messages=messages,
tools=[get_weather],
tool_choice="auto",
response_format=OutputStruct,
)
full_message = ""
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
output = OutputStruct.model_validate_json(full_message)
assert "Seattle" in output.location
assert "sunny" in output.weather
@@ -0,0 +1,36 @@
# Copyright (c) Microsoft. All rights reserved.
import asyncio
from random import randint
from typing import Annotated
from agent_framework.openai import OpenAIResponsesClient
from pydantic import Field
def get_weather(
location: Annotated[str, Field(description="The location to get the weather for.")],
) -> str:
"""Get the weather for a given location."""
conditions = ["sunny", "cloudy", "rainy", "stormy"]
return f"The weather in {location} is {conditions[randint(0, 3)]} with a high of {randint(10, 30)}°C."
async def main() -> None:
client = OpenAIResponsesClient(ai_model_id="gpt-4o-mini")
message = "What's the weather in Amsterdam and in Paris?"
stream = False
print(f"User: {message}")
if stream:
print("Assistant: ", end="")
async for chunk in client.get_streaming_response(message, tools=get_weather):
if str(chunk):
print(str(chunk), end="")
print("")
else:
response = await client.get_response(message, tools=get_weather)
print(f"Assistant: {response}")
if __name__ == "__main__":
asyncio.run(main())