From f43939d803f59595bd0e9926ef1218b8041c513b Mon Sep 17 00:00:00 2001 From: peterychang <49209570+peterychang@users.noreply.github.com> Date: Wed, 6 Aug 2025 10:18:38 -0400 Subject: [PATCH] Python: Azure Responses client (#311) * Azure Responses client * Fix a change made in the wrong place * allow api_version and token_endpoint to use env vars * Add getting started sample * add responses deployment name env var * update azure clients to use defaults for api_version and token_endpoint * make tests more reliable --------- Co-authored-by: Chris <66376200+crickman@users.noreply.github.com> --- .github/workflows/python-merge-tests.yml | 1 + .../azure/agent_framework_azure/__init__.py | 2 + .../_assistants_client.py | 6 +- .../agent_framework_azure/_chat_client.py | 8 +- .../_responses_client.py | 131 ++++++++ .../azure/agent_framework_azure/_shared.py | 29 +- python/packages/azure/tests/conftest.py | 1 + .../tests/test_azure_responses_client.py | 287 ++++++++++++++++++ .../main/agent_framework/azure/__init__.py | 1 + .../main/agent_framework/azure/__init__.pyi | 2 + .../openai/_responses_client.py | 126 ++++---- .../chat_client/azure_responses_client.py | 36 +++ 12 files changed, 555 insertions(+), 75 deletions(-) create mode 100644 python/packages/azure/agent_framework_azure/_responses_client.py create mode 100644 python/packages/azure/tests/test_azure_responses_client.py create mode 100644 python/samples/getting_started/chat_client/azure_responses_client.py diff --git a/.github/workflows/python-merge-tests.yml b/.github/workflows/python-merge-tests.yml index 72bf84cab3..8568b19134 100644 --- a/.github/workflows/python-merge-tests.yml +++ b/.github/workflows/python-merge-tests.yml @@ -112,6 +112,7 @@ jobs: env: UV_PYTHON: ${{ matrix.python-version }} AZURE_OPENAI_CHAT_DEPLOYMENT_NAME: ${{ vars.AZUREOPENAI__CHATDEPLOYMENTNAME }} + AZURE_OPENAI_RESPONSES_DEPLOYMENT_NAME: ${{ vars.AZUREOPENAI__RESPONSESDEPLOYMENTNAME }} AZURE_OPENAI_ENDPOINT: ${{ secrets.AZUREOPENAI__ENDPOINT }} PACKAGE_NAME: "azure" defaults: diff --git a/python/packages/azure/agent_framework_azure/__init__.py b/python/packages/azure/agent_framework_azure/__init__.py index 2904204954..aaaa369379 100644 --- a/python/packages/azure/agent_framework_azure/__init__.py +++ b/python/packages/azure/agent_framework_azure/__init__.py @@ -5,6 +5,7 @@ import importlib.metadata from ._assistants_client import AzureAssistantsClient from ._chat_client import AzureChatClient from ._entra_id_authentication import get_entra_auth_token +from ._responses_client import AzureResponsesClient from ._shared import AzureOpenAISettings try: @@ -16,6 +17,7 @@ __all__ = [ "AzureAssistantsClient", "AzureChatClient", "AzureOpenAISettings", + "AzureResponsesClient", "__version__", "get_entra_auth_token", ] diff --git a/python/packages/azure/agent_framework_azure/_assistants_client.py b/python/packages/azure/agent_framework_azure/_assistants_client.py index 1e3d1f85f3..582b6e12da 100644 --- a/python/packages/azure/agent_framework_azure/_assistants_client.py +++ b/python/packages/azure/agent_framework_azure/_assistants_client.py @@ -10,7 +10,6 @@ from pydantic import SecretStr, ValidationError from pydantic.networks import AnyUrl from ._shared import ( - DEFAULT_AZURE_TOKEN_ENDPOINT, AzureOpenAISettings, ) @@ -75,10 +74,11 @@ class AzureAssistantsClient(OpenAIAssistantsClient): base_url=AnyUrl(base_url) if base_url else None, endpoint=AnyUrl(endpoint) if endpoint else None, chat_deployment_name=deployment_name, - api_version=api_version or self.DEFAULT_AZURE_API_VERSION, + api_version=api_version, env_file_path=env_file_path, env_file_encoding=env_file_encoding, - token_endpoint=token_endpoint or DEFAULT_AZURE_TOKEN_ENDPOINT, + token_endpoint=token_endpoint, + default_api_version=self.DEFAULT_AZURE_API_VERSION, ) except ValidationError as ex: raise ServiceInitializationError("Failed to create Azure OpenAI settings.", ex) from ex diff --git a/python/packages/azure/agent_framework_azure/_chat_client.py b/python/packages/azure/agent_framework_azure/_chat_client.py index 6093e34fbd..f9ea9a5a99 100644 --- a/python/packages/azure/agent_framework_azure/_chat_client.py +++ b/python/packages/azure/agent_framework_azure/_chat_client.py @@ -26,8 +26,6 @@ from pydantic import SecretStr, ValidationError from pydantic.networks import AnyUrl from ._shared import ( - DEFAULT_AZURE_API_VERSION, - DEFAULT_AZURE_TOKEN_ENDPOINT, AzureOpenAIConfigBase, AzureOpenAISettings, ) @@ -87,16 +85,18 @@ class AzureChatClient(AzureOpenAIConfigBase, OpenAIChatClientBase): base_url=AnyUrl(base_url) if base_url else None, endpoint=AnyUrl(endpoint) if endpoint else None, chat_deployment_name=deployment_name, - api_version=api_version or DEFAULT_AZURE_API_VERSION, + api_version=api_version, env_file_path=env_file_path, env_file_encoding=env_file_encoding, - token_endpoint=token_endpoint or DEFAULT_AZURE_TOKEN_ENDPOINT, + token_endpoint=token_endpoint, ) except ValidationError as exc: raise ServiceInitializationError(f"Failed to validate settings: {exc}") from exc if not azure_openai_settings.chat_deployment_name: raise ServiceInitializationError("chat_deployment_name is required.") + if not azure_openai_settings.api_version: + raise ServiceInitializationError("api_version is required.") super().__init__( deployment_name=azure_openai_settings.chat_deployment_name, diff --git a/python/packages/azure/agent_framework_azure/_responses_client.py b/python/packages/azure/agent_framework_azure/_responses_client.py new file mode 100644 index 0000000000..401ba1f7d6 --- /dev/null +++ b/python/packages/azure/agent_framework_azure/_responses_client.py @@ -0,0 +1,131 @@ +# Copyright (c) Microsoft. All rights reserved. + +from collections.abc import Mapping +from typing import Any, ClassVar +from urllib.parse import urljoin + +from agent_framework import use_tool_calling +from agent_framework.exceptions import ServiceInitializationError +from agent_framework.openai._responses_client import OpenAIResponsesClientBase +from agent_framework.openai._shared import OpenAIModelTypes +from agent_framework.telemetry import use_telemetry +from openai.lib.azure import AsyncAzureADTokenProvider, AsyncAzureOpenAI +from pydantic import SecretStr, ValidationError +from pydantic.networks import AnyUrl + +from ._shared import ( + AzureOpenAIConfigBase, + AzureOpenAISettings, +) + + +@use_telemetry +@use_tool_calling +class AzureResponsesClient(AzureOpenAIConfigBase, OpenAIResponsesClientBase): + """Azure Responses completion class.""" + + MODEL_PROVIDER_NAME: ClassVar[str] = "azure_openai" # type: ignore[reportIncompatibleVariableOverride, misc] + + def __init__( + self, + api_key: str | None = None, + deployment_name: str | None = None, + endpoint: str | None = None, + base_url: str | None = None, + api_version: str | None = None, + ad_token: str | None = None, + ad_token_provider: AsyncAzureADTokenProvider | None = None, + token_endpoint: str | None = None, + default_headers: Mapping[str, str] | None = None, + async_client: AsyncAzureOpenAI | None = None, + env_file_path: str | None = None, + env_file_encoding: str | None = None, + instruction_role: str | None = None, + ) -> None: + """Initialize an AzureResponses service. + + Args: + api_key: The optional api key. If provided, will override the value in the + env vars or .env file. + deployment_name: The optional deployment. If provided, will override the value + (responses_deployment_name) in the env vars or .env file. + endpoint: The optional deployment endpoint. If provided will override the value + in the env vars or .env file. + base_url: The optional deployment base_url. If provided will override the value + in the env vars or .env file. Currently, the base_url must end with "/openai/v1/" + api_version: The optional deployment api version. If provided will override the value + in the env vars or .env file. Currently, the api_version must be "preview". + ad_token: The Azure Active Directory token. (Optional) + ad_token_provider: The Azure Active Directory token provider. (Optional) + token_endpoint: The token endpoint to request an Azure token. (Optional) + default_headers: The default headers mapping of string keys to + string values for HTTP requests. (Optional) + async_client: An existing client to use. (Optional) + env_file_path: Use the environment settings file as a fallback to using env vars. + env_file_encoding: The encoding of the environment settings file, defaults to 'utf-8'. + instruction_role: The role to use for 'instruction' messages, for example, summarization + prompts could use `developer` or `system`. (Optional) + """ + try: + # Filter out any None values from the arguments + azure_openai_settings = AzureOpenAISettings( + api_key=SecretStr(api_key) if api_key else None, + base_url=AnyUrl(base_url) if base_url else None, + endpoint=AnyUrl(endpoint) if endpoint else None, + responses_deployment_name=deployment_name, + api_version=api_version, + env_file_path=env_file_path, + env_file_encoding=env_file_encoding, + token_endpoint=token_endpoint, + default_api_version="preview", + ) + # TODO(peterychang): This is a temporary hack to ensure that the base_url is set correctly + # while this feature is in preview. + # But we should only do this if we're on azure. Private deployments may not need this. + if ( + not azure_openai_settings.base_url + and azure_openai_settings.endpoint + and str(azure_openai_settings.endpoint).rstrip("/").endswith("openai.azure.com") + ): + azure_openai_settings.base_url = AnyUrl(urljoin(str(azure_openai_settings.endpoint), "/openai/v1/")) + except ValidationError as exc: + raise ServiceInitializationError(f"Failed to validate settings: {exc}") from exc + + if not azure_openai_settings.responses_deployment_name: + raise ServiceInitializationError("responses_deployment_name is required.") + if not azure_openai_settings.api_version: + raise ServiceInitializationError("api_version is required.") + + super().__init__( + deployment_name=azure_openai_settings.responses_deployment_name, + endpoint=azure_openai_settings.endpoint, + base_url=azure_openai_settings.base_url, + api_version=azure_openai_settings.api_version, + api_key=azure_openai_settings.api_key.get_secret_value() if azure_openai_settings.api_key else None, + ad_token=ad_token, + ad_token_provider=ad_token_provider, + token_endpoint=azure_openai_settings.token_endpoint, + default_headers=default_headers, + ai_model_type=OpenAIModelTypes.RESPONSE, + client=async_client, + instruction_role=instruction_role, + ) + + @classmethod + def from_dict(cls, settings: dict[str, Any]) -> "AzureResponsesClient": + """Initialize an Open AI service from a dictionary of settings. + + Args: + settings: A dictionary of settings for the service. + """ + return AzureResponsesClient( + api_key=settings.get("api_key"), + deployment_name=settings.get("deployment_name"), + endpoint=settings.get("endpoint"), + base_url=settings.get("base_url"), + api_version=settings.get("api_version"), + ad_token=settings.get("ad_token"), + ad_token_provider=settings.get("ad_token_provider"), + default_headers=settings.get("default_headers"), + env_file_path=settings.get("env_file_path"), + ) diff --git a/python/packages/azure/agent_framework_azure/_shared.py b/python/packages/azure/agent_framework_azure/_shared.py index 3bf526313f..e04a9a2404 100644 --- a/python/packages/azure/agent_framework_azure/_shared.py +++ b/python/packages/azure/agent_framework_azure/_shared.py @@ -1,7 +1,7 @@ # Copyright (c) Microsoft. All rights reserved. - import logging +import sys from collections.abc import Awaitable, Callable, Mapping from copy import copy from typing import Any, ClassVar, Final @@ -11,10 +11,15 @@ from agent_framework.exceptions import ServiceInitializationError from agent_framework.openai._shared import OpenAIHandler, OpenAIModelTypes from agent_framework.telemetry import USER_AGENT_KEY from openai.lib.azure import AsyncAzureOpenAI -from pydantic import ConfigDict, SecretStr, validate_call +from pydantic import ConfigDict, SecretStr, model_validator, validate_call from ._entra_id_authentication import get_entra_auth_token +if sys.version_info >= (3, 11): + from typing import Self # pragma: no cover +else: + from typing_extensions import Self # pragma: no cover + logger: logging.Logger = logging.getLogger(__name__) @@ -94,11 +99,15 @@ class AzureOpenAISettings(AFBaseSettings): your resource from the Azure portal, the endpoint should end in openai.azure.com. If both base_url and endpoint are supplied, base_url will be used. (Env var AZURE_OPENAI_ENDPOINT) - api_version: The API version to use. The default value is "2024-02-01". + api_version: The API version to use. The default value is `default_api_version`. (Env var AZURE_OPENAI_API_VERSION) token_endpoint: The token endpoint to use to retrieve the authentication token. - The default value is "https://cognitiveservices.azure.com/.default". + The default value is `default_token_endpoint`. (Env var AZURE_OPENAI_TOKEN_ENDPOINT) + default_api_version: The default API version to use if not specified. + The default value is "2024-10-21". + default_token_endpoint: The default token endpoint to use if not specified. + The default value is "https://cognitiveservices.azure.com/.default". Parameters: env_file_path: The path to the .env file to load settings from. @@ -118,8 +127,10 @@ class AzureOpenAISettings(AFBaseSettings): endpoint: HttpsUrl | None = None base_url: HttpsUrl | None = None api_key: SecretStr | None = None - api_version: str = DEFAULT_AZURE_API_VERSION - token_endpoint: str = DEFAULT_AZURE_TOKEN_ENDPOINT + api_version: str | None = None + token_endpoint: str | None = None + default_api_version: str = DEFAULT_AZURE_API_VERSION + default_token_endpoint: str = DEFAULT_AZURE_TOKEN_ENDPOINT def get_azure_openai_auth_token(self, token_endpoint: str | None = None) -> str | None: """Retrieve a Microsoft Entra Auth Token for a given token endpoint for the use with Azure OpenAI. @@ -143,6 +154,12 @@ class AzureOpenAISettings(AFBaseSettings): raise ServiceInitializationError("Please provide a token endpoint to retrieve the authentication token.") return get_entra_auth_token(endpoint_to_use) + @model_validator(mode="after") + def _validate_fields(self) -> Self: + self.api_version = self.api_version or self.default_api_version + self.token_endpoint = self.token_endpoint or self.default_token_endpoint + return self + class AzureOpenAIConfigBase(OpenAIHandler): """Internal class for configuring a connection to an Azure OpenAI service.""" diff --git a/python/packages/azure/tests/conftest.py b/python/packages/azure/tests/conftest.py index f15d0217cb..28349f15df 100644 --- a/python/packages/azure/tests/conftest.py +++ b/python/packages/azure/tests/conftest.py @@ -32,6 +32,7 @@ def azure_openai_unit_test_env(monkeypatch, exclude_list, override_env_param_dic env_vars = { "AZURE_OPENAI_ENDPOINT": "https://test-endpoint.com", "AZURE_OPENAI_CHAT_DEPLOYMENT_NAME": "test_chat_deployment", + "AZURE_OPENAI_RESPONSES_DEPLOYMENT_NAME": "test_chat_deployment", "AZURE_OPENAI_TEXT_DEPLOYMENT_NAME": "test_text_deployment", "AZURE_OPENAI_EMBEDDING_DEPLOYMENT_NAME": "test_embedding_deployment", "AZURE_OPENAI_TEXT_TO_IMAGE_DEPLOYMENT_NAME": "test_text_to_image_deployment", diff --git a/python/packages/azure/tests/test_azure_responses_client.py b/python/packages/azure/tests/test_azure_responses_client.py new file mode 100644 index 0000000000..10dc33842a --- /dev/null +++ b/python/packages/azure/tests/test_azure_responses_client.py @@ -0,0 +1,287 @@ +# Copyright (c) Microsoft. All rights reserved. + +import os +from typing import Annotated + +import pytest +from agent_framework import ChatClient, ChatMessage, ChatResponse, ChatResponseUpdate, TextContent, ai_function +from agent_framework.azure import AzureResponsesClient +from agent_framework.exceptions import ServiceInitializationError, ServiceResponseException +from pydantic import BaseModel + +skip_if_azure_integration_tests_disabled = pytest.mark.skipif( + os.getenv("RUN_INTEGRATION_TESTS", "false").lower() != "true" + or os.getenv("AZURE_OPENAI_ENDPOINT", "") in ("", "https://test-endpoint.com"), + reason="No real AZURE_OPENAI_ENDPOINT 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(azure_openai_unit_test_env: dict[str, str]) -> None: + # Test successful initialization + azure_responses_client = AzureResponsesClient() + + assert azure_responses_client.ai_model_id == azure_openai_unit_test_env["AZURE_OPENAI_RESPONSES_DEPLOYMENT_NAME"] + assert isinstance(azure_responses_client, ChatClient) + + +def test_init_validation_fail() -> None: + # Test successful initialization + with pytest.raises(ServiceInitializationError): + AzureResponsesClient(api_key="34523", deployment_name={"test": "dict"}) # type: ignore + + +def test_init_ai_model_id_constructor(azure_openai_unit_test_env: dict[str, str]) -> None: + # Test successful initialization + ai_model_id = "test_model_id" + azure_responses_client = AzureResponsesClient(deployment_name=ai_model_id) + + assert azure_responses_client.ai_model_id == ai_model_id + assert isinstance(azure_responses_client, ChatClient) + + +def test_init_with_default_header(azure_openai_unit_test_env: dict[str, str]) -> None: + default_headers = {"X-Unit-Test": "test-guid"} + + # Test successful initialization + azure_responses_client = AzureResponsesClient( + default_headers=default_headers, + ) + + assert azure_responses_client.ai_model_id == azure_openai_unit_test_env["AZURE_OPENAI_RESPONSES_DEPLOYMENT_NAME"] + assert isinstance(azure_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 azure_responses_client.client.default_headers + assert azure_responses_client.client.default_headers[key] == value + + +@pytest.mark.parametrize("exclude_list", [["AZURE_OPENAI_RESPONSES_DEPLOYMENT_NAME"]], indirect=True) +def test_init_with_empty_model_id(azure_openai_unit_test_env: dict[str, str]) -> None: + with pytest.raises(ServiceInitializationError): + AzureResponsesClient( + env_file_path="test.env", + ) + + +def test_serialize(azure_openai_unit_test_env: dict[str, str]) -> None: + default_headers = {"X-Unit-Test": "test-guid"} + + settings = { + "ai_model_id": azure_openai_unit_test_env["AZURE_OPENAI_RESPONSES_DEPLOYMENT_NAME"], + "api_key": azure_openai_unit_test_env["AZURE_OPENAI_API_KEY"], + "default_headers": default_headers, + } + + azure_responses_client = AzureResponsesClient.from_dict(settings) + dumped_settings = azure_responses_client.to_dict() + assert dumped_settings["ai_model_id"] == azure_openai_unit_test_env["AZURE_OPENAI_RESPONSES_DEPLOYMENT_NAME"] + assert dumped_settings["api_key"] == azure_openai_unit_test_env["AZURE_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"] + + +@skip_if_azure_integration_tests_disabled +async def test_azure_responses_client_response() -> None: + """Test azure responses client responses.""" + azure_responses_client = AzureResponsesClient() + + assert isinstance(azure_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 azure_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 azure_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.lower() + + +@skip_if_azure_integration_tests_disabled +async def test_azure_responses_client_response_tools() -> None: + """Test azure responses client tools.""" + azure_responses_client = AzureResponsesClient() + + assert isinstance(azure_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 azure_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 azure_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.lower() + + +@skip_if_azure_integration_tests_disabled +async def test_azure_responses_client_streaming() -> None: + """Test Azure azure responses client streaming responses.""" + azure_responses_client = AzureResponsesClient() + + assert isinstance(azure_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 = azure_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/azure/azure-python/issues/2305 + with pytest.raises(ServiceResponseException): + response = azure_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.lower() + + +@skip_if_azure_integration_tests_disabled +async def test_azure_responses_client_streaming_tools() -> None: + """Test azure responses client streaming tools.""" + azure_responses_client = AzureResponsesClient() + + assert isinstance(azure_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 = azure_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/azure/azure-python/issues/2305 + with pytest.raises(ServiceResponseException): + response = azure_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.lower() diff --git a/python/packages/main/agent_framework/azure/__init__.py b/python/packages/main/agent_framework/azure/__init__.py index ffd117b06c..1e28004537 100644 --- a/python/packages/main/agent_framework/azure/__init__.py +++ b/python/packages/main/agent_framework/azure/__init__.py @@ -9,6 +9,7 @@ _IMPORTS = [ "AzureAssistantsClient", "AzureChatClient", "AzureOpenAISettings", + "AzureResponsesClient", "get_entra_auth_token", "__version__", ] diff --git a/python/packages/main/agent_framework/azure/__init__.pyi b/python/packages/main/agent_framework/azure/__init__.pyi index bb808c02ef..69d9995ce0 100644 --- a/python/packages/main/agent_framework/azure/__init__.pyi +++ b/python/packages/main/agent_framework/azure/__init__.pyi @@ -4,6 +4,7 @@ from agent_framework_azure import ( AzureAssistantsClient, AzureChatClient, AzureOpenAISettings, + AzureResponsesClient, __version__, get_entra_auth_token, ) @@ -12,6 +13,7 @@ __all__ = [ "AzureAssistantsClient", "AzureChatClient", "AzureOpenAISettings", + "AzureResponsesClient", "__version__", "get_entra_auth_token", ] diff --git a/python/packages/main/agent_framework/openai/_responses_client.py b/python/packages/main/agent_framework/openai/_responses_client.py index ccbc342cc1..a387a1281c 100644 --- a/python/packages/main/agent_framework/openai/_responses_client.py +++ b/python/packages/main/agent_framework/openai/_responses_client.py @@ -52,68 +52,7 @@ __all__ = ["OpenAIResponsesClient"] # region ResponsesClient -@use_telemetry -@use_tool_calling -class OpenAIResponsesClient(OpenAIConfigBase, ChatClientBase, OpenAIHandler): - """OpenAI Responses client class.""" - - MODEL_PROVIDER_NAME: ClassVar[str] = "openai" # type: ignore[reportIncompatibleVariableOverride, misc] - - 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, - instruction_role: str | None = None, - env_file_path: str | None = None, - env_file_encoding: str | None = None, - ) -> None: - """Initialize an OpenAIChatCompletion service. - - Args: - ai_model_id: OpenAI model name, see - https://platform.openai.com/docs/models - api_key: The optional API key to use. If provided will override, - the env vars or .env file value. - org_id: 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: An existing client to use. (Optional) - instruction_role: The role to use for 'instruction' messages, for example, - "system" or "developer". If not provided, the default is "system". - env_file_path: Use the environment settings file as a fallback - to environment variables. (Optional) - env_file_encoding: The encoding of the environment settings file. (Optional) - """ - 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, - ) - +class OpenAIResponsesClientBase(OpenAIHandler, ChatClientBase): 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. @@ -544,6 +483,69 @@ class OpenAIResponsesClient(OpenAIConfigBase, ChatClientBase, OpenAIHandler): "logprobs": getattr(output, "logprobs", None), } + +@use_telemetry +@use_tool_calling +class OpenAIResponsesClient(OpenAIConfigBase, OpenAIResponsesClientBase): + """OpenAI Responses client class.""" + + MODEL_PROVIDER_NAME: ClassVar[str] = "openai" # type: ignore[reportIncompatibleVariableOverride, misc] + + 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, + instruction_role: str | None = None, + env_file_path: str | None = None, + env_file_encoding: str | None = None, + ) -> None: + """Initialize an OpenAIChatCompletion service. + + Args: + ai_model_id: OpenAI model name, see + https://platform.openai.com/docs/models + api_key: The optional API key to use. If provided will override, + the env vars or .env file value. + org_id: 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: An existing client to use. (Optional) + instruction_role: The role to use for 'instruction' messages, for example, + "system" or "developer". If not provided, the default is "system". + env_file_path: Use the environment settings file as a fallback + to environment variables. (Optional) + env_file_encoding: The encoding of the environment settings file. (Optional) + """ + 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, + ) + @classmethod def from_dict(cls, settings: dict[str, Any]) -> "OpenAIResponsesClient": """Initialize an Open AI service from a dictionary of settings. diff --git a/python/samples/getting_started/chat_client/azure_responses_client.py b/python/samples/getting_started/chat_client/azure_responses_client.py new file mode 100644 index 0000000000..24dfce5328 --- /dev/null +++ b/python/samples/getting_started/chat_client/azure_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.azure import AzureResponsesClient +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 = AzureResponsesClient() + 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())