Python: Fix runtime response format for responses client (#2440)

* Fix runtime response format for responses client

* Handle run time schema for Azure AI Client.
This commit is contained in:
Evan Mattson
2025-11-25 16:50:20 +09:00
committed by GitHub
Unverified
parent e303cc963d
commit 2a4802eac3
8 changed files with 346 additions and 17 deletions
@@ -1,7 +1,7 @@
# Copyright (c) Microsoft. All rights reserved.
import sys
from collections.abc import MutableSequence
from collections.abc import Mapping, MutableSequence
from typing import Any, ClassVar, TypeVar
from agent_framework import (
@@ -14,7 +14,7 @@ from agent_framework import (
use_chat_middleware,
use_function_invocation,
)
from agent_framework.exceptions import ServiceInitializationError
from agent_framework.exceptions import ServiceInitializationError, ServiceInvalidRequestError
from agent_framework.observability import use_observability
from agent_framework.openai._responses_client import OpenAIBaseResponsesClient
from azure.ai.projects.aio import AIProjectClient
@@ -22,7 +22,9 @@ from azure.ai.projects.models import (
MCPTool,
PromptAgentDefinition,
PromptAgentDefinitionText,
ResponseTextFormatConfigurationJsonObject,
ResponseTextFormatConfigurationJsonSchema,
ResponseTextFormatConfigurationText,
)
from azure.core.credentials_async import AsyncTokenCredential
from azure.core.exceptions import ResourceNotFoundError
@@ -188,6 +190,40 @@ class AzureAIClient(OpenAIBaseResponsesClient):
"""Close the project_client."""
await self._close_client_if_needed()
def _create_text_format_config(
self, response_format: Any
) -> (
ResponseTextFormatConfigurationJsonSchema
| ResponseTextFormatConfigurationJsonObject
| ResponseTextFormatConfigurationText
):
"""Convert response_format into Azure text format configuration."""
if isinstance(response_format, type) and issubclass(response_format, BaseModel):
return ResponseTextFormatConfigurationJsonSchema(
name=response_format.__name__,
schema=response_format.model_json_schema(),
)
if isinstance(response_format, Mapping):
format_config = self._convert_response_format(response_format)
format_type = format_config.get("type")
if format_type == "json_schema":
config_kwargs: dict[str, Any] = {
"name": format_config.get("name") or "response",
"schema": format_config["schema"],
}
if "strict" in format_config:
config_kwargs["strict"] = format_config["strict"]
if "description" in format_config:
config_kwargs["description"] = format_config["description"]
return ResponseTextFormatConfigurationJsonSchema(**config_kwargs)
if format_type == "json_object":
return ResponseTextFormatConfigurationJsonObject()
if format_type == "text":
return ResponseTextFormatConfigurationText()
raise ServiceInvalidRequestError("response_format must be a Pydantic model or mapping.")
async def _get_agent_reference_or_create(
self, run_options: dict[str, Any], messages_instructions: str | None
) -> dict[str, str]:
@@ -228,12 +264,7 @@ class AzureAIClient(OpenAIBaseResponsesClient):
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(),
)
)
args["text"] = PromptAgentDefinitionText(format=self._create_text_format_config(response_format))
# Combine instructions from messages and options
combined_instructions = [
@@ -561,6 +561,56 @@ async def test_azure_ai_client_agent_creation_with_response_format(
assert "description" in schema["properties"]
async def test_azure_ai_client_agent_creation_with_mapping_response_format(
mock_project_client: MagicMock,
) -> None:
"""Test agent creation when response_format is provided as a mapping."""
client = create_test_azure_ai_client(mock_project_client, agent_name="test-agent")
mock_agent = MagicMock()
mock_agent.name = "test-agent"
mock_agent.version = "1.0"
mock_project_client.agents.create_version = AsyncMock(return_value=mock_agent)
runtime_schema = {
"title": "WeatherDigest",
"type": "object",
"properties": {
"location": {"type": "string"},
"conditions": {"type": "string"},
"temperature_c": {"type": "number"},
"advisory": {"type": "string"},
},
"required": ["location", "conditions", "temperature_c", "advisory"],
"additionalProperties": False,
}
run_options = {
"model": "test-model",
"response_format": {
"type": "json_schema",
"json_schema": {
"name": runtime_schema["title"],
"strict": True,
"schema": runtime_schema,
},
},
}
await client._get_agent_reference_or_create(run_options, None) # type: ignore
call_args = mock_project_client.agents.create_version.call_args
created_definition = call_args[1]["definition"]
assert hasattr(created_definition, "text")
assert created_definition.text is not None
format_config = created_definition.text.format
assert isinstance(format_config, ResponseTextFormatConfigurationJsonSchema)
assert format_config.name == runtime_schema["title"]
assert format_config.schema == runtime_schema
assert format_config.strict is True
async def test_azure_ai_client_prepare_options_excludes_response_format(
mock_project_client: MagicMock,
) -> None:
@@ -91,18 +91,21 @@ class OpenAIBaseResponsesClient(OpenAIBase, BaseChatClient):
) -> ChatResponse:
client = await self.ensure_client()
run_options = await self.prepare_options(messages, chat_options)
response_format = run_options.pop("response_format", None)
text_config = run_options.pop("text", None)
text_format, text_config = self._prepare_text_config(response_format=response_format, text_config=text_config)
if text_config:
run_options["text"] = text_config
try:
response_format = run_options.pop("response_format", None)
if not response_format:
if not text_format:
response = await client.responses.create(
stream=False,
**run_options,
)
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
parsed_response: ParsedResponse[BaseModel] = await client.responses.parse(
text_format=response_format,
text_format=text_format,
stream=False,
**run_options,
)
@@ -134,9 +137,13 @@ class OpenAIBaseResponsesClient(OpenAIBase, BaseChatClient):
client = await self.ensure_client()
run_options = await self.prepare_options(messages, chat_options)
function_call_ids: dict[int, tuple[str, str]] = {} # output_index: (call_id, name)
response_format = run_options.pop("response_format", None)
text_config = run_options.pop("text", None)
text_format, text_config = self._prepare_text_config(response_format=response_format, text_config=text_config)
if text_config:
run_options["text"] = text_config
try:
response_format = run_options.pop("response_format", None)
if not response_format:
if not text_format:
response = await client.responses.create(
stream=True,
**run_options,
@@ -147,9 +154,8 @@ class OpenAIBaseResponsesClient(OpenAIBase, BaseChatClient):
)
yield update
return
# create call does not support response_format, so we need to handle it via stream call
async with client.responses.stream(
text_format=response_format,
text_format=text_format,
**run_options,
) as response:
async for chunk in response:
@@ -173,6 +179,71 @@ class OpenAIBaseResponsesClient(OpenAIBase, BaseChatClient):
inner_exception=ex,
) from ex
def _prepare_text_config(
self,
*,
response_format: Any,
text_config: MutableMapping[str, Any] | None,
) -> tuple[type[BaseModel] | None, dict[str, Any] | None]:
"""Normalize response_format into Responses text configuration and parse target."""
prepared_text = dict(text_config) if isinstance(text_config, MutableMapping) else None
if text_config is not None and not isinstance(text_config, MutableMapping):
raise ServiceInvalidRequestError("text must be a mapping when provided.")
if response_format is None:
return None, prepared_text
if isinstance(response_format, type) and issubclass(response_format, BaseModel):
if prepared_text and "format" in prepared_text:
raise ServiceInvalidRequestError("response_format cannot be combined with explicit text.format.")
return response_format, prepared_text
if isinstance(response_format, Mapping):
format_config = self._convert_response_format(response_format)
if prepared_text is None:
prepared_text = {}
elif "format" in prepared_text and prepared_text["format"] != format_config:
raise ServiceInvalidRequestError("Conflicting response_format definitions detected.")
prepared_text["format"] = format_config
return None, prepared_text
raise ServiceInvalidRequestError("response_format must be a Pydantic model or mapping.")
def _convert_response_format(self, response_format: Mapping[str, Any]) -> dict[str, Any]:
"""Convert Chat style response_format into Responses text format config."""
if "format" in response_format and isinstance(response_format["format"], Mapping):
return dict(response_format["format"])
format_type = response_format.get("type")
if format_type == "json_schema":
schema_section = response_format.get("json_schema", response_format)
if not isinstance(schema_section, Mapping):
raise ServiceInvalidRequestError("json_schema response_format must be a mapping.")
schema = schema_section.get("schema")
if schema is None:
raise ServiceInvalidRequestError("json_schema response_format requires a schema.")
name = (
schema_section.get("name")
or schema_section.get("title")
or (schema.get("title") if isinstance(schema, Mapping) else None)
or "response"
)
format_config: dict[str, Any] = {
"type": "json_schema",
"name": name,
"schema": schema,
}
if "strict" in schema_section:
format_config["strict"] = schema_section["strict"]
if "description" in schema_section and schema_section["description"] is not None:
format_config["description"] = schema_section["description"]
return format_config
if format_type in {"json_object", "text"}:
return {"type": format_type}
raise ServiceInvalidRequestError("Unsupported response_format provided for Responses client.")
def get_conversation_id(
self, response: OpenAIResponse | ParsedResponse[BaseModel], store: bool | None
) -> str | None: