diff --git a/python/packages/main/agent_framework/_types.py b/python/packages/main/agent_framework/_types.py index d689b908de..84fc7c7b59 100644 --- a/python/packages/main/agent_framework/_types.py +++ b/python/packages/main/agent_framework/_types.py @@ -78,6 +78,7 @@ KNOWN_MEDIA_TYPES = [ __all__ = [ "AIContent", "AIContents", + "AITool", "AgentRunResponse", "AgentRunResponseUpdate", "ChatFinishReason", diff --git a/python/packages/main/agent_framework/openai/__init__.py b/python/packages/main/agent_framework/openai/__init__.py index e3be59dcc3..1b1a251d17 100644 --- a/python/packages/main/agent_framework/openai/__init__.py +++ b/python/packages/main/agent_framework/openai/__init__.py @@ -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 diff --git a/python/packages/main/agent_framework/openai/_chat_client.py b/python/packages/main/agent_framework/openai/_chat_client.py index 836c61c8d4..5385f4fa33 100644 --- a/python/packages/main/agent_framework/openai/_chat_client.py +++ b/python/packages/main/agent_framework/openai/_chat_client.py @@ -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 diff --git a/python/packages/main/agent_framework/openai/_responses_client.py b/python/packages/main/agent_framework/openai/_responses_client.py new file mode 100644 index 0000000000..92c27e8790 --- /dev/null +++ b/python/packages/main/agent_framework/openai/_responses_client.py @@ -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 diff --git a/python/packages/main/agent_framework/openai/_shared.py b/python/packages/main/agent_framework/openai/_shared.py index f841dfb6a1..86d4a9d2f4 100644 --- a/python/packages/main/agent_framework/openai/_shared.py +++ b/python/packages/main/agent_framework/openai/_shared.py @@ -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 diff --git a/python/packages/main/tests/openai/test_openai_responses_client.py b/python/packages/main/tests/openai/test_openai_responses_client.py new file mode 100644 index 0000000000..8a083140e1 --- /dev/null +++ b/python/packages/main/tests/openai/test_openai_responses_client.py @@ -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 diff --git a/python/samples/getting_started/chat_client/openai_responses_client.py b/python/samples/getting_started/chat_client/openai_responses_client.py new file mode 100644 index 0000000000..cd8bc231c4 --- /dev/null +++ b/python/samples/getting_started/chat_client/openai_responses_client.py @@ -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())