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
@@ -823,19 +823,28 @@ class RawGeminiChatClient(
|
||||
|
||||
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))
|
||||
|
||||
@@ -1157,6 +1157,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
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user