mirror of
https://github.com/BerriAI/litellm.git
synced 2026-11-14 09:03:16 +00:00
Merge c00cf1823c into 08f2a98250
This commit is contained in:
commit
ef5dd22108
4 changed files with 139 additions and 4 deletions
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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"):
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
[
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue