From 363f9fe5da3a338abca045842a1bbfd548820653 Mon Sep 17 00:00:00 2001 From: michelligabriele Date: Sat, 11 Apr 2026 18:40:39 +0200 Subject: [PATCH] fix(proxy): preserve dict guardrail HTTPException.detail + bedrock context (#25558) --- litellm/proxy/common_request_processing.py | 67 ++++++- .../guardrail_hooks/bedrock_guardrails.py | 159 +++++++++++++++- litellm/proxy/utils.py | 124 +++++++++--- .../test_bedrock_guardrails.py | 152 ++++++++++++++- .../proxy/test_common_request_processing.py | 179 +++++++++++++++++- tests/test_litellm/proxy/test_proxy_utils.py | 76 ++++++++ 6 files changed, 716 insertions(+), 41 deletions(-) diff --git a/litellm/proxy/common_request_processing.py b/litellm/proxy/common_request_processing.py index 09200c9684..037f913ad0 100644 --- a/litellm/proxy/common_request_processing.py +++ b/litellm/proxy/common_request_processing.py @@ -9,6 +9,7 @@ from typing import ( Any, AsyncGenerator, Callable, + Dict, Literal, Optional, Tuple, @@ -65,6 +66,37 @@ from litellm.proxy.litellm_pre_call_utils import add_litellm_data_to_request from litellm.types.utils import ModelResponse, ModelResponseStream, Usage +def _serialize_http_exception_detail( + detail: Any, +) -> Tuple[str, Optional[dict]]: + """ + Convert an HTTPException.detail value into (message, structured_fields) + for ProxyException / SSE error frames. + + Dict-detail HTTPExceptions raised by guardrails were previously str()-mangled + into a Python repr blob, producing unparseable error responses on both the + streaming and non-streaming proxy surfaces. This helper extracts a clean + human-readable message while preserving the full payload as structured + fields, so the dominant guardrail shapes (`{"error": "..."}` flat and + `{"error": {"message": "..."}}` nested) both round-trip cleanly. + """ + if isinstance(detail, str): + return detail, None + if isinstance(detail, dict): + err = detail.get("error") + if isinstance(err, str): + return err, detail + if isinstance(err, dict): + nested_msg = err.get("message") + if isinstance(nested_msg, str): + return nested_msg, detail + msg = detail.get("message") + if isinstance(msg, str): + return msg, detail + return json.dumps(detail), detail + return str(detail), None + + async def _parse_event_data_for_error(event_line: Union[str, bytes]) -> Optional[int]: """Parses an event line and returns an error code if present, else None.""" event_line = ( @@ -223,12 +255,28 @@ async def create_response( # Preserve status code from HTTPException (e.g., guardrail blocks) error_status = getattr(e, "status_code", status.HTTP_500_INTERNAL_SERVER_ERROR) - error_detail = getattr(e, "detail", "Error processing stream start") - if not isinstance(error_detail, str): - error_detail = str(error_detail) + raw_detail = getattr(e, "detail", "Error processing stream start") + message, structured_fields = _serialize_http_exception_detail(raw_detail) + + existing_fields = getattr(e, "provider_specific_fields", None) or {} + if structured_fields: + merged_fields: Optional[dict] = {**existing_fields, **structured_fields} + else: + merged_fields = existing_fields or None + + # Match ProxyException.to_dict() shape so streaming and non-streaming + # error frames are byte-identical. + error_obj: Dict[str, Any] = { + "message": message, + "type": getattr(e, "type", "None"), + "param": getattr(e, "param", "None"), + "code": str(error_status), + } + if merged_fields: + error_obj["provider_specific_fields"] = merged_fields async def error_gen_message() -> AsyncGenerator[str, None]: - yield f"data: {json.dumps({'error': {'message': error_detail, 'code': error_status}})}\n\n" + yield f"data: {json.dumps({'error': error_obj})}\n\n" yield "data: [DONE]\n\n" return StreamingResponse( @@ -1593,12 +1641,19 @@ class ProxyBaseLLMRequestProcessing: pass if isinstance(e, HTTPException): + raw_detail = getattr(e, "detail", str(e)) + message, structured_fields = _serialize_http_exception_detail(raw_detail) + existing_fields = getattr(e, "provider_specific_fields", None) or {} + if structured_fields: + merged_fields: Optional[dict] = {**existing_fields, **structured_fields} + else: + merged_fields = existing_fields or None raise ProxyException( - message=getattr(e, "detail", str(e)), + message=message, type=getattr(e, "type", "None"), param=getattr(e, "param", "None"), code=getattr(e, "status_code", status.HTTP_400_BAD_REQUEST), - provider_specific_fields=getattr(e, "provider_specific_fields", None), + provider_specific_fields=merged_fields, headers=headers, ) elif isinstance(e, httpx.HTTPStatusError): diff --git a/litellm/proxy/guardrails/guardrail_hooks/bedrock_guardrails.py b/litellm/proxy/guardrails/guardrail_hooks/bedrock_guardrails.py index 8ef188bb23..067d3a007f 100644 --- a/litellm/proxy/guardrails/guardrail_hooks/bedrock_guardrails.py +++ b/litellm/proxy/guardrails/guardrail_hooks/bedrock_guardrails.py @@ -18,6 +18,7 @@ from typing import ( TYPE_CHECKING, Any, AsyncGenerator, + Dict, List, Literal, NamedTuple, @@ -636,6 +637,141 @@ class BedrockGuardrail(CustomGuardrail, BaseAWSLLM): return (status_code, err) return (status_code, message) + def _extract_blocked_assessments( + self, response: BedrockGuardrailResponse + ) -> List[dict]: + """ + Walk the Bedrock guardrail response and emit a structured list of + BLOCKED assessment entries describing exactly which policies fired. + + Mirrors the iteration in `_should_raise_guardrail_blocked_exception()` + but produces a list of `{policy, matches}` dicts instead of a bool. + Each `match` carries the originating subcategory, type, action, and + matched term where available, so the client can render a precise + explanation of the violation. + """ + blocked: List[dict] = [] + assessments = response.get("assessments", []) or [] + + for assessment in assessments: + # Topic policy + topic_policy = assessment.get("topicPolicy") + if topic_policy: + topic_matches = [ + { + "category": "topics", + "name": t.get("name"), + "type": t.get("type"), + "action": t.get("action"), + } + for t in (topic_policy.get("topics") or []) + if t.get("action") == "BLOCKED" + ] + if topic_matches: + blocked.append({"policy": "topicPolicy", "matches": topic_matches}) + + # Content policy + content_policy = assessment.get("contentPolicy") + if content_policy: + content_matches = [ + { + "category": "filters", + "type": f.get("type"), + "confidence": f.get("confidence"), + "filterStrength": f.get("filterStrength"), + "action": f.get("action"), + } + for f in (content_policy.get("filters") or []) + if f.get("action") == "BLOCKED" + ] + if content_matches: + blocked.append( + {"policy": "contentPolicy", "matches": content_matches} + ) + + # Word policy + word_policy = assessment.get("wordPolicy") + if word_policy: + word_matches: List[dict] = [] + for w in word_policy.get("customWords") or []: + if w.get("action") == "BLOCKED": + word_matches.append( + { + "category": "customWords", + "match": w.get("match"), + "action": w.get("action"), + } + ) + for w in word_policy.get("managedWordLists") or []: + if w.get("action") == "BLOCKED": + word_matches.append( + { + "category": "managedWordLists", + "type": w.get("type"), + "match": w.get("match"), + "action": w.get("action"), + } + ) + if word_matches: + blocked.append({"policy": "wordPolicy", "matches": word_matches}) + + # Sensitive information policy (PII) + sensitive_info = assessment.get("sensitiveInformationPolicy") + if sensitive_info: + pii_matches: List[dict] = [] + for p in sensitive_info.get("piiEntities") or []: + if p.get("action") == "BLOCKED": + pii_matches.append( + { + "category": "piiEntities", + "type": p.get("type"), + "match": p.get("match"), + "action": p.get("action"), + } + ) + for r in sensitive_info.get("regexes") or []: + if r.get("action") == "BLOCKED": + pii_matches.append( + { + "category": "regexes", + "name": r.get("name"), + "regex": r.get("regex"), + "match": r.get("match"), + "action": r.get("action"), + } + ) + if pii_matches: + blocked.append( + { + "policy": "sensitiveInformationPolicy", + "matches": pii_matches, + } + ) + + # Contextual grounding policy + contextual = assessment.get("contextualGroundingPolicy") + if contextual: + grounding_matches = [ + { + "category": "filters", + "type": f.get("type"), + "threshold": f.get("threshold"), + "score": f.get("score"), + "action": f.get("action"), + } + for f in (contextual.get("filters") or []) + if f.get("action") == "BLOCKED" + ] + if grounding_matches: + blocked.append( + { + "policy": "contextualGroundingPolicy", + "matches": grounding_matches, + } + ) + + return blocked + def _get_http_exception_for_blocked_guardrail( self, response: BedrockGuardrailResponse ) -> Union[HTTPException, GuardrailInterventionNormalStringError]: @@ -655,14 +791,21 @@ class BedrockGuardrail(CustomGuardrail, BaseAWSLLM): return GuardrailInterventionNormalStringError( message=bedrock_guardrail_output_text ) - else: - return HTTPException( - status_code=400, - detail={ - "error": "Violated guardrail policy", - "bedrock_guardrail_response": bedrock_guardrail_output_text, - }, - ) + + detail: Dict[str, Any] = { + "error": "Violated guardrail policy", + "bedrock_guardrail_response": bedrock_guardrail_output_text, + } + if self.guardrailIdentifier: + detail["guardrailIdentifier"] = self.guardrailIdentifier + if self.guardrailVersion: + detail["guardrailVersion"] = self.guardrailVersion + + assessments = self._extract_blocked_assessments(response) + if assessments: + detail["assessments"] = assessments + + return HTTPException(status_code=400, detail=detail) def _should_raise_guardrail_blocked_exception( self, response: BedrockGuardrailResponse diff --git a/litellm/proxy/utils.py b/litellm/proxy/utils.py index 88a2e1e95c..e15b48577d 100644 --- a/litellm/proxy/utils.py +++ b/litellm/proxy/utils.py @@ -15,6 +15,8 @@ from email.mime.text import MIMEText from typing import ( TYPE_CHECKING, Any, + AsyncGenerator, + Awaitable, Dict, List, Literal, @@ -300,6 +302,30 @@ def _accepts_litellm_call_info(cb: CustomLogger) -> bool: return _CALLBACK_ACCEPTS_CALL_INFO[key] +def _enrich_http_exception_with_guardrail_context( + exc: BaseException, callback: Any +) -> None: + """ + If `exc` is an HTTPException with a dict `detail`, mutate it in place to + add `guardrail_name` and `guardrail_mode` taken from the callback instance. + + Uses setdefault so guardrails that already populate these fields explicitly + win over the inferred defaults. No-op for non-HTTPException, non-dict-detail, + or callbacks without `guardrail_name`. Never raises. + """ + if not isinstance(exc, HTTPException): + return + detail = getattr(exc, "detail", None) + if not isinstance(detail, dict): + return + guardrail_name = getattr(callback, "guardrail_name", None) + if guardrail_name: + detail.setdefault("guardrail_name", guardrail_name) + event_hook = getattr(callback, "event_hook", None) + if event_hook: + detail.setdefault("guardrail_mode", event_hook) + + class ProxyLogging: """ Logging/Custom Handlers for proxy. @@ -1063,6 +1089,7 @@ class ProxyLogging: except Exception as e: status = "error" error_type = type(e).__name__ + _enrich_http_exception_with_guardrail_context(e, callback) # Re-raise the exception to maintain existing behavior raise finally: @@ -1431,6 +1458,40 @@ class ProxyLogging: except Exception as e: raise e + @staticmethod + async def _run_guardrail_task_with_enrichment( + callback: Any, coro: Awaitable[Any] + ) -> Any: + """ + Await `coro`; if it raises an HTTPException with dict detail, + enrich the detail with the originating callback's `guardrail_name` + and `guardrail_mode` before re-raising. + """ + try: + return await coro + except Exception as e: + _enrich_http_exception_with_guardrail_context(e, callback) + raise + + @staticmethod + async def _wrap_streaming_iterator_with_enrichment( + callback: Any, gen: AsyncGenerator[Any, None] + ) -> AsyncGenerator[Any, None]: + """ + Yield from `gen`; if iteration raises an HTTPException with dict detail, + enrich the detail with the originating callback's `guardrail_name` and + `guardrail_mode` before re-raising. Used to wrap each layer of the + async_post_call_streaming_iterator_hook chain so the enrichment is + attributed to the callback that produced the chunk pipeline at that + point in the chain. + """ + try: + async for chunk in gen: + yield chunk + except Exception as e: + _enrich_http_exception_with_guardrail_context(e, callback) + raise + async def during_call_hook( self, data: dict, @@ -1481,16 +1542,22 @@ class ProxyLogging: and user_api_key_dict is not None ): data["guardrail_to_apply"] = callback - guardrail_task = unified_guardrail.async_moderation_hook( - user_api_key_dict=user_api_key_dict, - data=data, - call_type=call_type, + guardrail_task = self._run_guardrail_task_with_enrichment( + callback, + unified_guardrail.async_moderation_hook( + user_api_key_dict=user_api_key_dict, + data=data, + call_type=call_type, + ), ) else: - guardrail_task = callback.async_moderation_hook( - data=data, - user_api_key_dict=user_api_key_auth_dict, # type: ignore - call_type=call_type, # type: ignore + guardrail_task = self._run_guardrail_task_with_enrichment( + callback, + callback.async_moderation_hook( + data=data, + user_api_key_dict=user_api_key_auth_dict, # type: ignore + call_type=call_type, # type: ignore + ), ) guardrail_tasks.append(guardrail_task) @@ -1985,19 +2052,27 @@ class ProxyLogging: if "apply_guardrail" in type(callback).__dict__: data["guardrail_to_apply"] = callback - guardrail_response = ( - await unified_guardrail.async_post_call_success_hook( + try: + guardrail_response = ( + await unified_guardrail.async_post_call_success_hook( + user_api_key_dict=user_api_key_dict, + data=data, + response=response, + ) + ) + except Exception as e: + _enrich_http_exception_with_guardrail_context(e, callback) + raise + else: + try: + guardrail_response = await callback.async_post_call_success_hook( user_api_key_dict=user_api_key_dict, data=data, response=response, ) - ) - else: - guardrail_response = await callback.async_post_call_success_hook( - user_api_key_dict=user_api_key_dict, - data=data, - response=response, - ) + except Exception as e: + _enrich_http_exception_with_guardrail_context(e, callback) + raise if guardrail_response is not None: response = guardrail_response @@ -2206,29 +2281,32 @@ class ProxyLogging: "async_post_call_streaming_iterator_hook" in type(callback).__dict__ ): - current_response = ( + current_response = self._wrap_streaming_iterator_with_enrichment( + _callback, _callback.async_post_call_streaming_iterator_hook( user_api_key_dict=user_api_key_dict, response=current_response, request_data=request_data, - ) + ), ) elif "apply_guardrail" in type(callback).__dict__: request_data["guardrail_to_apply"] = callback - current_response = ( + current_response = self._wrap_streaming_iterator_with_enrichment( + _callback, unified_guardrail.async_post_call_streaming_iterator_hook( user_api_key_dict=user_api_key_dict, request_data=request_data, response=current_response, - ) + ), ) else: - current_response = ( + current_response = self._wrap_streaming_iterator_with_enrichment( + _callback, _callback.async_post_call_streaming_iterator_hook( user_api_key_dict=user_api_key_dict, response=current_response, request_data=request_data, - ) + ), ) # Actually iterate through the chained async generator and yield chunks 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 84d320a0a2..010ead425c 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 @@ -1186,6 +1186,156 @@ async def test_bedrock_guardrail_blocked_content_with_masking_enabled(): # Verify exception details assert exc_info.value.status_code == 400 assert "Violated guardrail policy" in str(exc_info.value.detail) - + print("✅ BLOCKED content with masking enabled raises exception correctly") + +# --------------------------------------------------------------------------- +# L3: _extract_blocked_assessments + _get_http_exception_for_blocked_guardrail +# Regression coverage for case 2026-04-10-internal-bedrock-guardrail-streaming-error. +# --------------------------------------------------------------------------- + + +def _make_guardrail() -> BedrockGuardrail: + return BedrockGuardrail( + guardrail_name="bedrock-pii-guard", + guardrailIdentifier="amgllac6xf3r", + guardrailVersion="1", + ) + + +def test_extract_blocked_assessments_pii_entity(): + """L3: PII entity match (BLOCKED) is surfaced with category, type, and matched term.""" + g = _make_guardrail() + response = { + "action": "GUARDRAIL_INTERVENED", + "assessments": [ + { + "sensitiveInformationPolicy": { + "piiEntities": [ + {"type": "NAME", "action": "BLOCKED", "match": "Jack"}, + {"type": "EMAIL", "action": "ANONYMIZED", "match": "x@y.z"}, + ] + } + } + ], + } + blocked = g._extract_blocked_assessments(response) + assert len(blocked) == 1 + assert blocked[0]["policy"] == "sensitiveInformationPolicy" + matches = blocked[0]["matches"] + assert len(matches) == 1 # only the BLOCKED one is surfaced + assert matches[0]["category"] == "piiEntities" + assert matches[0]["type"] == "NAME" + assert matches[0]["match"] == "Jack" + + +def test_extract_blocked_assessments_multiple_policies(): + """L3: multiple policies fired in one assessment must all be reported.""" + g = _make_guardrail() + response = { + "action": "GUARDRAIL_INTERVENED", + "assessments": [ + { + "topicPolicy": { + "topics": [ + {"name": "Investment", "type": "DENY", "action": "BLOCKED"} + ] + }, + "contentPolicy": { + "filters": [ + { + "type": "VIOLENCE", + "confidence": "HIGH", + "filterStrength": "HIGH", + "action": "BLOCKED", + } + ] + }, + "wordPolicy": { + "customWords": [{"match": "forbidden", "action": "BLOCKED"}] + }, + } + ], + } + blocked = g._extract_blocked_assessments(response) + policies = {entry["policy"] for entry in blocked} + assert policies == {"topicPolicy", "contentPolicy", "wordPolicy"} + + +def test_extract_blocked_assessments_only_anonymized_returns_empty(): + """L3: if all matches are ANONYMIZED (not BLOCKED), the list is empty.""" + g = _make_guardrail() + response = { + "action": "GUARDRAIL_INTERVENED", + "assessments": [ + { + "sensitiveInformationPolicy": { + "piiEntities": [ + {"type": "NAME", "action": "ANONYMIZED", "match": "Jack"} + ] + } + } + ], + } + assert g._extract_blocked_assessments(response) == [] + + +def test_extract_blocked_assessments_no_assessments(): + """L3: response with no assessments returns an empty list, not an error.""" + g = _make_guardrail() + assert g._extract_blocked_assessments({"action": "NONE"}) == [] + assert g._extract_blocked_assessments({"assessments": None}) == [] + + +def test_get_http_exception_includes_assessments_and_identifier(): + """L3: end-to-end — _get_http_exception_for_blocked_guardrail emits the new fields.""" + g = _make_guardrail() + response = { + "action": "GUARDRAIL_INTERVENED", + "outputs": [{"text": "Sorry, the model cannot answer this question."}], + "assessments": [ + { + "sensitiveInformationPolicy": { + "piiEntities": [ + {"type": "NAME", "action": "BLOCKED", "match": "Jack"} + ] + } + } + ], + } + exc = g._get_http_exception_for_blocked_guardrail(response) + assert isinstance(exc, HTTPException) + assert exc.status_code == 400 + assert exc.detail["error"] == "Violated guardrail policy" + assert ( + exc.detail["bedrock_guardrail_response"] + == "Sorry, the model cannot answer this question." + ) + assert exc.detail["guardrailIdentifier"] == "amgllac6xf3r" + assert exc.detail["guardrailVersion"] == "1" + assert exc.detail["assessments"][0]["policy"] == "sensitiveInformationPolicy" + assert exc.detail["assessments"][0]["matches"][0]["type"] == "NAME" + + +def test_get_http_exception_no_blocked_assessments_omits_field(): + """L3: when no assessments are blocked, the `assessments` key is omitted entirely.""" + g = _make_guardrail() + response = { + "action": "GUARDRAIL_INTERVENED", + "outputs": [{"text": "blocked"}], + "assessments": [ + { + "sensitiveInformationPolicy": { + "piiEntities": [ + {"type": "NAME", "action": "ANONYMIZED", "match": "Jack"} + ] + } + } + ], + } + exc = g._get_http_exception_for_blocked_guardrail(response) + assert isinstance(exc, HTTPException) + assert "assessments" not in exc.detail + assert exc.detail["guardrailIdentifier"] == "amgllac6xf3r" + diff --git a/tests/test_litellm/proxy/test_common_request_processing.py b/tests/test_litellm/proxy/test_common_request_processing.py index 781b559651..0cc65fe493 100644 --- a/tests/test_litellm/proxy/test_common_request_processing.py +++ b/tests/test_litellm/proxy/test_common_request_processing.py @@ -886,14 +886,17 @@ class TestCommonRequestProcessingHelpers: response = await create_response(mock_gen, "text/event-stream", {}) assert response.status_code == status.HTTP_500_INTERNAL_SERVER_ERROR content = await self.consume_stream(response) + # Streaming SSE error frame now mirrors ProxyException.to_dict() shape + # so streaming and non-streaming surfaces emit byte-identical errors. expected_error_data = { "error": { "message": "Error processing stream start", - "code": status.HTTP_500_INTERNAL_SERVER_ERROR, + "type": "None", + "param": "None", + "code": str(status.HTTP_500_INTERNAL_SERVER_ERROR), } } assert len(content) == 2 - # Use json.dumps to match the formatting in create_streaming_response's exception handler import json assert content[0] == f"data: {json.dumps(expected_error_data)}\n\n" @@ -919,13 +922,130 @@ class TestCommonRequestProcessingHelpers: expected_error_data = { "error": { "message": "Content blocked by guardrail", - "code": 400, + "type": "None", + "param": "None", + "code": "400", } } assert len(content) == 2 assert content[0] == f"data: {json.dumps(expected_error_data)}\n\n" assert content[1] == "data: [DONE]\n\n" + async def test_create_streaming_response_http_exception_dict_detail_bedrock_shape( + self, + ): + """ + Bedrock-style dict detail (with the post-L3 shape) must be preserved as + structured `provider_specific_fields` in the SSE error frame, not stringified + into a Python-repr blob inside `error.message`. Regression for case + 2026-04-10-internal-bedrock-guardrail-streaming-error. + """ + import json + + mock_gen = AsyncMock() + mock_gen.__anext__.side_effect = HTTPException( + status_code=400, + detail={ + "error": "Violated guardrail policy", + "bedrock_guardrail_response": "Sorry, the model cannot answer this question. Prompt is blocked", + "guardrailIdentifier": "amgllac6xf3r", + "guardrailVersion": "1", + "assessments": [ + { + "policy": "sensitiveInformationPolicy", + "matches": [ + { + "category": "piiEntities", + "type": "NAME", + "action": "BLOCKED", + "match": "Jack", + } + ], + } + ], + "guardrail_name": "bedrock-pii-guard", + "guardrail_mode": "post_call", + }, + ) + + response = await create_response(mock_gen, "text/event-stream", {}) + assert response.status_code == 400 + content = await self.consume_stream(response) + assert len(content) == 2 + assert content[1] == "data: [DONE]\n\n" + + payload = json.loads(content[0][len("data: ") :].strip()) + assert payload["error"]["message"] == "Violated guardrail policy" + assert payload["error"]["code"] == "400" + psf = payload["error"]["provider_specific_fields"] + assert psf["guardrail_name"] == "bedrock-pii-guard" + assert psf["guardrail_mode"] == "post_call" + assert psf["guardrailIdentifier"] == "amgllac6xf3r" + assert psf["assessments"][0]["policy"] == "sensitiveInformationPolicy" + assert psf["assessments"][0]["matches"][0]["type"] == "NAME" + + async def test_create_streaming_response_http_exception_dict_detail_nested_error_shape( + self, + ): + """PANW Prisma AIRS-style nested `{"error": {"message": ...}}` detail must + extract `error.message` as the human-readable summary while preserving the + full payload.""" + import json + + mock_gen = AsyncMock() + mock_gen.__anext__.side_effect = HTTPException( + status_code=400, + detail={ + "error": { + "message": "MCP request blocked: no rewritable argument field present", + "type": "guardrail_violation", + "code": "panw_prisma_airs_blocked", + } + }, + ) + response = await create_response(mock_gen, "text/event-stream", {}) + content = await self.consume_stream(response) + payload = json.loads(content[0][len("data: ") :].strip()) + assert ( + payload["error"]["message"] + == "MCP request blocked: no rewritable argument field present" + ) + assert ( + payload["error"]["provider_specific_fields"]["error"]["code"] + == "panw_prisma_airs_blocked" + ) + + async def test_serialize_http_exception_detail_helper(self): + """Direct unit coverage for the L1 helper across all branches.""" + from litellm.proxy.common_request_processing import ( + _serialize_http_exception_detail, + ) + import json as _json + + assert _serialize_http_exception_detail("plain") == ("plain", None) + + msg, fields = _serialize_http_exception_detail( + {"error": "Violated", "extra": "x"} + ) + assert msg == "Violated" + assert fields == {"error": "Violated", "extra": "x"} + + msg, fields = _serialize_http_exception_detail( + {"error": {"message": "blocked", "code": "x"}} + ) + assert msg == "blocked" + assert fields == {"error": {"message": "blocked", "code": "x"}} + + msg, fields = _serialize_http_exception_detail({"message": "top-level"}) + assert msg == "top-level" + assert fields == {"message": "top-level"} + + msg, fields = _serialize_http_exception_detail({"weird": ["a", "b"]}) + assert msg == _json.dumps({"weird": ["a", "b"]}) + assert fields == {"weird": ["a", "b"]} + + assert _serialize_http_exception_detail(42) == ("42", None) + async def test_create_streaming_response_first_chunk_error_string_code(self): """ Test that when the first chunk contains a string error code, a JSON error response is returned @@ -1853,3 +1973,56 @@ class TestHasAttributeErrorInChain: exc_a.__context__ = exc_b exc_b.__context__ = exc_a # circular assert _has_attribute_error_in_chain(exc_a) is False + + +@pytest.mark.asyncio +class TestHandleLLMApiExceptionDictDetail: + """ + Coverage for `_handle_llm_api_exception` HTTPException branch (Site 2). + Regression for case 2026-04-10-internal-bedrock-guardrail-streaming-error: + dict-detail HTTPExceptions raised by guardrails must round-trip cleanly + through ProxyException instead of being str()-mangled into a Python repr. + """ + + async def _invoke(self, exc: Exception): + from litellm.proxy._types import ProxyException, UserAPIKeyAuth + + processor = ProxyBaseLLMRequestProcessing(data={}) + user_api_key_dict = UserAPIKeyAuth(api_key="sk-test") + proxy_logging_obj = MagicMock() + proxy_logging_obj.post_call_failure_hook = AsyncMock(return_value=None) + proxy_logging_obj.post_call_response_headers_hook = AsyncMock(return_value={}) + + try: + await processor._handle_llm_api_exception( + e=exc, + user_api_key_dict=user_api_key_dict, + proxy_logging_obj=proxy_logging_obj, + ) + except ProxyException as raised: + return raised + raise AssertionError("ProxyException was not raised") + + async def test_dict_detail_bedrock_shape_preserved(self): + exc = HTTPException( + status_code=400, + detail={ + "error": "Violated guardrail policy", + "bedrock_guardrail_response": "...", + "guardrail_name": "bedrock-pii-guard", + }, + ) + proxy_exc = await self._invoke(exc) + assert proxy_exc.message == "Violated guardrail policy" + assert ( + proxy_exc.provider_specific_fields["guardrail_name"] + == "bedrock-pii-guard" + ) + # No Python repr leakage of the dict into the message field. + assert "{'error':" not in proxy_exc.message + + async def test_string_detail_unchanged(self): + exc = HTTPException(status_code=400, detail="Content blocked by guardrail") + proxy_exc = await self._invoke(exc) + assert proxy_exc.message == "Content blocked by guardrail" + assert proxy_exc.provider_specific_fields is None diff --git a/tests/test_litellm/proxy/test_proxy_utils.py b/tests/test_litellm/proxy/test_proxy_utils.py index 4b50e9a4d3..ed7cc98e21 100644 --- a/tests/test_litellm/proxy/test_proxy_utils.py +++ b/tests/test_litellm/proxy/test_proxy_utils.py @@ -190,3 +190,79 @@ def test_get_projected_spend_over_limit_includes_current_spend(monkeypatch): projected_spend, projected_exceeded_date = result assert projected_spend == 290.0 assert projected_exceeded_date == real_datetime.date(2026, 4, 21) + + +# --------------------------------------------------------------------------- +# L2: _enrich_http_exception_with_guardrail_context +# Regression coverage for case 2026-04-10-internal-bedrock-guardrail-streaming-error. +# --------------------------------------------------------------------------- + + +def test_enrich_http_exception_with_guardrail_context_dict_detail(): + """L2: dict-detail HTTPException is enriched with guardrail_name and mode.""" + from litellm.proxy.utils import _enrich_http_exception_with_guardrail_context + + class StubCallback: + guardrail_name = "bedrock-pii-guard" + event_hook = "post_call" + + exc = HTTPException( + status_code=400, detail={"error": "Violated guardrail policy"} + ) + _enrich_http_exception_with_guardrail_context(exc, StubCallback()) + assert exc.detail["guardrail_name"] == "bedrock-pii-guard" + assert exc.detail["guardrail_mode"] == "post_call" + + +def test_enrich_http_exception_string_detail_noop(): + """L2: string-detail HTTPException is not mutated (can't add fields to a str).""" + from litellm.proxy.utils import _enrich_http_exception_with_guardrail_context + + class StubCallback: + guardrail_name = "x" + event_hook = "pre_call" + + exc = HTTPException(status_code=400, detail="Content blocked") + _enrich_http_exception_with_guardrail_context(exc, StubCallback()) + assert exc.detail == "Content blocked" + + +def test_enrich_http_exception_setdefault_does_not_overwrite(): + """L2: a guardrail that already populates guardrail_name explicitly wins.""" + from litellm.proxy.utils import _enrich_http_exception_with_guardrail_context + + class StubCallback: + guardrail_name = "inferred-name" + event_hook = "pre_call" + + exc = HTTPException( + status_code=400, + detail={"error": "x", "guardrail_name": "explicit-name"}, + ) + _enrich_http_exception_with_guardrail_context(exc, StubCallback()) + assert exc.detail["guardrail_name"] == "explicit-name" + + +def test_enrich_http_exception_non_http_exception_noop(): + """L2: non-HTTPException is left alone and the helper does not raise.""" + from litellm.proxy.utils import _enrich_http_exception_with_guardrail_context + + class StubCallback: + guardrail_name = "x" + event_hook = "pre_call" + + exc = ValueError("not an HTTPException") + _enrich_http_exception_with_guardrail_context(exc, StubCallback()) + assert str(exc) == "not an HTTPException" + + +def test_enrich_http_exception_callback_without_guardrail_name_noop(): + """L2: callback without guardrail_name attribute leaves detail alone.""" + from litellm.proxy.utils import _enrich_http_exception_with_guardrail_context + + class StubCallback: + pass + + exc = HTTPException(status_code=400, detail={"error": "x"}) + _enrich_http_exception_with_guardrail_context(exc, StubCallback()) + assert exc.detail == {"error": "x"}