From 249733e71c6549b318386da2e70679874b82f225 Mon Sep 17 00:00:00 2001 From: Yucheng Zhu Date: Wed, 12 Aug 2026 17:02:51 -0700 Subject: [PATCH] fix(guardrails): keep the stream error message a string and drop an unreachable branch --- litellm/proxy/guardrails/anthropic_sse.py | 9 ++++++--- .../guardrail_hooks/bedrock_guardrails.py | 3 --- .../guardrail_hooks/test_bedrock_guardrails.py | 17 +++++++++++++++++ 3 files changed, 23 insertions(+), 6 deletions(-) diff --git a/litellm/proxy/guardrails/anthropic_sse.py b/litellm/proxy/guardrails/anthropic_sse.py index 9e00e0398f1..db7cc283c40 100644 --- a/litellm/proxy/guardrails/anthropic_sse.py +++ b/litellm/proxy/guardrails/anthropic_sse.py @@ -102,10 +102,13 @@ def anthropic_sse_error_frames(message: str | Mapping[str, object]) -> tuple[byt """Anthropic error event, for a failure discovered after the response headers were flushed. Once a keepalive ping has been sent a raise cannot reach the client, so the failure has to - travel as a frame. A structured message is emitted as JSON rather than stringified, so the - guardrail's own detail stays machine readable. + travel as a frame. A structured detail is JSON-serialized into the string so it stays machine + readable without violating the string-typed contract. """ - body: Final = json.dumps(message, default=str) + # AnthropicErrorDetail.message is a str, so a structured detail is serialized into the string + # rather than nested as an object + text: Final = message if isinstance(message, str) else json.dumps(message, default=str) + body: Final = json.dumps(text) return ( f'event: error\ndata: {{"type": "error", "error": {{"type": "guardrail_error", ' f'"message": {body}}}}}\n\n'.encode(), diff --git a/litellm/proxy/guardrails/guardrail_hooks/bedrock_guardrails.py b/litellm/proxy/guardrails/guardrail_hooks/bedrock_guardrails.py index 24119a37eeb..345c6b524b1 100644 --- a/litellm/proxy/guardrails/guardrail_hooks/bedrock_guardrails.py +++ b/litellm/proxy/guardrails/guardrail_hooks/bedrock_guardrails.py @@ -2664,9 +2664,6 @@ class BedrockGuardrail(CustomGuardrail, BaseAWSLLM): ) if _original_usage is not None: assembled_model_response.usage = _original_usage - if raw_sse: - assembled_model_response.id = _pre_block_response.id - assembled_model_response.model = _pre_block_response.model output_guardrail_response = None ######################################################################### 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 8ba8aa1373d..2022429eeb2 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 @@ -2954,6 +2954,23 @@ async def test_streaming_hook_block_stream_keeps_upstream_identity(): assert b"my-proxy-alias" not in body +@pytest.mark.asyncio +async def test_streaming_block_error_frame_message_is_a_string(): + """AnthropicErrorDetail.message is typed str, so a structured guardrail detail is serialized.""" + guardrail = _sse_guardrail() + + with patch.object(guardrail, "make_bedrock_api_request", new_callable=AsyncMock) as mock_api: + mock_api.side_effect = HTTPException( + status_code=400, detail={"error": "Violated guardrail policy", "guardrailIdentifier": "gid"} + ) + delivered = await _drain_streaming_hook(guardrail) + + frame = next(line for line in b"".join(delivered).decode().splitlines() if line.startswith("data: ")) + message = json.loads(frame[6:])["error"]["message"] + assert isinstance(message, str) + assert json.loads(message)["error"] == "Violated guardrail policy" + + @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."""