fix(proxy): preserve dict guardrail HTTPException.detail + bedrock context (#25558)

This commit is contained in:
michelligabriele 2026-04-11 18:40:39 +02:00 committed by GitHub
parent 2fe615b373
commit 363f9fe5da
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 716 additions and 41 deletions

View file

@ -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):

View file

@ -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

View file

@ -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

View file

@ -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"

View file

@ -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

View file

@ -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"}