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
@@ -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",