mirror of
https://github.com/microsoft/agent-framework.git
synced 2026-06-16 21:04:09 +08:00
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:
committed by
GitHub
Unverified
parent
e303cc963d
commit
2a4802eac3
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user