mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-16 23:41:43 +00:00
fix(guardrail_translation): deliver stream rewrites on incomplete and failed responses terminals
This commit is contained in:
parent
85fea1a675
commit
4fbe4ce2e2
2 changed files with 93 additions and 23 deletions
|
|
@ -86,6 +86,15 @@ class ResponsesStreamChunk(TypedDict, total=False):
|
|||
text: ReadOnly[str]
|
||||
|
||||
|
||||
_TERMINAL_ENVELOPE_EVENT_TYPES: Final = frozenset(
|
||||
{
|
||||
ResponsesAPIStreamEvents.RESPONSE_COMPLETED.value,
|
||||
ResponsesAPIStreamEvents.RESPONSE_FAILED.value,
|
||||
ResponsesAPIStreamEvents.RESPONSE_INCOMPLETE.value,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def _next_stream_sequence_number(responses_so_far: Sequence[Any] | None) -> int:
|
||||
sequence_numbers: Final = (
|
||||
item.get("sequence_number") if isinstance(item, dict) else getattr(item, "sequence_number", None)
|
||||
|
|
@ -507,14 +516,17 @@ class OpenAIResponsesHandler(BaseTranslation):
|
|||
chunk, apply the guardrail, then write the result back in-place so the
|
||||
caller sees the modified content (e.g. PII tokens replaced).
|
||||
|
||||
For ``response.completed`` events (the normal end-of-stream signal) we
|
||||
use the same per-item extraction + task-mapping approach as
|
||||
``process_output_response`` so that unmasking / blocking works correctly
|
||||
for every output item. With ``deliver_ended_stream_rewrites`` the earlier
|
||||
text-carrying events (``response.output_text.delta`` / ``.done``,
|
||||
For terminal envelope events (``response.completed``, and equally
|
||||
``response.incomplete`` / ``response.failed``, whose envelopes carry the
|
||||
partial output) we use the same per-item extraction + task-mapping
|
||||
approach as ``process_output_response`` so that unmasking / blocking
|
||||
works correctly for every output item. With
|
||||
``deliver_ended_stream_rewrites`` the earlier text-carrying events
|
||||
(``response.output_text.delta`` / ``.done``,
|
||||
``response.content_part.done``, ``response.output_item.done``) are synced
|
||||
to the rewritten completed response too, so a client reading deltas sees
|
||||
the rewrite instead of the raw model output.
|
||||
to the rewritten envelope too, so a client reading deltas sees the
|
||||
rewrite instead of the raw model output; a rewrite observed where no
|
||||
write-back is possible fails closed instead of releasing raw output.
|
||||
"""
|
||||
if not responses_so_far:
|
||||
return responses_so_far
|
||||
|
|
@ -526,14 +538,16 @@ class OpenAIResponsesHandler(BaseTranslation):
|
|||
return responses_so_far
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# Case 1: response.completed — full response is available in the #
|
||||
# final chunk; iterate output items, apply guardrail, write back. #
|
||||
# Case 1: terminal envelope events (completed/incomplete/failed). #
|
||||
# the accumulated response is available in the final chunk; iterate #
|
||||
# output items, apply guardrail, write back. Falls through to the #
|
||||
# string fallback when the envelope yields nothing to check. #
|
||||
# ------------------------------------------------------------------ #
|
||||
if final_chunk.get("type") == "response.completed":
|
||||
if final_chunk.get("type") in _TERMINAL_ENVELOPE_EVENT_TYPES:
|
||||
response_obj: Final[ResponseOutputEnvelope] = final_chunk.get("response") or {}
|
||||
if not hasattr(response_obj, "get"):
|
||||
return responses_so_far
|
||||
outputs: Final[Sequence[object]] = response_obj.get("output") or []
|
||||
outputs: Final[Sequence[object]] = (
|
||||
(response_obj.get("output") or []) if hasattr(response_obj, "get") else []
|
||||
)
|
||||
|
||||
texts_to_check: Final[list[str]] = []
|
||||
tool_calls_to_check: Final[list[ChatCompletionToolCallChunk]] = []
|
||||
|
|
@ -596,8 +610,7 @@ class OpenAIResponsesHandler(BaseTranslation):
|
|||
stream_events=responses_so_far[:-1],
|
||||
rewrites_by_position=rewrites_by_position,
|
||||
)
|
||||
|
||||
return responses_so_far
|
||||
return responses_so_far
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# Case 2: response.output_item.done — extract tool calls only. #
|
||||
|
|
@ -623,7 +636,8 @@ class OpenAIResponsesHandler(BaseTranslation):
|
|||
# ------------------------------------------------------------------ #
|
||||
# Fallback: apply guardrail to the accumulated text string. #
|
||||
# No structured write-back is possible here; guardrails that only #
|
||||
# need to block/flag (not rewrite) still work correctly. #
|
||||
# need to block/flag (not rewrite) still work correctly, and a #
|
||||
# rewrite a caller expects delivered fails closed instead. #
|
||||
# ------------------------------------------------------------------ #
|
||||
string_so_far: Final = self.get_streaming_string_so_far(responses_so_far)
|
||||
if string_so_far:
|
||||
|
|
@ -633,12 +647,17 @@ class OpenAIResponsesHandler(BaseTranslation):
|
|||
)
|
||||
if response_model:
|
||||
fallback_inputs["model"] = response_model
|
||||
await guardrail_to_apply.apply_guardrail(
|
||||
fallback_outputs: Final = await guardrail_to_apply.apply_guardrail(
|
||||
inputs=fallback_inputs,
|
||||
request_data=request_data if request_data is not None else {},
|
||||
input_type="response",
|
||||
logging_obj=litellm_logging_obj,
|
||||
)
|
||||
fallback_texts: Final = fallback_outputs.get("texts")
|
||||
if deliver_ended_stream_rewrites and fallback_texts and tuple(fallback_texts) != (string_so_far,):
|
||||
from litellm.proxy.policy_engine.pipeline_executor import UndeliverableStreamRewrite
|
||||
|
||||
raise UndeliverableStreamRewrite(guardrail_to_apply.guardrail_name or "unknown")
|
||||
return responses_so_far
|
||||
|
||||
@staticmethod
|
||||
|
|
@ -704,12 +723,7 @@ class OpenAIResponsesHandler(BaseTranslation):
|
|||
"""
|
||||
if not responses_so_far:
|
||||
return False
|
||||
terminal_types: Final = {
|
||||
ResponsesAPIStreamEvents.RESPONSE_COMPLETED.value,
|
||||
ResponsesAPIStreamEvents.RESPONSE_FAILED.value,
|
||||
ResponsesAPIStreamEvents.RESPONSE_INCOMPLETE.value,
|
||||
}
|
||||
return responses_so_far[-1].get("type") in terminal_types
|
||||
return responses_so_far[-1].get("type") in _TERMINAL_ENVELOPE_EVENT_TYPES
|
||||
|
||||
def build_stream_error_items(
|
||||
self,
|
||||
|
|
|
|||
|
|
@ -1171,6 +1171,62 @@ class TestOpenAIResponsesHandlerStreamingOutputProcessing:
|
|||
assert events[4]["item"]["content"][0]["text"] == "hello [MASKED]"
|
||||
assert events[5]["response"]["output"][0]["content"][0]["text"] == "hello [MASKED]"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize("terminal_type", ["response.incomplete", "response.failed"])
|
||||
async def test_deliver_ended_stream_rewrites_syncs_non_completed_terminals(self, terminal_type):
|
||||
handler = OpenAIResponsesHandler()
|
||||
events = self._ended_stream_events()
|
||||
events[-1]["type"] = terminal_type
|
||||
events[-1]["response"]["status"] = terminal_type.split(".")[-1]
|
||||
|
||||
result = await handler.process_output_streaming_response(
|
||||
responses_so_far=events,
|
||||
guardrail_to_apply=self._masking_guardrail(),
|
||||
litellm_logging_obj=None,
|
||||
deliver_ended_stream_rewrites=True,
|
||||
)
|
||||
|
||||
assert result is events
|
||||
assert events[0]["delta"] == "hello [MASKED]"
|
||||
assert events[1]["delta"] == ""
|
||||
assert events[2]["text"] == "hello [MASKED]"
|
||||
assert events[3]["part"]["text"] == "hello [MASKED]"
|
||||
assert events[4]["item"]["content"][0]["text"] == "hello [MASKED]"
|
||||
assert events[5]["response"]["output"][0]["content"][0]["text"] == "hello [MASKED]"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_fallback_rewrite_with_delivery_expected_fails_closed(self):
|
||||
from litellm.proxy.policy_engine.pipeline_executor import UndeliverableStreamRewrite
|
||||
|
||||
handler = OpenAIResponsesHandler()
|
||||
events = [
|
||||
{"type": "response.output_text.delta", "output_index": 0, "content_index": 0, "delta": "hello "},
|
||||
{"type": "response.output_text.done", "output_index": 0, "content_index": 0, "text": "hello world"},
|
||||
]
|
||||
|
||||
with pytest.raises(UndeliverableStreamRewrite):
|
||||
await handler.process_output_streaming_response(
|
||||
responses_so_far=events,
|
||||
guardrail_to_apply=self._masking_guardrail(),
|
||||
litellm_logging_obj=None,
|
||||
deliver_ended_stream_rewrites=True,
|
||||
)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_fallback_rewrite_without_delivery_expected_does_not_raise(self):
|
||||
handler = OpenAIResponsesHandler()
|
||||
events = [
|
||||
{"type": "response.output_text.done", "output_index": 0, "content_index": 0, "text": "hello world"},
|
||||
]
|
||||
|
||||
result = await handler.process_output_streaming_response(
|
||||
responses_so_far=events,
|
||||
guardrail_to_apply=self._masking_guardrail(),
|
||||
litellm_logging_obj=None,
|
||||
)
|
||||
|
||||
assert result is events
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_ended_stream_rewrite_leaves_delta_events_untouched_by_default(self):
|
||||
handler = OpenAIResponsesHandler()
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue