mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-05 08:07:05 +00:00
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:
commit
708c396b90
9 changed files with 723 additions and 169 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
|
@ -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 {})
|
||||
|
|
|
|||
|
|
@ -177,7 +177,7 @@
|
|||
"limit": 8
|
||||
},
|
||||
"RUF019": {
|
||||
"limit": 31
|
||||
"limit": 27
|
||||
},
|
||||
"RUF046": {
|
||||
"limit": 4
|
||||
|
|
|
|||
|
|
@ -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"""
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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 = {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue