fix(responses): make guardrail input provenance O(n) and guard non-list structured_messages

_input_item_provenance converted every input prefix, so an n-item request paid
for n+1 full conversions. It now converts each item once, glues consecutive
function_call items (plus their trailing-assistant context) into units so the
transform's tool_call merging is reproduced inside the unit conversion, and
verifies the unit concatenation against one full conversion, bailing to the
full-conversion fallback on any mismatch. Messages from multi-item units are
tainted, which keeps parallel tool calls patchable exactly like the old prefix
pass while unpredicted merges fall back safely.

A guardrail handing back a non-list structured_messages payload (the
HiddenLayer v2 evaluation dict) previously fell through the length-mismatch
fallback and 500ed converting the dict's keys as messages. The write-back is
now skipped for non-list payloads, restoring the previous no-write-back
behavior on the Responses surface.

Also refreshes the compresr texts-mirror docstring, which still claimed the
Responses translation cannot round-trip structured_messages.
This commit is contained in:
mateo-berri 2026-08-29 22:04:26 -07:00
parent 98a9a7e525
commit 574010a2ce
3 changed files with 365 additions and 13 deletions

View file

@ -29,6 +29,7 @@ Output: response.output is List[GenericResponseOutputItem] where each has:
"""
from collections.abc import Mapping, Sequence
from itertools import accumulate
from types import MappingProxyType
from typing import TYPE_CHECKING, Any, Final, Union, cast
@ -119,33 +120,85 @@ def _rewritten_input_item(item: Mapping[str, object], rewritten: object) -> Mapp
return {**item, field: converted_value} # mutable-ok: request input items must stay JSON-plain dicts
def _is_function_call_item(item: object) -> bool:
return isinstance(item, Mapping) and item.get("type") in ("function_call", "custom_tool_call")
def _last_message_role(messages: Sequence[object]) -> str | None:
if not messages:
return None
last: Final = messages[-1]
role: Final = last.get("role") if isinstance(last, Mapping) else getattr(last, "role", None)
return role if isinstance(role, str) else None
def _provenance_unit_bounds(
raw_input: Sequence[object],
solo_conversions: Sequence[Sequence[object]],
) -> tuple[tuple[int, int], ...]:
trailing_roles: Final = tuple(
accumulate(
(_last_message_role(messages) for messages in solo_conversions),
lambda previous, current: current if current is not None else previous,
)
)
start_indexes: Final = tuple(
index
for index in range(len(raw_input))
if index == 0 or not (_is_function_call_item(raw_input[index]) and trailing_roles[index - 1] == "assistant")
)
return tuple(zip(start_indexes, (*start_indexes[1:], len(raw_input))))
def _input_item_provenance(
raw_input: Sequence[object],
expected_messages: Sequence[object],
) -> tuple[Mapping[int, int], frozenset[int]] | None:
if not all(isinstance(item, Mapping) for item in raw_input):
return None
prefixes: Final = tuple(
solo_conversions: Final = tuple(
LiteLLMCompletionResponsesConfig.transform_responses_api_input_to_messages(
input=cast("ResponseInputParam", raw_input[:count]), # cast-ok: items checked as Mappings above
input=cast("ResponseInputParam", [item]), # cast-ok: items checked as Mappings above
responses_api_request=_EMPTY_RESPONSES_REQUEST,
)
for count in range(len(raw_input) + 1)
for item in raw_input
)
if tuple(prefixes[-1]) != tuple(expected_messages):
full_conversion: Final = tuple(
LiteLLMCompletionResponsesConfig.transform_responses_api_input_to_messages(
input=cast("ResponseInputParam", list(raw_input)), # cast-ok: items checked as Mappings above
responses_api_request=_EMPTY_RESPONSES_REQUEST,
)
)
if full_conversion != tuple(expected_messages):
return None
units: Final = _provenance_unit_bounds(raw_input, solo_conversions)
unit_messages: Final = tuple(
tuple(solo_conversions[start])
if end - start == 1
else tuple(
LiteLLMCompletionResponsesConfig.transform_responses_api_input_to_messages(
input=cast("ResponseInputParam", list(raw_input[start:end])), # cast-ok: checked as Mappings above
responses_api_request=_EMPTY_RESPONSES_REQUEST,
)
)
for start, end in units
)
if tuple(message for messages in unit_messages for message in messages) != full_conversion:
return None
boundaries: Final = tuple(accumulate((len(messages) for messages in unit_messages), initial=0))
item_for_message: Final = MappingProxyType(
{
message_index: item_index
for item_index in range(len(raw_input))
for message_index in range(len(prefixes[item_index]), len(prefixes[item_index + 1]))
message_index: start
for unit_index, (start, end) in enumerate(units)
if end - start == 1
for message_index in range(boundaries[unit_index], boundaries[unit_index + 1])
}
)
tainted: Final = frozenset(
message_index
for item_index in range(len(raw_input))
for message_index in range(len(prefixes[item_index]))
if prefixes[item_index + 1][message_index] != prefixes[item_index][message_index]
for unit_index, (start, end) in enumerate(units)
if end - start > 1
for message_index in range(boundaries[unit_index], boundaries[unit_index + 1])
)
return item_for_message, tainted
@ -351,6 +404,8 @@ class OpenAIResponsesHandler(BaseTranslation):
original_messages: Sequence[object],
structured_messages: Sequence[AllMessageValues],
) -> None:
if not isinstance(structured_messages, list):
return
if _patch_rewritten_rows_into_input(data, original_messages, structured_messages):
return
input_items, instructions = (

View file

@ -916,9 +916,10 @@ class CompresrGuardrail(CustomGuardrail):
def _mirror_texts_channel(input_texts: object, applied: _CompressionResult) -> list[object] | None:
"""Compressed content mirrored into the Responses `texts` channel.
The chat/Anthropic handlers round-trip ``structured_messages``; the
Responses translation cannot rebuild its input from chat messages and
instead writes back through ``texts``. This matches by value, so a
The chat/Anthropic/Responses handlers round-trip
``structured_messages``; translations without that round-trip write
back through ``texts``, so the compressed content is mirrored there
too. This matches by value, so a
replacement is applied only when it is unambiguous: one compression per
text, and every occurrence in ``texts`` accounted for by a compressed
target. Anything else is left uncompressed rather than risk a wrong or

