mirror of
https://github.com/microsoft/agent-framework.git
synced 2026-06-16 21:04:09 +08:00
Python: Allow AzureOpenAIResponsesClient creation with Foundry project endpoint (#3814)
* Initial plan * feat: extend AzureOpenAIResponsesClient to support Foundry project endpoints Add project_client and project_endpoint parameters to allow creating the client via an Azure AI Foundry project. When provided, the client uses AIProjectClient.get_openai_client() to obtain the OpenAI client. The azure-ai-projects package is imported lazily and only required when using the project endpoint path. Co-authored-by: eavanvalkenburg <13749212+eavanvalkenburg@users.noreply.github.com> * fix: address code review - remove duplicate MagicMock imports in tests Co-authored-by: eavanvalkenburg <13749212+eavanvalkenburg@users.noreply.github.com> * fix: add type field to Responses API input items and add Foundry sample - Add 'type: message' to input items in _prepare_message_for_openai to comply with the Responses API schema requirement - Filter out empty dicts from unsupported content types to prevent sending items with invalid empty type values - Add azure_responses_client_with_foundry.py sample demonstrating AzureOpenAIResponsesClient with project_endpoint - Update README and pyrightconfig.samples.json accordingly * updates to response format and setup * fix: patch AIProjectClient at correct module path in test Patch agent_framework.azure._responses_client.AIProjectClient instead of azure.ai.projects.aio.AIProjectClient since the import is at module level. * docs: add Foundry sample to READMEs and document AZURE_AI_PROJECT_ENDPOINT env var --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: eavanvalkenburg <13749212+eavanvalkenburg@users.noreply.github.com> Co-authored-by: eavanvalkenburg <github@vanvalkenburg.eu>
This commit is contained in:
committed by
GitHub
Unverified
parent
235c578059
commit
a427af91a9
@@ -7,11 +7,14 @@ from collections.abc import Mapping, Sequence
|
||||
from typing import TYPE_CHECKING, Any, Generic
|
||||
from urllib.parse import urljoin
|
||||
|
||||
from azure.ai.projects.aio import AIProjectClient
|
||||
from azure.core.credentials import TokenCredential
|
||||
from openai.lib.azure import AsyncAzureADTokenProvider, AsyncAzureOpenAI
|
||||
from openai import AsyncOpenAI
|
||||
from openai.lib.azure import AsyncAzureADTokenProvider
|
||||
from pydantic import ValidationError
|
||||
|
||||
from .._middleware import ChatMiddlewareLayer
|
||||
from .._telemetry import AGENT_FRAMEWORK_USER_AGENT
|
||||
from .._tools import FunctionInvocationConfiguration, FunctionInvocationLayer
|
||||
from ..exceptions import ServiceInitializationError
|
||||
from ..observability import ChatTelemetryLayer
|
||||
@@ -72,7 +75,9 @@ class AzureOpenAIResponsesClient( # type: ignore[misc]
|
||||
token_endpoint: str | None = None,
|
||||
credential: TokenCredential | None = None,
|
||||
default_headers: Mapping[str, str] | None = None,
|
||||
async_client: AsyncAzureOpenAI | None = None,
|
||||
async_client: AsyncOpenAI | None = None,
|
||||
project_client: Any | None = None,
|
||||
project_endpoint: str | None = None,
|
||||
env_file_path: str | None = None,
|
||||
env_file_encoding: str | None = None,
|
||||
instruction_role: str | None = None,
|
||||
@@ -82,6 +87,14 @@ class AzureOpenAIResponsesClient( # type: ignore[misc]
|
||||
) -> None:
|
||||
"""Initialize an Azure OpenAI Responses client.
|
||||
|
||||
The client can be created in two ways:
|
||||
|
||||
1. **Direct Azure OpenAI** (default): Provide endpoint, api_key, or credential
|
||||
to connect directly to an Azure OpenAI deployment.
|
||||
2. **Foundry project endpoint**: Provide a ``project_client`` or ``project_endpoint``
|
||||
(with ``credential``) to create the client via an Azure AI Foundry project.
|
||||
This requires the ``azure-ai-projects`` package to be installed.
|
||||
|
||||
Keyword Args:
|
||||
api_key: The API key. If provided, will override the value in the env vars or .env file.
|
||||
Can also be set via environment variable AZURE_OPENAI_API_KEY.
|
||||
@@ -105,6 +118,12 @@ class AzureOpenAIResponsesClient( # type: ignore[misc]
|
||||
default_headers: The default headers mapping of string keys to
|
||||
string values for HTTP requests.
|
||||
async_client: An existing client to use.
|
||||
project_client: An existing ``AIProjectClient`` (from ``azure.ai.projects.aio``) to use.
|
||||
The OpenAI client will be obtained via ``project_client.get_openai_client()``.
|
||||
Requires the ``azure-ai-projects`` package.
|
||||
project_endpoint: The Azure AI Foundry project endpoint URL.
|
||||
When provided with ``credential``, an ``AIProjectClient`` will be created
|
||||
and used to obtain the OpenAI client. Requires the ``azure-ai-projects`` package.
|
||||
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
|
||||
@@ -132,6 +151,27 @@ class AzureOpenAIResponsesClient( # type: ignore[misc]
|
||||
# Or loading from a .env file
|
||||
client = AzureOpenAIResponsesClient(env_file_path="path/to/.env")
|
||||
|
||||
# Using a Foundry project endpoint
|
||||
from azure.identity import DefaultAzureCredential
|
||||
|
||||
client = AzureOpenAIResponsesClient(
|
||||
project_endpoint="https://your-project.services.ai.azure.com",
|
||||
deployment_name="gpt-4o",
|
||||
credential=DefaultAzureCredential(),
|
||||
)
|
||||
|
||||
# Or using an existing AIProjectClient
|
||||
from azure.ai.projects.aio import AIProjectClient
|
||||
|
||||
project_client = AIProjectClient(
|
||||
endpoint="https://your-project.services.ai.azure.com",
|
||||
credential=DefaultAzureCredential(),
|
||||
)
|
||||
client = AzureOpenAIResponsesClient(
|
||||
project_client=project_client,
|
||||
deployment_name="gpt-4o",
|
||||
)
|
||||
|
||||
# Using custom ChatOptions with type safety:
|
||||
from typing import TypedDict
|
||||
from agent_framework.azure import AzureOpenAIResponsesOptions
|
||||
@@ -146,6 +186,15 @@ class AzureOpenAIResponsesClient( # type: ignore[misc]
|
||||
"""
|
||||
if model_id := kwargs.pop("model_id", None) and not deployment_name:
|
||||
deployment_name = str(model_id)
|
||||
|
||||
# Project client path: create OpenAI client from an Azure AI Foundry project
|
||||
if async_client is None and (project_client is not None or project_endpoint is not None):
|
||||
async_client = self._create_client_from_project(
|
||||
project_client=project_client,
|
||||
project_endpoint=project_endpoint,
|
||||
credential=credential,
|
||||
)
|
||||
|
||||
try:
|
||||
azure_openai_settings = AzureOpenAISettings(
|
||||
# pydantic settings will see if there is a value, if not, will try the env var or .env file
|
||||
@@ -195,9 +244,48 @@ class AzureOpenAIResponsesClient( # type: ignore[misc]
|
||||
function_invocation_configuration=function_invocation_configuration,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _create_client_from_project(
|
||||
*,
|
||||
project_client: AIProjectClient | None,
|
||||
project_endpoint: str | None,
|
||||
credential: TokenCredential | None,
|
||||
) -> AsyncOpenAI:
|
||||
"""Create an AsyncOpenAI client from an Azure AI Foundry project.
|
||||
|
||||
Args:
|
||||
project_client: An existing AIProjectClient to use.
|
||||
project_endpoint: The Azure AI Foundry project endpoint URL.
|
||||
credential: Azure credential for authentication.
|
||||
|
||||
Returns:
|
||||
An AsyncAzureOpenAI client obtained from the project client.
|
||||
|
||||
Raises:
|
||||
ServiceInitializationError: If required parameters are missing or
|
||||
the azure-ai-projects package is not installed.
|
||||
"""
|
||||
if project_client is not None:
|
||||
return project_client.get_openai_client()
|
||||
|
||||
if not project_endpoint:
|
||||
raise ServiceInitializationError(
|
||||
"Azure AI project endpoint is required when project_client is not provided."
|
||||
)
|
||||
if not credential:
|
||||
raise ServiceInitializationError(
|
||||
"Azure credential is required when using project_endpoint without a project_client."
|
||||
)
|
||||
project_client = AIProjectClient(
|
||||
endpoint=project_endpoint,
|
||||
credential=credential, # type: ignore[arg-type]
|
||||
user_agent=AGENT_FRAMEWORK_USER_AGENT,
|
||||
)
|
||||
return project_client.get_openai_client()
|
||||
|
||||
@override
|
||||
def _check_model_presence(self, run_options: dict[str, Any]) -> None:
|
||||
if not run_options.get("model"):
|
||||
def _check_model_presence(self, options: dict[str, Any]) -> None:
|
||||
if not options.get("model"):
|
||||
if not self.model_id:
|
||||
raise ValueError("deployment_name must be a non-empty string")
|
||||
run_options["model"] = self.model_id
|
||||
options["model"] = self.model_id
|
||||
|
||||
@@ -9,6 +9,7 @@ from copy import copy
|
||||
from typing import Any, ClassVar, Final
|
||||
|
||||
from azure.core.credentials import TokenCredential
|
||||
from openai import AsyncOpenAI
|
||||
from openai.lib.azure import AsyncAzureOpenAI
|
||||
from pydantic import SecretStr, model_validator
|
||||
|
||||
@@ -162,7 +163,7 @@ class AzureOpenAIConfigMixin(OpenAIBase):
|
||||
token_endpoint: str | None = None,
|
||||
credential: TokenCredential | None = None,
|
||||
default_headers: Mapping[str, str] | None = None,
|
||||
client: AsyncAzureOpenAI | None = None,
|
||||
client: AsyncOpenAI | None = None,
|
||||
instruction_role: str | None = None,
|
||||
**kwargs: Any,
|
||||
) -> None:
|
||||
|
||||
@@ -901,6 +901,7 @@ class RawOpenAIResponsesClient( # type: ignore[misc]
|
||||
"""Prepare a chat message for the OpenAI Responses API format."""
|
||||
all_messages: list[dict[str, Any]] = []
|
||||
args: dict[str, Any] = {
|
||||
"type": "message",
|
||||
"role": message.role,
|
||||
}
|
||||
for content in message.contents:
|
||||
@@ -911,16 +912,22 @@ class RawOpenAIResponsesClient( # type: ignore[misc]
|
||||
case "function_result":
|
||||
new_args: dict[str, Any] = {}
|
||||
new_args.update(self._prepare_content_for_openai(message.role, content, call_id_to_id)) # type: ignore[arg-type]
|
||||
all_messages.append(new_args)
|
||||
if new_args:
|
||||
all_messages.append(new_args)
|
||||
case "function_call":
|
||||
function_call = self._prepare_content_for_openai(message.role, content, call_id_to_id) # type: ignore[arg-type]
|
||||
all_messages.append(function_call) # type: ignore
|
||||
if function_call:
|
||||
all_messages.append(function_call) # type: ignore
|
||||
case "function_approval_response" | "function_approval_request":
|
||||
all_messages.append(self._prepare_content_for_openai(message.role, content, call_id_to_id)) # type: ignore
|
||||
prepared = self._prepare_content_for_openai(Role(message.role), content, call_id_to_id)
|
||||
if prepared:
|
||||
all_messages.append(prepared) # type: ignore
|
||||
case _:
|
||||
if "content" not in args:
|
||||
args["content"] = []
|
||||
args["content"].append(self._prepare_content_for_openai(message.role, content, call_id_to_id)) # type: ignore
|
||||
prepared_content = self._prepare_content_for_openai(message.role, content, call_id_to_id) # type: ignore
|
||||
if prepared_content:
|
||||
if "content" not in args:
|
||||
args["content"] = []
|
||||
args["content"].append(prepared_content) # type: ignore
|
||||
if "content" in args or "tool_calls" in args:
|
||||
all_messages.append(args)
|
||||
return all_messages
|
||||
|
||||
@@ -34,6 +34,7 @@ dependencies = [
|
||||
# connectors and functions
|
||||
"openai>=1.99.0",
|
||||
"azure-identity>=1,<2",
|
||||
"azure-ai-projects >= 2.0.0b3",
|
||||
"mcp[ws]>=1.24.0,<2",
|
||||
"packaging>=24.1",
|
||||
]
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import json
|
||||
import os
|
||||
from typing import Annotated, Any
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
from azure.identity import AzureCliCredential
|
||||
@@ -115,6 +116,119 @@ def test_init_with_empty_model_id(azure_openai_unit_test_env: dict[str, str]) ->
|
||||
)
|
||||
|
||||
|
||||
def test_init_with_project_client(azure_openai_unit_test_env: dict[str, str]) -> None:
|
||||
"""Test initialization with an existing AIProjectClient."""
|
||||
from unittest.mock import patch
|
||||
|
||||
from openai import AsyncOpenAI
|
||||
|
||||
# Create a mock AIProjectClient that returns a mock AsyncOpenAI client
|
||||
mock_openai_client = MagicMock(spec=AsyncOpenAI)
|
||||
mock_openai_client.default_headers = {}
|
||||
|
||||
mock_project_client = MagicMock()
|
||||
mock_project_client.get_openai_client.return_value = mock_openai_client
|
||||
|
||||
with patch(
|
||||
"agent_framework.azure._responses_client.AzureOpenAIResponsesClient._create_client_from_project",
|
||||
return_value=mock_openai_client,
|
||||
):
|
||||
azure_responses_client = AzureOpenAIResponsesClient(
|
||||
project_client=mock_project_client,
|
||||
deployment_name="gpt-4o",
|
||||
)
|
||||
|
||||
assert azure_responses_client.model_id == "gpt-4o"
|
||||
assert azure_responses_client.client is mock_openai_client
|
||||
assert isinstance(azure_responses_client, SupportsChatGetResponse)
|
||||
|
||||
|
||||
def test_init_with_project_endpoint(azure_openai_unit_test_env: dict[str, str]) -> None:
|
||||
"""Test initialization with a project endpoint and credential."""
|
||||
from unittest.mock import patch
|
||||
|
||||
from openai import AsyncOpenAI
|
||||
|
||||
mock_openai_client = MagicMock(spec=AsyncOpenAI)
|
||||
mock_openai_client.default_headers = {}
|
||||
|
||||
with patch(
|
||||
"agent_framework.azure._responses_client.AzureOpenAIResponsesClient._create_client_from_project",
|
||||
return_value=mock_openai_client,
|
||||
):
|
||||
azure_responses_client = AzureOpenAIResponsesClient(
|
||||
project_endpoint="https://test-project.services.ai.azure.com",
|
||||
deployment_name="gpt-4o",
|
||||
credential=AzureCliCredential(),
|
||||
)
|
||||
|
||||
assert azure_responses_client.model_id == "gpt-4o"
|
||||
assert azure_responses_client.client is mock_openai_client
|
||||
assert isinstance(azure_responses_client, SupportsChatGetResponse)
|
||||
|
||||
|
||||
def test_create_client_from_project_with_project_client() -> None:
|
||||
"""Test _create_client_from_project with an existing project client."""
|
||||
from openai import AsyncOpenAI
|
||||
|
||||
mock_openai_client = MagicMock(spec=AsyncOpenAI)
|
||||
mock_project_client = MagicMock()
|
||||
mock_project_client.get_openai_client.return_value = mock_openai_client
|
||||
|
||||
result = AzureOpenAIResponsesClient._create_client_from_project(
|
||||
project_client=mock_project_client,
|
||||
project_endpoint=None,
|
||||
credential=None,
|
||||
)
|
||||
|
||||
assert result is mock_openai_client
|
||||
mock_project_client.get_openai_client.assert_called_once()
|
||||
|
||||
|
||||
def test_create_client_from_project_with_endpoint() -> None:
|
||||
"""Test _create_client_from_project with a project endpoint."""
|
||||
from unittest.mock import patch
|
||||
|
||||
from openai import AsyncOpenAI
|
||||
|
||||
mock_openai_client = MagicMock(spec=AsyncOpenAI)
|
||||
mock_credential = MagicMock()
|
||||
|
||||
with patch("agent_framework.azure._responses_client.AIProjectClient") as MockAIProjectClient:
|
||||
mock_instance = MockAIProjectClient.return_value
|
||||
mock_instance.get_openai_client.return_value = mock_openai_client
|
||||
|
||||
result = AzureOpenAIResponsesClient._create_client_from_project(
|
||||
project_client=None,
|
||||
project_endpoint="https://test-project.services.ai.azure.com",
|
||||
credential=mock_credential,
|
||||
)
|
||||
|
||||
assert result is mock_openai_client
|
||||
MockAIProjectClient.assert_called_once()
|
||||
mock_instance.get_openai_client.assert_called_once()
|
||||
|
||||
|
||||
def test_create_client_from_project_missing_endpoint() -> None:
|
||||
"""Test _create_client_from_project raises error when endpoint is missing."""
|
||||
with pytest.raises(ServiceInitializationError, match="project endpoint is required"):
|
||||
AzureOpenAIResponsesClient._create_client_from_project(
|
||||
project_client=None,
|
||||
project_endpoint=None,
|
||||
credential=MagicMock(),
|
||||
)
|
||||
|
||||
|
||||
def test_create_client_from_project_missing_credential() -> None:
|
||||
"""Test _create_client_from_project raises error when credential is missing."""
|
||||
with pytest.raises(ServiceInitializationError, match="credential is required"):
|
||||
AzureOpenAIResponsesClient._create_client_from_project(
|
||||
project_client=None,
|
||||
project_endpoint="https://test-project.services.ai.azure.com",
|
||||
credential=None,
|
||||
)
|
||||
|
||||
|
||||
def test_serialize(azure_openai_unit_test_env: dict[str, str]) -> None:
|
||||
default_headers = {"X-Unit-Test": "test-guid"}
|
||||
|
||||
|
||||
@@ -798,12 +798,8 @@ def test_chat_message_with_error_content() -> None:
|
||||
|
||||
result = client._prepare_message_for_openai(message, call_id_to_id)
|
||||
|
||||
# Message should be prepared with empty content list since ErrorContent returns {}
|
||||
assert len(result) == 1
|
||||
prepared_message = result[0]
|
||||
assert prepared_message["role"] == "assistant"
|
||||
# Content should be a list with empty dict since ErrorContent returns {}
|
||||
assert prepared_message.get("content") == [{}]
|
||||
# Message should be empty since ErrorContent is filtered out
|
||||
assert len(result) == 0
|
||||
|
||||
|
||||
def test_chat_message_with_usage_content() -> None:
|
||||
@@ -823,12 +819,8 @@ def test_chat_message_with_usage_content() -> None:
|
||||
|
||||
result = client._prepare_message_for_openai(message, call_id_to_id)
|
||||
|
||||
# Message should be prepared with empty content list since UsageContent returns {}
|
||||
assert len(result) == 1
|
||||
prepared_message = result[0]
|
||||
assert prepared_message["role"] == "assistant"
|
||||
# Content should be a list with empty dict since UsageContent returns {}
|
||||
assert prepared_message.get("content") == [{}]
|
||||
# Message should be empty since UsageContent is filtered out
|
||||
assert len(result) == 0
|
||||
|
||||
|
||||
def test_hosted_file_content_preparation() -> None:
|
||||
|
||||
Reference in New Issue
Block a user