This commit is contained in:
구동하 2026-11-06 16:39:11 +09:00 • committed by GitHub
commit ef5dd22108
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 139 additions and 4 deletions

View file

@ -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)

View file

@ -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"):

View file

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

View file

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