Merge pull request #39366 from BerriAI/litellm_responses_guardrail_namespace_tools

fix(responses): keep namespace tools intact when a guardrail returns them unchanged
This commit is contained in:
Mateo Wang 2026-09-02 17:19:21 -07:00 committed by GitHub
commit 708c396b90
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 723 additions and 169 deletions

View file

@ -24,7 +24,7 @@
"limit": 19
},
"reportExplicitAny": {
"limit": 4125
"limit": 4124
},
"reportFunctionMemberAccess": {
"limit": 7
@ -99,7 +99,7 @@
"limit": 0
},
"reportUnknownArgumentType": {
"limit": 44364
"limit": 44362
},
"reportUnknownLambdaType": {
"limit": 109
@ -117,13 +117,13 @@
"limit": 111
},
"reportUnnecessaryComparison": {
"limit": 692
"limit": 687
},
"reportUnnecessaryContains": {
"limit": 5
"limit": 4
},
"reportUnnecessaryIsInstance": {
"limit": 826
"limit": 823
},
"reportUntypedBaseClass": {
"limit": 0

View file

@ -28,6 +28,7 @@ Output: response.output is List[GenericResponseOutputItem] where each has:
- text: str
"""
import copy
import time
import uuid
from collections.abc import Mapping, Sequence
@ -36,7 +37,6 @@ from types import MappingProxyType
from typing import TYPE_CHECKING, Any, Final, Union, cast
from openai.types.responses.response_function_tool_call import ResponseFunctionToolCall
from openai.types.responses.tool_param import FunctionToolParam
from pydantic import BaseModel, TypeAdapter
from typing_extensions import ReadOnly, TypedDict
@ -49,6 +49,7 @@ from litellm.llms.base_llm.guardrail_translation.utils import (
blocked_responses_stream_usage,
stream_item_field,
)
from litellm.llms.openai.responses.guardrail_translation.tool_merge import merge_guardrailed_tools
from litellm.responses.litellm_completion_transformation.transformation import (
LiteLLMCompletionResponsesConfig,
)
@ -62,7 +63,6 @@ from litellm.types.llms.openai import (
ContentPartDonePartOutputText,
ErrorEvent,
ErrorEventError,
OpenAIMcpServerTool,
OutputItemAddedEvent,
OutputItemDoneEvent,
OutputTextDeltaEvent,
@ -157,23 +157,31 @@ class OpenAIResponsesHandler(BaseTranslation):
Handles both string input and list of message objects.
"""
input_data: Final[str | ResponseInputParam | None] = data.get("input")
tools_to_check: Final[list[ChatCompletionToolParam]] = []
if input_data is None:
return data
structured_messages: Final = self.get_structured_messages(data)
raw_tools: Final = data.get("tools")
original_tools: Final[tuple[Mapping[str, object], ...]] = (
tuple(raw_tools) if isinstance(raw_tools, list) else ()
)
flattened_tool_groups: Final = tuple(
form.chat_tools for form in LiteLLMCompletionResponsesConfig.responses_tools_to_chat_forms(original_tools)
)
flattened_tools: Final = tuple(
cast(ChatCompletionToolParam, tool) # cast-ok: mcp tools ride along in the guardrail's tool list
for group in flattened_tool_groups
for tool in group
)
tools_to_check: Final[list[ChatCompletionToolParam]] = list( # mutable-ok: guardrail inputs want a list
copy.deepcopy(flattened_tools)
)
# Handle simple string input
if isinstance(input_data, str):
inputs = GenericGuardrailAPIInputs(texts=[input_data])
original_tools: list[dict[str, object]] = []
# Extract and transform tools if present
if "tools" in data and data["tools"]:
original_tools = list(data["tools"])
self._extract_and_transform_tools(data["tools"], tools_to_check)
if tools_to_check:
inputs["tools"] = tools_to_check
if tools_to_check:
inputs["tools"] = tools_to_check
if structured_messages:
inputs["structured_messages"] = structured_messages
# Include model information if available
@ -189,7 +197,9 @@ class OpenAIResponsesHandler(BaseTranslation):
)
guardrailed_texts = guardrailed_inputs.get("texts", [])
data["input"] = guardrailed_texts[0] if guardrailed_texts else input_data
self._apply_guardrailed_tools_to_data(data, original_tools, guardrailed_inputs.get("tools"))
self._apply_guardrailed_tools_to_data(
data, original_tools, flattened_tool_groups, guardrailed_inputs.get("tools")
)
verbose_proxy_logger.debug("OpenAI Responses API: Processed string input")
return data
@ -200,7 +210,6 @@ class OpenAIResponsesHandler(BaseTranslation):
texts_to_check: Final[list[str]] = []
images_to_check: Final[list[str]] = []
task_mappings: Final[list[tuple[int, int | None]]] = []
original_tools_list: Final[list[dict[str, object]]] = list(data.get("tools") or [])
# Step 1: Extract all text content, images, and tools
for msg_idx, message in enumerate(input_data):
@ -212,10 +221,6 @@ class OpenAIResponsesHandler(BaseTranslation):
task_mappings=task_mappings,
)
# Extract and transform tools if present
if "tools" in data and data["tools"]:
self._extract_and_transform_tools(data["tools"], tools_to_check)
# Step 2: Apply guardrail to all texts in batch
if texts_to_check:
inputs = GenericGuardrailAPIInputs(texts=texts_to_check)
@ -238,9 +243,7 @@ class OpenAIResponsesHandler(BaseTranslation):
guardrailed_texts = guardrailed_inputs.get("texts", [])
self._apply_guardrailed_tools_to_data(
data,
original_tools_list,
guardrailed_inputs.get("tools"),
data, original_tools, flattened_tool_groups, guardrailed_inputs.get("tools")
)
# Step 3: Map guardrail responses back to original input structure
@ -267,73 +270,18 @@ class OpenAIResponsesHandler(BaseTranslation):
names.append(str(tool["server_label"]))
return names
def _extract_and_transform_tools(
self,
tools: list[FunctionToolParam | OpenAIMcpServerTool],
tools_to_check: list[ChatCompletionToolParam],
) -> None:
"""
Extract and transform tools from Responses API format to Chat Completion format.
Uses the LiteLLM transformation function to convert Responses API tools
to Chat Completion tools that can be passed to guardrails.
"""
if tools is not None and isinstance(tools, list):
# Transform Responses API tools to Chat Completion tools
(
transformed_tools,
_,
) = LiteLLMCompletionResponsesConfig.transform_responses_api_tools_to_chat_completion_tools(tools)
tools_to_check.extend(cast(list[ChatCompletionToolParam], transformed_tools))
def _remap_tools_to_responses_api_format(self, guardrailed_tools: list[Any]) -> list[dict[str, object]]:
"""
Remap guardrail-returned tools (Chat Completion format) back to
Responses API request tool format.
"""
return LiteLLMCompletionResponsesConfig.transform_chat_completion_tool_params_to_responses_api_tools(
guardrailed_tools
)
def _merge_tools_after_guardrail(
self,
original_tools: list[dict[str, object]],
remapped: list[dict[str, object]],
) -> list[dict[str, object]]:
"""
Merge remapped guardrailed tools with original tools that were not sent
to the guardrail (e.g. web_search, web_search_preview), preserving order.
Tools a guardrail appended (``remapped`` longer than ``original_tools``)
have no original slot and are kept so an injected tool is not dropped.
"""
if not original_tools:
return remapped
result: Final[list[dict[str, object]]] = []
j = 0
for tool in original_tools:
if isinstance(tool, dict) and tool.get("type") in (
"web_search",
"web_search_preview",
):
result.append(tool)
else:
if j < len(remapped):
result.append(remapped[j])
j += 1
# Keep guardrail-appended tools that matched no original slot above.
result.extend(remapped[j:])
return result
def _apply_guardrailed_tools_to_data(
self,
data: dict,
original_tools: list[dict[str, object]],
guardrailed_tools: list[ChatCompletionToolParam] | None,
original_tools: Sequence[Mapping[str, object]],
flattened_tool_groups: Sequence[Sequence[Mapping[str, object]]],
guardrailed_tools: Sequence[ChatCompletionToolParam] | None,
) -> None:
"""Remap guardrailed tools to Responses API format and merge with original, then set data['tools']."""
if guardrailed_tools is not None:
remapped: Final = self._remap_tools_to_responses_api_format(guardrailed_tools)
data["tools"] = self._merge_tools_after_guardrail(original_tools, remapped)
if guardrailed_tools is None:
return
data["tools"] = list( # mutable-ok: downstream wants a list # rebind-ok: in-place request rewrite
merge_guardrailed_tools(original_tools, flattened_tool_groups, guardrailed_tools)
)
def _extract_input_text_and_images(
self,

View file

@ -0,0 +1,182 @@
from collections.abc import Iterable, Mapping, Sequence
from itertools import accumulate, chain, groupby
from types import MappingProxyType
from typing import Final, TypeAlias
from pydantic import BaseModel, TypeAdapter, ValidationError
from litellm._logging import verbose_logger
from litellm.responses.litellm_completion_transformation.transformation import (
NAMESPACE_DESCRIPTION_SEPARATOR,
LiteLLMCompletionResponsesConfig,
)
Tool: TypeAlias = Mapping[str, object]
IndexedKey: TypeAlias = tuple[str, int]
_TOOL_ADAPTER: Final = TypeAdapter(dict[str, object])
_CHAT_TOOL_TOP_LEVEL_KEYS: Final = frozenset({"type", "function"})
def _as_tool(value: object) -> Tool | None:
candidate: Final = value.model_dump(exclude_unset=True) if isinstance(value, BaseModel) else value
try:
return _TOOL_ADAPTER.validate_python(candidate)
except ValidationError:
return None
def _validated_tools(values: Iterable[object]) -> tuple[Tool, ...]:
validated: Final = tuple(map(_as_tool, values))
dropped: Final = sum(tool is None for tool in validated)
if dropped:
verbose_logger.warning("Dropping %d guardrail-returned tools that are not objects", dropped)
return tuple(tool for tool in validated if tool is not None)
def _is_function(tool: Tool) -> bool:
return tool.get("type") == "function"
def _chat_tool_key(tool: Tool) -> str:
tool_type: Final = str(tool.get("type") or "")
function: Final = _as_tool(tool.get("function"))
if function is not None:
return f"{tool_type}:{function.get('name') or ''}"
return f"{tool_type}:{tool.get('server_label') or tool.get('name') or ''}"
def _indexed_keys(tools: Sequence[Tool]) -> tuple[IndexedKey, ...]:
keys: Final = tuple(_chat_tool_key(tool) for tool in tools)
positions_by_key: Final = groupby(sorted(range(len(keys)), key=keys.__getitem__), key=keys.__getitem__)
ordinal_by_position: Final = MappingProxyType(
{position: ordinal for _, positions in positions_by_key for ordinal, position in enumerate(positions)}
)
return tuple((key, ordinal_by_position[position]) for position, key in enumerate(keys))
def _namespace_members(namespace: Tool) -> tuple[Tool, ...]:
members: Final = namespace.get("tools")
if not isinstance(members, Sequence) or isinstance(members, (str, bytes)):
return ()
return tuple(member for member in map(_as_tool, members) if member is not None)
def _function_fields(tool: Tool) -> Tool:
function: Final = _as_tool(tool.get("function"))
return function if function is not None else MappingProxyType({})
def _without_namespace_prefix(key: str, value: object, prefix: str) -> object:
if key != "description" or not isinstance(value, str) or not value.startswith(prefix):
return value
return value[len(prefix) :]
def _rebuilt_member(member: Tool, flattened: Tool, guardrailed: Tool, namespace_description: str) -> Tool:
flattened_function: Final = _function_fields(flattened)
prefix: Final = f"{namespace_description}{NAMESPACE_DESCRIPTION_SEPARATOR}" if namespace_description else ""
changed_function: Final = MappingProxyType(
{
key: _without_namespace_prefix(key, value, prefix)
for key, value in _function_fields(guardrailed).items()
if flattened_function.get(key) != value
}
)
changed_extras: Final = MappingProxyType(
{
key: value
for key, value in guardrailed.items()
if key not in _CHAT_TOOL_TOP_LEVEL_KEYS and flattened.get(key) != value
}
)
return {**member, **changed_extras, **changed_function} # mutable-ok: json.dumps rejects MappingProxyType
def _rebuilt_function_members(
function_members: Sequence[Tool],
flattened_group: Sequence[Tool],
group_keys: Sequence[IndexedKey],
guardrailed_by_key: Mapping[IndexedKey, Tool],
namespace_description: str,
) -> tuple[Tool | None, ...]:
return tuple(
None
if key not in guardrailed_by_key
else member
if guardrailed_by_key[key] == flattened
else _rebuilt_member(member, flattened, guardrailed_by_key[key], namespace_description)
for member, flattened, key in zip(function_members, flattened_group, group_keys)
)
def _rebuilt_namespace(
original: Tool,
members: Sequence[Tool],
flattened_group: Sequence[Tool],
group_keys: Sequence[IndexedKey],
guardrailed_by_key: Mapping[IndexedKey, Tool],
) -> tuple[Tool, ...]:
namespace_description: Final = str(original.get("description") or "")
rebuilt_functions: Final = iter(
_rebuilt_function_members(
tuple(member for member in members if _is_function(member)),
flattened_group,
group_keys,
guardrailed_by_key,
namespace_description,
)
)
rebuilt_members: Final = tuple(
rebuilt
for rebuilt in (next(rebuilt_functions) if _is_function(member) else member for member in members)
if rebuilt is not None
)
if not rebuilt_members:
return ()
return ({**original, "tools": list(rebuilt_members)},) # mutable-ok: json.dumps needs a plain dict and list
def _merged_original(
original: Tool,
flattened_group: Sequence[Tool],
group_keys: Sequence[IndexedKey],
guardrailed_by_key: Mapping[IndexedKey, Tool],
) -> tuple[Tool, ...]:
if not group_keys:
return (original,)
guardrailed_group: Final = tuple(guardrailed_by_key[key] for key in group_keys if key in guardrailed_by_key)
if guardrailed_group == tuple(flattened_group):
return (original,)
members: Final = _namespace_members(original) if original.get("type") == "namespace" else ()
if members and sum(map(_is_function, members)) == len(flattened_group):
return _rebuilt_namespace(original, members, flattened_group, group_keys, guardrailed_by_key)
if not guardrailed_group:
return ()
return tuple(
LiteLLMCompletionResponsesConfig.transform_chat_completion_tool_params_to_responses_api_tools(guardrailed_group)
)
def merge_guardrailed_tools(
original_tools: Sequence[Tool],
flattened_groups: Sequence[Sequence[Tool]],
guardrailed_tools: Iterable[object],
) -> tuple[Tool, ...]:
guardrailed: Final = _validated_tools(guardrailed_tools)
flattened_keys: Final = _indexed_keys(tuple(chain.from_iterable(flattened_groups)))
guardrailed_keys: Final = _indexed_keys(guardrailed)
guardrailed_by_key: Final = MappingProxyType(dict(zip(guardrailed_keys, guardrailed)))
group_ends: Final = tuple(accumulate(len(group) for group in flattened_groups))
group_key_slices: Final = tuple(
flattened_keys[end - len(group) : end] for group, end in zip(flattened_groups, group_ends)
)
merged_originals: Final = chain.from_iterable(
_merged_original(original, group, group_keys, guardrailed_by_key)
for original, group, group_keys in zip(original_tools, flattened_groups, group_key_slices)
)
owned_keys: Final = frozenset(flattened_keys)
appended: Final = LiteLLMCompletionResponsesConfig.transform_chat_completion_tool_params_to_responses_api_tools(
tuple(tool for key, tool in zip(guardrailed_keys, guardrailed) if key not in owned_keys)
)
return tuple(chain(merged_originals, appended))

View file

@ -6,6 +6,7 @@ import json
import re
import uuid
from collections.abc import Iterable, Iterator, Mapping, Sequence
from dataclasses import dataclass
from types import MappingProxyType
from typing import (
TYPE_CHECKING,
@ -102,6 +103,15 @@ from .custom_tools import (
NamespaceNameMap: TypeAlias = Mapping[str, tuple[str, str]]
NamespaceTool: TypeAlias = Mapping[str, object]
ResponseTools: TypeAlias = Sequence[Mapping[str, object]] | None
ChatToolParam: TypeAlias = ChatCompletionToolParam | OpenAIMcpServerTool
NAMESPACE_DESCRIPTION_SEPARATOR: Final = "\n\n"
@dataclass(frozen=True, slots=True)
class ResponsesToolChatForm:
chat_tools: tuple[ChatToolParam, ...]
web_search_options: OpenAIWebSearchOptions | None
if TYPE_CHECKING:
from openai.types.responses.response_apply_patch_tool_call import (
@ -1771,7 +1781,7 @@ class LiteLLMCompletionResponsesConfig:
tool_name: Final = str(namespace_tool.get("name") or "")
raw_description: Final = str(namespace_tool.get("description") or "")
description: Final = (
f"{namespace_description}\n\n{raw_description}"
f"{namespace_description}{NAMESPACE_DESCRIPTION_SEPARATOR}{raw_description}"
if nested and namespace_description and raw_description
else namespace_description
if nested and namespace_description
@ -1837,9 +1847,78 @@ class LiteLLMCompletionResponsesConfig:
+ ", ".join(sorted(conflicting_tool_names))
)
@staticmethod
def _responses_tool_to_chat_form(tool: Mapping[str, object]) -> ResponsesToolChatForm:
tool_type: Final = tool.get("type")
if tool_type == "mcp":
return ResponsesToolChatForm(chat_tools=(cast(OpenAIMcpServerTool, tool),), web_search_options=None)
if tool_type == "web_search_preview" or tool_type == "web_search":
_search_context_size: Final[Literal["low", "medium", "high"]] = cast(
Literal["low", "medium", "high"], tool.get("search_context_size")
)
_user_location: Final[OpenAIWebSearchUserLocation | None] = cast(
OpenAIWebSearchUserLocation | None,
tool.get("user_location") or None,
)
return ResponsesToolChatForm(
chat_tools=(),
web_search_options=OpenAIWebSearchOptions(
search_context_size=_search_context_size,
user_location=_user_location,
),
)
if tool_type == "function":
typed_tool: Final = cast(FunctionToolParam, tool)
raw_parameters: Final = typed_tool.get("parameters", {}) or {}
parameters: Final = (
{**raw_parameters} # mutable-ok: json.dumps rejects MappingProxyType
if "type" in raw_parameters
else {**raw_parameters, "type": "object"} # mutable-ok: json.dumps rejects MappingProxyType
)
chat_completion_tool: Final[dict[str, object]] = {
"type": "function",
"function": {
"name": typed_tool.get("name") or "",
"description": typed_tool.get("description") or "",
"parameters": parameters,
"strict": typed_tool.get("strict", False) or False,
},
}
if tool.get("cache_control"):
chat_completion_tool["cache_control"] = tool.get("cache_control")
if tool.get("defer_loading"):
chat_completion_tool["defer_loading"] = tool.get("defer_loading")
if tool.get("allowed_callers"):
chat_completion_tool["allowed_callers"] = tool.get("allowed_callers")
if tool.get("input_examples"):
chat_completion_tool["input_examples"] = tool.get("input_examples")
return ResponsesToolChatForm(
chat_tools=(cast(ChatCompletionToolParam, chat_completion_tool),), web_search_options=None
)
if tool_type == "namespace":
return ResponsesToolChatForm(
chat_tools=LiteLLMCompletionResponsesConfig._namespace_chat_tools(tool), web_search_options=None
)
if tool_type == "custom":
converted: Final = convert_custom_tool_to_function_tool(tool)
return ResponsesToolChatForm(chat_tools=() if converted is None else (converted,), web_search_options=None)
if tool_type in ("computer_use", "image_generation", "shell"):
verbose_logger.warning(
"Dropping Responses API tool of type '%s': it has no Chat Completions "
"equivalent and the target provider would reject the request.",
tool_type,
)
return ResponsesToolChatForm(chat_tools=(), web_search_options=None)
return ResponsesToolChatForm(chat_tools=(cast(ChatToolParam, tool),), web_search_options=None)
@staticmethod
def responses_tools_to_chat_forms(tools: ResponseTools) -> tuple[ResponsesToolChatForm, ...]:
LiteLLMCompletionResponsesConfig._validate_namespace_name_collisions(tools)
return tuple(LiteLLMCompletionResponsesConfig._responses_tool_to_chat_form(tool) for tool in tools or ())
@staticmethod
def transform_responses_api_tools_to_chat_completion_tools(
tools: list[FunctionToolParam | OpenAIMcpServerTool] | None,
tools: ResponseTools,
) -> tuple[
list[ChatCompletionToolParam | OpenAIMcpServerTool],
OpenAIWebSearchOptions | None,
@ -1849,73 +1928,16 @@ class LiteLLMCompletionResponsesConfig:
"""
if tools is None:
return [], None
LiteLLMCompletionResponsesConfig._validate_namespace_name_collisions(tools)
chat_completion_tools: Final[list[ChatCompletionToolParam | OpenAIMcpServerTool]] = []
web_search_options: OpenAIWebSearchOptions | None = None
for tool in tools:
if tool.get("type") == "mcp":
chat_completion_tools.append(cast(OpenAIMcpServerTool, tool))
elif tool.get("type") == "web_search_preview" or tool.get("type") == "web_search":
_search_context_size: Literal["low", "medium", "high"] = cast(
Literal["low", "medium", "high"], tool.get("search_context_size")
)
_user_location: OpenAIWebSearchUserLocation | None = cast(
OpenAIWebSearchUserLocation | None,
tool.get("user_location") or None,
)
web_search_options = OpenAIWebSearchOptions(
search_context_size=_search_context_size,
user_location=_user_location,
)
elif tool.get("type") == "function":
typed_tool = cast(FunctionToolParam, tool)
# Ensure parameters has "type": "object" as required by providers like Anthropic
parameters = dict(typed_tool.get("parameters", {}) or {})
if not parameters or "type" not in parameters:
parameters["type"] = "object"
chat_completion_tool: dict[str, object] = {
"type": "function",
"function": {
"name": typed_tool.get("name") or "",
"description": typed_tool.get("description") or "",
"parameters": parameters,
"strict": typed_tool.get("strict", False) or False,
},
}
if tool.get("cache_control"):
chat_completion_tool["cache_control"] = tool.get("cache_control")
if tool.get("defer_loading"):
chat_completion_tool["defer_loading"] = tool.get("defer_loading")
if tool.get("allowed_callers"):
chat_completion_tool["allowed_callers"] = tool.get("allowed_callers")
if tool.get("input_examples"):
chat_completion_tool["input_examples"] = tool.get("input_examples")
chat_completion_tools.append(cast(ChatCompletionToolParam, chat_completion_tool))
elif tool.get("type") == "namespace":
chat_completion_tools.extend(LiteLLMCompletionResponsesConfig._namespace_chat_tools(tool))
elif tool.get("type") == "custom":
converted = convert_custom_tool_to_function_tool(tool)
if converted is not None:
chat_completion_tools.append(converted)
else:
_tool_type = tool.get("type")
if _tool_type in ("computer_use", "image_generation", "shell"):
# Drop unsupported Responses-API-only tool types that have no
# Chat Completions equivalent. Passing them through verbatim
# causes providers to reject the request with "'function' is a
# required property".
verbose_logger.warning(
"Dropping Responses API tool of type '%s': it has no Chat Completions "
"equivalent and the target provider would reject the request.",
_tool_type,
)
continue
chat_completion_tools.append(cast(ChatCompletionToolParam | OpenAIMcpServerTool, tool))
return chat_completion_tools, web_search_options
forms: Final = LiteLLMCompletionResponsesConfig.responses_tools_to_chat_forms(tools)
web_search_options: Final = next(
(form.web_search_options for form in reversed(forms) if form.web_search_options is not None),
None,
)
return [chat_tool for form in forms for chat_tool in form.chat_tools], web_search_options
@staticmethod
def transform_chat_completion_tool_params_to_responses_api_tools(
chat_completion_tools: list[ChatCompletionToolParam | OpenAIMcpServerTool] | None,
chat_completion_tools: Sequence[Mapping[str, object]] | None,
) -> list[dict[str, object]]:
"""
Transform Chat Completion tool params (e.g. from guardrail output) back to
@ -1926,9 +1948,6 @@ class LiteLLMCompletionResponsesConfig:
return []
result: Final[list[dict[str, object]]] = []
for tool in chat_completion_tools:
if not isinstance(tool, dict):
result.append(tool)
continue
if tool.get("type") == "function":
fn = cast(_ToolFunctionDefinition, tool.get("function") or {})
parameters = dict(fn.get("parameters", {}) or {})

View file

@ -177,7 +177,7 @@
"limit": 8
},
"RUF019": {
"limit": 31
"limit": 27
},
"RUF046": {
"limit": 4

View file

@ -5,6 +5,8 @@ Tests the handler's ability to process input/output for the Responses API
with guardrail transformations.
"""
import copy
from collections.abc import Callable
from typing import Any, List, Literal, Optional, Tuple
from unittest.mock import AsyncMock, MagicMock
@ -19,6 +21,10 @@ from litellm.llms import get_guardrail_translation_mapping
from litellm.llms.openai.responses.guardrail_translation.handler import (
OpenAIResponsesHandler,
)
from litellm.llms.openai.responses.guardrail_translation.tool_merge import merge_guardrailed_tools
from litellm.responses.litellm_completion_transformation.transformation import (
LiteLLMCompletionResponsesConfig,
)
from litellm.types.llms.openai import ResponsesAPIResponse
from litellm.types.responses.main import GenericResponseOutputItem, OutputText
from litellm.types.utils import CallTypes, GenericGuardrailAPIInputs
@ -1287,14 +1293,14 @@ class TestOpenAIResponsesHandlerToolInjection:
"""A tool a guardrail injects must survive the write-back to Responses format."""
def test_merge_keeps_guardrail_appended_tool(self):
"""_merge_tools_after_guardrail must not drop the extra appended tool."""
handler = OpenAIResponsesHandler()
"""merge_guardrailed_tools must not drop the extra appended tool."""
original = [{"type": "function", "name": "a"}]
remapped = [
{"type": "function", "name": "a"},
{"type": "function", "name": "b"},
groups = [form.chat_tools for form in LiteLLMCompletionResponsesConfig.responses_tools_to_chat_forms(original)]
guardrailed = [
*groups[0],
{"type": "function", "function": {"name": "b", "description": "", "parameters": {"type": "object"}}},
]
merged = handler._merge_tools_after_guardrail(original, remapped)
merged = merge_guardrailed_tools(original, groups, guardrailed)
assert [t["name"] for t in merged] == ["a", "b"]
@pytest.mark.asyncio
@ -1323,6 +1329,194 @@ class TestOpenAIResponsesHandlerToolInjection:
assert "injected_tool" in names
class ToolEditingGuardrail(CustomGuardrail):
"""Guardrail that rewrites the flattened chat tools it was handed through ``edit``"""
def __init__(self, edit: Callable[[list[dict]], list[dict]], **kwargs):
super().__init__(**kwargs)
self.edit = edit
async def apply_guardrail(
self,
inputs: GenericGuardrailAPIInputs,
request_data: dict,
input_type: Literal["request", "response"],
logging_obj: Any | None = None,
) -> GenericGuardrailAPIInputs:
inputs["tools"] = self.edit(list(inputs.get("tools") or []))
return inputs
def _codex_request(input_value):
"""A Responses API request shaped like what the Codex CLI sends when an MCP server is configured"""
return {
"model": "gpt-5.3-codex",
"input": input_value,
"tools": [
{
"type": "function",
"name": "get_weather",
"description": "Weather lookup",
"parameters": {"type": "object", "properties": {"city": {"type": "string"}}},
"strict": False,
},
{
"type": "namespace",
"name": "mcp__confluence",
"description": "Confluence tools",
"tools": [
{
"type": "function",
"name": "confluence_get_page",
"description": "Get a page",
"parameters": {"type": "object", "properties": {"id": {"type": "string"}}, "required": ["id"]},
"strict": False,
},
{
"type": "function",
"name": "confluence_search",
"description": "Search pages",
"parameters": {"type": "object", "properties": {"q": {"type": "string"}}},
"strict": False,
},
],
},
{
"type": "custom",
"name": "apply_patch",
"description": "Apply a patch",
"format": {"type": "grammar", "syntax": "lark", "definition": 'start: "x"'},
},
{"type": "web_search"},
],
}
def _tool_named(tools, name):
return next(tool for tool in tools if tool.get("name") == name)
class TestOpenAIResponsesHandlerNamespaceTools:
"""Codex sends MCP tools as ``namespace`` tools; a guardrail must never flatten them (GH #39183)"""
@pytest.mark.asyncio
@pytest.mark.parametrize(
"input_value",
["hi", [{"role": "user", "content": "hi", "type": "message"}]],
ids=["string_input", "list_input"],
)
async def test_pass_through_guardrail_leaves_tools_untouched(self, input_value):
data = _codex_request(input_value)
expected_tools = copy.deepcopy(data["tools"])
result = await OpenAIResponsesHandler().process_input_messages(
data, MockPassThroughGuardrail(guardrail_name="test")
)
assert result["tools"] == expected_tools
@pytest.mark.asyncio
async def test_appending_guardrail_keeps_namespace_and_adds_tool(self):
data = _codex_request("hi")
expected_tools = copy.deepcopy(data["tools"])
result = await OpenAIResponsesHandler().process_input_messages(
data, ToolAppendingGuardrail(guardrail_name="test")
)
assert result["tools"][:-1] == expected_tools
assert result["tools"][-1]["type"] == "function"
assert result["tools"][-1]["name"] == "injected_tool"
@pytest.mark.asyncio
async def test_dropping_one_member_prunes_only_that_member(self):
data = _codex_request("hi")
expected_tools = copy.deepcopy(data["tools"])
guardrail = ToolEditingGuardrail(
edit=lambda tools: [t for t in tools if t["function"]["name"] != "mcp__confluence__confluence_search"],
guardrail_name="test",
)
result = await OpenAIResponsesHandler().process_input_messages(data, guardrail)
namespace = _tool_named(result["tools"], "mcp__confluence")
assert [member["name"] for member in namespace["tools"]] == ["confluence_get_page"]
assert namespace["tools"][0] == expected_tools[1]["tools"][0]
assert [t for t in result["tools"] if t is not namespace] == [expected_tools[0], *expected_tools[2:]]
@pytest.mark.asyncio
async def test_editing_a_member_lands_on_that_member_without_the_namespace_prefix(self):
data = _codex_request("hi")
expected_tools = copy.deepcopy(data["tools"])
def redact_search(tools):
for tool in tools:
if tool["function"]["name"] == "mcp__confluence__confluence_search":
tool["function"]["description"] = "Confluence tools\n\nREDACTED"
return tools
result = await OpenAIResponsesHandler().process_input_messages(
data, ToolEditingGuardrail(edit=redact_search, guardrail_name="test")
)
namespace = _tool_named(result["tools"], "mcp__confluence")
assert namespace["tools"][0] == expected_tools[1]["tools"][0]
assert namespace["tools"][1] == {**expected_tools[1]["tools"][1], "description": "REDACTED"}
assert {k: v for k, v in namespace.items() if k != "tools"} == {
k: v for k, v in expected_tools[1].items() if k != "tools"
}
@pytest.mark.asyncio
async def test_dropping_every_member_drops_the_namespace(self):
data = _codex_request("hi")
expected_tools = copy.deepcopy(data["tools"])
guardrail = ToolEditingGuardrail(
edit=lambda tools: [t for t in tools if not t["function"]["name"].startswith("mcp__confluence__")],
guardrail_name="test",
)
result = await OpenAIResponsesHandler().process_input_messages(data, guardrail)
assert result["tools"] == [expected_tools[0], *expected_tools[2:]]
@pytest.mark.asyncio
async def test_edited_top_level_function_is_rewritten_in_place(self):
data = _codex_request("hi")
expected_tools = copy.deepcopy(data["tools"])
def rename_weather(tools):
for tool in tools:
if tool["function"]["name"] == "get_weather":
tool["function"]["description"] = "Weather lookup (guarded)"
return tools
result = await OpenAIResponsesHandler().process_input_messages(
data, ToolEditingGuardrail(edit=rename_weather, guardrail_name="test")
)
assert result["tools"][0] == {**expected_tools[0], "description": "Weather lookup (guarded)"}
assert result["tools"][1:] == expected_tools[1:]
class TestOpenAIResponsesHandlerMalformedTools:
@pytest.mark.asyncio
async def test_request_tools_that_are_not_a_list_never_reach_the_guardrail(self):
handler = OpenAIResponsesHandler()
seen: list[list[dict]] = []
def record(tools):
seen.append(tools)
return tools
guardrail = ToolEditingGuardrail(edit=record, guardrail_name="test")
data = {"input": "hi", "tools": {"type": "function", "name": "get_weather"}}
result = await handler.process_input_messages(data, guardrail)
assert seen == [[]]
assert result["input"] == "hi"
class TestBuildBlockSseChunks:
"""build_block_sse_chunks turns a streaming ModifyResponseException into 200 SSE events"""

View file

@ -0,0 +1,198 @@
"""
Unit tests for merge_guardrailed_tools, which writes guardrail-returned chat tools back onto the
Responses API request tools they were flattened from
"""
import copy
from litellm.llms.openai.responses.guardrail_translation.tool_merge import merge_guardrailed_tools
from litellm.responses.litellm_completion_transformation.transformation import (
LiteLLMCompletionResponsesConfig,
)
from litellm.types.proxy.guardrails.guardrail_hooks.generic_guardrail_api import GuardrailToolParam
def _groups(tools):
return [form.chat_tools for form in LiteLLMCompletionResponsesConfig.responses_tools_to_chat_forms(tools)]
def _flat(groups):
return [chat_tool for group in groups for chat_tool in group]
def _function(name, description=""):
return {"type": "function", "name": name, "description": description, "parameters": {"type": "object"}}
def test_unchanged_tools_come_back_as_the_original_objects():
original = [
_function("a"),
{"type": "namespace", "name": "ns", "description": "NS", "tools": [_function("x"), _function("y")]},
{"type": "mcp", "server_label": "deepwiki", "server_url": "https://mcp.deepwiki.com/mcp"},
{"type": "web_search"},
]
groups = _groups(original)
merged = merge_guardrailed_tools(original, groups, _flat(groups))
assert list(merged) == original
assert all(merged_tool is original_tool for merged_tool, original_tool in zip(merged, original))
def test_guardrail_reordering_unchanged_tools_keeps_request_order():
original = [_function("a"), {"type": "namespace", "name": "ns", "tools": [_function("x")]}, {"type": "web_search"}]
groups = _groups(original)
merged = merge_guardrailed_tools(original, groups, list(reversed(_flat(groups))))
assert list(merged) == original
def test_duplicate_function_names_are_matched_by_ordinal():
original = [_function("dup", "first"), _function("dup", "second")]
groups = _groups(original)
merged = merge_guardrailed_tools(original, groups, _flat(groups)[:1])
assert list(merged) == [original[0]]
def test_interleaved_duplicate_names_keep_their_own_ordinals():
original = [
_function("dup", "a"),
_function("other", "x"),
_function("dup", "b"),
_function("dup", "c"),
_function("other", "y"),
]
groups = _groups(original)
flat = _flat(groups)
edited = {**flat[3], "function": {**flat[3]["function"], "description": "changed"}}
merged = merge_guardrailed_tools(original, groups, [*flat[:3], edited, flat[4]])
assert list(merged) == [*original[:3], {**_function("dup", "changed"), "strict": False}, original[4]]
assert all(merged[position] is original[position] for position in (0, 1, 2, 4))
def test_edited_mcp_tool_is_rewritten():
original = [{"type": "mcp", "server_label": "deepwiki", "server_url": "https://mcp.deepwiki.com/mcp"}]
groups = _groups(original)
edited = [{**groups[0][0], "allowed_tools": ["read_wiki_structure"]}]
merged = merge_guardrailed_tools(original, groups, edited)
assert list(merged) == edited
def test_injected_tool_lands_after_the_request_tools_when_request_had_none():
injected = {"type": "function", "function": {"name": "b", "description": "d", "parameters": {"type": "object"}}}
merged = merge_guardrailed_tools([], [], [injected])
assert list(merged) == [
{"type": "function", "name": "b", "description": "d", "parameters": {"type": "object"}, "strict": False}
]
def test_empty_guardrail_output_keeps_only_tools_never_sent_to_the_guardrail():
original = [_function("a"), {"type": "web_search"}, {"type": "namespace", "name": "ns", "tools": [_function("x")]}]
merged = merge_guardrailed_tools(original, _groups(original), [])
assert list(merged) == [{"type": "web_search"}]
def test_member_edit_strips_only_the_namespace_description_prefix():
original = [{"type": "namespace", "name": "ns", "description": "NS", "tools": [_function("x", "X doc")]}]
groups = _groups(original)
assert groups[0][0]["function"]["description"] == "NS\n\nX doc"
edited = [{**groups[0][0], "function": {**groups[0][0]["function"], "description": "NS\n\nX doc (guarded)"}}]
merged = merge_guardrailed_tools(original, groups, edited)
assert list(merged) == [
{"type": "namespace", "name": "ns", "description": "NS", "tools": [_function("x", "X doc (guarded)")]}
]
def test_namespace_keeps_a_non_function_member_when_a_function_member_is_edited():
custom_member = {"type": "custom", "name": "grep", "description": "Grep", "format": {"type": "text"}}
original = [
{"type": "namespace", "name": "ns", "description": "NS", "tools": [_function("read", "Read"), custom_member]}
]
groups = _groups(original)
edited = copy.deepcopy(_flat(groups))
edited[0]["function"]["description"] = "NS\n\nEDITED"
merged = merge_guardrailed_tools(original, groups, edited)
assert len(merged) == 1
assert [member["name"] for member in merged[0]["tools"]] == ["read", "grep"]
assert merged[0]["tools"][0]["description"] == "EDITED"
assert merged[0]["tools"][1] == custom_member
def test_namespace_keeps_its_non_function_members_when_every_function_member_is_dropped():
custom_member = {"type": "custom", "name": "grep", "description": "Grep", "format": {"type": "text"}}
original = [
{"type": "namespace", "name": "ns", "description": "NS", "tools": [_function("read"), custom_member]},
_function("a"),
]
groups = _groups(original)
merged = merge_guardrailed_tools(original, groups, [groups[1][0]])
assert list(merged) == [{"type": "namespace", "name": "ns", "description": "NS", "tools": [custom_member]}, _function("a")]
def test_member_extras_edited_by_the_guardrail_land_on_that_member():
original = [{"type": "namespace", "name": "ns", "tools": [_function("read")]}]
groups = _groups(original)
edited = copy.deepcopy(_flat(groups))
edited[0]["cache_control"] = {"type": "ephemeral"}
merged = merge_guardrailed_tools(original, groups, edited)
assert merged[0]["tools"][0]["cache_control"] == {"type": "ephemeral"}
assert merged[0]["tools"][0]["name"] == "read"
def test_guardrail_output_is_read_once():
original = [_function("a"), {"type": "namespace", "name": "ns", "tools": [_function("x")]}]
groups = _groups(original)
merged = merge_guardrailed_tools(original, groups, (chat_tool for chat_tool in _flat(groups)))
assert list(merged) == original
def test_pydantic_guardrail_tools_round_trip_like_dicts():
original = [_function("a"), {"type": "namespace", "name": "ns", "tools": [_function("x", "X doc")]}]
groups = _groups(original)
models = [GuardrailToolParam.model_validate(chat_tool) for chat_tool in _flat(groups)]
merged = merge_guardrailed_tools(original, groups, models)
assert list(merged) == original
assert all(merged_tool is original_tool for merged_tool, original_tool in zip(merged, original))
def test_pydantic_guardrail_edit_lands_on_the_member():
original = [{"type": "namespace", "name": "ns", "tools": [_function("x", "X doc")]}]
groups = _groups(original)
edited = copy.deepcopy(_flat(groups))
edited[0]["function"]["description"] = "EDITED"
merged = merge_guardrailed_tools(original, groups, [GuardrailToolParam.model_validate(edited[0])])
assert list(merged) == [{"type": "namespace", "name": "ns", "tools": [_function("x", "EDITED")]}]
def test_non_object_guardrail_items_are_dropped():
original = [_function("a")]
groups = _groups(original)
merged = merge_guardrailed_tools(original, groups, [*_flat(groups), "junk", None])
assert list(merged) == original

View file

@ -1928,6 +1928,19 @@ class TestToolTransformation:
assert result_tool["function"]["parameters"]["type"] == "object"
assert "properties" in result_tool["function"]["parameters"]
def test_transform_function_tools_parameters_keep_client_key_order(self):
tools = [
{"type": "function", "name": "a", "parameters": {"properties": {"arg": {"type": "string"}}, "required": ["arg"]}},
{"type": "function", "name": "b", "parameters": {"type": "object", "properties": {}}},
]
result_tools, _ = LiteLLMCompletionResponsesConfig.transform_responses_api_tools_to_chat_completion_tools(
tools=tools
)
assert list(result_tools[0]["function"]["parameters"]) == ["properties", "required", "type"]
assert list(result_tools[1]["function"]["parameters"]) == ["type", "properties"]
def test_transform_function_tools_empty_parameters(self):
"""Test that empty parameters get 'type': 'object' added"""
function_tool = {

View file

@ -1,9 +1,9 @@
{
"LIT001": {
"limit": 22358
"limit": 22334
},
"LIT002": {
"limit": 26772
"limit": 26765
},
"LIT003": {
"limit": 261
@ -15,7 +15,7 @@
"limit": 0
},
"LIT006": {
"limit": 1039
"limit": 1038
},
"LIT007": {
"limit": 0
@ -27,10 +27,10 @@
"limit": 0
},
"LIT010": {
"limit": 16486
"limit": 16482
},
"LIT011": {
"limit": 5521
"limit": 5520
},
"LIT012": {
"limit": 4495