diff --git a/python/packages/azure-ai/agent_framework_azure_ai/_client.py b/python/packages/azure-ai/agent_framework_azure_ai/_client.py index e10fc19068..b06b7b5df0 100644 --- a/python/packages/azure-ai/agent_framework_azure_ai/_client.py +++ b/python/packages/azure-ai/agent_framework_azure_ai/_client.py @@ -2,7 +2,7 @@ import sys from collections.abc import Mapping, MutableSequence -from typing import Any, ClassVar, TypeVar +from typing import Any, ClassVar, TypeVar, cast from agent_framework import ( AGENT_FRAMEWORK_USER_AGENT, @@ -379,6 +379,15 @@ async def _prepare_options( """Take ChatOptions and create the specific options for Azure AI.""" prepared_messages, instructions = self._prepare_messages_for_azure_ai(messages) run_options = await super()._prepare_options(prepared_messages, chat_options, **kwargs) + + # WORKAROUND: Azure AI Projects 'create responses' API has schema divergence from OpenAI's + # Responses API. Azure requires 'type' at item level and 'annotations' in content items. + # See: https://github.com/Azure/azure-sdk-for-python/issues/44493 + # See: https://github.com/microsoft/agent-framework/issues/2926 + # TODO(agent-framework#2926): Remove this workaround when Azure SDK aligns with OpenAI schema. + if "input" in run_options and isinstance(run_options["input"], list): + run_options["input"] = self._transform_input_for_azure_ai(cast(list[dict[str, Any]], run_options["input"])) + if not self._is_application_endpoint: # Application-scoped response APIs do not support "agent" property. agent_reference = await self._get_agent_reference_or_create(run_options, instructions) @@ -393,6 +402,44 @@ async def _prepare_options( return run_options + def _transform_input_for_azure_ai(self, input_items: list[dict[str, Any]]) -> list[dict[str, Any]]: + """Transform input items to match Azure AI Projects expected schema. + + WORKAROUND: Azure AI Projects 'create responses' API expects a different schema than OpenAI's + Responses API. Azure requires 'type' at the item level, and requires 'annotations' + only for output_text content items (assistant messages), not for input_text content items + (user messages). This helper adapts the OpenAI-style input to the Azure schema. + + See: https://github.com/Azure/azure-sdk-for-python/issues/44493 + TODO(agent-framework#2926): Remove when Azure SDK aligns with OpenAI schema. + """ + transformed: list[dict[str, Any]] = [] + for item in input_items: + new_item: dict[str, Any] = dict(item) + + # Add 'type': 'message' at item level for role-based items + if "role" in new_item and "type" not in new_item: + new_item["type"] = "message" + + # Add 'annotations' only to output_text content items (assistant messages) + # User messages (input_text) do NOT support annotations in Azure AI + if "content" in new_item and isinstance(new_item["content"], list): + new_content: list[dict[str, Any] | Any] = [] + for content_item in new_item["content"]: + if isinstance(content_item, dict): + new_content_item: dict[str, Any] = dict(content_item) + # Only add annotations to output_text (assistant content) + if new_content_item.get("type") == "output_text" and "annotations" not in new_content_item: + new_content_item["annotations"] = [] + new_content.append(new_content_item) + else: + new_content.append(content_item) + new_item["content"] = new_content + + transformed.append(new_item) + + return transformed + @override def _get_current_conversation_id(self, chat_options: ChatOptions, **kwargs: Any) -> str | None: """Get the current conversation ID from chat options or kwargs.""" diff --git a/python/packages/azure-ai/tests/test_azure_ai_client.py b/python/packages/azure-ai/tests/test_azure_ai_client.py index 028e8fbdb8..2ca49c2033 100644 --- a/python/packages/azure-ai/tests/test_azure_ai_client.py +++ b/python/packages/azure-ai/tests/test_azure_ai_client.py @@ -286,6 +286,93 @@ async def test_azure_ai_client_prepare_messages_for_azure_ai_no_system_messages( assert instructions is None +def test_azure_ai_client_transform_input_for_azure_ai(mock_project_client: MagicMock) -> None: + """Test _transform_input_for_azure_ai adds required fields for Azure AI schema. + + WORKAROUND TEST: Azure AI Projects API requires 'type' at item level and + 'annotations' in output_text content items, which OpenAI's Responses API does not require. + See: https://github.com/Azure/azure-sdk-for-python/issues/44493 + See: https://github.com/microsoft/agent-framework/issues/2926 + """ + client = create_test_azure_ai_client(mock_project_client) + + # Input in OpenAI Responses API format (what agent-framework generates) + openai_format_input = [ + { + "role": "user", + "content": [ + {"type": "input_text", "text": "Hello"}, + ], + }, + { + "role": "assistant", + "content": [ + {"type": "output_text", "text": "Hi there!"}, + ], + }, + ] + + result = client._transform_input_for_azure_ai(openai_format_input) # type: ignore + + # Verify 'type': 'message' added at item level + assert result[0]["type"] == "message" + assert result[1]["type"] == "message" + + # Verify 'annotations' added ONLY to output_text (assistant) content, NOT input_text (user) + assert result[0]["content"][0]["type"] == "input_text" # user content type preserved + assert "annotations" not in result[0]["content"][0] # user message - no annotations + assert result[1]["content"][0]["type"] == "output_text" # assistant content type preserved + assert result[1]["content"][0]["annotations"] == [] # assistant message - has annotations + + # Verify original fields preserved + assert result[0]["role"] == "user" + assert result[0]["content"][0]["text"] == "Hello" + assert result[1]["role"] == "assistant" + assert result[1]["content"][0]["text"] == "Hi there!" + + +def test_azure_ai_client_transform_input_preserves_existing_fields(mock_project_client: MagicMock) -> None: + """Test _transform_input_for_azure_ai preserves existing type and annotations.""" + client = create_test_azure_ai_client(mock_project_client) + + # Input that already has the fields (shouldn't duplicate) + input_with_fields = [ + { + "type": "message", + "role": "assistant", + "content": [ + {"type": "output_text", "text": "Hello", "annotations": [{"some": "annotation"}]}, + ], + }, + ] + + result = client._transform_input_for_azure_ai(input_with_fields) # type: ignore + + # Should preserve existing values, not overwrite + assert result[0]["type"] == "message" + assert result[0]["content"][0]["annotations"] == [{"some": "annotation"}] + + +def test_azure_ai_client_transform_input_handles_non_dict_content(mock_project_client: MagicMock) -> None: + """Test _transform_input_for_azure_ai handles non-dict content items.""" + client = create_test_azure_ai_client(mock_project_client) + + # Input with string content (edge case) + input_with_string_content = [ + { + "role": "user", + "content": ["plain string content"], + }, + ] + + result = client._transform_input_for_azure_ai(input_with_string_content) # type: ignore + + # Should add 'type': 'message' at item level even with non-dict content + assert result[0]["type"] == "message" + # Non-dict content items should be preserved without modification + assert result[0]["content"] == ["plain string content"] + + async def test_azure_ai_client_prepare_options_basic(mock_project_client: MagicMock) -> None: """Test prepare_options basic functionality.""" client = create_test_azure_ai_client(mock_project_client, agent_name="test-agent", agent_version="1.0")