View file

@ -1450,3 +1450,299 @@ class TestStructuredMessagesWriteBack:
assert result["input"] is original_input
assert [_texts(item) for item in result["input"]] == [["Hello [GUARDRAILED]"], ["Again [GUARDRAILED]"]]
class AllToolOutputsRewriteGuardrail(CustomGuardrail):
"""Guardrail that compresses every tool-result row."""
async def apply_guardrail(
self,
inputs: GenericGuardrailAPIInputs,
request_data: dict,
input_type: Literal["request", "response"],
logging_obj: Optional[Any] = None,
) -> GenericGuardrailAPIInputs:
messages = list(inputs.get("structured_messages") or [])
rewritten = [
{**m, "content": COMPRESSED_MARKER} if isinstance(m, dict) and m.get("role") == "tool" else m
for m in messages
]
return {**inputs, "structured_messages": rewritten}
class AssistantRewriteGuardrail(CustomGuardrail):
"""Guardrail that rewrites the first assistant row's content."""
async def apply_guardrail(
self,
inputs: GenericGuardrailAPIInputs,
request_data: dict,
input_type: Literal["request", "response"],
logging_obj: Optional[Any] = None,
) -> GenericGuardrailAPIInputs:
messages = list(inputs.get("structured_messages") or [])
first = next(i for i, m in enumerate(messages) if isinstance(m, dict) and m.get("role") == "assistant")
rewritten = [{**m, "content": COMPRESSED_MARKER} if i == first else m for i, m in enumerate(messages)]
return {**inputs, "structured_messages": rewritten}
class DictStructuredMessagesGuardrail(CustomGuardrail):
"""Guardrail that hands back a raw evaluation dict instead of a message list,
the way HiddenLayer v2 does."""
async def apply_guardrail(
self,
inputs: GenericGuardrailAPIInputs,
request_data: dict,
input_type: Literal["request", "response"],
logging_obj: Optional[Any] = None,
) -> GenericGuardrailAPIInputs:
return {**inputs, "structured_messages": {"evaluation": "allowed", "messages": []}}
def _parallel_tool_call_input() -> list:
return [
{"id": "fc_1", "type": "function_call", "call_id": "call_1", "name": "read_a", "arguments": "{}"},
{"id": "fc_2", "type": "function_call", "call_id": "call_2", "name": "read_b", "arguments": "{}"},
{"type": "function_call_output", "call_id": "call_1", "output": "memo " * 400},
{"type": "function_call_output", "call_id": "call_2", "output": "note " * 400},
{"role": "user", "content": "What is the codename?"},
]
class TestProvenancePatching:
"""The O(n) provenance pass must keep patching rewritten rows in place for the
shapes real agent loops produce, and fall back safely everywhere else."""
@pytest.mark.asyncio
async def test_parallel_tool_call_outputs_both_patched(self):
handler = OpenAIResponsesHandler()
raw_input = _parallel_tool_call_input()
fc_1, fc_2 = raw_input[0], raw_input[1]
data = {"model": "gpt-5.6", "input": raw_input}
result = await handler.process_input_messages(data, AllToolOutputsRewriteGuardrail())
assert result["input"][0] is fc_1
assert result["input"][1] is fc_2
assert result["input"][2] == {"type": "function_call_output", "call_id": "call_1", "output": COMPRESSED_MARKER}
assert result["input"][3] == {"type": "function_call_output", "call_id": "call_2", "output": COMPRESSED_MARKER}
assert result["input"][4] == {"role": "user", "content": "What is the codename?"}
@pytest.mark.asyncio
async def test_assistant_turn_with_tool_call_keeps_items_verbatim(self):
handler = OpenAIResponsesHandler()
assistant_item = {"role": "assistant", "content": "Let me read the memo."}
function_call_item = {
"id": "fc_9",
"type": "function_call",
"call_id": "call_9",
"name": "read_document",
"arguments": '{"path": "memo.txt"}',
}
data = {
"model": "gpt-5.6",
"input": [
assistant_item,
function_call_item,
{"type": "function_call_output", "call_id": "call_9", "output": "memo " * 400},
{"role": "user", "content": "What is the codename?"},
],
}
result = await handler.process_input_messages(data, ToolOutputRewriteGuardrail())
assert result["input"][0] is assistant_item
assert result["input"][1] is function_call_item
assert result["input"][2] == {"type": "function_call_output", "call_id": "call_9", "output": COMPRESSED_MARKER}
@pytest.mark.asyncio
async def test_rewrite_of_merged_tool_call_message_falls_back(self):
handler = OpenAIResponsesHandler()
raw_input = _parallel_tool_call_input()
data = {"model": "gpt-5.6", "input": raw_input}
result = await handler.process_input_messages(data, AssistantRewriteGuardrail())
assert not any(item is original for item in result["input"] for original in raw_input)
assistant_items = [item for item in result["input"] if item.get("role") == "assistant"]
assert [_texts(item) for item in assistant_items] == [[COMPRESSED_MARKER]]
@pytest.mark.asyncio
async def test_rewrite_of_lone_function_call_message_falls_back(self):
handler = OpenAIResponsesHandler()
data = {
"model": "gpt-5.6",
"input": [
{"id": "fc_1", "type": "function_call", "call_id": "call_1", "name": "read_a", "arguments": "{}"},
{"type": "function_call_output", "call_id": "call_1", "output": "memo memo"},
{"role": "user", "content": "What is the codename?"},
],
}
raw_input = data["input"]
result = await handler.process_input_messages(data, AssistantRewriteGuardrail())
assert not any(item is original for item in result["input"] for original in raw_input)
assistant_items = [item for item in result["input"] if item.get("role") == "assistant"]
assert [_texts(item) for item in assistant_items] == [[COMPRESSED_MARKER]]
def test_provenance_bails_on_non_mapping_item(self):
from litellm.llms.openai.responses.guardrail_translation.handler import _input_item_provenance
assert _input_item_provenance(["not a mapping"], []) is None
def test_provenance_bails_when_expected_messages_disagree(self):
from litellm.llms.openai.responses.guardrail_translation.handler import _input_item_provenance
assert _input_item_provenance([{"role": "user", "content": "hi"}], [{"role": "user", "content": "bye"}]) is None
def test_provenance_bails_on_unpredicted_merge(self):
from litellm.llms.openai.responses.guardrail_translation.handler import _input_item_provenance
from litellm.responses.litellm_completion_transformation.transformation import (
LiteLLMCompletionResponsesConfig,
)
raw_input = [
{"id": "fc_1", "type": "function_call", "call_id": "call_1", "name": "read_a", "arguments": "{}"},
{"role": "assistant", "content": "Reading the memo now."},
]
expected = LiteLLMCompletionResponsesConfig.transform_responses_api_input_to_messages(
input=raw_input, responses_api_request={}
)
assert len(expected) == 1
assert _input_item_provenance(raw_input, expected) is None
def test_provenance_maps_and_taints_parallel_tool_calls(self):
from litellm.llms.openai.responses.guardrail_translation.handler import _input_item_provenance
from litellm.responses.litellm_completion_transformation.transformation import (
LiteLLMCompletionResponsesConfig,
)
raw_input = _parallel_tool_call_input()
expected = LiteLLMCompletionResponsesConfig.transform_responses_api_input_to_messages(
input=raw_input, responses_api_request={}
)
provenance = _input_item_provenance(raw_input, expected)
assert provenance is not None
item_for_message, tainted = provenance
assert tainted == {0}
assert dict(item_for_message) == {1: 2, 2: 3, 3: 4}
class TestDictStructuredMessagesGuard:
"""A guardrail handing back a non-list structured_messages payload must not
blow up the request; the write-back is skipped instead."""
@pytest.mark.asyncio
async def test_list_input_survives_dict_structured_messages(self):
handler = OpenAIResponsesHandler()
original_input = [{"role": "user", "content": "Hello"}]
data = {"model": "gpt-5.6", "input": original_input}
result = await handler.process_input_messages(data, DictStructuredMessagesGuardrail())
assert result["input"] is original_input
assert result["input"] == [{"role": "user", "content": "Hello"}]
@pytest.mark.asyncio
async def test_string_input_survives_dict_structured_messages(self):
handler = OpenAIResponsesHandler()
data = {"model": "gpt-5.6", "input": "Hello there"}
result = await handler.process_input_messages(data, DictStructuredMessagesGuardrail())
assert result["input"] == "Hello there"
class SystemRewriteGuardrail(CustomGuardrail):
"""Guardrail that rewrites the system row, the way prompt-hardening guardrails do."""
def __init__(self, rewritten_content: Any = COMPRESSED_MARKER):
super().__init__()
self.rewritten_content = rewritten_content
async def apply_guardrail(
self,
inputs: GenericGuardrailAPIInputs,
request_data: dict,
input_type: Literal["request", "response"],
logging_obj: Optional[Any] = None,
) -> GenericGuardrailAPIInputs:
messages = list(inputs.get("structured_messages") or [])
first = next(i for i, m in enumerate(messages) if isinstance(m, dict) and m.get("role") == "system")
rewritten = [
{**m, "content": self.rewritten_content} if i == first else m for i, m in enumerate(messages)
]
return {**inputs, "structured_messages": rewritten}
class TestPatchEdgeBranches:
@pytest.mark.asyncio
async def test_multimodal_user_item_rewritten_through_conversion(self):
handler = OpenAIResponsesHandler()
data = {
"model": "gpt-5.6",
"input": [
{"role": "user", "content": [{"type": "input_text", "text": "memo " * 400}]},
{"role": "user", "content": "What is the codename?"},
],
}
result = await handler.process_input_messages(data, StructuredRewriteGuardrail())
assert _texts(result["input"][0]) == [COMPRESSED_MARKER]
assert result["input"][1] == {"role": "user", "content": "What is the codename?"}
@pytest.mark.asyncio
async def test_instructions_rewrite_lands_in_instructions_field(self):
handler = OpenAIResponsesHandler()
user_item = {"role": "user", "content": "What is the codename?"}
data = {
"model": "gpt-5.6",
"instructions": "Answer from the memo only.",
"input": [user_item],
}
result = await handler.process_input_messages(data, SystemRewriteGuardrail())
assert result["instructions"] == COMPRESSED_MARKER
assert result["input"][0] is user_item
@pytest.mark.asyncio
async def test_non_string_instructions_rewrite_falls_back(self):
handler = OpenAIResponsesHandler()
user_item = {"role": "user", "content": "What is the codename?"}
data = {
"model": "gpt-5.6",
"instructions": "Answer from the memo only.",
"input": [user_item],
}
result = await handler.process_input_messages(
data, SystemRewriteGuardrail(rewritten_content=[{"type": "text", "text": COMPRESSED_MARKER}])
)
assert result["input"][0] is not user_item
@pytest.mark.asyncio
async def test_unpredicted_merge_falls_back_through_patch(self):
handler = OpenAIResponsesHandler()
raw_input = [
{"id": "fc_1", "type": "function_call", "call_id": "call_1", "name": "read_a", "arguments": "{}"},
{"role": "assistant", "content": "Reading the memo now."},
{"type": "function_call_output", "call_id": "call_1", "output": "memo memo"},
{"role": "user", "content": "memo " * 400},
]
data = {"model": "gpt-5.6", "input": raw_input}
result = await handler.process_input_messages(data, StructuredRewriteGuardrail())
assert not any(item is original for item in result["input"] for original in raw_input)
user_items = [item for item in result["input"] if item.get("role") == "user"]
assert _texts(user_items[0]) == [COMPRESSED_MARKER]
def test_item_rewrite_field_ignores_non_string_type(self):
from litellm.llms.openai.responses.guardrail_translation.handler import _item_rewrite_field
assert _item_rewrite_field({"type": 123, "content": "hello"}) is None