fix(proxy): preserve dict guardrail HTTPException.detail + bedrock context#25558
Conversation
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
Codecov Report✅ All modified and coverable lines are covered by tests. 📢 Thoughts on this report? Let us know! |
Greptile SummaryThis PR fixes three compounding issues when a guardrail raises
Confidence Score: 5/5Safe to merge; all remaining findings are P2 style suggestions on a niche edge case and an acknowledged backwards-incompatible format alignment. The three-layer fix is well-structured, additive, and comprehensively tested with 16 new mock-only tests. The one P2 concern (streaming path calling bare json.dumps on a dict that could contain a Mode Pydantic object when tag-based mode selection is used) is a niche edge case that does not affect standard Bedrock or string-mode guardrail configurations. All other findings are style or backwards-compat notes already documented in the PR. litellm/proxy/utils.py — _enrich_http_exception_with_guardrail_context stores event_hook (which can be a Mode Pydantic model) directly into the dict that later passes through bare json.dumps in the streaming error path.
|
| Filename | Overview |
|---|---|
| litellm/proxy/common_request_processing.py | New _serialize_http_exception_detail helper correctly extracts message and preserves dict payload; both streaming and non-streaming error sites wired consistently. |
| litellm/proxy/utils.py | L2 enrichment helpers are clean; event_hook could be a Mode Pydantic object that is not JSON-serializable via the plain json.dumps in the streaming error path. |
| litellm/proxy/guardrails/guardrail_hooks/bedrock_guardrails.py | New _extract_blocked_assessments mirrors the existing _should_raise_guardrail_blocked_exception iteration correctly; additive changes to _get_http_exception_for_blocked_guardrail preserve existing keys. |
| tests/test_litellm/proxy/test_common_request_processing.py | Two existing assertions updated for new SSE shape (intentional); five new tests cover L1 helper branches and both Bedrock/PANW dict shapes end-to-end. |
| tests/test_litellm/proxy/test_proxy_utils.py | Five new unit tests cover all branches of _enrich_http_exception_with_guardrail_context including setdefault non-overwrite and no-op cases. |
| tests/test_litellm/proxy/guardrails/guardrail_hooks/test_bedrock_guardrails.py | Six new tests cover L3 assessment extraction (PII, multi-policy, ANONYMIZED-only, no assessments) and end-to-end _get_http_exception_for_blocked_guardrail; all mock-based with no network calls. |
Flowchart
%%{init: {'theme': 'neutral'}}%%
flowchart TD
A[Guardrail raises HTTPException\ndetail = dict] --> B{Lifecycle stage}
B --> |pre_call| C[_process_guardrail_callback\nexcept block]
B --> |during_call| D[_run_guardrail_task_with_enrichment]
B --> |post_call| E[post_call_success_hook\ntry/except wrapper]
B --> |streaming| F[_wrap_streaming_iterator_with_enrichment]
C --> G[_enrich_http_exception_with_guardrail_context\nadd guardrail_name + guardrail_mode via setdefault]
D --> G
E --> G
F --> G
G --> H{Request type}
H --> |streaming| I[create_response exception branch\n_serialize_http_exception_detail]
H --> |non-streaming| J[_handle_llm_api_exception\n_serialize_http_exception_detail]
I --> K[SSE error frame\nmessage + type + param + code + provider_specific_fields]
J --> L[ProxyException\nmessage + provider_specific_fields]
M[BedrockGuardrail raises\nHTTPException] --> N[L3: _get_http_exception_for_blocked_guardrail\nadds guardrailIdentifier + guardrailVersion\n+ assessments from _extract_blocked_assessments]
N --> A
Reviews (1): Last reviewed commit: "fix(proxy): preserve dict guardrail HTTP..." | Re-trigger Greptile
| 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) |
There was a problem hiding this comment.
guardrail_mode may store a non-serializable value in the streaming error path
callback.event_hook can be a Mode Pydantic model (for tag-based mode selection) or a List[GuardrailEventHooks] — neither of which is serializable by the standard json.dumps call in create_response's error_gen_message. When a streaming request hits such a guardrail, the inner json.dumps({'error': error_obj}) would raise TypeError: Object of type Mode is not JSON serializable, causing the stream to break rather than returning a clean 400 error frame.
Consider coercing event_hook to a plain string before storing it:
event_hook = getattr(callback, "event_hook", None)
if event_hook is not None:
if isinstance(event_hook, list):
mode_str: Any = [str(h) for h in event_hook]
else:
mode_str = str(event_hook) # works for GuardrailEventHooks (str enum) and Mode
detail.setdefault("guardrail_mode", mode_str)| @@ -919,13 +922,130 @@ async def test_create_streaming_response_generator_raises_http_exception( | |||
| 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" | |||
There was a problem hiding this comment.
Existing test assertions updated for new SSE frame shape
Two assertions were changed to match the new streaming error frame format — "code" is now a string ("500" / "400") instead of an integer, and "type" / "param" fields were added. These updates reflect the intentional alignment with ProxyException.to_dict() shape and are documented in the PR's backwards-compatibility notes, so they don't weaken coverage. Worth noting for reviewers: clients that previously compared error.code === 400 (strict integer equality) will see different output.
Rule Used: What: Flag any modifications to existing tests and... (source)
363f9fe
into
BerriAI:litellm_internal_staging_04_11_2026
Title
fix(proxy): preserve dict guardrail HTTPException.detail + bedrock context
Relevant issues
Pre-Submission checklist
Please complete all items before asking a LiteLLM maintainer to review your PR
tests/test_litellm/directory, Adding at least 1 test is a hard requirement - see detailsmake test-unit@greptileaiand received a Confidence Score of at least 4/5 before requesting a maintainer reviewDelays in PR merge?
If you're seeing a delay in your PR being merged, ping the LiteLLM Team on Slack (#pr-review).
CI (LiteLLM team)
Branch creation CI run
Link:
CI run for the last commit
Link:
Merge / cherry-pick CI run
Links:
Type
🐛 Bug Fix
Changes
The bug
When a guardrail raises
HTTPException(status_code=..., detail={dict})— as Bedrock Guardrails (and any guardrail that wants to return structured violation context) does — the proxy collapses that dict into a string viastr()at two error-handling sites inlitellm/proxy/common_request_processing.py. The result is a Python-repr blob (single quotes, escaped commas) insideerror.messageon both the streaming SSE error frame and the non-streaming JSON response. The wire format is technically valid JSON wrapping an invalid JSONmessagefield — unparseable by any client.For a Bedrock guardrail block on a streaming request, the user previously saw:
Two related defects compound the unclarity even after a hypothetical serialization fix:
ProxyLogging) never enriches the error with the originating guardrail's name or lifecycle stage. Even with clean serialization, the user can't tell which of their configured guardrails fired or at what stage.assessmentslist returned byapply_guardrail, keeping only the cannedoutputs[].text. The user can't tell whether they tripped PII detection, a topic policy, a content filter, or a custom word list.This PR addresses all three layers in one commit so the customer-facing error is actually clear, not just technically parseable.
The fix — three layers, one commit
L1 — Centralize dict-detail serialization at the proxy boundary
litellm/proxy/common_request_processing.py_serialize_http_exception_detail(detail) -> Tuple[str, Optional[dict]]. Documented fallback chain so the dominant guardrail shapes both round-trip cleanly:detail['error']if str (Bedrock-style flat)detail['error']['message']ifdetail['error']is a dict with a strmessage(PANW Prisma AIRS-style nested)detail['message']if strjson.dumps(detail)— JSON, never Python reprcreate_response()exception branch. The SSE error frame is rebuilt to mirrorProxyException.to_dict()exactly, so streaming and non-streaming surfaces emit byte-identicalerrorobjects._handle_llm_api_exception()HTTPException branch.ProxyExceptionitself is not modified — the# DO NOT MODIFY THISconstraint at_types.py:3398is respected.L2 — Enrich
HTTPException.detailwith guardrail name + lifecycle stage at the dispatcherlitellm/proxy/utils.py_enrich_http_exception_with_guardrail_context(exc, callback). Mutates the exception'sdetaildict in place viasetdefaultto addguardrail_nameandguardrail_mode, taken from the callback instance. Usessetdefaultso guardrails that already populate these fields explicitly win over the inferred defaults. No-op for non-HTTPException, non-dict-detail, or callbacks withoutguardrail_name. Never raises.ProxyLoggingstatic-method helpers:_run_guardrail_task_with_enrichment(callback, coro)— wraps an awaited coroutine, enriches on except, re-raises._wrap_streaming_iterator_with_enrichment(callback, gen)— wraps a chained async generator and enriches on iteration error. Needed becauseasync_post_call_streaming_iterator_hookbuilds wrapped generators rather than awaiting coroutines, so exceptions raise during the consumer'sasync forrather than at construction. Each layer of the chain attributes its own callback.ProxyLogging:_process_guardrail_callback(pre_call) — direct enrichment in the existing except block.during_call_hook— both branches (apply_guardrailunified path + barecallback.async_moderation_hookpath) wrap the task in_run_guardrail_task_with_enrichment.post_call_success_hook— both branches wrap the innerawaitin a try/except that enriches and re-raises (the existing outer try/except at the loop level only re-raises, so per-callback attribution requires inner wrapping).async_post_call_streaming_iterator_hook— all 3 branches (regularasync_post_call_streaming_iterator_hook+apply_guardrailunified + fallback) wrap each chained generator in_wrap_streaming_iterator_with_enrichment.This means the other 27 guardrail hook implementations get L1 (clean serialization) and L2 (guardrail name + lifecycle stage) for free, with no per-provider changes.
L3 — Surface Bedrock assessments at the source
litellm/proxy/guardrails/guardrail_hooks/bedrock_guardrails.py_extract_blocked_assessments(response) -> List[dict]onBedrockGuardrail. Walks the same five policy categories that the existing_should_raise_guardrail_blocked_exception()already iterates (topicPolicy,contentPolicy,wordPolicy× {customWords, managedWordLists},sensitiveInformationPolicy× {piiEntities, regexes},contextualGroundingPolicy) and emits a structured list of{policy, matches}entries. Eachmatchpreserves the originating sub-key (piiEntities,regexes,customWords, etc.) undercategory, plus type/action/match where available. Mirrors the existing iteration so future maintenance keeps the two methods in sync naturally._get_http_exception_for_blocked_guardrail()extended to also addguardrailIdentifierandguardrailVersion(both already onselffrom the constructor) plus theassessmentslist to the detail dict. Existingerrorandbedrock_guardrail_responsekeys are preserved at the top level — additive only, no rename, no restructure. Thedisable_exception_on_block=TrueGuardrailInterventionNormalStringErrorbranch is untouched.How the three layers compose
L3 puts rich provider-specific content into
HTTPException.detail. L2 enriches that samedetaildict with the dispatcher-level context (which guardrail, which stage). L1 makes sure all of it survives the trip to the client without being stringified, on both the streaming and non-streaming surfaces.Final wire shape
For a Bedrock PII block, both surfaces now return (byte-identical
errorobject):{ "error": { "message": "Violated guardrail policy", "type": "None", "param": "None", "code": "400", "provider_specific_fields": { "error": "Violated guardrail policy", "bedrock_guardrail_response": "Sorry, the model cannot answer this question. Prompt is blocked", "guardrailIdentifier": "<id>", "guardrailVersion": "<version>", "assessments": [ { "policy": "sensitiveInformationPolicy", "matches": [ {"category": "piiEntities", "type": "NAME", "match": "<matched-term>", "action": "BLOCKED"} ] } ], "guardrail_name": "<configured-name>", "guardrail_mode": "post_call" } } }Streaming wraps this in
data: {...}\n\ndata: [DONE]\n\nand non-streaming returns it as the response body. The user now sees: which guardrail blocked them, at what lifecycle stage, the Bedrock guardrail identifier (so they can find it in the AWS console), and the exact assessment that fired down to the matched term and policy sub-category.Backwards-compatibility notes
type,param, and stringifiescodeto align withProxyException.to_dict()(which is already the contract on the non-streaming surface). Realistically nothing was parsing the prior Python-repr blob insidemessage(it was invalid JSON), so this is not a regression in any meaningful sense — but flagging it explicitly. Two existing test assertions intest_common_request_processing.pyare updated for the new shape (test_create_streaming_response_generator_raises_unexpected_exception,test_create_streaming_response_generator_raises_http_exception).errorandbedrock_guardrail_responsekeys remain at the top level. Any existing client parsing those is unaffected. The new fields (guardrailIdentifier,guardrailVersion,assessments) appear alongside, never replacing.setdefaultthroughout, so guardrails that explicitly setguardrail_nameorguardrail_modein their detail dict (none currently do, but it's possible) win over the inferred values. The helper never raises and is safe to call on non-HTTPException, non-dict-detail, or callbacks withoutguardrail_name.Tests added
L1 —
tests/test_litellm/proxy/test_common_request_processing.py:test_serialize_http_exception_detail_helper— direct unit coverage for all branches of the helper (str / flat-dict / nested-error-dict / top-level-message-dict / opaque-dict / non-str non-dict).test_create_streaming_response_http_exception_dict_detail_bedrock_shape— full Bedrock dict detail survives asprovider_specific_fieldsin the SSE frame.test_create_streaming_response_http_exception_dict_detail_nested_error_shape— PANW-style{"error": {"message": ...}}shape extractserror.messagewhile preserving the full payload.TestHandleLLMApiExceptionDictDetailclass — non-streaming branch coverage (dict detail preserved + string detail unchanged).L2 —
tests/test_litellm/proxy/test_proxy_utils.py:_enrich_http_exception_with_guardrail_context(dict-detail enriches / string-detail noop /setdefaultdoes not overwrite / non-HTTPException noop / callback withoutguardrail_namenoop).L3 —
tests/test_litellm/proxy/guardrails/guardrail_hooks/test_bedrock_guardrails.py:_extract_blocked_assessments(PII entity / multiple policies / only-anonymized empty / no-assessments empty)._get_http_exception_for_blocked_guardrail(with assessments and identifier / no blocked assessments omits the field).All targeted suites pass locally:
Out of scope (deliberate)
ProxyException—# DO NOT MODIFY THISconstraint respected.GuardrailInterventionNormalStringErrorfallback in the Bedrock helper — separatedisable_exception_on_block=Truecode path, out of scope.ProxyLoggingdispatch to use_execute_guardrail_hookfor all four hook types — the current per-site wrap is the smallest possible change; full unification is a separate refactor.