fix(guardrails): deliver an unscannable Anthropic stream as an error frame

This commit is contained in:
Yucheng Zhu 2026-08-11 20:08:33 -07:00
parent 3e99d5ff73
commit f9e4f9f8a6
2 changed files with 22 additions and 17 deletions

View file

@ -27,7 +27,7 @@ import litellm
from litellm._logging import verbose_proxy_logger
from litellm.caching import DualCache
from litellm.constants import BEDROCK_APPLY_GUARDRAIL_CHUNK_BUDGET_CHARS
from litellm.exceptions import GuardrailRaisedException, ModifyResponseException
from litellm.exceptions import ModifyResponseException
from litellm.integrations.custom_guardrail import CustomGuardrail
from litellm.litellm_core_utils.core_helpers import redact_nested_match_and_regex_keys
from litellm.llms.base_llm.guardrail_translation.utils import (
@ -2679,12 +2679,14 @@ class BedrockGuardrail(CustomGuardrail, BaseAWSLLM):
async for chunk in mock_response:
yield chunk
elif raw_sse:
# Forwarding an unscannable stream would silently disable the guardrail, so fail closed
# the way tool_permission does
raise GuardrailRaisedException(
guardrail_name=self.guardrail_name,
message="Streamed response could not be assembled for scanning, blocking it",
)
# Forwarding an unscannable stream would silently disable the guardrail, so fail closed.
# A raise cannot reach the client once a keepalive ping has flushed the headers, so the
# refusal travels as a frame, matching how a block is delivered above
for error_frame in anthropic_sse_error_frames(
f"{self.guardrail_name}: streamed response could not be assembled for scanning, blocking it"
):
yield error_frame
return
else:
for chunk in all_chunks:
yield chunk

View file

@ -2956,23 +2956,27 @@ async def test_streaming_hook_block_stream_keeps_upstream_identity():
@pytest.mark.asyncio
async def test_streaming_hook_fails_closed_when_raw_sse_cannot_be_assembled():
"""An unscannable stream must not be delivered: forwarding it silently disables the guardrail."""
from litellm.exceptions import GuardrailRaisedException
guardrail = _sse_guardrail()
async def _unparseable_stream():
yield b'data: {"type":"content_block_delta"}\n\n'
with patch.object(guardrail, "make_bedrock_api_request", new_callable=AsyncMock) as mock_api:
with pytest.raises(GuardrailRaisedException):
async for _ in guardrail.async_post_call_streaming_iterator_hook(
delivered = [
chunk
async for chunk in guardrail.async_post_call_streaming_iterator_hook(
user_api_key_dict=UserAPIKeyAuth(),
response=_unparseable_stream(),
request_data={"model": "claude-sonnet-4-5"},
):
pass
)
]
mock_api.assert_not_called()
body = b"".join(delivered)
# a raise cannot reach the client once a keepalive ping has flushed the headers
assert b"event: error" in body
assert b"could not be assembled" in body
assert b"content_block_delta" not in body
@pytest.mark.asyncio
@ -2981,8 +2985,6 @@ async def test_streaming_hook_fails_closed_when_assembler_raises_api_error():
That exception message is the exact 500 this fix exists to remove.
"""
from litellm.exceptions import GuardrailRaisedException
guardrail = _sse_guardrail()
with patch(
@ -2996,8 +2998,9 @@ async def test_streaming_hook_fails_closed_when_assembler_raises_api_error():
model="",
),
):
with pytest.raises(GuardrailRaisedException):
await _drain_streaming_hook(guardrail)
delivered = await _drain_streaming_hook(guardrail)
assert b"event: error" in b"".join(delivered)
@pytest.mark.asyncio