mirror of
https://github.com/microsoft/agent-framework.git
synced 2026-06-16 21:04:09 +08:00
Python: Support OpenAI and Gemini allowed_tools tool choice (#5322)
* 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> * Python: Support OpenAI `allowed_tools` tool choice in Python SDK Fixes #5309 * 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> * 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> * 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> * 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> * 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> --------- Co-authored-by: Copilot <copilot@github.com> Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
committed by
GitHub
Unverified
parent
f5419b9f38
commit
570a4d54c2
@@ -1296,6 +1296,12 @@ class RawOpenAIChatClient( # type: ignore[misc]
|
||||
"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:
|
||||
|
||||
@@ -662,6 +662,12 @@ class RawOpenAIChatCompletionClient( # type: ignore[misc]
|
||||
"type": "function",
|
||||
"function": {"name": func_name},
|
||||
}
|
||||
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."
|
||||
)
|
||||
run_options["tool_choice"] = mode
|
||||
else:
|
||||
run_options["tool_choice"] = mode
|
||||
|
||||
|
||||
@@ -4259,6 +4259,12 @@ def test_with_callable_api_key() -> None:
|
||||
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",
|
||||
@@ -4813,6 +4819,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
|
||||
|
||||
|
||||
|
||||
@@ -1430,6 +1430,57 @@ def test_tool_choice_required_with_function_name(
|
||||
assert prepared_options["tool_choice"]["function"]["name"] == "get_weather"
|
||||
|
||||
|
||||
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 falls back to plain mode (Chat Completions API unsupported)."""
|
||||
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 prepared_options["tool_choice"] == "auto"
|
||||
|
||||
|
||||
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 required mode falls back to 'required'."""
|
||||
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 prepared_options["tool_choice"] == "required"
|
||||
|
||||
|
||||
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()
|
||||
@@ -1590,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",
|
||||
|
||||
Reference in New Issue
Block a user