From 2b30d6742a53994cb5a5b853584e865fb91e5617 Mon Sep 17 00:00:00 2001 From: eavanvalkenburg Date: Fri, 13 Feb 2026 12:56:50 +0100 Subject: [PATCH 1/7] Guard AzureAIClient runtime tool and structured output overrides --- .../agent_framework_azure_ai/_client.py | 131 +++++++++++++++--- .../azure-ai/tests/test_azure_ai_client.py | 96 +++++++++++++ 2 files changed, 210 insertions(+), 17 deletions(-) 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 79a30b0d81..a0a913e7e6 100644 --- a/python/packages/azure-ai/agent_framework_azure_ai/_client.py +++ b/python/packages/azure-ai/agent_framework_azure_ai/_client.py @@ -2,9 +2,10 @@ from __future__ import annotations +import json import sys from collections.abc import Callable, Mapping, MutableMapping, Sequence -from typing import Any, ClassVar, Generic, Literal, TypedDict, TypeVar, cast +from typing import Any, ClassVar, Generic, Literal, TypedDict, TypeVar, cast, get_args from agent_framework import ( AGENT_FRAMEWORK_USER_AGENT, @@ -95,6 +96,10 @@ class RawAzureAIClient(RawOpenAIResponsesClient[AzureAIClientOptionsT], Generic[ """ OTEL_PROVIDER_NAME: ClassVar[str] = "azure.ai" # type: ignore[reportIncompatibleVariableOverride, misc] + _RUNTIME_OVERRIDE_WARNING: ClassVar[str] = ( + "AzureAIClient does not support runtime tools or structured_output overrides after agent creation. " + "Use ResponsesClient instead." + ) def __init__( self, @@ -218,6 +223,10 @@ class MyOptions(ChatOptions, total=False): self._is_application_endpoint = "/applications/" in project_client._config.endpoint # type: ignore # Track whether we should close client connection self._should_close_client = should_close_client + # Track creation-time agent configuration for runtime mismatch warnings. + self._tracks_created_agent_configuration = False + self._created_agent_tool_names: set[str] = set() + self._created_agent_structured_output_signature: str | None = None async def configure_azure_monitor( self, @@ -395,6 +404,11 @@ async def _get_agent_reference_or_create( ) self.agent_version = created_agent.version + self._tracks_created_agent_configuration = True + self._created_agent_tool_names = self._extract_tool_names(run_options.get("tools")) + self._created_agent_structured_output_signature = self._get_structured_output_signature(chat_options) + else: + self._warn_if_runtime_overrides_changed(run_options, chat_options) return {"name": self.agent_name, "version": self.agent_version, "type": "agent_reference"} @@ -403,6 +417,103 @@ async def _close_client_if_needed(self) -> None: if self._should_close_client: await self.project_client.close() + def _get_supported_option_keys(self) -> set[str]: + """Resolve option keys from the concrete client options TypedDict.""" + option_type: Any = AzureAIProjectAgentOptions + original_type = getattr(self, "__orig_class__", None) + if original_type is not None: + type_args = get_args(original_type) + if type_args and hasattr(type_args[0], "__annotations__"): + option_type = type_args[0] + annotations = getattr(option_type, "__annotations__", {}) + return set(annotations) + + def _extract_tool_names(self, tools: Any) -> set[str]: + """Extract comparable tool names from runtime tool payloads.""" + if not isinstance(tools, Sequence) or isinstance(tools, str | bytes): + return set() + return {self._get_tool_name(tool) for tool in tools} + + def _get_tool_name(self, tool: Any) -> str: + """Get a stable name for a tool for runtime comparison.""" + if isinstance(tool, FunctionTool): + return tool.name + if isinstance(tool, Mapping): + tool_type = tool.get("type") + if tool_type == "function": + if isinstance(function_data := tool.get("function"), Mapping) and function_data.get("name"): + return str(function_data["name"]) + if tool.get("name"): + return str(tool["name"]) + if tool.get("name"): + return str(tool["name"]) + if tool.get("server_label"): + return f"mcp:{tool['server_label']}" + if tool_type: + return str(tool_type) + if getattr(tool, "name", None): + return str(tool.name) + if getattr(tool, "server_label", None): + return f"mcp:{tool.server_label}" + if getattr(tool, "type", None): + return str(tool.type) + return type(tool).__name__ + + def _get_structured_output_signature(self, chat_options: Mapping[str, Any] | None) -> str | None: + """Build a stable signature for structured_output/response_format values.""" + if not chat_options: + return None + response_format = chat_options.get("response_format") + if response_format is None: + return None + if isinstance(response_format, type): + return f"{response_format.__module__}.{response_format.__qualname__}" + if isinstance(response_format, Mapping): + return json.dumps(response_format, sort_keys=True, default=str) + return str(response_format) + + def _warn_if_runtime_overrides_changed( + self, + run_options: Mapping[str, Any], + chat_options: Mapping[str, Any] | None, + ) -> None: + """Warn when runtime tools or structured_output differ from creation-time configuration.""" + if not self._tracks_created_agent_configuration: + return + + runtime_tools = run_options.get("tools") + tools_changed = False + if runtime_tools is not None: + tools_changed = self._extract_tool_names(runtime_tools) != self._created_agent_tool_names + + runtime_structured_output = self._get_structured_output_signature(chat_options) + structured_output_changed = ( + runtime_structured_output is not None + and runtime_structured_output != self._created_agent_structured_output_signature + ) + + if tools_changed or structured_output_changed: + logger.warning(self._RUNTIME_OVERRIDE_WARNING) + + def _remove_agent_level_run_options(self, run_options: dict[str, Any]) -> None: + """Remove request-level options that Azure AI only supports at agent creation time.""" + supported_option_keys = self._get_supported_option_keys() + agent_level_option_to_run_keys = { + "model_id": ("model",), + "tools": ("tools",), + "response_format": ("response_format", "text", "text_format"), + "rai_config": ("rai_config",), + "temperature": ("temperature",), + "top_p": ("top_p",), + "reasoning": ("reasoning",), + } + + for option_key, run_keys in agent_level_option_to_run_keys.items(): + if option_key not in supported_option_keys: + continue + for run_key in run_keys: + run_options.pop(run_key, None) + @override async def _prepare_options( self, @@ -427,22 +538,8 @@ async def _prepare_options( agent_reference = await self._get_agent_reference_or_create(run_options, instructions, options) run_options["extra_body"] = {"agent": agent_reference} - # Remove properties that are not supported on request level - # but were configured on agent level - exclude = [ - "model", - "tools", - "response_format", - "rai_config", - "temperature", - "top_p", - "text", - "text_format", - "reasoning", - ] - - for property in exclude: - run_options.pop(property, None) + # Remove only keys that map to this client's declared options TypedDict. + self._remove_agent_level_run_options(run_options) return run_options 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 1114747d1b..370cdc99ee 100644 --- a/python/packages/azure-ai/tests/test_azure_ai_client.py +++ b/python/packages/azure-ai/tests/test_azure_ai_client.py @@ -130,6 +130,9 @@ def create_test_azure_ai_client( client.conversation_id = conversation_id client._is_application_endpoint = False # type: ignore client._should_close_client = should_close_client # type: ignore + client._tracks_created_agent_configuration = False # type: ignore + client._created_agent_tool_names = set() # type: ignore + client._created_agent_structured_output_signature = None # type: ignore client.additional_properties = {} client.middleware = None @@ -773,6 +776,31 @@ async def test_agent_creation_with_tools( assert call_args[1]["definition"].tools == test_tools +async def test_runtime_tools_override_logs_warning( + mock_project_client: MagicMock, +) -> None: + """Test warning is logged when runtime tools differ from creation-time tools.""" + 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) + + await client._get_agent_reference_or_create( + {"model": "test-model", "tools": [{"type": "function", "name": "tool_one"}]}, + None, + ) + + with patch("agent_framework_azure_ai._client.logger.warning") as mock_warning: + await client._get_agent_reference_or_create( + {"model": "test-model", "tools": [{"type": "function", "name": "tool_two"}]}, + None, + ) + mock_warning.assert_called_once() + assert "Use ResponsesClient instead." in mock_warning.call_args[0][0] + + async def test_use_latest_version_existing_agent( mock_project_client: MagicMock, ) -> None: @@ -872,6 +900,13 @@ class ResponseFormatModel(BaseModel): model_config = ConfigDict(extra="forbid") +class AlternateResponseFormatModel(BaseModel): + """Alternate model for structured output warning checks.""" + + summary: str + confidence: float + + async def test_agent_creation_with_response_format( mock_project_client: MagicMock, ) -> None: @@ -964,6 +999,33 @@ async def test_agent_creation_with_mapping_response_format( assert format_config.strict is True +async def test_runtime_structured_output_override_logs_warning( + mock_project_client: MagicMock, +) -> None: + """Test warning is logged when runtime structured_output differs from creation-time configuration.""" + 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) + + await client._get_agent_reference_or_create( + {"model": "test-model"}, + None, + {"response_format": ResponseFormatModel}, + ) + + with patch("agent_framework_azure_ai._client.logger.warning") as mock_warning: + await client._get_agent_reference_or_create( + {"model": "test-model"}, + None, + {"response_format": AlternateResponseFormatModel}, + ) + mock_warning.assert_called_once() + assert "Use ResponsesClient instead." in mock_warning.call_args[0][0] + + async def test_prepare_options_excludes_response_format( mock_project_client: MagicMock, ) -> None: @@ -1001,6 +1063,40 @@ async def test_prepare_options_excludes_response_format( assert run_options["extra_body"]["agent"]["name"] == "test-agent" +async def test_prepare_options_keeps_values_for_unsupported_option_keys( + mock_project_client: MagicMock, +) -> None: + """Test that run_options removal only applies to keys from the concrete options TypedDict.""" + client = create_test_azure_ai_client(mock_project_client, agent_name="test-agent", agent_version="1.0") + messages = [Message(role="user", contents=[Content.from_text(text="Hello")])] + + with ( + patch( + "agent_framework.openai._responses_client.RawOpenAIResponsesClient._prepare_options", + return_value={ + "model": "test-model", + "tools": [{"type": "function", "name": "weather"}], + "text": {"format": {"type": "json_schema", "name": "schema"}}, + "text_format": ResponseFormatModel, + "custom_option": "keep-me", + }, + ), + patch.object( + client, + "_get_agent_reference_or_create", + return_value={"name": "test-agent", "version": "1.0", "type": "agent_reference"}, + ), + patch.object(client, "_get_supported_option_keys", return_value={"model_id"}), + ): + run_options = await client._prepare_options(messages, {}) + + assert "model" not in run_options + assert "tools" in run_options + assert "text" in run_options + assert "text_format" in run_options + assert run_options["custom_option"] == "keep-me" + + def test_get_conversation_id_with_store_true_and_conversation_id() -> None: """Test _get_conversation_id returns conversation ID when store is True and conversation exists.""" client = create_test_azure_ai_client(MagicMock()) From 8376098f68d162a63cd5e8233043c55fdfc888d4 Mon Sep 17 00:00:00 2001 From: eavanvalkenburg Date: Fri, 13 Feb 2026 13:30:38 +0100 Subject: [PATCH 2/7] Simplify AzureAI runtime option pruning logic --- .../agent_framework_azure_ai/_client.py | 27 +++++-------------- .../azure-ai/tests/test_azure_ai_client.py | 9 +++---- 2 files changed, 10 insertions(+), 26 deletions(-) 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 a0a913e7e6..a6c6558890 100644 --- a/python/packages/azure-ai/agent_framework_azure_ai/_client.py +++ b/python/packages/azure-ai/agent_framework_azure_ai/_client.py @@ -5,7 +5,7 @@ import json import sys from collections.abc import Callable, Mapping, MutableMapping, Sequence -from typing import Any, ClassVar, Generic, Literal, TypedDict, TypeVar, cast, get_args +from typing import Any, ClassVar, Generic, Literal, TypedDict, TypeVar, cast from agent_framework import ( AGENT_FRAMEWORK_USER_AGENT, @@ -96,10 +96,6 @@ class RawAzureAIClient(RawOpenAIResponsesClient[AzureAIClientOptionsT], Generic[ """ OTEL_PROVIDER_NAME: ClassVar[str] = "azure.ai" # type: ignore[reportIncompatibleVariableOverride, misc] - _RUNTIME_OVERRIDE_WARNING: ClassVar[str] = ( - "AzureAIClient does not support runtime tools or structured_output overrides after agent creation. " - "Use ResponsesClient instead." - ) def __init__( self, @@ -417,17 +413,6 @@ async def _close_client_if_needed(self) -> None: if self._should_close_client: await self.project_client.close() - def _get_supported_option_keys(self) -> set[str]: - """Resolve option keys from the concrete client options TypedDict.""" - option_type: Any = AzureAIProjectAgentOptions - original_type = getattr(self, "__orig_class__", None) - if original_type is not None: - type_args = get_args(original_type) - if type_args and hasattr(type_args[0], "__annotations__"): - option_type = type_args[0] - annotations = getattr(option_type, "__annotations__", {}) - return set(annotations) - def _extract_tool_names(self, tools: Any) -> set[str]: """Extract comparable tool names from runtime tool payloads.""" if not isinstance(tools, Sequence) or isinstance(tools, str | bytes): @@ -493,11 +478,13 @@ def _warn_if_runtime_overrides_changed( ) if tools_changed or structured_output_changed: - logger.warning(self._RUNTIME_OVERRIDE_WARNING) + logger.warning( + "AzureAIClient does not support runtime tools or structured_output overrides after agent creation. " + "Use ResponsesClient instead." + ) def _remove_agent_level_run_options(self, run_options: dict[str, Any]) -> None: """Remove request-level options that Azure AI only supports at agent creation time.""" - supported_option_keys = self._get_supported_option_keys() agent_level_option_to_run_keys = { "model_id": ("model",), "tools": ("tools",), @@ -508,9 +495,7 @@ def _remove_agent_level_run_options(self, run_options: dict[str, Any]) -> None: "reasoning": ("reasoning",), } - for option_key, run_keys in agent_level_option_to_run_keys.items(): - if option_key not in supported_option_keys: - continue + for run_keys in agent_level_option_to_run_keys.values(): for run_key in run_keys: run_options.pop(run_key, None) 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 370cdc99ee..df837a1523 100644 --- a/python/packages/azure-ai/tests/test_azure_ai_client.py +++ b/python/packages/azure-ai/tests/test_azure_ai_client.py @@ -1066,7 +1066,7 @@ async def test_prepare_options_excludes_response_format( async def test_prepare_options_keeps_values_for_unsupported_option_keys( mock_project_client: MagicMock, ) -> None: - """Test that run_options removal only applies to keys from the concrete options TypedDict.""" + """Test that run_options removal only applies to known AzureAI agent-level option mappings.""" client = create_test_azure_ai_client(mock_project_client, agent_name="test-agent", agent_version="1.0") messages = [Message(role="user", contents=[Content.from_text(text="Hello")])] @@ -1086,14 +1086,13 @@ async def test_prepare_options_keeps_values_for_unsupported_option_keys( "_get_agent_reference_or_create", return_value={"name": "test-agent", "version": "1.0", "type": "agent_reference"}, ), - patch.object(client, "_get_supported_option_keys", return_value={"model_id"}), ): run_options = await client._prepare_options(messages, {}) assert "model" not in run_options - assert "tools" in run_options - assert "text" in run_options - assert "text_format" in run_options + assert "tools" not in run_options + assert "text" not in run_options + assert "text_format" not in run_options assert run_options["custom_option"] == "keep-me" From 8a2b5a15c151ce3bbac3558476d9bc28d5c28fb6 Mon Sep 17 00:00:00 2001 From: eavanvalkenburg Date: Mon, 16 Feb 2026 10:54:13 +0100 Subject: [PATCH 3/7] small fix --- .../agent_framework_azure_ai/_client.py | 55 ++++++++----------- 1 file changed, 23 insertions(+), 32 deletions(-) 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 a6c6558890..ee1aa62c44 100644 --- a/python/packages/azure-ai/agent_framework_azure_ai/_client.py +++ b/python/packages/azure-ai/agent_framework_azure_ai/_client.py @@ -2,6 +2,7 @@ from __future__ import annotations +from contextlib import suppress import json import sys from collections.abc import Callable, Mapping, MutableMapping, Sequence @@ -220,7 +221,7 @@ class MyOptions(ChatOptions, total=False): # Track whether we should close client connection self._should_close_client = should_close_client # Track creation-time agent configuration for runtime mismatch warnings. - self._tracks_created_agent_configuration = False + self.warn_runtime_tools_and_structure_changed = False self._created_agent_tool_names: set[str] = set() self._created_agent_structured_output_signature: str | None = None @@ -346,18 +347,18 @@ async def _get_agent_reference_or_create( "Agent name is required. Provide 'agent_name' when initializing AzureAIClient " "or 'name' when initializing Agent." ) + # If the agent exists and we do not want to track agent configuration, return early + if self.agent_version is not None and not self.warn_runtime_tools_and_structure_changed: + return {"name": self.agent_name, "version": self.agent_version, "type": "agent_reference"} # If no agent_version is provided, either use latest version or create a new agent: if self.agent_version is None: # Try to use latest version if requested and agent exists if self.use_latest_version: - try: + with suppress(ResourceNotFoundError): existing_agent = await self.project_client.agents.get(self.agent_name) self.agent_version = existing_agent.versions.latest.version return {"name": self.agent_name, "version": self.agent_version, "type": "agent_reference"} - except ResourceNotFoundError: - # Agent doesn't exist, fall through to creation logic - pass if "model" not in run_options or not run_options["model"]: raise ServiceInitializationError( @@ -400,11 +401,26 @@ async def _get_agent_reference_or_create( ) self.agent_version = created_agent.version - self._tracks_created_agent_configuration = True + self.warn_runtime_tools_and_structure_changed = True self._created_agent_tool_names = self._extract_tool_names(run_options.get("tools")) self._created_agent_structured_output_signature = self._get_structured_output_signature(chat_options) else: - self._warn_if_runtime_overrides_changed(run_options, chat_options) + runtime_tools = run_options.get("tools") + tools_changed = False + if runtime_tools is not None: + tools_changed = self._extract_tool_names(runtime_tools) != self._created_agent_tool_names + + runtime_structured_output = self._get_structured_output_signature(chat_options) + structured_output_changed = ( + runtime_structured_output is not None + and runtime_structured_output != self._created_agent_structured_output_signature + ) + + if tools_changed or structured_output_changed: + logger.warning( + "AzureAIClient does not support runtime tools or structured_output overrides after agent creation. " + "Use ResponsesClient instead." + ) return {"name": self.agent_name, "version": self.agent_version, "type": "agent_reference"} @@ -457,31 +473,6 @@ def _get_structured_output_signature(self, chat_options: Mapping[str, Any] | Non return json.dumps(response_format, sort_keys=True, default=str) return str(response_format) - def _warn_if_runtime_overrides_changed( - self, - run_options: Mapping[str, Any], - chat_options: Mapping[str, Any] | None, - ) -> None: - """Warn when runtime tools or structured_output differ from creation-time configuration.""" - if not self._tracks_created_agent_configuration: - return - - runtime_tools = run_options.get("tools") - tools_changed = False - if runtime_tools is not None: - tools_changed = self._extract_tool_names(runtime_tools) != self._created_agent_tool_names - - runtime_structured_output = self._get_structured_output_signature(chat_options) - structured_output_changed = ( - runtime_structured_output is not None - and runtime_structured_output != self._created_agent_structured_output_signature - ) - - if tools_changed or structured_output_changed: - logger.warning( - "AzureAIClient does not support runtime tools or structured_output overrides after agent creation. " - "Use ResponsesClient instead." - ) def _remove_agent_level_run_options(self, run_options: dict[str, Any]) -> None: """Remove request-level options that Azure AI only supports at agent creation time.""" From f4c391297e5023ce89cfe22674d25e54caa3e68c Mon Sep 17 00:00:00 2001 From: eavanvalkenburg Date: Mon, 16 Feb 2026 11:08:37 +0100 Subject: [PATCH 4/7] slight update --- python/packages/azure-ai/agent_framework_azure_ai/_client.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) 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 ee1aa62c44..5f5c793f60 100644 --- a/python/packages/azure-ai/agent_framework_azure_ai/_client.py +++ b/python/packages/azure-ai/agent_framework_azure_ai/_client.py @@ -2,10 +2,10 @@ from __future__ import annotations -from contextlib import suppress import json import sys from collections.abc import Callable, Mapping, MutableMapping, Sequence +from contextlib import suppress from typing import Any, ClassVar, Generic, Literal, TypedDict, TypeVar, cast from agent_framework import ( @@ -419,7 +419,7 @@ async def _get_agent_reference_or_create( if tools_changed or structured_output_changed: logger.warning( "AzureAIClient does not support runtime tools or structured_output overrides after agent creation. " - "Use ResponsesClient instead." + "Use AzureOpenAIResponsesClient instead." ) return {"name": self.agent_name, "version": self.agent_version, "type": "agent_reference"} @@ -473,7 +473,6 @@ def _get_structured_output_signature(self, chat_options: Mapping[str, Any] | Non return json.dumps(response_format, sort_keys=True, default=str) return str(response_format) - def _remove_agent_level_run_options(self, run_options: dict[str, Any]) -> None: """Remove request-level options that Azure AI only supports at agent creation time.""" agent_level_option_to_run_keys = { From 0b34efe33c87f39b1a7c0a85961d7b39ed270737 Mon Sep 17 00:00:00 2001 From: eavanvalkenburg Date: Mon, 16 Feb 2026 11:34:01 +0100 Subject: [PATCH 5/7] fix error message in test --- python/packages/azure-ai/tests/test_azure_ai_client.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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 df837a1523..8220fe84f0 100644 --- a/python/packages/azure-ai/tests/test_azure_ai_client.py +++ b/python/packages/azure-ai/tests/test_azure_ai_client.py @@ -798,7 +798,7 @@ async def test_runtime_tools_override_logs_warning( None, ) mock_warning.assert_called_once() - assert "Use ResponsesClient instead." in mock_warning.call_args[0][0] + assert "Use AzureOpenAIResponsesClient instead." in mock_warning.call_args[0][0] async def test_use_latest_version_existing_agent( @@ -1023,7 +1023,7 @@ async def test_runtime_structured_output_override_logs_warning( {"response_format": AlternateResponseFormatModel}, ) mock_warning.assert_called_once() - assert "Use ResponsesClient instead." in mock_warning.call_args[0][0] + assert "Use AzureOpenAIResponsesClient instead." in mock_warning.call_args[0][0] async def test_prepare_options_excludes_response_format( From f32f366af08887b792ddcc8115058c17c9fa4174 Mon Sep 17 00:00:00 2001 From: eavanvalkenburg Date: Mon, 16 Feb 2026 11:37:58 +0100 Subject: [PATCH 6/7] fix test var --- python/packages/azure-ai/tests/test_azure_ai_client.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 8220fe84f0..7b38695653 100644 --- a/python/packages/azure-ai/tests/test_azure_ai_client.py +++ b/python/packages/azure-ai/tests/test_azure_ai_client.py @@ -130,7 +130,7 @@ def create_test_azure_ai_client( client.conversation_id = conversation_id client._is_application_endpoint = False # type: ignore client._should_close_client = should_close_client # type: ignore - client._tracks_created_agent_configuration = False # type: ignore + client.warn_runtime_tools_and_structure_changed = False # type: ignore client._created_agent_tool_names = set() # type: ignore client._created_agent_structured_output_signature = None # type: ignore client.additional_properties = {} From 72c90a427df5b65459f81237c02aea482fe533cc Mon Sep 17 00:00:00 2001 From: eavanvalkenburg Date: Mon, 16 Feb 2026 13:31:46 +0100 Subject: [PATCH 7/7] Move Azure AI runtime override checks Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../agent_framework_azure_ai/_client.py | 47 ++++++---- .../azure-ai/tests/test_azure_ai_client.py | 94 +++++++++++++++---- 2 files changed, 101 insertions(+), 40 deletions(-) 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 5f5c793f60..82338f3d8d 100644 --- a/python/packages/azure-ai/agent_framework_azure_ai/_client.py +++ b/python/packages/azure-ai/agent_framework_azure_ai/_client.py @@ -404,24 +404,6 @@ async def _get_agent_reference_or_create( self.warn_runtime_tools_and_structure_changed = True self._created_agent_tool_names = self._extract_tool_names(run_options.get("tools")) self._created_agent_structured_output_signature = self._get_structured_output_signature(chat_options) - else: - runtime_tools = run_options.get("tools") - tools_changed = False - if runtime_tools is not None: - tools_changed = self._extract_tool_names(runtime_tools) != self._created_agent_tool_names - - runtime_structured_output = self._get_structured_output_signature(chat_options) - structured_output_changed = ( - runtime_structured_output is not None - and runtime_structured_output != self._created_agent_structured_output_signature - ) - - if tools_changed or structured_output_changed: - logger.warning( - "AzureAIClient does not support runtime tools or structured_output overrides after agent creation. " - "Use AzureOpenAIResponsesClient instead." - ) - return {"name": self.agent_name, "version": self.agent_version, "type": "agent_reference"} async def _close_client_if_needed(self) -> None: @@ -473,8 +455,33 @@ def _get_structured_output_signature(self, chat_options: Mapping[str, Any] | Non return json.dumps(response_format, sort_keys=True, default=str) return str(response_format) - def _remove_agent_level_run_options(self, run_options: dict[str, Any]) -> None: + def _remove_agent_level_run_options( + self, + run_options: dict[str, Any], + chat_options: Mapping[str, Any] | None = None, + ) -> None: """Remove request-level options that Azure AI only supports at agent creation time.""" + runtime_tools = run_options.get("tools") + runtime_structured_output = self._get_structured_output_signature(chat_options) + + if runtime_tools is not None or runtime_structured_output is not None: + tools_changed = runtime_tools is not None + structured_output_changed = runtime_structured_output is not None + + if self.warn_runtime_tools_and_structure_changed: + if runtime_tools is not None: + tools_changed = self._extract_tool_names(runtime_tools) != self._created_agent_tool_names + if runtime_structured_output is not None: + structured_output_changed = ( + runtime_structured_output != self._created_agent_structured_output_signature + ) + + if tools_changed or structured_output_changed: + logger.warning( + "AzureAIClient does not support runtime tools or structured_output overrides after agent creation. " + "Use AzureOpenAIResponsesClient instead." + ) + agent_level_option_to_run_keys = { "model_id": ("model",), "tools": ("tools",), @@ -514,7 +521,7 @@ async def _prepare_options( run_options["extra_body"] = {"agent": agent_reference} # Remove only keys that map to this client's declared options TypedDict. - self._remove_agent_level_run_options(run_options) + self._remove_agent_level_run_options(run_options, options) return run_options 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 7b38695653..73b4d3394e 100644 --- a/python/packages/azure-ai/tests/test_azure_ai_client.py +++ b/python/packages/azure-ai/tests/test_azure_ai_client.py @@ -786,19 +786,70 @@ async def test_runtime_tools_override_logs_warning( mock_agent.name = "test-agent" mock_agent.version = "1.0" mock_project_client.agents.create_version = AsyncMock(return_value=mock_agent) + messages = [Message(role="user", contents=[Content.from_text(text="Hello")])] - await client._get_agent_reference_or_create( - {"model": "test-model", "tools": [{"type": "function", "name": "tool_one"}]}, - None, - ) + with patch( + "agent_framework.openai._responses_client.RawOpenAIResponsesClient._prepare_options", + return_value={"model": "test-model", "tools": [{"type": "function", "name": "tool_one"}]}, + ): + await client._prepare_options(messages, {}) + + with ( + patch( + "agent_framework.openai._responses_client.RawOpenAIResponsesClient._prepare_options", + return_value={"model": "test-model", "tools": [{"type": "function", "name": "tool_two"}]}, + ), + patch("agent_framework_azure_ai._client.logger.warning") as mock_warning, + ): + await client._prepare_options(messages, {}) + mock_warning.assert_called_once() + assert "Use AzureOpenAIResponsesClient instead." in mock_warning.call_args[0][0] + + +async def test_prepare_options_logs_warning_for_tools_with_existing_agent_version( + mock_project_client: MagicMock, +) -> None: + """Test warning is logged when tools are supplied against an existing agent version.""" + client = create_test_azure_ai_client(mock_project_client, agent_name="test-agent", agent_version="1.0") + messages = [Message(role="user", contents=[Content.from_text(text="Hello")])] + + with ( + patch( + "agent_framework.openai._responses_client.RawOpenAIResponsesClient._prepare_options", + return_value={"model": "test-model", "tools": [{"type": "function", "name": "tool_one"}]}, + ), + patch("agent_framework_azure_ai._client.logger.warning") as mock_warning, + ): + run_options = await client._prepare_options(messages, {}) - with patch("agent_framework_azure_ai._client.logger.warning") as mock_warning: - await client._get_agent_reference_or_create( - {"model": "test-model", "tools": [{"type": "function", "name": "tool_two"}]}, - None, - ) mock_warning.assert_called_once() assert "Use AzureOpenAIResponsesClient instead." in mock_warning.call_args[0][0] + assert "tools" not in run_options + + +async def test_prepare_options_logs_warning_for_tools_on_application_endpoint( + mock_project_client: MagicMock, +) -> None: + """Test warning is logged when runtime tools are removed for application endpoints.""" + client = create_test_azure_ai_client(mock_project_client) + client._is_application_endpoint = True # type: ignore + messages = [Message(role="user", contents=[Content.from_text(text="Hello")])] + + with ( + patch( + "agent_framework.openai._responses_client.RawOpenAIResponsesClient._prepare_options", + return_value={"model": "test-model", "tools": [{"type": "function", "name": "tool_one"}]}, + ), + patch.object(client, "_get_agent_reference_or_create", new_callable=AsyncMock) as mock_get_agent_reference, + patch("agent_framework_azure_ai._client.logger.warning") as mock_warning, + ): + run_options = await client._prepare_options(messages, {}) + + mock_get_agent_reference.assert_not_called() + mock_warning.assert_called_once() + assert "Use AzureOpenAIResponsesClient instead." in mock_warning.call_args[0][0] + assert "tools" not in run_options + assert "extra_body" not in run_options async def test_use_latest_version_existing_agent( @@ -1009,19 +1060,22 @@ async def test_runtime_structured_output_override_logs_warning( mock_agent.name = "test-agent" mock_agent.version = "1.0" mock_project_client.agents.create_version = AsyncMock(return_value=mock_agent) + messages = [Message(role="user", contents=[Content.from_text(text="Hello")])] - await client._get_agent_reference_or_create( - {"model": "test-model"}, - None, - {"response_format": ResponseFormatModel}, - ) + with patch( + "agent_framework.openai._responses_client.RawOpenAIResponsesClient._prepare_options", + return_value={"model": "test-model"}, + ): + await client._prepare_options(messages, {"response_format": ResponseFormatModel}) - with patch("agent_framework_azure_ai._client.logger.warning") as mock_warning: - await client._get_agent_reference_or_create( - {"model": "test-model"}, - None, - {"response_format": AlternateResponseFormatModel}, - ) + with ( + patch( + "agent_framework.openai._responses_client.RawOpenAIResponsesClient._prepare_options", + return_value={"model": "test-model"}, + ), + patch("agent_framework_azure_ai._client.logger.warning") as mock_warning, + ): + await client._prepare_options(messages, {"response_format": AlternateResponseFormatModel}) mock_warning.assert_called_once() assert "Use AzureOpenAIResponsesClient instead." in mock_warning.call_args[0][0]