From 350c9dd3e63ebd286af52249adbb4515da25c509 Mon Sep 17 00:00:00 2001 From: Copilot Date: Fri, 17 Apr 2026 03:06:11 +0000 Subject: [PATCH 1/7] Support OpenAI allowed_tools in ToolMode (#5309) Add allowed_tools field to ToolMode TypedDict, enabling users to restrict which tools the model may call via the OpenAI allowed_tools tool_choice type. This preserves prompt caching by keeping all tools in the tools list while limiting which ones the model can invoke. - Add allowed_tools: list[str] to ToolMode TypedDict - Add validation in validate_tool_mode() (only valid when mode == "auto") - Convert to OpenAI API format in _prepare_options() - Add tests for validation and API payload generation Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../packages/core/agent_framework/_types.py | 7 +- python/packages/core/tests/core/test_types.py | 20 +++++ .../agent_framework_openai/_chat_client.py | 6 ++ .../tests/openai/test_openai_chat_client.py | 84 +++++++++++++++++++ .../conversations/file_history_provider.py | 2 +- ...story_provider_conversation_persistence.py | 2 +- 6 files changed, 117 insertions(+), 4 deletions(-) diff --git a/python/packages/core/agent_framework/_types.py b/python/packages/core/agent_framework/_types.py index 4b6c2f0401..284166a87c 100644 --- a/python/packages/core/agent_framework/_types.py +++ b/python/packages/core/agent_framework/_types.py @@ -3148,11 +3148,12 @@ class ToolMode(TypedDict, total=False): Fields: mode: One of "auto", "required", or "none". required_function_name: Optional function name when `mode == "required"`. + allowed_tools: Optional list of tool names when `mode == "auto"`. """ mode: Literal["auto", "required", "none"] required_function_name: str - + allowed_tools: list[str] # region TypedDict-based Chat Options @@ -3384,7 +3385,7 @@ def validate_tool_mode( Returns: A ToolMode dict (contains keys: "mode", and optionally - "required_function_name"), or ``None`` when not provided. + "required_function_name" or "allowed_tools"), or ``None`` when not provided. Raises: ContentError: If the tool_choice string is invalid. @@ -3401,6 +3402,8 @@ def validate_tool_mode( raise ContentError(f"Invalid tool choice: {tool_choice['mode']}") if tool_choice["mode"] != "required" and "required_function_name" in tool_choice: raise ContentError("tool_choice with mode other than 'required' cannot have 'required_function_name'") + if tool_choice["mode"] != "auto" and "allowed_tools" in tool_choice: + raise ContentError("tool_choice with mode other than 'auto' cannot have 'allowed_tools'") return tool_choice diff --git a/python/packages/core/tests/core/test_types.py b/python/packages/core/tests/core/test_types.py index 4298563209..a6d41e45c1 100644 --- a/python/packages/core/tests/core/test_types.py +++ b/python/packages/core/tests/core/test_types.py @@ -1072,16 +1072,20 @@ def test_chat_tool_mode(): required_any: ToolMode = {"mode": "required"} required_mode: ToolMode = {"mode": "required", "required_function_name": "example_function"} none_mode: ToolMode = {"mode": "none"} + allowed_mode: ToolMode = {"mode": "auto", "allowed_tools": ["get_weather", "search_docs"]} # Check the type and content assert auto_mode["mode"] == "auto" assert "required_function_name" not in auto_mode + assert "allowed_tools" not in auto_mode assert required_any["mode"] == "required" assert "required_function_name" not in required_any assert required_mode["mode"] == "required" assert required_mode["required_function_name"] == "example_function" assert none_mode["mode"] == "none" assert "required_function_name" not in none_mode + assert allowed_mode["mode"] == "auto" + assert allowed_mode["allowed_tools"] == ["get_weather", "search_docs"] # equality of dicts assert {"mode": "required", "required_function_name": "example_function"} == { @@ -1139,6 +1143,22 @@ def test_chat_options_tool_choice_validation(): with raises(ContentError): validate_tool_mode({"mode": "auto", "required_function_name": "should_not_be_here"}) + # Valid allowed_tools + assert validate_tool_mode({"mode": "auto", "allowed_tools": ["get_weather"]}) == { + "mode": "auto", + "allowed_tools": ["get_weather"], + } + assert validate_tool_mode({"mode": "auto", "allowed_tools": ["get_weather", "search_docs"]}) == { + "mode": "auto", + "allowed_tools": ["get_weather", "search_docs"], + } + + # allowed_tools invalid with non-auto modes + with raises(ContentError): + validate_tool_mode({"mode": "required", "allowed_tools": ["get_weather"]}) + with raises(ContentError): + validate_tool_mode({"mode": "none", "allowed_tools": ["get_weather"]}) + def test_chat_options_merge(tool_tool, ai_tool) -> None: """Test merge_chat_options utility function.""" diff --git a/python/packages/openai/agent_framework_openai/_chat_client.py b/python/packages/openai/agent_framework_openai/_chat_client.py index 4aba988b39..d9775a602e 100644 --- a/python/packages/openai/agent_framework_openai/_chat_client.py +++ b/python/packages/openai/agent_framework_openai/_chat_client.py @@ -1215,6 +1215,12 @@ async def _prepare_options( "type": "function", "name": func_name, } + elif mode == "auto" and (allowed := tool_mode.get("allowed_tools")) is not None: + run_options["tool_choice"] = { + "type": "allowed_tools", + "mode": "auto", + "tools": [{"type": "function", "name": name} for name in allowed], + } else: run_options["tool_choice"] = mode else: diff --git a/python/packages/openai/tests/openai/test_openai_chat_client.py b/python/packages/openai/tests/openai/test_openai_chat_client.py index 4472a218bc..827eeed80b 100644 --- a/python/packages/openai/tests/openai/test_openai_chat_client.py +++ b/python/packages/openai/tests/openai/test_openai_chat_client.py @@ -4211,6 +4211,90 @@ async def test_prepare_options_excludes_continuation_token() -> None: assert run_options["background"] is True +async def test_prepare_options_allowed_tools() -> None: + """Test that _prepare_options converts allowed_tools to OpenAI API format.""" + client = OpenAIChatClient(model="test-model", api_key="test-key") + + @tool + def get_weather(city: str) -> str: + """Get the weather for a city.""" + return f"Sunny in {city}" + + @tool + def search_docs(query: str) -> str: + """Search documentation.""" + return f"Results for {query}" + + messages = [Message(role="user", contents=[Content.from_text(text="Hello")])] + options: dict[str, Any] = { + "model": "test-model", + "tools": [get_weather, search_docs], + "tool_choice": {"mode": "auto", "allowed_tools": ["get_weather"]}, + } + + run_options = await client._prepare_options(messages, options) + + assert run_options["tool_choice"] == { + "type": "allowed_tools", + "mode": "auto", + "tools": [{"type": "function", "name": "get_weather"}], + } + + +async def test_prepare_options_allowed_tools_multiple() -> None: + """Test that _prepare_options converts multiple allowed_tools correctly.""" + client = OpenAIChatClient(model="test-model", api_key="test-key") + + @tool + def get_weather(city: str) -> str: + """Get the weather for a city.""" + return f"Sunny in {city}" + + @tool + def search_docs(query: str) -> str: + """Search documentation.""" + return f"Results for {query}" + + messages = [Message(role="user", contents=[Content.from_text(text="Hello")])] + options: dict[str, Any] = { + "model": "test-model", + "tools": [get_weather, search_docs], + "tool_choice": {"mode": "auto", "allowed_tools": ["get_weather", "search_docs"]}, + } + + run_options = await client._prepare_options(messages, options) + + assert run_options["tool_choice"] == { + "type": "allowed_tools", + "mode": "auto", + "tools": [ + {"type": "function", "name": "get_weather"}, + {"type": "function", "name": "search_docs"}, + ], + } + + +async def test_prepare_options_auto_without_allowed_tools() -> None: + """Test that auto mode without allowed_tools still returns plain 'auto' string.""" + client = OpenAIChatClient(model="test-model", api_key="test-key") + + @tool + def get_weather(city: str) -> str: + """Get the weather for a city.""" + return f"Sunny in {city}" + + messages = [Message(role="user", contents=[Content.from_text(text="Hello")])] + options: dict[str, Any] = { + "model": "test-model", + "tools": [get_weather], + "tool_choice": {"mode": "auto"}, + } + + run_options = await client._prepare_options(messages, options) + + assert run_options["tool_choice"] == "auto" + + # endregion diff --git a/python/samples/02-agents/conversations/file_history_provider.py b/python/samples/02-agents/conversations/file_history_provider.py index 04a87f8224..1b457bcf55 100644 --- a/python/samples/02-agents/conversations/file_history_provider.py +++ b/python/samples/02-agents/conversations/file_history_provider.py @@ -21,7 +21,7 @@ from pydantic import Field try: - import orjson + import orjson # type: ignore[reportMissingImports] except ImportError: orjson = None diff --git a/python/samples/02-agents/conversations/file_history_provider_conversation_persistence.py b/python/samples/02-agents/conversations/file_history_provider_conversation_persistence.py index 70c5d7e8e8..bb9b69df12 100644 --- a/python/samples/02-agents/conversations/file_history_provider_conversation_persistence.py +++ b/python/samples/02-agents/conversations/file_history_provider_conversation_persistence.py @@ -22,7 +22,7 @@ from pydantic import Field try: - import orjson + import orjson # type: ignore[reportMissingImports] except ImportError: orjson = None From c7e57c07aaa1e11685d1223b194f4c7690b76418 Mon Sep 17 00:00:00 2001 From: Copilot Date: Fri, 17 Apr 2026 03:49:36 +0000 Subject: [PATCH 2/7] Python: Support OpenAI `allowed_tools` tool choice in Python SDK Fixes #5309 --- .../packages/core/agent_framework/_types.py | 1 + .../hyperlight/test_hyperlight_codeact.py | 19 ++++--------------- 2 files changed, 5 insertions(+), 15 deletions(-) diff --git a/python/packages/core/agent_framework/_types.py b/python/packages/core/agent_framework/_types.py index 284166a87c..f063ca78fc 100644 --- a/python/packages/core/agent_framework/_types.py +++ b/python/packages/core/agent_framework/_types.py @@ -3155,6 +3155,7 @@ class ToolMode(TypedDict, total=False): required_function_name: str allowed_tools: list[str] + # region TypedDict-based Chat Options diff --git a/python/packages/hyperlight/tests/hyperlight/test_hyperlight_codeact.py b/python/packages/hyperlight/tests/hyperlight/test_hyperlight_codeact.py index 528b6e3b5b..8089aa8a6c 100644 --- a/python/packages/hyperlight/tests/hyperlight/test_hyperlight_codeact.py +++ b/python/packages/hyperlight/tests/hyperlight/test_hyperlight_codeact.py @@ -823,9 +823,7 @@ async def test_sandbox_runs_simple_code(restored_sandbox) -> None: @skip_if_hyperlight_integration_tests_disabled async def test_sandbox_stdout_and_stderr_captured(restored_sandbox) -> None: - result = restored_sandbox.run( - 'import sys\nprint("out")\nprint("err", file=sys.stderr)' - ) + result = restored_sandbox.run('import sys\nprint("out")\nprint("err", file=sys.stderr)') assert result.success assert "out" in result.stdout assert "err" in result.stderr @@ -910,19 +908,12 @@ async def test_output_dir_cleared_between_invocations() -> None: # First invocation: write a file result1 = await run_tool.invoke( - arguments={ - "code": ( - 'with open("/output/stale.txt", "w") as f:\n' - ' f.write("first")\n' - 'print("wrote")\n' - ) - } + arguments={"code": ('with open("/output/stale.txt", "w") as f:\n f.write("first")\nprint("wrote")\n')} ) assert result1[0].type == "code_interpreter_tool_result" outputs1 = result1[0].outputs or [] assert any( - item.type == "data" and "stale.txt" in (item.additional_properties or {}).get("path", "") - for item in outputs1 + item.type == "data" and "stale.txt" in (item.additional_properties or {}).get("path", "") for item in outputs1 ), "First invocation should produce stale.txt" # Second invocation: no file writes @@ -971,9 +962,7 @@ async def _concurrent_task(): concurrent_ran = True release.set() - code_task = asyncio.create_task( - run_tool.invoke(arguments={"code": 'print("done")\n'}) - ) + code_task = asyncio.create_task(run_tool.invoke(arguments={"code": 'print("done")\n'})) await _concurrent_task() result = await code_task From e2386867dc5debe9566efa7d871774958ed06a1a Mon Sep 17 00:00:00 2001 From: Copilot Date: Fri, 17 Apr 2026 03:57:51 +0000 Subject: [PATCH 3/7] Fix #5309: Validate allowed_tools shape and add Chat Completions client support - validate_tool_mode now checks allowed_tools is a non-string sequence of strings and normalizes to list[str], raising ContentError for invalid types - Add missing allowed_tools branch in _chat_completion_client._prepare_options so allowed_tools is emitted as the OpenAI allowed_tools wire format instead of being silently dropped - Add tests for invalid allowed_tools types (string, int, mixed), empty list, tuple normalization, and Chat Completions client payload generation Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../packages/core/agent_framework/_types.py | 9 ++++ python/packages/core/tests/core/test_types.py | 19 ++++++++ .../_chat_completion_client.py | 6 +++ .../test_openai_chat_completion_client.py | 43 +++++++++++++++++++ 4 files changed, 77 insertions(+) diff --git a/python/packages/core/agent_framework/_types.py b/python/packages/core/agent_framework/_types.py index f063ca78fc..13ed02895a 100644 --- a/python/packages/core/agent_framework/_types.py +++ b/python/packages/core/agent_framework/_types.py @@ -3405,6 +3405,15 @@ def validate_tool_mode( raise ContentError("tool_choice with mode other than 'required' cannot have 'required_function_name'") if tool_choice["mode"] != "auto" and "allowed_tools" in tool_choice: raise ContentError("tool_choice with mode other than 'auto' cannot have 'allowed_tools'") + if "allowed_tools" in tool_choice: + allowed_tools = tool_choice["allowed_tools"] + if isinstance(allowed_tools, str) or not isinstance(allowed_tools, Sequence): + raise ContentError("tool_choice 'allowed_tools' must be a non-string sequence of strings") + if not all(isinstance(tool_name, str) for tool_name in allowed_tools): + raise ContentError("tool_choice 'allowed_tools' must contain only strings") + normalized_tool_choice = dict(tool_choice) + normalized_tool_choice["allowed_tools"] = list(allowed_tools) + return cast(ToolMode, normalized_tool_choice) return tool_choice diff --git a/python/packages/core/tests/core/test_types.py b/python/packages/core/tests/core/test_types.py index a6d41e45c1..d00c79b511 100644 --- a/python/packages/core/tests/core/test_types.py +++ b/python/packages/core/tests/core/test_types.py @@ -1159,6 +1159,25 @@ def test_chat_options_tool_choice_validation(): with raises(ContentError): validate_tool_mode({"mode": "none", "allowed_tools": ["get_weather"]}) + # allowed_tools must be a non-string sequence of strings + with raises(ContentError): + validate_tool_mode({"mode": "auto", "allowed_tools": "get_weather"}) + with raises(ContentError): + validate_tool_mode({"mode": "auto", "allowed_tools": 123}) + with raises(ContentError): + validate_tool_mode({"mode": "auto", "allowed_tools": ["get_weather", 123]}) + + # Empty list is valid (caller explicitly allows no tools) + assert validate_tool_mode({"mode": "auto", "allowed_tools": []}) == { + "mode": "auto", + "allowed_tools": [], + } + + # Tuple is normalized to list + result = validate_tool_mode({"mode": "auto", "allowed_tools": ("get_weather",)}) + assert result is not None + assert result["allowed_tools"] == ["get_weather"] + def test_chat_options_merge(tool_tool, ai_tool) -> None: """Test merge_chat_options utility function.""" diff --git a/python/packages/openai/agent_framework_openai/_chat_completion_client.py b/python/packages/openai/agent_framework_openai/_chat_completion_client.py index 2da59e031e..a60cf32384 100644 --- a/python/packages/openai/agent_framework_openai/_chat_completion_client.py +++ b/python/packages/openai/agent_framework_openai/_chat_completion_client.py @@ -662,6 +662,12 @@ def _prepare_options(self, messages: Sequence[Message], options: Mapping[str, An "type": "function", "function": {"name": func_name}, } + elif mode == "auto" and (allowed := tool_mode.get("allowed_tools")) is not None: + run_options["tool_choice"] = { + "type": "allowed_tools", + "mode": "auto", + "tools": [{"type": "function", "name": name} for name in allowed], + } else: run_options["tool_choice"] = mode diff --git a/python/packages/openai/tests/openai/test_openai_chat_completion_client.py b/python/packages/openai/tests/openai/test_openai_chat_completion_client.py index c012433b08..6f079cb00d 100644 --- a/python/packages/openai/tests/openai/test_openai_chat_completion_client.py +++ b/python/packages/openai/tests/openai/test_openai_chat_completion_client.py @@ -1430,6 +1430,49 @@ def test_tool_choice_required_with_function_name( assert prepared_options["tool_choice"]["function"]["name"] == "get_weather" +def test_tool_choice_allowed_tools( + openai_unit_test_env: dict[str, str], +) -> None: + """Test that tool_choice with allowed_tools is correctly prepared.""" + client = OpenAIChatCompletionClient() + + messages = [Message(role="user", contents=["test"])] + options = { + "tools": [get_weather], + "tool_choice": {"mode": "auto", "allowed_tools": ["get_weather"]}, + } + + prepared_options = client._prepare_options(messages, options) + + assert "tool_choice" in prepared_options + assert prepared_options["tool_choice"]["type"] == "allowed_tools" + assert prepared_options["tool_choice"]["mode"] == "auto" + assert prepared_options["tool_choice"]["tools"] == [{"type": "function", "name": "get_weather"}] + + +def test_tool_choice_allowed_tools_multiple( + openai_unit_test_env: dict[str, str], +) -> None: + """Test that tool_choice with multiple allowed_tools is correctly prepared.""" + client = OpenAIChatCompletionClient() + + messages = [Message(role="user", contents=["test"])] + options = { + "tools": [get_weather], + "tool_choice": {"mode": "auto", "allowed_tools": ["get_weather", "search_docs"]}, + } + + prepared_options = client._prepare_options(messages, options) + + assert "tool_choice" in prepared_options + assert prepared_options["tool_choice"]["type"] == "allowed_tools" + assert prepared_options["tool_choice"]["mode"] == "auto" + assert prepared_options["tool_choice"]["tools"] == [ + {"type": "function", "name": "get_weather"}, + {"type": "function", "name": "search_docs"}, + ] + + def test_response_format_dict_passthrough(openai_unit_test_env: dict[str, str]) -> None: """Test that response_format as dict is passed through directly.""" client = OpenAIChatCompletionClient() From 593f60761c6e91a9091fa2b7b7135c77bdb27433 Mon Sep 17 00:00:00 2001 From: Copilot Date: Mon, 20 Apr 2026 21:54:04 +0000 Subject: [PATCH 4/7] fix: support allowed_tools with mode 'required' in addition to 'auto' OpenAI's allowed_tools tool_choice type supports both mode 'auto' and 'required'. Update validation, client conversion, and tests to allow both modes instead of restricting to 'auto' only. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../packages/core/agent_framework/_types.py | 6 +- python/packages/core/tests/core/test_types.py | 10 ++- .../_chat_completion_client.py | 4 +- .../test_openai_chat_completion_client.py | 61 +++++++++++++++++++ 4 files changed, 73 insertions(+), 8 deletions(-) diff --git a/python/packages/core/agent_framework/_types.py b/python/packages/core/agent_framework/_types.py index 13ed02895a..cb248551cc 100644 --- a/python/packages/core/agent_framework/_types.py +++ b/python/packages/core/agent_framework/_types.py @@ -3148,7 +3148,7 @@ class ToolMode(TypedDict, total=False): Fields: mode: One of "auto", "required", or "none". required_function_name: Optional function name when `mode == "required"`. - allowed_tools: Optional list of tool names when `mode == "auto"`. + allowed_tools: Optional list of tool names when `mode` is `"auto"` or `"required"`. """ mode: Literal["auto", "required", "none"] @@ -3403,8 +3403,8 @@ def validate_tool_mode( raise ContentError(f"Invalid tool choice: {tool_choice['mode']}") if tool_choice["mode"] != "required" and "required_function_name" in tool_choice: raise ContentError("tool_choice with mode other than 'required' cannot have 'required_function_name'") - if tool_choice["mode"] != "auto" and "allowed_tools" in tool_choice: - raise ContentError("tool_choice with mode other than 'auto' cannot have 'allowed_tools'") + if tool_choice["mode"] not in ("auto", "required") and "allowed_tools" in tool_choice: + raise ContentError("tool_choice 'allowed_tools' is only valid when mode is 'auto' or 'required'") if "allowed_tools" in tool_choice: allowed_tools = tool_choice["allowed_tools"] if isinstance(allowed_tools, str) or not isinstance(allowed_tools, Sequence): diff --git a/python/packages/core/tests/core/test_types.py b/python/packages/core/tests/core/test_types.py index d00c79b511..52e1e1ed39 100644 --- a/python/packages/core/tests/core/test_types.py +++ b/python/packages/core/tests/core/test_types.py @@ -1153,9 +1153,13 @@ def test_chat_options_tool_choice_validation(): "allowed_tools": ["get_weather", "search_docs"], } - # allowed_tools invalid with non-auto modes - with raises(ContentError): - validate_tool_mode({"mode": "required", "allowed_tools": ["get_weather"]}) + # allowed_tools valid with required mode + assert validate_tool_mode({"mode": "required", "allowed_tools": ["get_weather"]}) == { + "mode": "required", + "allowed_tools": ["get_weather"], + } + + # allowed_tools invalid with none mode with raises(ContentError): validate_tool_mode({"mode": "none", "allowed_tools": ["get_weather"]}) diff --git a/python/packages/openai/agent_framework_openai/_chat_completion_client.py b/python/packages/openai/agent_framework_openai/_chat_completion_client.py index a60cf32384..e599b0cbde 100644 --- a/python/packages/openai/agent_framework_openai/_chat_completion_client.py +++ b/python/packages/openai/agent_framework_openai/_chat_completion_client.py @@ -662,10 +662,10 @@ def _prepare_options(self, messages: Sequence[Message], options: Mapping[str, An "type": "function", "function": {"name": func_name}, } - elif mode == "auto" and (allowed := tool_mode.get("allowed_tools")) is not None: + elif mode in ("auto", "required") and (allowed := tool_mode.get("allowed_tools")) is not None: run_options["tool_choice"] = { "type": "allowed_tools", - "mode": "auto", + "mode": mode, "tools": [{"type": "function", "name": name} for name in allowed], } else: diff --git a/python/packages/openai/tests/openai/test_openai_chat_completion_client.py b/python/packages/openai/tests/openai/test_openai_chat_completion_client.py index 6f079cb00d..ffde9aaa65 100644 --- a/python/packages/openai/tests/openai/test_openai_chat_completion_client.py +++ b/python/packages/openai/tests/openai/test_openai_chat_completion_client.py @@ -1473,6 +1473,67 @@ def test_tool_choice_allowed_tools_multiple( ] +def test_tool_choice_allowed_tools_empty( + openai_unit_test_env: dict[str, str], +) -> None: + """Test that tool_choice with empty allowed_tools produces allowed_tools payload.""" + client = OpenAIChatCompletionClient() + + messages = [Message(role="user", contents=["test"])] + options = { + "tools": [get_weather], + "tool_choice": {"mode": "auto", "allowed_tools": []}, + } + + prepared_options = client._prepare_options(messages, options) + + assert "tool_choice" in prepared_options + assert prepared_options["tool_choice"] == { + "type": "allowed_tools", + "mode": "auto", + "tools": [], + } + + +def test_tool_choice_allowed_tools_required_mode( + openai_unit_test_env: dict[str, str], +) -> None: + """Test that tool_choice with allowed_tools and mode required is correctly prepared.""" + client = OpenAIChatCompletionClient() + + messages = [Message(role="user", contents=["test"])] + options = { + "tools": [get_weather], + "tool_choice": {"mode": "required", "allowed_tools": ["get_weather"]}, + } + + prepared_options = client._prepare_options(messages, options) + + assert "tool_choice" in prepared_options + assert prepared_options["tool_choice"] == { + "type": "allowed_tools", + "mode": "required", + "tools": [{"type": "function", "name": "get_weather"}], + } + + +def test_tool_choice_auto_dict_without_allowed_tools( + openai_unit_test_env: dict[str, str], +) -> None: + """Test that tool_choice dict with mode auto and no allowed_tools falls through to plain 'auto'.""" + client = OpenAIChatCompletionClient() + + messages = [Message(role="user", contents=["test"])] + options = { + "tools": [get_weather], + "tool_choice": {"mode": "auto"}, + } + + prepared_options = client._prepare_options(messages, options) + + assert prepared_options["tool_choice"] == "auto" + + def test_response_format_dict_passthrough(openai_unit_test_env: dict[str, str]) -> None: """Test that response_format as dict is passed through directly.""" client = OpenAIChatCompletionClient() From dd1c2348f159ef78461db31be9640ecd5792da2c Mon Sep 17 00:00:00 2001 From: Giles Odigwe Date: Thu, 23 Apr 2026 13:06:03 -0700 Subject: [PATCH 5/7] fix: use Gemini VALIDATED mode for allowed_tools, warn in unsupported providers - Use FunctionCallingConfigMode.VALIDATED instead of ANY when allowed_tools is set with auto mode in Gemini, preserving optional tool-call semantics. - Handle allowed_tools in required mode with required_function_name precedence. - Fix allowed_names guard to use identity check (is not None) so empty lists are preserved. - Bump google-genai minimum to >=1.32.0 (VALIDATED added in that version). - Add warnings in Anthropic and Bedrock when allowed_tools is set but not supported. - Add Gemini unit tests for allowed_tools with auto, required, empty list, and required_function_name precedence scenarios. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../agent_framework_anthropic/_chat_client.py | 2 + .../agent_framework_bedrock/_chat_client.py | 2 + .../agent_framework_gemini/_chat_client.py | 15 +++- python/packages/gemini/pyproject.toml | 2 +- .../gemini/tests/test_gemini_client.py | 80 +++++++++++++++++++ python/uv.lock | 8 +- 6 files changed, 101 insertions(+), 8 deletions(-) diff --git a/python/packages/anthropic/agent_framework_anthropic/_chat_client.py b/python/packages/anthropic/agent_framework_anthropic/_chat_client.py index 6e5e5f14a6..d7660cac34 100644 --- a/python/packages/anthropic/agent_framework_anthropic/_chat_client.py +++ b/python/packages/anthropic/agent_framework_anthropic/_chat_client.py @@ -872,6 +872,8 @@ def _prepare_tools_for_anthropic(self, options: Mapping[str, Any]) -> dict[str, tool_mode = validate_tool_mode(options.get("tool_choice")) if tool_mode is None: return result or None + if "allowed_tools" in tool_mode: + logger.warning("allowed_tools is not supported by Anthropic; the setting will be ignored") allow_multiple = options.get("allow_multiple_tool_calls") match tool_mode.get("mode"): case "auto": diff --git a/python/packages/bedrock/agent_framework_bedrock/_chat_client.py b/python/packages/bedrock/agent_framework_bedrock/_chat_client.py index 3606cdf26b..66c99ce99e 100644 --- a/python/packages/bedrock/agent_framework_bedrock/_chat_client.py +++ b/python/packages/bedrock/agent_framework_bedrock/_chat_client.py @@ -405,6 +405,8 @@ def _prepare_options( tool_config = self._prepare_tools(options.get("tools")) if tool_mode := validate_tool_mode(options.get("tool_choice")): + if "allowed_tools" in tool_mode: + logger.warning("allowed_tools is not supported by Bedrock; the setting will be ignored") match tool_mode.get("mode"): case "none": # Bedrock doesn't support toolChoice "none". diff --git a/python/packages/gemini/agent_framework_gemini/_chat_client.py b/python/packages/gemini/agent_framework_gemini/_chat_client.py index b0fa52a676..0915ec3249 100644 --- a/python/packages/gemini/agent_framework_gemini/_chat_client.py +++ b/python/packages/gemini/agent_framework_gemini/_chat_client.py @@ -823,19 +823,28 @@ def _prepare_tool_config(self, tool_choice: Any) -> types.ToolConfig | None: match tool_mode.get("mode"): case "auto": - function_calling_mode, allowed_names = types.FunctionCallingConfigMode.AUTO, None + if "allowed_tools" in tool_mode: + function_calling_mode = types.FunctionCallingConfigMode.VALIDATED + allowed_names = list(tool_mode["allowed_tools"]) + else: + function_calling_mode, allowed_names = types.FunctionCallingConfigMode.AUTO, None case "none": function_calling_mode, allowed_names = types.FunctionCallingConfigMode.NONE, None case "required": function_calling_mode = types.FunctionCallingConfigMode.ANY name = tool_mode.get("required_function_name") - allowed_names = [name] if name else None + if name: + allowed_names = [name] + elif "allowed_tools" in tool_mode: + allowed_names = list(tool_mode["allowed_tools"]) + else: + allowed_names = None case unknown_mode: logger.warning("Unsupported tool_choice mode for Gemini: %s", unknown_mode) return None function_calling_kwargs: dict[str, Any] = {"mode": function_calling_mode} - if allowed_names: + if allowed_names is not None: function_calling_kwargs["allowed_function_names"] = allowed_names return types.ToolConfig(function_calling_config=types.FunctionCallingConfig(**function_calling_kwargs)) diff --git a/python/packages/gemini/pyproject.toml b/python/packages/gemini/pyproject.toml index 2a2d03fe0e..c7d507e553 100644 --- a/python/packages/gemini/pyproject.toml +++ b/python/packages/gemini/pyproject.toml @@ -25,7 +25,7 @@ classifiers = [ ] dependencies = [ "agent-framework-core>=1.1.1,<2.0", - "google-genai>=1.0.0,<2.0.0", + "google-genai>=1.32.0,<2.0.0", ] [tool.uv] diff --git a/python/packages/gemini/tests/test_gemini_client.py b/python/packages/gemini/tests/test_gemini_client.py index d5fcf5dbe0..0179876828 100644 --- a/python/packages/gemini/tests/test_gemini_client.py +++ b/python/packages/gemini/tests/test_gemini_client.py @@ -1155,6 +1155,86 @@ async def test_unknown_tool_choice_mode_is_ignored() -> None: assert not hasattr(config, "tool_config") or config.tool_config is None +async def test_tool_choice_auto_with_allowed_tools_uses_VALIDATED() -> None: + """Maps auto + allowed_tools to FunctionCallingConfigMode.VALIDATED with allowed_function_names.""" + tool = _make_dummy_tool() + client, mock = _make_gemini_client() + mock.aio.models.generate_content = AsyncMock(return_value=_make_response([_make_part(text="Hi")])) + + await client.get_response( + messages=[Message(role="user", contents=[Content.from_text("Hi")])], + options={ + "tools": [tool], + "tool_choice": {"mode": "auto", "allowed_tools": ["dummy", "other"]}, + }, + ) + + config: types.GenerateContentConfig = mock.aio.models.generate_content.call_args.kwargs["config"] + function_calling_config = config.tool_config.function_calling_config + assert function_calling_config.mode == "VALIDATED" + assert function_calling_config.allowed_function_names == ["dummy", "other"] + + +async def test_tool_choice_auto_with_empty_allowed_tools_uses_VALIDATED() -> None: + """Maps auto + empty allowed_tools to VALIDATED with empty allowed_function_names.""" + tool = _make_dummy_tool() + client, mock = _make_gemini_client() + mock.aio.models.generate_content = AsyncMock(return_value=_make_response([_make_part(text="Hi")])) + + await client.get_response( + messages=[Message(role="user", contents=[Content.from_text("Hi")])], + options={ + "tools": [tool], + "tool_choice": {"mode": "auto", "allowed_tools": []}, + }, + ) + + config: types.GenerateContentConfig = mock.aio.models.generate_content.call_args.kwargs["config"] + function_calling_config = config.tool_config.function_calling_config + assert function_calling_config.mode == "VALIDATED" + assert function_calling_config.allowed_function_names == [] + + +async def test_tool_choice_required_with_allowed_tools_uses_ANY() -> None: + """Maps required + allowed_tools to ANY with allowed_function_names.""" + tool = _make_dummy_tool() + client, mock = _make_gemini_client() + mock.aio.models.generate_content = AsyncMock(return_value=_make_response([_make_part(text="Hi")])) + + await client.get_response( + messages=[Message(role="user", contents=[Content.from_text("Hi")])], + options={ + "tools": [tool], + "tool_choice": {"mode": "required", "allowed_tools": ["dummy"]}, + }, + ) + + config: types.GenerateContentConfig = mock.aio.models.generate_content.call_args.kwargs["config"] + function_calling_config = config.tool_config.function_calling_config + assert function_calling_config.mode == "ANY" + assert function_calling_config.allowed_function_names == ["dummy"] + + +async def test_tool_choice_required_function_name_takes_precedence_over_allowed_tools() -> None: + """When both required_function_name and allowed_tools are present, required_function_name wins.""" + tool = _make_dummy_tool() + client, mock = _make_gemini_client() + mock.aio.models.generate_content = AsyncMock(return_value=_make_response([_make_part(text="Hi")])) + + await client.get_response( + messages=[Message(role="user", contents=[Content.from_text("Hi")])], + options={ + "tools": [tool], + "tool_choice": {"mode": "required", "required_function_name": "dummy", "allowed_tools": ["other"]}, + }, + ) + + config: types.GenerateContentConfig = mock.aio.models.generate_content.call_args.kwargs["config"] + function_calling_config = config.tool_config.function_calling_config + assert function_calling_config.mode == "ANY" + assert function_calling_config.allowed_function_names == ["dummy"] + + # built-in tool factories diff --git a/python/uv.lock b/python/uv.lock index a1acd778bb..a7318109c6 100644 --- a/python/uv.lock +++ b/python/uv.lock @@ -550,7 +550,7 @@ dependencies = [ [package.metadata] requires-dist = [ { name = "agent-framework-core", editable = "packages/core" }, - { name = "google-genai", specifier = ">=1.0.0,<2.0.0" }, + { name = "google-genai", specifier = ">=1.32.0,<2.0.0" }, ] [[package]] @@ -2509,7 +2509,7 @@ requests = [ [[package]] name = "google-genai" -version = "1.68.0" +version = "1.73.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "anyio", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, @@ -2523,9 +2523,9 @@ dependencies = [ { name = "typing-extensions", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, { name = "websockets", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/9c/2c/f059982dbcb658cc535c81bbcbe7e2c040d675f4b563b03cdb01018a4bc3/google_genai-1.68.0.tar.gz", hash = "sha256:ac30c0b8bc630f9372993a97e4a11dae0e36f2e10d7c55eacdca95a9fa14ca96", size = 511285, upload-time = "2026-03-18T01:03:18.243Z" } +sdist = { url = "https://files.pythonhosted.org/packages/3d/d8/40f5f107e5a2976bbac52d421f04d14fc221b55a8f05e66be44b2f739fe6/google_genai-1.73.1.tar.gz", hash = "sha256:b637e3a3b9e2eccc46f27136d470165803de84eca52abfed2e7352081a4d5a15", size = 530998, upload-time = "2026-04-14T21:06:19.153Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/84/de/7d3ee9c94b74c3578ea4f88d45e8de9405902f857932334d81e89bce3dfa/google_genai-1.68.0-py3-none-any.whl", hash = "sha256:a1bc9919c0e2ea2907d1e319b65471d3d6d58c54822039a249fe1323e4178d15", size = 750912, upload-time = "2026-03-18T01:03:15.983Z" }, + { url = "https://files.pythonhosted.org/packages/65/af/508e0528015240d710c6763f7c89ff44fab9a94a80b4377e265d692cbfd6/google_genai-1.73.1-py3-none-any.whl", hash = "sha256:af2d2287d25e42a187de19811ef33beb2e347c7e2bdb4dc8c467d78254e43a2c", size = 783595, upload-time = "2026-04-14T21:06:17.464Z" }, ] [[package]] From f0273be3493addd47940d54a79995f5ee67a2037 Mon Sep 17 00:00:00 2001 From: Giles Odigwe Date: Fri, 24 Apr 2026 08:23:31 -0700 Subject: [PATCH 6/7] fix: Chat Completions API does not support allowed_tools, add integration tests - Chat Completions API (_chat_completion_client.py) now warns and falls back to plain mode when allowed_tools is set, since the /chat/completions endpoint does not support the allowed_tools type. - Add allowed_tools integration test param to both OpenAIChatClient (Responses API) and OpenAIChatCompletionClient parametrized option tests. - Update Chat Completions unit tests to reflect the warn-and-fallback behavior. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../_chat_completion_client.py | 10 +-- .../tests/openai/test_openai_chat_client.py | 6 ++ .../test_openai_chat_completion_client.py | 71 ++++--------------- 3 files changed, 23 insertions(+), 64 deletions(-) diff --git a/python/packages/openai/agent_framework_openai/_chat_completion_client.py b/python/packages/openai/agent_framework_openai/_chat_completion_client.py index e599b0cbde..41a05dbb0f 100644 --- a/python/packages/openai/agent_framework_openai/_chat_completion_client.py +++ b/python/packages/openai/agent_framework_openai/_chat_completion_client.py @@ -663,11 +663,11 @@ def _prepare_options(self, messages: Sequence[Message], options: Mapping[str, An "function": {"name": func_name}, } elif mode in ("auto", "required") and (allowed := tool_mode.get("allowed_tools")) is not None: - run_options["tool_choice"] = { - "type": "allowed_tools", - "mode": mode, - "tools": [{"type": "function", "name": name} for name in allowed], - } + logger.warning( + "allowed_tools is not supported by the Chat Completions API; " + "the setting will be ignored. Use OpenAIChatClient (Responses API) instead." + ) + run_options["tool_choice"] = mode else: run_options["tool_choice"] = mode diff --git a/python/packages/openai/tests/openai/test_openai_chat_client.py b/python/packages/openai/tests/openai/test_openai_chat_client.py index 34c97ffc5e..e4ad603b45 100644 --- a/python/packages/openai/tests/openai/test_openai_chat_client.py +++ b/python/packages/openai/tests/openai/test_openai_chat_client.py @@ -3962,6 +3962,12 @@ async def get_api_key() -> str: True, id="tool_choice_required", ), + param( + "tool_choice", + {"mode": "auto", "allowed_tools": ["get_weather"]}, + True, + id="tool_choice_allowed_tools", + ), param("response_format", OutputStruct, True, id="response_format_pydantic"), param( "response_format", diff --git a/python/packages/openai/tests/openai/test_openai_chat_completion_client.py b/python/packages/openai/tests/openai/test_openai_chat_completion_client.py index ffde9aaa65..400514c51f 100644 --- a/python/packages/openai/tests/openai/test_openai_chat_completion_client.py +++ b/python/packages/openai/tests/openai/test_openai_chat_completion_client.py @@ -1430,10 +1430,10 @@ def test_tool_choice_required_with_function_name( assert prepared_options["tool_choice"]["function"]["name"] == "get_weather" -def test_tool_choice_allowed_tools( +def test_tool_choice_allowed_tools_falls_back_to_mode( openai_unit_test_env: dict[str, str], ) -> None: - """Test that tool_choice with allowed_tools is correctly prepared.""" + """Test that tool_choice with allowed_tools falls back to plain mode (Chat Completions API unsupported).""" client = OpenAIChatCompletionClient() messages = [Message(role="user", contents=["test"])] @@ -1444,61 +1444,13 @@ def test_tool_choice_allowed_tools( prepared_options = client._prepare_options(messages, options) - assert "tool_choice" in prepared_options - assert prepared_options["tool_choice"]["type"] == "allowed_tools" - assert prepared_options["tool_choice"]["mode"] == "auto" - assert prepared_options["tool_choice"]["tools"] == [{"type": "function", "name": "get_weather"}] - - -def test_tool_choice_allowed_tools_multiple( - openai_unit_test_env: dict[str, str], -) -> None: - """Test that tool_choice with multiple allowed_tools is correctly prepared.""" - client = OpenAIChatCompletionClient() - - messages = [Message(role="user", contents=["test"])] - options = { - "tools": [get_weather], - "tool_choice": {"mode": "auto", "allowed_tools": ["get_weather", "search_docs"]}, - } - - prepared_options = client._prepare_options(messages, options) - - assert "tool_choice" in prepared_options - assert prepared_options["tool_choice"]["type"] == "allowed_tools" - assert prepared_options["tool_choice"]["mode"] == "auto" - assert prepared_options["tool_choice"]["tools"] == [ - {"type": "function", "name": "get_weather"}, - {"type": "function", "name": "search_docs"}, - ] - - -def test_tool_choice_allowed_tools_empty( - openai_unit_test_env: dict[str, str], -) -> None: - """Test that tool_choice with empty allowed_tools produces allowed_tools payload.""" - client = OpenAIChatCompletionClient() - - messages = [Message(role="user", contents=["test"])] - options = { - "tools": [get_weather], - "tool_choice": {"mode": "auto", "allowed_tools": []}, - } - - prepared_options = client._prepare_options(messages, options) - - assert "tool_choice" in prepared_options - assert prepared_options["tool_choice"] == { - "type": "allowed_tools", - "mode": "auto", - "tools": [], - } + assert prepared_options["tool_choice"] == "auto" -def test_tool_choice_allowed_tools_required_mode( +def test_tool_choice_allowed_tools_required_mode_falls_back( openai_unit_test_env: dict[str, str], ) -> None: - """Test that tool_choice with allowed_tools and mode required is correctly prepared.""" + """Test that tool_choice with allowed_tools and required mode falls back to 'required'.""" client = OpenAIChatCompletionClient() messages = [Message(role="user", contents=["test"])] @@ -1509,12 +1461,7 @@ def test_tool_choice_allowed_tools_required_mode( prepared_options = client._prepare_options(messages, options) - assert "tool_choice" in prepared_options - assert prepared_options["tool_choice"] == { - "type": "allowed_tools", - "mode": "required", - "tools": [{"type": "function", "name": "get_weather"}], - } + assert prepared_options["tool_choice"] == "required" def test_tool_choice_auto_dict_without_allowed_tools( @@ -1694,6 +1641,12 @@ class OutputStruct(BaseModel): False, id="tool_choice_required", ), + param( + "tool_choice", + {"mode": "auto", "allowed_tools": ["get_weather"]}, + False, + id="tool_choice_allowed_tools", + ), param("response_format", OutputStruct, True, id="response_format_pydantic"), param( "response_format", From dfa3faae53fa286a192851ac8daca66263c0eb04 Mon Sep 17 00:00:00 2001 From: Giles Odigwe Date: Fri, 24 Apr 2026 10:08:31 -0700 Subject: [PATCH 7/7] fix: remove unused walrus operator variable in chat completion client Remove assigned-but-never-used variable 'allowed' flagged by ruff F841. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../openai/agent_framework_openai/_chat_completion_client.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/python/packages/openai/agent_framework_openai/_chat_completion_client.py b/python/packages/openai/agent_framework_openai/_chat_completion_client.py index 41a05dbb0f..c31242b128 100644 --- a/python/packages/openai/agent_framework_openai/_chat_completion_client.py +++ b/python/packages/openai/agent_framework_openai/_chat_completion_client.py @@ -662,7 +662,7 @@ def _prepare_options(self, messages: Sequence[Message], options: Mapping[str, An "type": "function", "function": {"name": func_name}, } - elif mode in ("auto", "required") and (allowed := tool_mode.get("allowed_tools")) is not None: + elif mode in ("auto", "required") and tool_mode.get("allowed_tools") is not None: logger.warning( "allowed_tools is not supported by the Chat Completions API; " "the setting will be ignored. Use OpenAIChatClient (Responses API) instead."