fix(guardrails): scan Responses tool results in texts-mode guardrails (#48827)

* fix(guardrails): scan Responses tool results in texts-mode guardrails

* fix(guardrails): keep guardrail-added tools and non-text parts on Responses tool scans

Tool result texts now come from the same conversion that builds the chat tool message, so items without a call_id, dict outputs, and outputs with no text parts stay aligned for Bedrock and PANW. A changed joined text replaces the first text part and keeps the other parts. scan_only_tool_results appends tools the guardrail returns with new names, like chat, and tool_result and computer_call_output items are scanned through the shared type list.

* fix(guardrails): skip empty and screenshot tool outputs and keep text part types on Responses scans

* fix(guardrails): keep Bedrock latest-turn masking aligned when tool outputs send no text

* fix(guardrails): scan each Responses tool output text part on its own

* fix(guardrails): split a Responses tool output's structured row when only textless outputs share its call_id

* fix(guardrails): pair each Responses tool output with its own structured row

---------

Co-authored-by: mateo-berri <277851410+mateo-berri@users.noreply.github.com>
This commit is contained in:
mika-lt 2026-10-28 20:01:07 -07:00 • committed by GitHub
parent 83ae1bd63e
commit 4e5f585c2a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 1010 additions and 108 deletions

View file

@ -32,11 +32,11 @@ import copy
import json
import time
import uuid
from collections.abc import Mapping, MutableSequence, Sequence
from collections.abc import Iterable, Mapping, MutableSequence, Sequence
from dataclasses import dataclass
from itertools import accumulate, chain, repeat
from itertools import accumulate, chain, groupby, repeat
from types import MappingProxyType
from typing import TYPE_CHECKING, Any, Final, NamedTuple, Protocol, Union, cast
from typing import TYPE_CHECKING, Any, Final, NamedTuple, Protocol, TypeVar, Union, cast
from pydantic import BaseModel, ConfigDict, TypeAdapter, ValidationError
from typing_extensions import ReadOnly, TypedDict
@ -55,7 +55,9 @@ from litellm.llms.base_llm.guardrail_translation.base_translation import (
)
from litellm.llms.base_llm.guardrail_translation.utils import (
blocked_responses_stream_usage,
effective_scan_only_tool_results_for_guardrail,
effective_skip_system_message_for_guardrail,
effective_skip_tool_message_for_guardrail,
list_field,
merge_guardrailed_scoped_messages,
role_out_of_guardrail_scope,
@ -65,7 +67,10 @@ from litellm.llms.base_llm.guardrail_translation.utils import (
stream_item_items,
unappliable_request_rewrite,
)
from litellm.llms.openai.responses.guardrail_translation.tool_merge import merge_guardrailed_tools
from litellm.llms.openai.responses.guardrail_translation.tool_merge import (
append_returned_tools,
merge_guardrailed_tools,
)
from litellm.responses.litellm_completion_transformation.compaction_items import (
compaction_summary,
decode_compaction_item,
@ -74,7 +79,9 @@ from litellm.responses.litellm_completion_transformation.compaction_items import
from litellm.responses.litellm_completion_transformation.custom_tools import without_malformed_tool_flags
from litellm.responses.litellm_completion_transformation.tool_search import is_server_tool_search_tool
from litellm.responses.litellm_completion_transformation.transformation import (
TOOL_CALL_OUTPUT_ITEM_TYPES,
LiteLLMCompletionResponsesConfig,
tool_output_to_chat_content,
)
from litellm.types.llms.base import LiteLLMBaseModel
from litellm.types.llms.openai import (
@ -270,6 +277,7 @@ _TOOL_CALL_PAYLOAD_EVENT_TYPES: Final = _TOOL_CALL_PAYLOAD_DELTA_EVENT_TYPES | f
)
_OUTPUT_ITEM_EVENT_TYPES: Final = frozenset({"response.output_item.added", "response.output_item.done"})
_OUTPUT_TEXT_EVENT_TYPES: Final = frozenset({"response.output_text.delta", "response.output_text.done"})
_TOOL_OUTPUT_TEXT_PART_TYPES: Final = frozenset({"input_text", "output_text", "text"})
_PATCHABLE_ITEM_FIELDS: Final[Mapping[str, str]] = MappingProxyType(
{"function_call_output": "output", "custom_tool_call_output": "output", "message": "content"}
)
@ -519,10 +527,109 @@ def scannable_instructions(data: Mapping[str, object], *, skip_system: bool = Fa
def _input_item_role(item: object) -> str:
if stream_item_field(item, "type") in TOOL_CALL_OUTPUT_ITEM_TYPES:
return "tool"
role: Final = item.get("role") if isinstance(item, Mapping) else None
return role.lower() if isinstance(role, str) else ""
def _scanned_text_field(item: Mapping[str, object]) -> str:
return "output" if item.get("type") in TOOL_CALL_OUTPUT_ITEM_TYPES else "content"
def _tool_output_part_text(part: object) -> str | None:
if not isinstance(part, dict):
return None
fields: Final = _item_fields_adapter.validate_python(part)
text: Final = fields.get("text")
return text if fields.get("type") in _TOOL_OUTPUT_TEXT_PART_TYPES and isinstance(text, str) else None
def tool_output_texts(item: object) -> tuple[tuple[int | None, str], ...]:
if not stream_item_field(item, "call_id") or stream_item_field(item, "type") == "computer_call_output":
return ()
parts: Final = stream_item_items(item, "output")
part_texts: Final = tuple(
(index, text) for index, part in enumerate(parts) if (text := _tool_output_part_text(part))
)
if part_texts:
return part_texts
tool_content: Final = tool_output_to_chat_content(stream_item_field(item, "output"))
return ((None, tool_content),) if isinstance(tool_content, str) and tool_content else ()
_Grouped: Final = TypeVar("_Grouped")
def _pair_call_id(pair: tuple[str, object]) -> str:
return pair[0]
def _grouped_by_call_id(pairs: Iterable[tuple[str, _Grouped]]) -> Mapping[str, tuple[_Grouped, ...]]:
ordered: Final = sorted(pairs, key=_pair_call_id)
return MappingProxyType(
{call_id: tuple(value for _, value in group) for call_id, group in groupby(ordered, key=_pair_call_id)}
)
def _texts_by_tool_row(
messages: Sequence[Mapping[str, object]], input_data: "str | ResponseInputParam"
) -> Mapping[int, tuple[str, ...]]:
if isinstance(input_data, str):
return MappingProxyType({})
output_texts_by_call_id: Final = _grouped_by_call_id(
(str(stream_item_field(item, "call_id")), tuple(text for _, text in tool_output_texts(item)))
for item in input_data
if stream_item_field(item, "type") in TOOL_CALL_OUTPUT_ITEM_TYPES
)
row_indices_by_call_id: Final = _grouped_by_call_id(
(str(message.get("tool_call_id")), index)
for index, message in enumerate(messages)
if message.get("role") == "tool"
)
return MappingProxyType(
dict(
chain.from_iterable(
zip(indices, output_texts_by_call_id[call_id], strict=True)
for call_id, indices in row_indices_by_call_id.items()
if len(indices) == len(output_texts_by_call_id.get(call_id, ()))
)
)
)
def _is_string_tool_row(message: Mapping[str, object]) -> bool:
return message.get("role") == "tool" and isinstance(message.get("content"), str)
def _split_into_text_parts(message: AllMessageValues, texts: tuple[str, ...]) -> AllMessageValues:
if not _is_string_tool_row(message) or len(texts) < 2:
return message
return cast( # cast-ok: a tool row whose content is its text parts as chat text blocks
"AllMessageValues", {**message, "content": [{"type": "text", "text": text} for text in texts]}
)
def _with_split_rows_restored(
rows: Sequence[AllMessageValues], split_rows: Sequence[tuple[AllMessageValues, AllMessageValues]]
) -> Sequence[AllMessageValues]:
if not isinstance(rows, list):
return rows
return [next((original for split, original in split_rows if row == split), row) for row in rows]
def _is_textless_tool_row(message: Mapping[str, object], texts: tuple[str, ...] | None) -> bool:
return _is_string_tool_row(message) and (not message.get("content") or texts == ())
def _rewritten_whole_text(item: object, field: str, guardrail_response: str) -> object | None:
if isinstance(stream_item_field(item, field), str):
return guardrail_response
if tool_output_texts(item) == ((None, guardrail_response),):
return None
return guardrail_response
def _patched_request_fields(
raw_input: object,
instructions: object,
@ -636,17 +743,28 @@ class OpenAIResponsesHandler(BaseTranslation):
Handles both string input and list of message objects.
"""
input_data: Final[str | ResponseInputParam | None] = data.get("input")
if not isinstance(input_data, (str, list)):
raw_input: Final[str | ResponseInputParam | None] = data.get("input")
if not isinstance(raw_input, (str, list)):
return data
input_data: Final = cast("str | ResponseInputParam", raw_input) # cast-ok: fields read via stream_item_field
skip_system: Final = effective_skip_system_message_for_guardrail(guardrail_to_apply)
skip_tool: Final = effective_skip_tool_message_for_guardrail(guardrail_to_apply)
scan_only: Final = effective_scan_only_tool_results_for_guardrail(guardrail_to_apply)
structured_messages: Final = self.get_structured_messages(data)
scoped_indices: Final = scoped_structured_message_indices(
structured_messages or [], scan_only_tool_results=False, skip_system=skip_system, skip_tool=False
all_messages: Final = structured_messages or []
texts_by_tool_row: Final = _texts_by_tool_row(all_messages, input_data)
scoped_indices: Final = tuple(
index
for index in scoped_structured_message_indices(
all_messages, scan_only_tool_results=scan_only, skip_system=skip_system, skip_tool=skip_tool
)
if not _is_textless_tool_row(all_messages[index], texts_by_tool_row.get(index))
)
scoped_structured_messages: Final = (
[structured_messages[index] for index in scoped_indices] if structured_messages else None
split_rows: Final = tuple(
(_split_into_text_parts(all_messages[index], texts_by_tool_row.get(index, ())), all_messages[index])
for index in scoped_indices
)
scoped_structured_messages: Final = [split for split, _ in split_rows] if structured_messages else None
raw_tools: Final = data.get("tools")
original_tools: Final[tuple[Mapping[str, object], ...]] = (
tuple(raw_tools) if isinstance(raw_tools, list) else ()
@ -661,7 +779,12 @@ class OpenAIResponsesHandler(BaseTranslation):
)
)
extracted: Final = self._extract_guardrail_inputs(
data, input_data, flattened_tool_groups, skip_system=skip_system
data,
input_data,
() if scan_only else flattened_tool_groups,
skip_system=skip_system,
skip_tool=skip_tool,
scan_only=scan_only,
)
if not extracted.inputs.get("texts"):
return data
@ -674,13 +797,14 @@ class OpenAIResponsesHandler(BaseTranslation):
logging_obj=litellm_logging_obj,
)
self._apply_guardrailed_tools_to_data(
data, original_tools, flattened_tool_groups, guardrailed_inputs.get("tools")
data, original_tools, flattened_tool_groups, guardrailed_inputs.get("tools"), scan_only=scan_only
)
written_back: Final = self._written_back_request_fields(
data,
structured_messages or (),
scoped_indices,
scoped_structured_messages,
tuple((split, original) for split, original in split_rows if split is not original),
guardrail_to_apply,
guardrailed_inputs,
)
@ -730,8 +854,10 @@ class OpenAIResponsesHandler(BaseTranslation):
flattened_tool_groups: Sequence[Sequence[Mapping[str, object]]],
*,
skip_system: bool = False,
skip_tool: bool = False,
scan_only: bool = False,
) -> _ExtractedInputs:
instructions: Final = scannable_instructions(data, skip_system=skip_system)
instructions: Final = scannable_instructions(data, skip_system=skip_system or scan_only)
texts_to_check: Final[list[str]] = [] if instructions is None else [instructions]
images_to_check: Final[list[str]] = []
task_mappings: Final[list[tuple[int, int | None]]] = []
@ -745,11 +871,15 @@ class OpenAIResponsesHandler(BaseTranslation):
)
)
if isinstance(input_data, str):
texts_to_check.append(input_data)
if not scan_only:
texts_to_check.append(input_data)
else:
for msg_idx, message in enumerate(input_data):
if role_out_of_guardrail_scope(
_input_item_role(message), skip_system_message=skip_system, skip_tool_message=False
_input_item_role(message),
skip_system_message=skip_system,
skip_tool_message=skip_tool,
scan_only_tool_results=scan_only,
):
continue
self._extract_input_text_and_images(
@ -775,12 +905,14 @@ class OpenAIResponsesHandler(BaseTranslation):
structured_messages: Sequence[AllMessageValues],
scoped_indices: Sequence[int],
scoped_structured_messages: Sequence[AllMessageValues] | None,
split_rows: Sequence[tuple[AllMessageValues, AllMessageValues]],
guardrail_to_apply: "CustomGuardrail",
guardrailed_inputs: GenericGuardrailAPIInputs,
) -> _RequestFields | None:
guardrailed: Final = guardrailed_inputs.get("structured_messages")
if guardrailed is None or guardrailed is scoped_structured_messages:
returned: Final = guardrailed_inputs.get("structured_messages")
if returned is None or returned is scoped_structured_messages:
return None
guardrailed: Final = _with_split_rows_restored(returned, split_rows)
covers_full_request: Final = len(scoped_indices) == len(structured_messages) or (
guardrail_to_apply.structured_messages_cover_full_request() and len(guardrailed) == len(structured_messages)
)
@ -814,11 +946,14 @@ class OpenAIResponsesHandler(BaseTranslation):
original_tools: Sequence[Mapping[str, object]],
flattened_tool_groups: Sequence[Sequence[Mapping[str, object]]],
guardrailed_tools: Sequence[ChatCompletionToolParam] | None,
*,
scan_only: bool,
) -> None:
if guardrailed_tools is None:
return
merge: Final = append_returned_tools if scan_only else merge_guardrailed_tools
data["tools"] = list( # rebind-ok: in-place request rewrite
merge_guardrailed_tools(original_tools, flattened_tool_groups, guardrailed_tools)
merge(original_tools, flattened_tool_groups, guardrailed_tools)
)
def _extract_input_text_and_images(
@ -839,6 +974,11 @@ class OpenAIResponsesHandler(BaseTranslation):
texts_to_check.append(summary)
task_mappings.append((msg_idx, None))
return
if message.get("type") in TOOL_CALL_OUTPUT_ITEM_TYPES:
for content_idx, text in tool_output_texts(message):
texts_to_check.append(text)
task_mappings.append((msg_idx, content_idx))
return
content: Final = message.get("content", None)
if content is None:
return
@ -890,18 +1030,20 @@ class OpenAIResponsesHandler(BaseTranslation):
)
continue
content = messages[msg_idx].get("content", None)
field = _scanned_text_field(messages[msg_idx])
content = messages[msg_idx].get(field, None)
if content is None:
continue
if isinstance(content, str) and content_idx_optional is None:
# Replace string content with guardrail response
messages[msg_idx]["content"] = guardrail_response
if content_idx_optional is None:
rewritten = _rewritten_whole_text(messages[msg_idx], field, guardrail_response)
if rewritten is not None:
messages[msg_idx][field] = rewritten
elif isinstance(content, list) and content_idx_optional is not None:
elif isinstance(content, list):
# Replace specific text item in list content
if isinstance(messages[msg_idx]["content"][content_idx_optional], dict):
messages[msg_idx]["content"][content_idx_optional]["text"] = guardrail_response
if isinstance(messages[msg_idx][field][content_idx_optional], dict):
messages[msg_idx][field][content_idx_optional]["text"] = guardrail_response
async def process_output_response(
self,

View file

@ -6,6 +6,7 @@ from typing import Final, TypeAlias
from pydantic import BaseModel, TypeAdapter, ValidationError
from litellm._logging import verbose_logger
from litellm.llms.base_llm.guardrail_translation.utils import merge_returned_tools_into_request_tools, openai_tool_name
from litellm.responses.litellm_completion_transformation.custom_tools import custom_tool_grammar_suffix
from litellm.responses.litellm_completion_transformation.transformation import (
NAMESPACE_DESCRIPTION_SEPARATOR,
@ -183,3 +184,18 @@ def merge_guardrailed_tools(
tuple(tool for key, tool in zip(guardrailed_keys, guardrailed) if key not in owned_keys)
)
return tuple(chain(merged_originals, appended))
def append_returned_tools(
original_tools: Sequence[Tool],
flattened_groups: Sequence[Sequence[Tool]],
returned_tools: Iterable[object],
) -> tuple[Tool, ...]:
request_chat_tools: Final = tuple(chain.from_iterable(flattened_groups))
additions: Final = merge_returned_tools_into_request_tools(
request_tools=request_chat_tools, returned_tools=_validated_tools(returned_tools), tool_name=openai_tool_name
)[len(request_chat_tools) :]
return (
*original_tools,
*LiteLLMCompletionResponsesConfig.transform_chat_completion_tool_params_to_responses_api_tools(additions),
)

View file

@ -49,6 +49,7 @@ from litellm.llms.base_llm.guardrail_translation.utils import (
from litellm.llms.bedrock.base_aws_llm import BaseAWSLLM, bedrock_bearer_token, run_aws_signing
from litellm.llms.bedrock.guardrail_attachments import find_request_attachments
from litellm.llms.custom_httpx.http_handler import (
AsyncHTTPHandler,
get_async_httpx_client,
httpxSpecialProvider,
)
@ -286,9 +287,12 @@ class BedrockGuardrail(CustomGuardrail, BaseAWSLLM):
streaming_sampling_rate: int | None = None,
streaming_end_of_stream_only: bool | None = None,
streaming_buffer_release_on_scan: bool | None = None,
async_handler: AsyncHTTPHandler | None = None,
**kwargs,
):
self.async_handler = get_async_httpx_client(llm_provider=httpxSpecialProvider.GuardrailCallback)
self.async_handler = async_handler or get_async_httpx_client(
llm_provider=httpxSpecialProvider.GuardrailCallback
)
self._set_streaming_params(
BedrockGuardrailStreamingParams.from_extras(
MappingProxyType(

View file

@ -6,20 +6,19 @@ model_list:
model: gpt-4
api_key: os.environ/OPENAI_API_KEY
litellm_settings:
guardrails:
- guardrail_name: "my-generic-guardrail"
litellm_params:
guardrail: generic_guardrail_api
mode: pre_call # Options: pre_call, post_call, during_call, [pre_call, post_call]
api_key: os.environ/GENERIC_GUARDRAIL_API_KEY # Optional if using Bearer auth
api_base: http://localhost:8080 # Required. Endpoint /beta/litellm_basic_guardrail_api is automatically appended
unreachable_fallback: fail_closed # Options: fail_closed (default, raise), fail_open (proceed if endpoint unreachable or upstream returns 502/503/504)
default_on: false # Set to true to apply to all requests by default
additional_provider_specific_params:
# Any additional parameters your guardrail API needs
api_version: "v1"
custom_param: "value"
guardrails:
- guardrail_name: "my-generic-guardrail"
litellm_params:
guardrail: generic_guardrail_api
mode: pre_call # Options: pre_call, post_call, during_call, [pre_call, post_call]
api_key: os.environ/GENERIC_GUARDRAIL_API_KEY # Optional if using Bearer auth
api_base: http://localhost:8080 # Required. Endpoint /beta/litellm_basic_guardrail_api is automatically appended
unreachable_fallback: fail_closed # Options: fail_closed (default, raise), fail_open (proceed if endpoint unreachable or upstream returns 502/503/504)
default_on: false # Set to true to apply to all requests by default
additional_provider_specific_params:
# Any additional parameters your guardrail API needs
api_version: "v1"
custom_param: "value"
# Usage examples:

View file

@ -30,6 +30,7 @@ from litellm.integrations.custom_guardrail import (
from litellm.llms.base_llm.guardrail_translation.utils import (
effective_scan_only_tool_results_for_guardrail,
effective_skip_system_message_for_guardrail,
effective_skip_tool_message_for_guardrail,
role_out_of_guardrail_scope,
)
from litellm.llms.custom_httpx.http_handler import (
@ -37,12 +38,16 @@ from litellm.llms.custom_httpx.http_handler import (
get_async_httpx_client,
httpxSpecialProvider,
)
from litellm.llms.openai.responses.guardrail_translation.handler import scannable_instructions
from litellm.llms.openai.responses.guardrail_translation.handler import (
scannable_instructions,
tool_output_texts,
)
from litellm.proxy._types import UserAPIKeyAuth
from litellm.proxy.common_utils.callback_utils import (
add_guardrail_scan_id,
add_guardrail_to_applied_guardrails_header,
)
from litellm.responses.litellm_completion_transformation.transformation import TOOL_CALL_OUTPUT_ITEM_TYPES
from litellm.types.guardrails import GuardrailEventHooks
from litellm.types.llms.base import LiteLLMBaseModel
from litellm.types.llms.openai import AllMessageValues
@ -112,12 +117,16 @@ class _ResponsesInputItem(LiteLLMBaseModel):
type: str | None = None
role: str | None = None
content: str | tuple[_ResponsesContentPart, ...] | None = None
output: object = None
call_id: object = None
def text_count(self, *, skip_system: bool) -> int:
if role_out_of_guardrail_scope(
(self.role or "").lower(), skip_system_message=skip_system, skip_tool_message=False
):
def text_count(self, *, skip_system: bool, skip_tool: bool) -> int:
is_tool_output: Final = self.type in TOOL_CALL_OUTPUT_ITEM_TYPES
role: Final = "tool" if is_tool_output else (self.role or "").lower()
if role_out_of_guardrail_scope(role, skip_system_message=skip_system, skip_tool_message=skip_tool):
return 0
if is_tool_output:
return len(tool_output_texts(self))
if isinstance(self.content, str):
return 1
if self.content is None:
@ -1692,13 +1701,14 @@ class PanwPrismaAirsHandler(CustomGuardrail):
request_data: Mapping[str, object],
*,
skip_system: bool,
skip_tool: bool,
) -> frozenset[int] | None:
"""Return the ``texts`` indices flattened from Responses ``reasoning`` input items.
The Responses translation handler gives those model-authored items the default
``user`` role, so the latest-turn selection must not mistake one for a human turn.
Empty for requests without a Responses ``input`` item list; None when the raw items
(after the leading ``instructions`` text, both minus whatever ``skip_system`` drops)
(after the leading ``instructions`` text, both minus whatever ``skip_system`` and ``skip_tool`` drop)
do not account for every entry of ``texts``.
"""
try:
@ -1708,7 +1718,7 @@ class PanwPrismaAirsHandler(CustomGuardrail):
if not isinstance(raw_input, tuple):
return frozenset()
offset: Final = 0 if scannable_instructions(request_data, skip_system=skip_system) is None else 1
counts: Final = tuple(item.text_count(skip_system=skip_system) for item in raw_input)
counts: Final = tuple(item.text_count(skip_system=skip_system, skip_tool=skip_tool) for item in raw_input)
if offset + sum(counts) != len(texts):
return None
starts: Final = itertools.accumulate(counts, initial=offset)
@ -1738,7 +1748,10 @@ class PanwPrismaAirsHandler(CustomGuardrail):
if sources is None:
return None
reasoning: Final = self._reasoning_item_text_indices(
texts, request_data, skip_system=effective_skip_system_message_for_guardrail(self)
texts,
request_data,
skip_system=effective_skip_system_message_for_guardrail(self),
skip_tool=effective_skip_tool_message_for_guardrail(self),
)
if reasoning is None:
return None

View file

@ -321,7 +321,7 @@ def _responses_file_part(item: Mapping[str, object]) -> _ResponsesFilePart:
)
def _decode_responses_media(item: Mapping[str, object]) -> _ResponsesMediaPart | None:
def decode_responses_media(item: Mapping[str, object]) -> _ResponsesMediaPart | None:
kind: Final = item.get("type")
if kind in ("input_image", "image_url", "computer_screenshot") and any(key in item for key in ("image_url", "url")):
image: Final = _responses_image_part(item)
@ -383,6 +383,52 @@ def _attribute_fields(value: object) -> dict[str, object]:
return dict(cast("Iterable[tuple[str, object]]", value)) # cast-ok: dict() raises on non-pair values, as before
def tool_output_to_chat_content(
output: object,
) -> str | list[ToolMessageContentPart]:
if output is None:
return ""
if isinstance(output, str):
return output
single_media: Final = decode_responses_media(output) if isinstance(output, Mapping) else None
if single_media is not None:
return [single_media]
if isinstance(output, list):
normalized_blocks: Final[list[ToolMessageContentPart]] = []
text_acc: Final[list[str]] = []
for part in output:
if not isinstance(part, dict):
continue
part_type = part.get("type")
if part_type in ("input_text", "output_text", "text"):
txt = part.get("text")
if isinstance(txt, str) and txt:
text_acc.append(txt)
normalized_blocks.append({"type": "text", "text": txt})
elif (media := decode_responses_media(part)) is not None:
normalized_blocks.append(media)
if any(b.get("type") != "text" for b in normalized_blocks):
return normalized_blocks
if text_acc:
return "".join(text_acc)
try:
return json.dumps(output)
except Exception:
return str(output)
try:
return json.dumps(output)
except Exception:
return str(output)
TOOL_CALL_OUTPUT_ITEM_TYPES: Final = frozenset(
{"function_call_output", "custom_tool_call_output", "computer_call_output", "tool_result"}
)
def _input_item_role(input_item: Mapping[str, object]) -> str:
return cast(str, input_item.get("role") or "user") # cast-ok: client-supplied role forwarded verbatim, unvalidated
@ -2371,12 +2417,7 @@ class LiteLLMCompletionResponsesConfig:
"""
Check if the input item is a tool call output
"""
return input_item.get("type") in [
"function_call_output",
"custom_tool_call_output",
"computer_call_output",
"tool_result", # Anthropic/MCP format
]
return input_item.get("type") in TOOL_CALL_OUTPUT_ITEM_TYPES
@staticmethod
def _is_input_item_function_call(input_item: Mapping[str, object]) -> bool:
@ -2400,58 +2441,7 @@ class LiteLLMCompletionResponsesConfig:
if not call_id:
return []
def _normalize_function_call_output_to_tool_content(
output: object,
) -> str | list[ToolMessageContentPart]:
"""
Preserve structured media for downstream chat adapters and token estimation.
Text-only outputs retain their existing string representation.
"""
if output is None:
return ""
if isinstance(output, str):
return output
single_media: Final = _decode_responses_media(output) if isinstance(output, Mapping) else None
if single_media is not None:
return [single_media]
# Some adapters represent tool output as a list of "input_*" parts
if isinstance(output, list):
normalized_blocks: Final[list[ToolMessageContentPart]] = []
text_acc: Final[list[str]] = []
for part in output:
if not isinstance(part, dict):
continue
part_type = part.get("type")
if part_type in ("input_text", "output_text", "text"):
txt = part.get("text")
if isinstance(txt, str) and txt:
text_acc.append(txt)
normalized_blocks.append({"type": "text", "text": txt})
elif (media := _decode_responses_media(part)) is not None:
normalized_blocks.append(media)
if any(b.get("type") != "text" for b in normalized_blocks):
return normalized_blocks
if text_acc:
return "".join(text_acc)
try:
# last resort: keep something meaningful for providers that require a string
import json as _json
return _json.dumps(output)
except Exception:
return str(output)
# Fallback for dict/number/etc.
try:
import json as _json
return _json.dumps(output)
except Exception:
return str(output)
tool_content: Final = _normalize_function_call_output_to_tool_content(tool_call_output.get("output"))
tool_content: Final = tool_output_to_chat_content(tool_call_output.get("output"))
output_cache_control: Final = _input_item_cache_control(tool_call_output)
tool_output_message: Final = (
ChatCompletionToolMessage(role="tool", content=tool_content, tool_call_id=str(call_id))
@ -2606,7 +2596,7 @@ class LiteLLMCompletionResponsesConfig:
content_list.append(item)
elif isinstance(item, dict):
if (
media := _decode_responses_media(
media := decode_responses_media(
fields := _validate_responses_media(_STR_KEY_DICT_ADAPTER, item)
)
) is not None:

View file

@ -7,12 +7,14 @@ with guardrail transformations.
import copy
import json
import uuid
from collections.abc import Callable
from typing import Any, Final, List, Literal, Optional, Tuple
from unittest.mock import AsyncMock, MagicMock, patch
import logging
import httpx
import pytest
@ -33,6 +35,10 @@ from litellm.llms.openai.responses.guardrail_translation.handler import (
)
from litellm.llms.openai.responses.guardrail_translation.tool_merge import merge_guardrailed_tools
from litellm.proxy.guardrails.guardrail_hooks.generic_guardrail_api import GenericGuardrailAPI
from litellm.llms.custom_httpx.http_handler import AsyncHTTPHandler
from litellm.proxy.guardrails.guardrail_hooks.bedrock_guardrails import BedrockGuardrail
from litellm.proxy.guardrails.guardrail_hooks.litellm_content_filter.content_filter import ContentFilterGuardrail
from litellm.types.guardrails import ContentFilterAction, ContentFilterPattern
from litellm.types.llms.openai import ChatCompletionToolCallChunk
from litellm.responses.litellm_completion_transformation.compaction_items import compaction_summary
from litellm.responses.litellm_completion_transformation.transformation import (
@ -83,6 +89,7 @@ class RecordingMaskingGuardrail(MockGuardrail):
super().__init__(guardrail_name=guardrail_name)
self.seen_texts: list[list[str]] = []
self.seen_message_contents: list[list[object]] = []
self.seen_tools: list[object] = []
async def apply_guardrail(
self,
@ -93,6 +100,7 @@ class RecordingMaskingGuardrail(MockGuardrail):
) -> GenericGuardrailAPIInputs:
self.seen_texts.append(list(inputs.get("texts", [])))
self.seen_message_contents.append([m["content"] for m in inputs.get("structured_messages") or []])
self.seen_tools.append(inputs.get("tools"))
return await super().apply_guardrail(inputs, request_data, input_type, logging_obj)
@ -588,6 +596,638 @@ class TestSkipSystemMessageScopesInstructions:
]
_TOOL_TURNS: Final = (
(
{"type": "function_call", "call_id": "call_1", "name": "get_weather", "arguments": '{"city": "Paris"}'},
"function_call_output",
),
(
{"type": "custom_tool_call", "call_id": "call_1", "name": "run_sql", "input": "select 1"},
"custom_tool_call_output",
),
)
def _tool_result_request(call_item: dict[str, str], output_type: str, output: object) -> dict[str, object]:
return {
"model": "gpt-5.4-mini",
"input": [
{"role": "user", "content": "Weather?"},
call_item,
{"type": output_type, "call_id": "call_1", "output": output},
],
}
def _bedrock_guardrail(
name: str, bedrock_api: Callable[[httpx.Request], httpx.Response], **settings: bool
) -> BedrockGuardrail:
return BedrockGuardrail(
guardrail_name=name,
guardrailIdentifier="test-guardrail",
guardrailVersion="DRAFT",
aws_access_key_id="test-access-key",
aws_secret_access_key="test-secret-key",
aws_region_name="us-east-1",
async_handler=AsyncHTTPHandler(transport=httpx.MockTransport(bedrock_api)),
**settings,
)
def _bedrock_block_texts(request: httpx.Request) -> list[str]:
return [block["text"]["text"] for block in json.loads(request.content)["content"]]
class TestToolResultItemsReachTextsGuardrails:
"""A Responses tool result is scanned and rewritten in its `output` field the way a chat `tool`
message's content is, and skip_tool_message_in_guardrail keeps it out of the scan the same way."""
@pytest.mark.asyncio
@pytest.mark.parametrize(("call_item", "output_type"), _TOOL_TURNS, ids=["function", "custom"])
async def test_string_output_is_scanned_and_rewritten_in_place(
self, call_item: dict[str, str], output_type: str
) -> None:
guardrail: Final = RecordingMaskingGuardrail(guardrail_name="test")
result: Final = await OpenAIResponsesHandler().process_input_messages(
_tool_result_request(call_item, output_type, "SECRET forecast"), guardrail
)
assert guardrail.seen_texts == [["Weather?", "SECRET forecast"]]
assert result["input"] == [
{"role": "user", "content": "Weather? [GUARDRAILED]"},
call_item,
{"type": output_type, "call_id": "call_1", "output": "SECRET forecast [GUARDRAILED]"},
]
@pytest.mark.asyncio
async def test_content_part_output_is_scanned_and_rewritten_in_its_part(self) -> None:
guardrail: Final = RecordingMaskingGuardrail(guardrail_name="test")
call_item, output_type = _TOOL_TURNS[0]
image_part: Final = {"type": "input_image", "image_url": "https://example.com/map.png"}
result: Final = await OpenAIResponsesHandler().process_input_messages(
_tool_result_request(
call_item, output_type, [{"type": "input_text", "text": "SECRET forecast"}, image_part]
),
guardrail,
)
assert guardrail.seen_texts == [["Weather?", "SECRET forecast"]]
assert result["input"][2]["output"] == [
{"type": "input_text", "text": "SECRET forecast [GUARDRAILED]"},
image_part,
]
@pytest.mark.asyncio
@pytest.mark.parametrize("part_type", ["input_text", "output_text"])
async def test_text_only_parts_are_scanned_and_rewritten_part_by_part(self, part_type: str) -> None:
guardrail: Final = RecordingMaskingGuardrail(guardrail_name="test")
call_item, output_type = _TOOL_TURNS[0]
parts: Final = [
{"type": part_type, "text": "SECRET forecast: ", "annotations": []},
{"type": part_type, "text": "sunny"},
]
result: Final = await OpenAIResponsesHandler().process_input_messages(
_tool_result_request(call_item, output_type, parts), guardrail
)
assert guardrail.seen_texts == [["Weather?", "SECRET forecast: ", "sunny"]]
assert result["input"][2]["output"] == [
{"type": part_type, "text": "SECRET forecast: [GUARDRAILED]", "annotations": []},
{"type": part_type, "text": "sunny [GUARDRAILED]"},
]
@pytest.mark.asyncio
async def test_a_pattern_ending_at_a_part_boundary_blocks_like_it_does_on_chat(self) -> None:
access_key: Final = "AKIA" + "SYNTHETIC0000000"
content_filter: Final = ContentFilterGuardrail(
guardrail_name="secrets",
patterns=[
ContentFilterPattern(
pattern_type="prebuilt", pattern_name="aws_access_key", action=ContentFilterAction.BLOCK
)
],
)
call_item, output_type = _TOOL_TURNS[0]
parts: Final = [
{"type": "input_text", "text": f"key={access_key}"},
{"type": "input_text", "text": "region=us-east-1"},
]
with pytest.raises(HTTPException) as blocked:
await OpenAIResponsesHandler().process_input_messages(
_tool_result_request(call_item, output_type, parts), content_filter
)
assert blocked.value.status_code == 400
@pytest.mark.asyncio
@pytest.mark.parametrize("empty_output", ["", None], ids=["empty_string", "null"])
async def test_empty_tool_output_sends_no_text_and_stays_as_sent(self, empty_output: str | None) -> None:
guardrail: Final = RecordingMaskingGuardrail(guardrail_name="test")
call_item, output_type = _TOOL_TURNS[0]
result: Final = await OpenAIResponsesHandler().process_input_messages(
_tool_result_request(call_item, output_type, empty_output), guardrail
)
assert guardrail.seen_texts == [["Weather?"]]
assert result["input"][2] == {"type": output_type, "call_id": "call_1", "output": empty_output}
@pytest.mark.asyncio
@pytest.mark.parametrize("latest_user_only", [False, True], ids=["all_texts", "latest_user_only"])
@pytest.mark.parametrize(
"tool_output",
[
{"type": "function_call_output", "call_id": "call_1", "output": ""},
{"type": "function_call_output", "call_id": "call_1", "output": None},
{
"type": "computer_call_output",
"call_id": "call_1",
"output": {"type": "computer_screenshot", "file_id": "file-screen"},
},
],
ids=["empty_string", "null", "file_id_screenshot"],
)
async def test_bedrock_masking_applies_to_a_request_with_a_textless_tool_output(
self, tool_output: dict[str, object], latest_user_only: bool
) -> None:
sent_texts: Final[list[list[str]]] = []
def bedrock_masking_emails(request: httpx.Request) -> httpx.Response:
texts: Final = _bedrock_block_texts(request)
sent_texts.append(texts)
outputs: Final = [{"text": text.replace("jane@example.com", "{EMAIL}")} for text in texts]
return httpx.Response(200, json={"action": "GUARDRAIL_INTERVENED", "outputs": outputs})
guardrail: Final = _bedrock_guardrail(
"bedrock-pre", bedrock_masking_emails, experimental_use_latest_role_message_only=latest_user_only
)
request: Final = {
"model": "gpt-5.4-mini",
"input": [
{"role": "user", "content": "Look up the order."},
{"type": "function_call", "call_id": "call_1", "name": "lookup", "arguments": "{}"},
copy.deepcopy(tool_output),
{"role": "user", "content": "Email jane@example.com the result."},
],
}
result: Final = await OpenAIResponsesHandler().process_input_messages(request, guardrail)
expected_texts: Final = (
[["Email jane@example.com the result."]]
if latest_user_only
else [["Look up the order.", "Email jane@example.com the result."]]
)
assert sent_texts == expected_texts
assert result["input"][3]["content"] == "Email {EMAIL} the result."
assert result["input"][2] == tool_output
@pytest.mark.asyncio
async def test_bedrock_latest_user_masking_applies_with_a_multi_part_tool_output(self) -> None:
sent_texts: Final[list[list[str]]] = []
def bedrock_masking_emails(request: httpx.Request) -> httpx.Response:
texts: Final = _bedrock_block_texts(request)
sent_texts.append(texts)
outputs: Final = [{"text": text.replace("jane@example.com", "{EMAIL}")} for text in texts]
return httpx.Response(200, json={"action": "GUARDRAIL_INTERVENED", "outputs": outputs})
guardrail: Final = _bedrock_guardrail(
"bedrock-pre", bedrock_masking_emails, experimental_use_latest_role_message_only=True
)
tool_output: Final = {
"type": "function_call_output",
"call_id": "call_1",
"output": [{"type": "input_text", "text": "order 42"}, {"type": "input_text", "text": "shipped"}],
}
request: Final = {
"model": "gpt-5.4-mini",
"input": [
{"role": "user", "content": "Look up the order."},
{"type": "function_call", "call_id": "call_1", "name": "lookup", "arguments": "{}"},
copy.deepcopy(tool_output),
{"role": "user", "content": "Email jane@example.com the result."},
],
}
result: Final = await OpenAIResponsesHandler().process_input_messages(request, guardrail)
assert sent_texts == [["Email jane@example.com the result."]]
assert result["input"][3]["content"] == "Email {EMAIL} the result."
assert result["input"][2] == tool_output
@pytest.mark.asyncio
async def test_bedrock_only_scan_new_messages_scans_only_the_new_tool_result(self) -> None:
sent_texts: Final[list[list[str]]] = []
def bedrock_allowing(request: httpx.Request) -> httpx.Response:
sent_texts.append(_bedrock_block_texts(request))
return httpx.Response(200, json={"action": "NONE", "outputs": []})
guardrail: Final = _bedrock_guardrail("bedrock-incremental", bedrock_allowing, only_scan_new_messages=True)
first_turn: Final = [
{"role": "user", "content": "Weather in Paris and Rome?"},
{"type": "function_call", "call_id": "call_1", "name": "get_weather", "arguments": '{"city": "Paris"}'},
{"type": "function_call_output", "call_id": "call_1", "output": "Paris: sunny"},
]
second_turn: Final = [
*first_turn,
{"type": "function_call", "call_id": "call_2", "name": "get_weather", "arguments": '{"city": "Rome"}'},
{"type": "function_call_output", "call_id": "call_2", "output": "Rome: rain"},
]
session: Final = {"litellm_session_id": f"session-{uuid.uuid4()}"}
for turn in (first_turn, second_turn):
await OpenAIResponsesHandler().process_input_messages(
{"model": "gpt-5.4-mini", "input": copy.deepcopy(turn), **session}, guardrail
)
assert sent_texts == [["Weather in Paris and Rome?", "Paris: sunny"], ["Rome: rain"]]
@pytest.mark.asyncio
async def test_file_id_screenshot_is_not_scanned_as_text_or_rewritten(self) -> None:
guardrail: Final = RecordingMaskingGuardrail(guardrail_name="test")
screenshot_output: Final = {
"type": "computer_call_output",
"call_id": "cu_1",
"output": {"type": "computer_screenshot", "file_id": "file-SECRET-screen"},
}
request: Final = {
"model": "gpt-5.4-mini",
"input": [
{"role": "user", "content": "Open the page"},
{"type": "computer_call", "call_id": "cu_1", "id": "cu_1", "action": {"type": "screenshot"}},
copy.deepcopy(screenshot_output),
],
}
result: Final = await OpenAIResponsesHandler().process_input_messages(request, guardrail)
assert guardrail.seen_texts == [["Open the page"]]
assert result["input"][2] == screenshot_output
@pytest.mark.asyncio
async def test_text_parts_between_file_images_are_rewritten_in_place(self) -> None:
guardrail: Final = RecordingMaskingGuardrail(guardrail_name="test")
call_item, output_type = _TOOL_TURNS[0]
logo_part: Final = {"type": "input_image", "file_id": "file-logo"}
image_part: Final = {"type": "input_image", "file_id": "file-map"}
parts: Final = [
logo_part,
{"type": "input_text", "text": "SECRET forecast: "},
image_part,
{"type": "input_text", "text": "sunny"},
]
result: Final = await OpenAIResponsesHandler().process_input_messages(
_tool_result_request(call_item, output_type, parts), guardrail
)
assert guardrail.seen_texts == [["Weather?", "SECRET forecast: ", "sunny"]]
assert result["input"][2]["output"] == [
logo_part,
{"type": "input_text", "text": "SECRET forecast: [GUARDRAILED]"},
image_part,
{"type": "input_text", "text": "sunny [GUARDRAILED]"},
]
@pytest.mark.asyncio
async def test_dict_output_is_rewritten_as_the_text_the_guardrail_returns(self) -> None:
guardrail: Final = RecordingMaskingGuardrail(guardrail_name="test")
call_item, output_type = _TOOL_TURNS[0]
result: Final = await OpenAIResponsesHandler().process_input_messages(
_tool_result_request(call_item, output_type, {"result": "SECRET forecast"}), guardrail
)
assert result["input"][2]["output"] == '{"result": "SECRET forecast"} [GUARDRAILED]'
@pytest.mark.asyncio
async def test_dict_output_the_guardrail_leaves_unchanged_stays_a_dict(self) -> None:
guardrail: Final = TextsReplacingGuardrail(guardrail_name="test", texts=("[MASKED]", '{"result": "sunny"}'))
call_item, output_type = _TOOL_TURNS[0]
result: Final = await OpenAIResponsesHandler().process_input_messages(
_tool_result_request(call_item, output_type, {"result": "sunny"}), guardrail
)
assert result["input"][0]["content"] == "[MASKED]"
assert result["input"][2]["output"] == {"result": "sunny"}
@pytest.mark.asyncio
async def test_tool_result_without_call_id_is_left_out_like_its_structured_row(self) -> None:
guardrail: Final = RecordingMaskingGuardrail(guardrail_name="test")
call_item, output_type = _TOOL_TURNS[0]
orphan: Final = {"type": output_type, "output": "SECRET orphan"}
request: Final = {
"model": "gpt-5.4-mini",
"input": [
{"role": "user", "content": "Weather?"},
call_item,
dict(orphan),
{"type": output_type, "call_id": "call_1", "output": "SECRET forecast"},
],
}
result: Final = await OpenAIResponsesHandler().process_input_messages(request, guardrail)
assert guardrail.seen_texts == [["Weather?", "SECRET forecast"]]
assert "SECRET orphan" not in guardrail.seen_message_contents[0]
assert result["input"][2] == orphan
assert result["input"][3]["output"] == "SECRET forecast [GUARDRAILED]"
@pytest.mark.asyncio
async def test_a_textless_output_sharing_a_call_id_keeps_the_text_output_in_structured_messages(self) -> None:
guardrail: Final = RecordingMaskingGuardrail(guardrail_name="test")
call_item, output_type = _TOOL_TURNS[0]
request: Final = _tool_result_request(call_item, output_type, "")
request["input"].append({"type": output_type, "call_id": "call_1", "output": "SECRET forecast"})
result: Final = await OpenAIResponsesHandler().process_input_messages(request, guardrail)
assert guardrail.seen_texts == [["Weather?", "SECRET forecast"]]
assert "SECRET forecast" in guardrail.seen_message_contents[0]
assert "" not in guardrail.seen_message_contents[0]
assert result["input"][3]["output"] == "SECRET forecast [GUARDRAILED]"
@pytest.mark.asyncio
async def test_multi_part_outputs_sharing_a_call_id_each_keep_their_own_text_in_structured_messages(
self,
) -> None:
guardrail: Final = RecordingMaskingGuardrail(guardrail_name="test")
call_item, output_type = _TOOL_TURNS[0]
request: Final = _tool_result_request(
call_item,
output_type,
[{"type": "input_text", "text": "first "}, {"type": "input_text", "text": "output"}],
)
request["input"].append(
{
"type": output_type,
"call_id": "call_1",
"output": [{"type": "input_text", "text": "second "}, {"type": "input_text", "text": "output"}],
}
)
result: Final = await OpenAIResponsesHandler().process_input_messages(request, guardrail)
assert guardrail.seen_texts == [["Weather?", "first ", "output", "second ", "output"]]
assert guardrail.seen_message_contents[0][2:] == [
[{"type": "text", "text": "first "}, {"type": "text", "text": "output"}],
[{"type": "text", "text": "second "}, {"type": "text", "text": "output"}],
]
assert [part["text"] for part in result["input"][3]["output"]] == [
"second [GUARDRAILED]",
"output [GUARDRAILED]",
]
@pytest.mark.asyncio
@pytest.mark.parametrize(
"textless_output",
[
{"type": "function_call_output", "call_id": "call_1", "output": ""},
{"type": "function_call_output", "call_id": "call_1", "output": None},
{
"type": "computer_call_output",
"call_id": "call_1",
"output": {"type": "computer_screenshot", "file_id": "file-screen"},
},
],
ids=["empty_string", "null", "file_id_screenshot"],
)
async def test_multi_part_output_sharing_a_call_id_with_a_textless_output_lists_its_parts_in_structured_messages(
self, textless_output: dict[str, object]
) -> None:
guardrail: Final = RecordingMaskingGuardrail(guardrail_name="test")
call_item, output_type = _TOOL_TURNS[0]
request: Final = _tool_result_request(
call_item,
output_type,
[{"type": "input_text", "text": "first "}, {"type": "input_text", "text": "output"}],
)
request["input"].append(copy.deepcopy(textless_output))
await OpenAIResponsesHandler().process_input_messages(request, guardrail)
assert guardrail.seen_texts == [["Weather?", "first ", "output"]]
assert guardrail.seen_message_contents[0][2:] == [
[{"type": "text", "text": "first "}, {"type": "text", "text": "output"}],
]
@pytest.mark.asyncio
async def test_tool_result_item_is_scanned_like_function_call_output(self) -> None:
guardrail: Final = RecordingMaskingGuardrail(guardrail_name="test")
call_item, _ = _TOOL_TURNS[0]
result: Final = await OpenAIResponsesHandler().process_input_messages(
_tool_result_request(call_item, "tool_result", "SECRET forecast"), guardrail
)
assert guardrail.seen_texts == [["Weather?", "SECRET forecast"]]
assert guardrail.seen_message_contents[0][2] == "SECRET forecast"
assert result["input"][2]["output"] == "SECRET forecast [GUARDRAILED]"
@pytest.mark.asyncio
async def test_text_only_parts_the_guardrail_leaves_unchanged_keep_their_parts(self) -> None:
call_item, output_type = _TOOL_TURNS[0]
parts: Final = [{"type": "input_text", "text": "SECRET forecast: "}, {"type": "input_text", "text": "sunny"}]
result: Final = await OpenAIResponsesHandler().process_input_messages(
_tool_result_request(call_item, output_type, copy.deepcopy(parts)), CustomGuardrail(guardrail_name="test")
)
assert result["input"][2]["output"] == parts
@pytest.mark.asyncio
async def test_text_parts_a_structured_guardrail_returns_unchanged_keep_their_parts(self) -> None:
class StructuredEchoGuardrail(CustomGuardrail):
async def apply_guardrail(
self,
inputs: GenericGuardrailAPIInputs,
request_data: dict[str, object],
input_type: Literal["request", "response"],
logging_obj: LiteLLMLoggingObj | None = None,
) -> GenericGuardrailAPIInputs:
return {**inputs, "structured_messages": copy.deepcopy(inputs.get("structured_messages") or [])}
call_item, output_type = _TOOL_TURNS[0]
parts: Final = [
{"type": "output_text", "text": "SECRET forecast: ", "annotations": []},
{"type": "output_text", "text": "sunny"},
]
result: Final = await OpenAIResponsesHandler().process_input_messages(
_tool_result_request(call_item, output_type, copy.deepcopy(parts)),
StructuredEchoGuardrail(guardrail_name="t"),
)
assert result["input"][2]["output"] == parts
@pytest.mark.asyncio
@pytest.mark.parametrize(
("output", "expected_texts"),
[
pytest.param("SECRET forecast", ["SECRET forecast"], id="string"),
pytest.param([{"type": "input_text", "text": "SECRET forecast"}], ["SECRET forecast"], id="one_part"),
pytest.param(
[{"type": "input_text", "text": "SECRET "}, {"type": "output_text", "text": "forecast"}],
["SECRET ", "forecast"],
id="parts",
),
pytest.param(
[
{"type": "input_text", "text": "SECRET "},
{"type": "input_image", "image_url": "https://example.com/map.png"},
{"type": "input_text", "text": ""},
{"type": "input_text", "text": "forecast"},
],
["SECRET ", "forecast"],
id="parts_with_image",
),
pytest.param(
[
{"type": "input_text", "text": "SECRET forecast"},
{"type": "summary_text", "text": "not tool output"},
],
["SECRET forecast"],
id="non_text_part_with_text_field",
),
pytest.param(
["SECRET forecast", {"type": "input_text", "text": "visible"}],
["visible"],
id="non_dict_part",
),
pytest.param([], ["[]"], id="empty"),
pytest.param({"result": "SECRET forecast"}, ['{"result": "SECRET forecast"}'], id="dict"),
pytest.param(
[{"type": "input_image", "file_id": "file-map"}],
['[{"type": "input_image", "file_id": "file-map"}]'],
id="no_text_parts",
),
pytest.param({"type": "input_image", "image_url": "https://example.com/map.png"}, [], id="single_image"),
],
)
async def test_tool_result_texts_match_the_structured_tool_message(
self, output: object, expected_texts: list[str]
) -> None:
guardrail: Final = RecordingMaskingGuardrail(guardrail_name="test")
call_item, output_type = _TOOL_TURNS[0]
await OpenAIResponsesHandler().process_input_messages(
_tool_result_request(call_item, output_type, output), guardrail
)
tool_content: Final = guardrail.seen_message_contents[0][2]
structured_texts: Final = (
[tool_content]
if isinstance(tool_content, str)
else [part["text"] for part in tool_content if part.get("type") == "text"]
)
tool_texts: Final = guardrail.seen_texts[0][1:]
assert tool_texts == expected_texts
assert tool_texts == structured_texts
@pytest.mark.asyncio
@pytest.mark.parametrize(("call_item", "output_type"), _TOOL_TURNS, ids=["function", "custom"])
async def test_skip_tool_message_keeps_the_tool_result_out_of_texts_and_rows(
self, call_item: dict[str, str], output_type: str
) -> None:
guardrail: Final = RecordingMaskingGuardrail(guardrail_name="test")
guardrail.skip_tool_message_in_guardrail = True
tool_result: Final = {"type": output_type, "call_id": "call_1", "output": "SECRET forecast"}
result: Final = await OpenAIResponsesHandler().process_input_messages(
_tool_result_request(call_item, output_type, "SECRET forecast"), guardrail
)
assert guardrail.seen_texts == [["Weather?"]]
assert "SECRET forecast" not in guardrail.seen_message_contents[0]
assert result["input"][0] == {"role": "user", "content": "Weather? [GUARDRAILED]"}
assert result["input"][2] == tool_result
@pytest.mark.asyncio
@pytest.mark.parametrize(("call_item", "output_type"), _TOOL_TURNS, ids=["function", "custom"])
async def test_scan_only_tool_results_sends_only_the_tool_result(
self, call_item: dict[str, str], output_type: str
) -> None:
guardrail: Final = RecordingMaskingGuardrail(guardrail_name="test")
guardrail.scan_only_tool_results = True
tool: Final = {"type": "function", "name": "get_weather", "parameters": {"type": "object", "properties": {}}}
request: Final = {
**_tool_result_request(call_item, output_type, "SECRET forecast"),
"instructions": "Answer briefly",
"tools": [tool],
}
result: Final = await OpenAIResponsesHandler().process_input_messages(request, guardrail)
assert guardrail.seen_texts == [["SECRET forecast"]]
assert guardrail.seen_message_contents == [["SECRET forecast"]]
assert guardrail.seen_tools == [None]
assert result["instructions"] == "Answer briefly"
assert result["tools"] == [tool]
assert result["input"] == [
{"role": "user", "content": "Weather?"},
call_item,
{"type": output_type, "call_id": "call_1", "output": "SECRET forecast [GUARDRAILED]"},
]
@pytest.mark.asyncio
async def test_scan_only_tool_results_appends_tools_the_guardrail_adds(self) -> None:
added_tool: Final = {
"type": "function",
"function": {"name": "retrieve", "description": "Fetch the full text", "parameters": {"type": "object"}},
}
renamed_original: Final = {
"type": "function",
"function": {"name": "get_weather", "description": "rewritten", "parameters": {"type": "object"}},
}
class ToolAddingGuardrail(RecordingMaskingGuardrail):
async def apply_guardrail(
self,
inputs: GenericGuardrailAPIInputs,
request_data: dict[str, object],
input_type: Literal["request", "response"],
logging_obj: LiteLLMLoggingObj | None = None,
) -> GenericGuardrailAPIInputs:
masked: Final = await super().apply_guardrail(inputs, request_data, input_type, logging_obj)
return {**masked, "tools": [renamed_original, added_tool]}
guardrail: Final = ToolAddingGuardrail(guardrail_name="test")
guardrail.scan_only_tool_results = True
call_item, output_type = _TOOL_TURNS[0]
tool: Final = {"type": "function", "name": "get_weather", "parameters": {"type": "object", "properties": {}}}
request: Final = {**_tool_result_request(call_item, output_type, "SECRET forecast"), "tools": [tool]}
result: Final = await OpenAIResponsesHandler().process_input_messages(request, guardrail)
assert guardrail.seen_tools == [None]
assert result["tools"][0] == tool
assert [(t["type"], t["name"]) for t in result["tools"]] == [
("function", "get_weather"),
("function", "retrieve"),
]
assert result["tools"][1]["description"] == "Fetch the full text"
@pytest.mark.asyncio
async def test_scan_only_tool_results_leaves_a_request_without_tool_results_unscanned(self) -> None:
guardrail: Final = RecordingMaskingGuardrail(guardrail_name="test")
guardrail.scan_only_tool_results = True
request: Final = {"model": "gpt-5.4-mini", "instructions": "Answer briefly", "input": "Weather?"}
result: Final = await OpenAIResponsesHandler().process_input_messages(dict(request), guardrail)
assert guardrail.seen_texts == []
assert result == request
class TestOpenAIResponsesHandlerOutputProcessing:
"""Test output processing functionality"""

View file

@ -4834,6 +4834,83 @@ class TestPanwAirsLatestRoleMessageOnlyEveryRequestShape:
None,
id="function_call_output",
),
pytest.param(
(
{"type": "function_call", "call_id": "call_1", "name": "lookup", "arguments": "{}"},
{
"type": "function_call_output",
"call_id": "call_1",
"output": [{"type": "input_text", "text": "tool "}, {"type": "input_text", "text": "result"}],
},
),
None,
id="function_call_output_text_parts",
),
pytest.param(
(
{"type": "function_call", "call_id": "call_1", "name": "lookup", "arguments": "{}"},
{
"type": "function_call_output",
"call_id": "call_1",
"output": [
{"type": "input_text", "text": "tool "},
{"type": "input_image", "image_url": "https://example.com/chart.png"},
{"type": "input_text", "text": "result"},
],
},
),
None,
id="function_call_output_text_and_image_parts",
),
pytest.param(
(
{"type": "function_call", "call_id": "call_1", "name": "lookup", "arguments": "{}"},
{"type": "function_call_output", "output": "orphan tool result"},
{"type": "function_call_output", "call_id": "call_1", "output": "tool result"},
),
None,
id="function_call_output_without_call_id",
),
pytest.param(
(
{"type": "function_call", "call_id": "call_1", "name": "lookup", "arguments": "{}"},
{"type": "tool_result", "call_id": "call_1", "output": "tool result"},
),
None,
id="tool_result",
),
pytest.param(
(
{"type": "computer_call", "call_id": "cu_1", "id": "cu_1", "action": {"type": "screenshot"}},
{
"type": "computer_call_output",
"call_id": "cu_1",
"output": {"type": "computer_screenshot", "image_url": "https://example.com/screen.png"},
},
),
None,
id="computer_call_output",
),
pytest.param(
(
{"type": "computer_call", "call_id": "cu_1", "id": "cu_1", "action": {"type": "screenshot"}},
{
"type": "computer_call_output",
"call_id": "cu_1",
"output": {"type": "computer_screenshot", "file_id": "file-screen"},
},
),
None,
id="computer_call_output_file_id",
),
pytest.param(
(
{"type": "function_call", "call_id": "call_1", "name": "lookup", "arguments": "{}"},
{"type": "function_call_output", "call_id": "call_1", "output": ""},
),
None,
id="function_call_output_empty",
),
pytest.param(
({"type": "reasoning", "id": "rs_1", "summary": [{"type": "summary_text", "text": "thinking"}]},),
None,
@ -4898,6 +4975,25 @@ class TestPanwAirsLatestRoleMessageOnlyEveryRequestShape:
assert [call.kwargs["content"] for call in mock_api.call_args_list] == [self.LATEST]
@pytest.mark.asyncio
async def test_flag_true_with_skip_tool_still_scans_only_the_latest_turn_on_responses(self) -> None:
from litellm.llms.openai.responses.guardrail_translation.handler import (
OpenAIResponsesHandler,
)
handler = make_handler(experimental_use_latest_role_message_only=True)
handler.skip_tool_message_in_guardrail = True
request_data = self._responses_request(
{"type": "function_call", "call_id": "call_1", "name": "lookup", "arguments": "{}"},
{"type": "function_call_output", "call_id": "call_1", "output": "tool result"},
{"role": "user", "content": self.LATEST},
)
patcher, mock_api = self._scan(handler)
with patcher:
await OpenAIResponsesHandler().process_input_messages(data=request_data, guardrail_to_apply=handler)
assert [call.kwargs["content"] for call in mock_api.call_args_list] == [self.LATEST]
@pytest.mark.asyncio
async def test_flag_false_responses_scans_instructions_and_full_history(self) -> None:
from litellm.llms.openai.responses.guardrail_translation.handler import (

View file

@ -111,6 +111,8 @@ def test_media_normalization_preserves_formats_references_and_metadata(kind: str
"keep",
),
([{"type": "input_image", "file_id": "file-only"}], '[{"type": "input_image", "file_id": "file-only"}]'),
({"ids": {7}}, "{'ids': {7}}"),
([{"type": "unknown", "ids": {7}}], "[{'type': 'unknown', 'ids': {7}}]"),
],
)
def test_tool_output_without_usable_media_keeps_its_existing_fallback(output: object, expected: str) -> None: