diff --git a/litellm/proxy/guardrails/guardrail_hooks/bedrock_guardrails.py b/litellm/proxy/guardrails/guardrail_hooks/bedrock_guardrails.py index 7ce8427a9de..a6852b6fb0b 100644 --- a/litellm/proxy/guardrails/guardrail_hooks/bedrock_guardrails.py +++ b/litellm/proxy/guardrails/guardrail_hooks/bedrock_guardrails.py @@ -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 diff --git a/tests/test_litellm/proxy/guardrails/guardrail_hooks/test_bedrock_guardrails.py b/tests/test_litellm/proxy/guardrails/guardrail_hooks/test_bedrock_guardrails.py index 68e8f8d7714..32af8c628fe 100644 --- a/tests/test_litellm/proxy/guardrails/guardrail_hooks/test_bedrock_guardrails.py +++ b/tests/test_litellm/proxy/guardrails/guardrail_hooks/test_bedrock_guardrails.py @@ -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