mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-08 22:21:35 +00:00
fix(proxy): preserve dict guardrail HTTPException.detail + bedrock context (#25558)
This commit is contained in:
parent
2fe615b373
commit
363f9fe5da
6 changed files with 716 additions and 41 deletions
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue