diff --git a/litellm/completion_extras/litellm_responses_transformation/transformation.py b/litellm/completion_extras/litellm_responses_transformation/transformation.py index 9902fe1d8fc..97a35951d03 100644 --- a/litellm/completion_extras/litellm_responses_transformation/transformation.py +++ b/litellm/completion_extras/litellm_responses_transformation/transformation.py @@ -426,6 +426,17 @@ def replayed_tool_search_tool_names(request: object) -> tuple[str, ...]: return tuple(chain.from_iterable(_tool_definition_name(tool) for tool in replayed_tool_search_tools(request))) +def with_namespace_members(tools: Sequence[object]) -> tuple[object, ...]: + """A Responses ``namespace`` tool stands for its member tools, as in a replayed search.""" + return tuple(chain.from_iterable(_namespace_members(tool) for tool in tools)) + + +def input_tool_search_tools(request: object) -> tuple[object, ...]: + """The tools a ``tool_search_output`` item in a Responses ``input`` loads.""" + items: Final = _tool_search_items_from_output_items(_json_field(request, "input")) + return with_namespace_members(tuple(chain.from_iterable(_loaded_tools(item) for item in items))) + + def _loads_any_tool(message: object, names: frozenset[str]) -> bool: loaded_names: Final = chain.from_iterable(_tool_definition_name(tool) for tool in _message_loaded_tools(message)) return any(name in names for name in loaded_names) diff --git a/litellm/llms/openai/responses/guardrail_translation/handler.py b/litellm/llms/openai/responses/guardrail_translation/handler.py index a18cbe13925..bf9c4c79ee7 100644 --- a/litellm/llms/openai/responses/guardrail_translation/handler.py +++ b/litellm/llms/openai/responses/guardrail_translation/handler.py @@ -50,7 +50,9 @@ from litellm._logging import verbose_proxy_logger from litellm.completion_extras.litellm_responses_transformation.transformation import ( LiteLLMResponsesTransformationHandler, OpenAiResponsesToChatCompletionStreamIterator, + input_tool_search_tools, tool_call_dict_from_output_item, + with_namespace_members, ) from litellm.litellm_core_utils.core_helpers import has_attributes from litellm.llms.base_llm.guardrail_translation.base_translation import ( @@ -76,6 +78,7 @@ from litellm.llms.openai.responses.guardrail_translation.tool_merge import ( append_returned_tools, merge_guardrailed_tools, ) +from litellm.responses.additional_tools import additional_tools_in_input from litellm.responses.litellm_completion_transformation.compaction_items import ( compaction_summary, decode_compaction_item, @@ -953,9 +956,12 @@ class OpenAIResponsesHandler(BaseTranslation): def extract_request_tool_names(self, data: Mapping[str, object]) -> list[str]: """Extract tool names from Responses API request (tools[].name for function - and custom, tools[].server_label for mcp).""" + and custom, tools[].server_label for mcp), including the tools additional_tools + items in input[] carry, opening namespace tools into their members and adding + the tools a tool_search_output in input[] loads.""" names: Final[list[str]] = [] - for tool in list_field(data, "tools"): + request_tools: Final = (*list_field(data, "tools"), *additional_tools_in_input(data.get("input"))) + for tool in (*with_namespace_members(request_tools), *input_tool_search_tools(data)): if not isinstance(tool, dict): continue if tool.get("type") in ("function", "custom") and tool.get("name"): diff --git a/litellm/responses/additional_tools.py b/litellm/responses/additional_tools.py index 9c7a3a5f72e..9373ca334c7 100644 --- a/litellm/responses/additional_tools.py +++ b/litellm/responses/additional_tools.py @@ -1,11 +1,11 @@ import json from collections.abc import Iterator, Mapping, Sequence from dataclasses import dataclass -from itertools import groupby +from itertools import chain, groupby from types import MappingProxyType from typing import Final, cast # noqa: TID251 # validating the openai tool union strips vendor keys from raw tools -from pydantic import ValidationError +from pydantic import TypeAdapter, ValidationError from litellm._logging import verbose_logger from litellm.types.llms.base import LiteLLMBaseModel @@ -29,6 +29,9 @@ class HoistedAdditionalTools: hoisted: tuple[ALL_RESPONSES_API_TOOL_PARAMS, ...] +_INPUT_ITEMS: Final = TypeAdapter(tuple[object, ...]) + + def _is_additional_tools_item(item: object) -> bool: try: return _InputItemType.model_validate(item).type == ADDITIONAL_TOOLS_INPUT_ITEM_TYPE @@ -86,6 +89,12 @@ def _unique_tools(tools: Sequence[Mapping[str, object]]) -> tuple[Mapping[str, o return tuple(_merged(copies_by_identity[identity]) for identity in first_seen_order) +def additional_tools_in_input(input: object) -> tuple[ALL_RESPONSES_API_TOOL_PARAMS, ...]: + """The tools that ``additional_tools`` input items carry, in input order.""" + items: Final = _INPUT_ITEMS.validate_python(input) if isinstance(input, list) else () + return tuple(chain.from_iterable(_tools_of_item(item) for item in items if _is_additional_tools_item(item))) + + def hoist_additional_tools( input: str | ResponseInputParam, tools: Sequence[ALL_RESPONSES_API_TOOL_PARAMS] | None, diff --git a/tests/unit/proxy/test_tools_allowlist_enforcement.py b/tests/unit/proxy/test_tools_allowlist_enforcement.py index e306f7be4f8..2bc18ec6cf8 100644 --- a/tests/unit/proxy/test_tools_allowlist_enforcement.py +++ b/tests/unit/proxy/test_tools_allowlist_enforcement.py @@ -86,6 +86,66 @@ class TestExtractRequestToolNames: "get_current_weather", ] + def test_openai_responses_namespace_tools(self): + """Codex sends MCP tools grouped in a namespace; the allowlist must see each member.""" + data = { + "tools": [ + {"type": "function", "name": "exec_command"}, + { + "type": "namespace", + "name": "mcp__mock", + "tools": [{"type": "function", "name": "search_issues"}, {"type": "function", "name": "get_issue"}], + }, + ] + } + assert extract_request_tool_names("/v1/responses", data) == ["exec_command", "search_issues", "get_issue"] + + def test_openai_responses_tool_search_output_in_input(self): + """With tool_search, deferred MCP tools arrive only in input[], never in tools[].""" + data = { + "tools": [{"type": "tool_search"}], + "input": [ + {"role": "user", "content": "find the issue"}, + {"type": "tool_search_call", "call_id": "ts_1", "execution": "client"}, + { + "type": "tool_search_output", + "call_id": "ts_1", + "tools": [ + { + "type": "namespace", + "name": "mcp__mock", + "tools": [{"type": "function", "name": "search_issues", "defer_loading": True}], + } + ], + }, + ], + } + assert extract_request_tool_names("/v1/responses", data) == ["search_issues"] + + def test_openai_responses_additional_tools_in_input(self): + """Codex on newer models leaves tools[] empty and sends every tool in an additional_tools input item.""" + data = { + "input": [ + { + "type": "additional_tools", + "role": "developer", + "tools": [ + { + "type": "namespace", + "name": "functions", + "tools": [{"type": "custom", "name": "exec"}, {"type": "function", "name": "wait"}], + }, + {"type": "function", "name": "search_issues"}, + ], + }, + {"role": "user", "content": "hi"}, + ], + } + assert extract_request_tool_names("/v1/responses", data) == ["exec", "wait", "search_issues"] + + def test_openai_responses_string_input_has_no_additional_tools(self): + assert extract_request_tool_names("/v1/responses", {"input": "hi"}) == [] + def test_anthropic_tools(self): data = {"tools": [{"name": "get_weather"}, {"name": "run_sql"}]} assert extract_request_tool_names("/v1/messages", data) == [ @@ -242,6 +302,55 @@ class TestCheckToolsAllowlist: assert exc_info.value.type == ProxyErrorTypes.tool_access_denied assert "restricted_tool" in str(exc_info.value.message) + @pytest.mark.parametrize( + "body", + [ + { + "tools": [ + {"type": "namespace", "name": "mcp__mock", "tools": [{"type": "function", "name": "run_shell"}]} + ] + }, + { + "tools": [{"type": "tool_search"}], + "input": [ + { + "type": "tool_search_output", + "call_id": "ts_1", + "tools": [ + { + "type": "namespace", + "name": "mcp__mock", + "tools": [{"type": "function", "name": "run_shell", "defer_loading": True}], + } + ], + } + ], + }, + { + "input": [ + { + "type": "additional_tools", + "role": "developer", + "tools": [{"type": "function", "name": "run_shell"}], + } + ] + }, + ], + ids=["namespace_in_tools", "tool_search_output_in_input", "additional_tools_in_input"], + ) + @pytest.mark.asyncio + async def test_disallowed_tool_outside_top_level_tools_raises_on_responses_route(self, body): + token = _token(metadata={"allowed_tools": ["get_weather"]}) + with pytest.raises(ProxyException) as exc_info: + await check_tools_allowlist( + request_body=body, + valid_token=token, + team_object=None, + route="/v1/responses", + ) + assert exc_info.value.type == ProxyErrorTypes.tool_access_denied + assert "run_shell" in str(exc_info.value.message) + @pytest.mark.parametrize( "loaded_tool", [