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:
Giles Odigwe
2026-04-29 10:43:47 -07:00
committed by GitHub
Unverified
parent f5419b9f38
commit 570a4d54c2
11 changed files with 900 additions and 593 deletions
+14 -1
View File
@@ -3246,10 +3246,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` is `"auto"` or `"required"`.
"""
mode: Literal["auto", "required", "none"]
required_function_name: str
allowed_tools: list[str]
# region TypedDict-based Chat Options
@@ -3482,7 +3484,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.
@@ -3499,6 +3501,17 @@ 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"] 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):
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
@@ -1087,16 +1087,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"} == {
@@ -1154,6 +1158,45 @@ 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 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"]})
# 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."""