From afa3712164dc41b552f57d0570409f24080abaf9 Mon Sep 17 00:00:00 2001 From: Dhruv-Sh-git Date: Wed, 28 Oct 2026 14:58:59 +0530 Subject: [PATCH 1/2] Python: convert nested SDK models in hosted tool sanitization --- .../foundry/agent_framework_foundry/_tools.py | 18 ++++++++- .../tests/foundry/test_foundry_chat_client.py | 37 +++++++++++++++++++ 2 files changed, 53 insertions(+), 2 deletions(-) diff --git a/python/packages/foundry/agent_framework_foundry/_tools.py b/python/packages/foundry/agent_framework_foundry/_tools.py index b392ab2dc56..cf7d9901c0e 100644 --- a/python/packages/foundry/agent_framework_foundry/_tools.py +++ b/python/packages/foundry/agent_framework_foundry/_tools.py @@ -33,6 +33,17 @@ def _validate_hosted_tool_payload(sanitized: Mapping[str, Any]) -> None: ) +def _to_plain_dict(value: Any) -> Any: + """Recursively convert Azure SDK models and mappings into plain dictionaries.""" + if hasattr(value, "as_dict") and callable(value.as_dict): + return _to_plain_dict(value.as_dict()) + if isinstance(value, Mapping): + return {k: _to_plain_dict(v) for k, v in value.items()} + if isinstance(value, (list, tuple)): + return [_to_plain_dict(v) for v in value] + return value + + def _sanitize_foundry_response_tool(tool_item: Any) -> Any: # pyright: ignore[reportUnusedFunction] """Return a Responses-API-safe tool payload for Foundry hosted tools. @@ -50,9 +61,12 @@ def _sanitize_foundry_response_tool(tool_item: Any) -> Any: # pyright: ignore[r defaulted (``file_search`` without ``vector_store_ids``, ``mcp`` without either ``server_url`` or ``project_connection_id``) raise ``ValueError`` with a message that points at the tool definition. + 4. Nested Azure SDK models (such as ``BingCustomSearchToolParameters`` or + ``AutoCodeInterpreterToolParam``) are recursively converted into plain + dictionaries so the resulting payload is JSON serializable. """ if isinstance(tool_item, FoundryMCPTool): - sanitized: dict[str, Any] = dict(cast("Mapping[str, Any]", tool_item)) + sanitized: dict[str, Any] = _to_plain_dict(tool_item) sanitized.pop("name", None) sanitized.pop("description", None) _validate_hosted_tool_payload(sanitized) @@ -61,7 +75,7 @@ def _sanitize_foundry_response_tool(tool_item: Any) -> Any: # pyright: ignore[r if isinstance(tool_item, Mapping): mapping = cast("Mapping[str, Any]", tool_item) if "type" in mapping and mapping.get("type") not in {"function", "custom"}: - sanitized = dict(mapping) + sanitized = _to_plain_dict(mapping) sanitized.pop("name", None) sanitized.pop("description", None) if sanitized.get("type") == "code_interpreter" and "container" not in sanitized: diff --git a/python/packages/foundry/tests/foundry/test_foundry_chat_client.py b/python/packages/foundry/tests/foundry/test_foundry_chat_client.py index 0a5defda458..54ff604be99 100644 --- a/python/packages/foundry/tests/foundry/test_foundry_chat_client.py +++ b/python/packages/foundry/tests/foundry/test_foundry_chat_client.py @@ -1336,6 +1336,43 @@ def test_prepare_tools_for_openai_strips_name_from_non_function_hosted_tool_dict assert "description" not in response_tools[1] +def test_prepare_tools_for_openai_serializes_bing_custom_search_tool() -> None: + """Hosted Bing custom search tool contains nested models that must be JSON serializable.""" + project_client = MagicMock() + project_client.get_openai_client.return_value = _make_mock_openai_client() + client = FoundryChatClient(project_client=project_client, model="test-model") + + tool = FoundryChatClient.get_bing_custom_search_tool( + connection_id="/subscriptions/x/connections/bing", + instance_name="inst", + market="zh-TW", + ) + + response_tools = client._prepare_tools_for_openai([tool]) + + assert len(response_tools) == 1 + # Ensure nested Azure SDK models were converted and are JSON serializable + dumped = json.dumps(response_tools) + assert "bing_custom_search_preview" in dumped + assert "project_connection_id" in dumped + assert "inst" in dumped + + +def test_prepare_tools_for_openai_serializes_code_interpreter_with_auto_param() -> None: + """Hosted code interpreter tool from factory must be JSON serializable.""" + project_client = MagicMock() + project_client.get_openai_client.return_value = _make_mock_openai_client() + client = FoundryChatClient(project_client=project_client, model="test-model") + + tool = FoundryChatClient.get_code_interpreter_tool() + response_tools = client._prepare_tools_for_openai([tool]) + + assert len(response_tools) == 1 + dumped = json.dumps(response_tools) + assert "code_interpreter" in dumped + assert "auto" in dumped + + @pytest.mark.flaky @pytest.mark.integration @skip_if_foundry_integration_tests_disabled From b49e2922ccdc822c4cd846df6776d8a51fa6dd26 Mon Sep 17 00:00:00 2001 From: Dhruv-Sh-git Date: Sat, 31 Oct 2026 03:04:46 +0530 Subject: [PATCH 2/2] Python: prioritize Mapping over as_dict in hosted tool dictionary conversion --- .../foundry/agent_framework_foundry/_tools.py | 8 +- .../tests/foundry/test_foundry_chat_client.py | 116 ++++++++++++++++++ 2 files changed, 121 insertions(+), 3 deletions(-) diff --git a/python/packages/foundry/agent_framework_foundry/_tools.py b/python/packages/foundry/agent_framework_foundry/_tools.py index cf7d9901c0e..7a60e7c9a54 100644 --- a/python/packages/foundry/agent_framework_foundry/_tools.py +++ b/python/packages/foundry/agent_framework_foundry/_tools.py @@ -35,12 +35,14 @@ def _validate_hosted_tool_payload(sanitized: Mapping[str, Any]) -> None: def _to_plain_dict(value: Any) -> Any: """Recursively convert Azure SDK models and mappings into plain dictionaries.""" + if isinstance(value, Mapping): + mapping = cast("Mapping[str, Any]", value) + return {k: _to_plain_dict(v) for k, v in mapping.items()} if hasattr(value, "as_dict") and callable(value.as_dict): return _to_plain_dict(value.as_dict()) - if isinstance(value, Mapping): - return {k: _to_plain_dict(v) for k, v in value.items()} if isinstance(value, (list, tuple)): - return [_to_plain_dict(v) for v in value] + items = cast("list[Any] | tuple[Any, ...]", value) + return [_to_plain_dict(v) for v in items] return value diff --git a/python/packages/foundry/tests/foundry/test_foundry_chat_client.py b/python/packages/foundry/tests/foundry/test_foundry_chat_client.py index 54ff604be99..05d7009a4f7 100644 --- a/python/packages/foundry/tests/foundry/test_foundry_chat_client.py +++ b/python/packages/foundry/tests/foundry/test_foundry_chat_client.py @@ -8,6 +8,7 @@ import os import sys import warnings +from collections.abc import Iterator, Mapping from functools import wraps from importlib import import_module from pathlib import Path @@ -47,6 +48,7 @@ from agent_framework_foundry import FoundryChatClient, RawFoundryChatClient from agent_framework_foundry._feature_usage import FeatureIndex, FeatureUsagePolicy +from agent_framework_foundry._tools import _to_plain_dict _OPENAI_HTTPX = cast(Any, import_module(DefaultAsyncHttpxClient.__mro__[1].__module__.partition(".")[0])) @@ -1373,6 +1375,120 @@ def test_prepare_tools_for_openai_serializes_code_interpreter_with_auto_param() assert "auto" in dumped +def test_to_plain_dict_prefers_mapping_over_incompatible_as_dict() -> None: + """Mapping objects with an incompatible as_dict method are converted without calling as_dict.""" + + class IncompatibleMapping(Mapping[str, Any]): + def __init__(self, data: dict[str, Any]) -> None: + self._data = data + self.as_dict_called = False + + def __getitem__(self, key: str) -> Any: + return self._data[key] + + def __iter__(self) -> Iterator[str]: + return iter(self._data) + + def __len__(self) -> int: + return len(self._data) + + def as_dict(self, required_arg: Any) -> dict[str, Any]: + self.as_dict_called = True + raise TypeError("as_dict() missing 1 required positional argument: 'required_arg'") + + class MockAzureSdkModel: + def __init__(self, payload: dict[str, Any]) -> None: + self._payload = payload + self.as_dict_called = False + + def as_dict(self) -> dict[str, Any]: + self.as_dict_called = True + return self._payload + + nested_mapping = IncompatibleMapping({"inner_key": "inner_value"}) + sdk_model = MockAzureSdkModel({"sdk_key": "sdk_value"}) + nested_list_item = IncompatibleMapping({"list_key": 42}) + + top_level = IncompatibleMapping({ + "type": "custom_hosted_tool", + "nested": nested_mapping, + "sdk": sdk_model, + "items": [nested_list_item], + }) + + result = _to_plain_dict(top_level) + + # Verify that the result and nested containers are regular plain dictionaries + assert type(result) is dict + assert type(result["nested"]) is dict + assert type(result["sdk"]) is dict + assert type(result["items"][0]) is dict + + # Verify values are recursively converted + assert result == { + "type": "custom_hosted_tool", + "nested": {"inner_key": "inner_value"}, + "sdk": {"sdk_key": "sdk_value"}, + "items": [{"list_key": 42}], + } + + # Verify that as_dict was never invoked on mapping instances + assert not top_level.as_dict_called + assert not nested_mapping.as_dict_called + assert not nested_list_item.as_dict_called + + # Verify that non-mapping Azure SDK model as_dict was invoked + assert sdk_model.as_dict_called + + +def test_prepare_tools_for_openai_handles_mapping_with_incompatible_as_dict() -> None: + """Hosted tool mapping with an incompatible as_dict method is sanitized and JSON serializable.""" + + class IncompatibleToolMapping(Mapping[str, Any]): + def __init__(self, data: dict[str, Any]) -> None: + self._data = data + self.as_dict_called = False + + def __getitem__(self, key: str) -> Any: + return self._data[key] + + def __iter__(self) -> Iterator[str]: + return iter(self._data) + + def __len__(self) -> int: + return len(self._data) + + def as_dict(self, required_arg: Any) -> dict[str, Any]: + self.as_dict_called = True + raise TypeError("as_dict() missing 1 required positional argument: 'required_arg'") + + project_client = MagicMock() + project_client.get_openai_client.return_value = _make_mock_openai_client() + client = FoundryChatClient(project_client=project_client, model="test-model") + + tool = IncompatibleToolMapping({ + "type": "code_interpreter", + "container": {"type": "auto"}, + "name": "read_model_name", + "description": "read_model_desc", + }) + + response_tools = client._prepare_tools_for_openai([tool]) + + assert len(response_tools) == 1 + sanitized = response_tools[0] + assert type(sanitized) is dict + assert "name" not in sanitized + assert "description" not in sanitized + assert sanitized["type"] == "code_interpreter" + assert sanitized["container"] == {"type": "auto"} + assert not tool.as_dict_called + + dumped = json.dumps(response_tools) + assert "code_interpreter" in dumped + assert "auto" in dumped + + @pytest.mark.flaky @pytest.mark.integration @skip_if_foundry_integration_tests_disabled