Skip to content

Commit 928c9d5

Browse files
authored
Python: Fix AzureAIClient failure when conversation history contains assistant messages (#3076)
* Fix AzureAIClient failure when conversation history contains assistant messages * Address PR review feedback: improve docstring and test assertions * Remove redundant cast
1 parent 0aba02c commit 928c9d5

2 files changed

Lines changed: 135 additions & 1 deletion

File tree

python/packages/azure-ai/agent_framework_azure_ai/_client.py

Lines changed: 48 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
import sys
44
from collections.abc import Mapping, MutableSequence
5-
from typing import Any, ClassVar, TypeVar
5+
from typing import Any, ClassVar, TypeVar, cast
66

77
from agent_framework import (
88
AGENT_FRAMEWORK_USER_AGENT,
@@ -379,6 +379,15 @@ async def _prepare_options(
379379
"""Take ChatOptions and create the specific options for Azure AI."""
380380
prepared_messages, instructions = self._prepare_messages_for_azure_ai(messages)
381381
run_options = await super()._prepare_options(prepared_messages, chat_options, **kwargs)
382+
383+
# WORKAROUND: Azure AI Projects 'create responses' API has schema divergence from OpenAI's
384+
# Responses API. Azure requires 'type' at item level and 'annotations' in content items.
385+
# See: https://github.com/Azure/azure-sdk-for-python/issues/44493
386+
# See: https://github.com/microsoft/agent-framework/issues/2926
387+
# TODO(agent-framework#2926): Remove this workaround when Azure SDK aligns with OpenAI schema.
388+
if "input" in run_options and isinstance(run_options["input"], list):
389+
run_options["input"] = self._transform_input_for_azure_ai(cast(list[dict[str, Any]], run_options["input"]))
390+
382391
if not self._is_application_endpoint:
383392
# Application-scoped response APIs do not support "agent" property.
384393
agent_reference = await self._get_agent_reference_or_create(run_options, instructions)
@@ -393,6 +402,44 @@ async def _prepare_options(
393402

394403
return run_options
395404

405+
def _transform_input_for_azure_ai(self, input_items: list[dict[str, Any]]) -> list[dict[str, Any]]:
406+
"""Transform input items to match Azure AI Projects expected schema.
407+
408+
WORKAROUND: Azure AI Projects 'create responses' API expects a different schema than OpenAI's
409+
Responses API. Azure requires 'type' at the item level, and requires 'annotations'
410+
only for output_text content items (assistant messages), not for input_text content items
411+
(user messages). This helper adapts the OpenAI-style input to the Azure schema.
412+
413+
See: https://github.com/Azure/azure-sdk-for-python/issues/44493
414+
TODO(agent-framework#2926): Remove when Azure SDK aligns with OpenAI schema.
415+
"""
416+
transformed: list[dict[str, Any]] = []
417+
for item in input_items:
418+
new_item: dict[str, Any] = dict(item)
419+
420+
# Add 'type': 'message' at item level for role-based items
421+
if "role" in new_item and "type" not in new_item:
422+
new_item["type"] = "message"
423+
424+
# Add 'annotations' only to output_text content items (assistant messages)
425+
# User messages (input_text) do NOT support annotations in Azure AI
426+
if "content" in new_item and isinstance(new_item["content"], list):
427+
new_content: list[dict[str, Any] | Any] = []
428+
for content_item in new_item["content"]:
429+
if isinstance(content_item, dict):
430+
new_content_item: dict[str, Any] = dict(content_item)
431+
# Only add annotations to output_text (assistant content)
432+
if new_content_item.get("type") == "output_text" and "annotations" not in new_content_item:
433+
new_content_item["annotations"] = []
434+
new_content.append(new_content_item)
435+
else:
436+
new_content.append(content_item)
437+
new_item["content"] = new_content
438+
439+
transformed.append(new_item)
440+
441+
return transformed
442+
396443
@override
397444
def _get_current_conversation_id(self, chat_options: ChatOptions, **kwargs: Any) -> str | None:
398445
"""Get the current conversation ID from chat options or kwargs."""

python/packages/azure-ai/tests/test_azure_ai_client.py

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -286,6 +286,93 @@ async def test_azure_ai_client_prepare_messages_for_azure_ai_no_system_messages(
286286
assert instructions is None
287287

288288

289+
def test_azure_ai_client_transform_input_for_azure_ai(mock_project_client: MagicMock) -> None:
290+
"""Test _transform_input_for_azure_ai adds required fields for Azure AI schema.
291+
292+
WORKAROUND TEST: Azure AI Projects API requires 'type' at item level and
293+
'annotations' in output_text content items, which OpenAI's Responses API does not require.
294+
See: https://github.com/Azure/azure-sdk-for-python/issues/44493
295+
See: https://github.com/microsoft/agent-framework/issues/2926
296+
"""
297+
client = create_test_azure_ai_client(mock_project_client)
298+
299+
# Input in OpenAI Responses API format (what agent-framework generates)
300+
openai_format_input = [
301+
{
302+
"role": "user",
303+
"content": [
304+
{"type": "input_text", "text": "Hello"},
305+
],
306+
},
307+
{
308+
"role": "assistant",
309+
"content": [
310+
{"type": "output_text", "text": "Hi there!"},
311+
],
312+
},
313+
]
314+
315+
result = client._transform_input_for_azure_ai(openai_format_input) # type: ignore
316+
317+
# Verify 'type': 'message' added at item level
318+
assert result[0]["type"] == "message"
319+
assert result[1]["type"] == "message"
320+
321+
# Verify 'annotations' added ONLY to output_text (assistant) content, NOT input_text (user)
322+
assert result[0]["content"][0]["type"] == "input_text" # user content type preserved
323+
assert "annotations" not in result[0]["content"][0] # user message - no annotations
324+
assert result[1]["content"][0]["type"] == "output_text" # assistant content type preserved
325+
assert result[1]["content"][0]["annotations"] == [] # assistant message - has annotations
326+
327+
# Verify original fields preserved
328+
assert result[0]["role"] == "user"
329+
assert result[0]["content"][0]["text"] == "Hello"
330+
assert result[1]["role"] == "assistant"
331+
assert result[1]["content"][0]["text"] == "Hi there!"
332+
333+
334+
def test_azure_ai_client_transform_input_preserves_existing_fields(mock_project_client: MagicMock) -> None:
335+
"""Test _transform_input_for_azure_ai preserves existing type and annotations."""
336+
client = create_test_azure_ai_client(mock_project_client)
337+
338+
# Input that already has the fields (shouldn't duplicate)
339+
input_with_fields = [
340+
{
341+
"type": "message",
342+
"role": "assistant",
343+
"content": [
344+
{"type": "output_text", "text": "Hello", "annotations": [{"some": "annotation"}]},
345+
],
346+
},
347+
]
348+
349+
result = client._transform_input_for_azure_ai(input_with_fields) # type: ignore
350+
351+
# Should preserve existing values, not overwrite
352+
assert result[0]["type"] == "message"
353+
assert result[0]["content"][0]["annotations"] == [{"some": "annotation"}]
354+
355+
356+
def test_azure_ai_client_transform_input_handles_non_dict_content(mock_project_client: MagicMock) -> None:
357+
"""Test _transform_input_for_azure_ai handles non-dict content items."""
358+
client = create_test_azure_ai_client(mock_project_client)
359+
360+
# Input with string content (edge case)
361+
input_with_string_content = [
362+
{
363+
"role": "user",
364+
"content": ["plain string content"],
365+
},
366+
]
367+
368+
result = client._transform_input_for_azure_ai(input_with_string_content) # type: ignore
369+
370+
# Should add 'type': 'message' at item level even with non-dict content
371+
assert result[0]["type"] == "message"
372+
# Non-dict content items should be preserved without modification
373+
assert result[0]["content"] == ["plain string content"]
374+
375+
289376
async def test_azure_ai_client_prepare_options_basic(mock_project_client: MagicMock) -> None:
290377
"""Test prepare_options basic functionality."""
291378
client = create_test_azure_ai_client(mock_project_client, agent_name="test-agent", agent_version="1.0")

0 commit comments

Comments
 (0)