Python: [Feature Branch] Structured Outputs and more examples for AzureAIClient (#1987)

* Small updates

* Added support for structured outputs

* Added code interpreter example

* More examples and fixes

* Added more examples and README

* Small fix

* Addressed PR feedback
This commit is contained in:
Dmytro Struk
2025-11-07 00:17:20 -08:00
committed by GitHub
Unverified
parent 915c749e41
commit 9423c1763c
17 changed files with 3915 additions and 3461 deletions
@@ -17,7 +17,11 @@ from agent_framework.exceptions import ServiceInitializationError
from agent_framework.observability import use_observability
from agent_framework.openai._responses_client import OpenAIBaseResponsesClient
from azure.ai.projects.aio import AIProjectClient
from azure.ai.projects.models import PromptAgentDefinition
from azure.ai.projects.models import (
PromptAgentDefinition,
PromptAgentDefinitionText,
ResponseTextFormatConfigurationJsonSchema,
)
from azure.core.credentials_async import AsyncTokenCredential
from azure.core.exceptions import ResourceNotFoundError
from openai.types.responses.parsed_response import (
@@ -86,24 +90,24 @@ class AzureAIClient(OpenAIBaseResponsesClient):
Examples:
.. code-block:: python
from agent_framework.azure import AzureAIAgentClient
from agent_framework.azure import AzureAIClient
from azure.identity.aio import DefaultAzureCredential
# Using environment variables
# Set AZURE_AI_PROJECT_ENDPOINT=https://your-project.cognitiveservices.azure.com
# Set AZURE_AI_MODEL_DEPLOYMENT_NAME=gpt-4
credential = DefaultAzureCredential()
client = AzureAIAgentClient(async_credential=credential)
client = AzureAIClient(async_credential=credential)
# Or passing parameters directly
client = AzureAIAgentClient(
client = AzureAIClient(
project_endpoint="https://your-project.cognitiveservices.azure.com",
model_deployment_name="gpt-4",
async_credential=credential,
)
# Or loading from a .env file
client = AzureAIAgentClient(async_credential=credential, env_file_path="path/to/.env")
client = AzureAIClient(async_credential=credential, env_file_path="path/to/.env")
"""
try:
azure_ai_settings = AzureAISettings(
@@ -211,12 +215,20 @@ class AzureAIClient(OpenAIBaseResponsesClient):
"can also be passed to the get_response methods."
)
args: dict[str, Any] = {
"model": run_options["model"],
}
args: dict[str, Any] = {"model": run_options["model"]}
if "tools" in run_options:
args["tools"] = run_options["tools"]
if "response_format" in run_options:
response_format = run_options["response_format"]
args["text"] = PromptAgentDefinitionText(
format=ResponseTextFormatConfigurationJsonSchema(
name=response_format.__name__,
schema=response_format.model_json_schema(),
)
)
# Combine instructions from messages and options
combined_instructions = [
instructions
@@ -226,8 +238,6 @@ class AzureAIClient(OpenAIBaseResponsesClient):
if combined_instructions:
args["instructions"] = "".join(combined_instructions)
# TODO (dmytrostruk): Add response format
created_agent = await self.project_client.agents.create_version(
agent_name=agent_name, definition=PromptAgentDefinition(**args)
)
@@ -288,13 +298,12 @@ class AzureAIClient(OpenAIBaseResponsesClient):
run_options["extra_body"] = {"agent": agent_reference}
# Remove properties that are not supported
# Model and tools captured in the agent setup
if "model" in run_options:
run_options.pop("model", None)
# Remove properties that are not supported on request level
# but were configured on agent level
exclude = ["model", "tools", "response_format"]
if "tools" in run_options:
run_options.pop("tools", None)
for property in exclude:
run_options.pop(property, None)
return run_options
+1 -1
View File
@@ -24,7 +24,7 @@ classifiers = [
]
dependencies = [
"agent-framework-core",
"azure-ai-projects >= 2.0.0a20251103001",
"azure-ai-projects >= 2.0.0a20251105001",
"azure-ai-agents == 1.2.0b5",
"aiohttp",
]
@@ -11,7 +11,10 @@ from agent_framework import (
TextContent,
)
from agent_framework.exceptions import ServiceInitializationError
from pydantic import ValidationError
from azure.ai.projects.models import (
ResponseTextFormatConfigurationJsonSchema,
)
from pydantic import BaseModel, ConfigDict, ValidationError
from agent_framework_azure_ai import AzureAIClient, AzureAISettings
@@ -531,6 +534,86 @@ async def test_azure_ai_client_use_latest_version_with_existing_agent_version(
assert agent_ref == {"name": "test-agent", "version": "3.0", "type": "agent_reference"}
class ResponseFormatModel(BaseModel):
"""Test Pydantic model for response format testing."""
name: str
value: int
description: str
model_config = ConfigDict(extra="forbid")
async def test_azure_ai_client_agent_creation_with_response_format(
mock_project_client: MagicMock,
) -> None:
"""Test agent creation with response_format configuration."""
client = create_test_azure_ai_client(mock_project_client, agent_name="test-agent")
# Mock agent creation response
mock_agent = MagicMock()
mock_agent.name = "test-agent"
mock_agent.version = "1.0"
mock_project_client.agents.create_version = AsyncMock(return_value=mock_agent)
run_options = {"model": "test-model", "response_format": ResponseFormatModel}
await client._get_agent_reference_or_create(run_options, None) # type: ignore
# Verify agent was created with response format configuration
call_args = mock_project_client.agents.create_version.call_args
created_definition = call_args[1]["definition"]
# Check that text format configuration was set
assert hasattr(created_definition, "text")
assert created_definition.text is not None
# Check that the format is a ResponseTextFormatConfigurationJsonSchema
assert hasattr(created_definition.text, "format")
format_config = created_definition.text.format
assert isinstance(format_config, ResponseTextFormatConfigurationJsonSchema)
# Check the schema name matches the model class name
assert format_config.name == "ResponseFormatModel"
# Check that schema was generated correctly
assert format_config.schema is not None
schema = format_config.schema
assert "properties" in schema
assert "name" in schema["properties"]
assert "value" in schema["properties"]
assert "description" in schema["properties"]
async def test_azure_ai_client_prepare_options_excludes_response_format(
mock_project_client: MagicMock,
) -> None:
"""Test that prepare_options excludes response_format from final run options."""
client = create_test_azure_ai_client(mock_project_client, agent_name="test-agent", agent_version="1.0")
messages = [ChatMessage(role=Role.USER, contents=[TextContent(text="Hello")])]
chat_options = ChatOptions()
with (
patch.object(
client.__class__.__bases__[0],
"prepare_options",
return_value={"model": "test-model", "response_format": ResponseFormatModel},
),
patch.object(
client,
"_get_agent_reference_or_create",
return_value={"name": "test-agent", "version": "1.0", "type": "agent_reference"},
),
):
run_options = await client.prepare_options(messages, chat_options)
# response_format should be excluded from final run options
assert "response_format" not in run_options
# But extra_body should contain agent reference
assert "extra_body" in run_options
assert run_options["extra_body"]["agent"]["name"] == "test-agent"
@pytest.fixture
def mock_project_client() -> MagicMock:
"""Fixture that provides a mock AIProjectClient."""
@@ -92,7 +92,8 @@ class OpenAIBaseResponsesClient(OpenAIBase, BaseChatClient):
client = await self.ensure_client()
run_options = await self.prepare_options(messages, chat_options)
try:
if not chat_options.response_format:
response_format = run_options.pop("response_format", None)
if not response_format:
response = await client.responses.create(
stream=False,
**run_options,
@@ -100,9 +101,8 @@ class OpenAIBaseResponsesClient(OpenAIBase, BaseChatClient):
chat_options.conversation_id = self.get_conversation_id(response, chat_options.store)
return self._create_response_content(response, chat_options=chat_options)
# create call does not support response_format, so we need to handle it via parse call
resp_format = chat_options.response_format
parsed_response: ParsedResponse[BaseModel] = await client.responses.parse(
text_format=resp_format,
text_format=response_format,
stream=False,
**run_options,
)
@@ -135,7 +135,8 @@ class OpenAIBaseResponsesClient(OpenAIBase, BaseChatClient):
run_options = await self.prepare_options(messages, chat_options)
function_call_ids: dict[int, tuple[str, str]] = {} # output_index: (call_id, name)
try:
if not chat_options.response_format:
response_format = run_options.pop("response_format", None)
if not response_format:
response = await client.responses.create(
stream=True,
**run_options,
@@ -148,7 +149,7 @@ class OpenAIBaseResponsesClient(OpenAIBase, BaseChatClient):
return
# create call does not support response_format, so we need to handle it via stream call
async with client.responses.stream(
text_format=chat_options.response_format,
text_format=response_format,
**run_options,
) as response:
async for chunk in response:
@@ -311,7 +312,6 @@ class OpenAIBaseResponsesClient(OpenAIBase, BaseChatClient):
run_options: dict[str, Any] = chat_options.to_dict(
exclude={
"type",
"response_format", # handled in inner get methods
"presence_penalty", # not supported
"frequency_penalty", # not supported
"logit_bias", # not supported
@@ -320,6 +320,10 @@ class OpenAIBaseResponsesClient(OpenAIBase, BaseChatClient):
"instructions", # already added as system message
}
)
if chat_options.response_format:
run_options["response_format"] = chat_options.response_format
translations = {
"model_id": "model",
"allow_multiple_tool_calls": "parallel_tool_calls",