fix(guardrails): match policy-pipeline block response to direct guardrail attachment - #31421
Conversation
|
|
Greptile SummaryThis PR fixes a response-parity bug where a guardrail attached through a flow-builder policy produced a generic
Confidence Score: 5/5Safe to merge. The gate is logically sound, the enrichment path is defensive (no-ops on non-HTTPException and missing callbacks), and the test suite covers all four scenario branches. The new _exception_changes_request_flow gate correctly identifies the two exception types the proxy interprets as alternate request flows and keeps them from escaping as those flows when a step is configured to block. The re-raise path is gated on the exception being non-None AND not a control-flow type, and enrichment is applied only when a matching callback is found. All production paths introduced in this commit are exercised by the new tests. No new issues were found beyond what has already been flagged in prior review threads. No files require special attention. The logic in litellm/proxy/utils.py (_handle_pipeline_result) is the most critical path and is covered by dedicated unit tests.
|
| Filename | Overview |
|---|---|
| litellm/proxy/policy_engine/pipeline_executor.py | Carries the guardrail's original exception through _run_step and execute_steps via a new 4-tuple return; renames _find_guardrail_callback to find_guardrail_callback (public) so _handle_pipeline_result can look up callbacks for enrichment. |
| litellm/proxy/utils.py | Adds _exception_changes_request_flow gate (SensitiveDataRouteException, ModifyResponseException) and updates _handle_pipeline_result to re-raise the guardrail's own exception verbatim when it is a true block, falling back to the generic guardrail_pipeline_error only when no underlying exception exists or when the exception would change request flow. |
| litellm/types/proxy/policy_engine/pipeline_types.py | Adds original_exception: Optional[Exception] field to PipelineExecutionResult (exclude=True from serialization) and enables arbitrary_types_allowed on the model config to allow non-Pydantic exceptions. |
| tests/test_litellm/proxy/policy_engine/test_pipeline_executor.py | Adds two new tests: one asserting blocking steps expose the guardrail's own exception on the result, one asserting unsupported pipeline mode yields an error outcome with no original_exception. |
| tests/test_litellm/proxy/utils/proxy_logging/test_guardrail_pipeline.py | Adds five new tests covering: re-raise of original guardrail exception end-to-end through _maybe_execute_pipelines, generic fallback when no exception present, enrichment with guardrail name/mode, and correct suppression of SensitiveDataRouteException and ModifyResponseException in favor of the generic block. |
Reviews (5): Last reviewed commit: "fix(guardrails): match policy-pipeline b..." | Re-trigger Greptile
Codecov Report✅ All modified and coverable lines are covered by tests. 📢 Thoughts on this report? Let us know! |
| else: | ||
| verbose_proxy_logger.error( | ||
| f"Pipeline: unexpected error from guardrail '{step.guardrail}': {e}" | ||
| ) | ||
| return ("error", None, str(e)) | ||
| return ("error", None, str(e), e) |
There was a problem hiding this comment.
Technical errors carry
original_exception and will be re-raised verbatim
_is_guardrail_intervention returns False for anything other than HTTP 400, ModifyResponseException, and a few specific exception types. When a guardrail raises a non-intervention error (e.g. an HTTPException(503) for a provider outage) and the step is configured with on_error: block, _run_step still stores the exception as original_exception=e. _handle_pipeline_result then re-raises it verbatim, so the client receives a 503 instead of the clean 400 guardrail_pipeline_error it got before this PR. Passing None here keeps the fallback path intact for technical failures while the intervention path still surfaces the correct guardrail exception.
| else: | |
| verbose_proxy_logger.error( | |
| f"Pipeline: unexpected error from guardrail '{step.guardrail}': {e}" | |
| ) | |
| return ("error", None, str(e)) | |
| return ("error", None, str(e), e) | |
| else: | |
| verbose_proxy_logger.error( | |
| f"Pipeline: unexpected error from guardrail '{step.guardrail}': {e}" | |
| ) | |
| return ("error", None, str(e), None) |
34b7ccc to
4bd2e1a
Compare
4bd2e1a to
69231c9
Compare
|
On the one flagged behavior: technical (non-intervention) guardrail errors mapped to Also added |
PR overviewAll previously flagged issues have been addressed. No open security concerns remain on this pull request. Security reviewNo open security issues remain on this pull request. Fixed/addressed: 1 · PR risk: 0/10 |
69231c9 to
3c7067f
Compare
|
@greptileai please re-review the latest commit; it adds a gate so control-flow guardrail exceptions (SensitiveDataRouteException reroute, ModifyResponseException passthrough) no longer bypass a configured on_fail: block |
…rail attachment When a guardrail blocked a request through a flow-builder policy pipeline, the proxy discarded the guardrail's own exception and synthesized a generic guardrail_pipeline_error response, so the same guardrail produced a different HTTP response and trace span depending on whether it was attached directly or via a policy. The pipeline now carries the guardrail's original exception and re-raises it verbatim on block, enriching it with the blocking guardrail's name and mode exactly as the direct path does, so the two attachment methods are indistinguishable to clients and tracing. The generic pipeline error remains only as a fallback for blocks with no underlying exception (e.g. a guardrail that could not be found). A guardrail can also raise a control-flow exception that the proxy turns into an alternate request flow rather than a block: SensitiveDataRouteException reroutes to another model and ModifyResponseException returns a 200 passthrough response. Re-raising those verbatim under a step configured on_fail=block would convert the configured block into the guardrail's alternate behavior, letting a user bypass the block. Those two exceptions now fall through to the generic pipeline block so the policy's block is honored; only exceptions that already represent a block (content-policy HTTP 400, GuardrailRaisedException, BlockedPiiEntityError) are re-raised verbatim. Resolves LIT-4041
3c7067f to
98e459c
Compare
Relevant issues
Linear ticket
Resolves LIT-4041
Pre-Submission checklist
Please complete all items before asking a LiteLLM maintainer to review your PR
@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).
Screenshots / Proof of Fix
A guardrail should produce the same blocked response no matter how it was enabled. Before this change, the same
openaimoderationguardrail returned the raw provider error when attached directly, but a genericguardrail_pipeline_errorwhen the very same guardrail was wrapped in a flow-builder policy. The whole concept of a policy is irrelevant to the client and to traces; the response and span for a guardrail block should look identical either wayRepro uses a real proxy on localhost:4041 hitting the live OpenAI Moderation API. Config attaches
openaimoderationdirectly to one model (claude-direct) and through a policy namedtest-mode1to another (claude-policy)Before the fix
Direct attachment returns the raw moderation result
{ "error": { "message": "Violated OpenAI moderation policy", "code": "400", "provider_specific_fields": { "error": "Violated OpenAI moderation policy", "moderation_result": { "violated_categories": ["harassment", "harassment/threatening", "self-harm/intent", "self-harm/instructions", "self-harm", "violence"], "category_scores": { "harassment": 0.65, "self-harm/intent": 0.986, "violence": 0.51, "...": "..." } }, "guardrail_name": "openaimoderation", "guardrail_mode": "pre_call" } } }The policy attachment instead returns a LiteLLM-synthesized wrapper, losing the provider detail
{ "error": { "message": "Content blocked by guardrail pipeline 'test-mode1'", "code": "400", "provider_specific_fields": { "error": { "message": "Content blocked by guardrail pipeline 'test-mode1'", "type": "guardrail_pipeline_error", "pipeline_context": { "policy": "test-mode1", "step_results": [{ "guardrail": "openaimoderation", "outcome": "fail", "action": "block" }] } } } } }After the fix
Same two curls. The policy attachment now returns the guardrail's own error, structurally identical to the direct case (the
category_scoresfloats differ only because each call to the live moderation API returns slightly different scores)Direct (
claude-direct){ "error": { "message": "Violated OpenAI moderation policy", "code": "400", "provider_specific_fields": { "error": "Violated OpenAI moderation policy", "moderation_result": { "violated_categories": ["harassment", "harassment/threatening", "self-harm/intent", "self-harm/instructions", "self-harm", "violence"], "category_scores": { "...": "..." } }, "guardrail_name": "openaimoderation", "guardrail_mode": "pre_call" } } }Policy (
claude-policy){ "error": { "message": "Violated OpenAI moderation policy", "code": "400", "provider_specific_fields": { "error": "Violated OpenAI moderation policy", "moderation_result": { "violated_categories": ["harassment", "harassment/threatening", "self-harm/intent", "self-harm/instructions", "self-harm", "violence"], "category_scores": { "...": "..." } }, "guardrail_name": "openaimoderation", "guardrail_mode": "pre_call" } } }Normalizing the non-deterministic float scores, the two responses are byte-for-byte identical and the
violated_categorieslists matchHonoring a configured block over a guardrail's alternate flow
Re-raising the guardrail's exception verbatim is correct for exceptions that already represent a block, but some guardrails raise control-flow exceptions the proxy turns into a different request flow rather than a block:
SensitiveDataRouteExceptionreroutes to another model andModifyResponseExceptionreturns a 200 passthrough response. If a pipeline step is configuredon_fail: block, re-raising one of those verbatim would convert the configured block into the guardrail's alternate behavior, so a user could bypass the block. Those two exceptions now fall through to the generic pipeline block; only exceptions that already represent a block are re-raised verbatimRepro uses a real proxy on localhost:4041. A passthrough-mode guardrail raises
ModifyResponseExceptionwhen the prompt contains a trigger word, and it is wrapped in a flow-builder policy whose single step ison_fail: block, attached toclaude-policy. A benign prompt reaches the live model in both cases; the trigger prompt is the interesting oneBefore the fix, the configured block is silently downgraded to the guardrail's 200 passthrough response
{ "choices": [ { "finish_reason": "content_filter", "message": { "role": "assistant", "content": "[redacted by passthrough guard]" } } ] }After the fix, the same request is blocked as the policy configured
{ "error": { "message": "Content blocked by guardrail pipeline 'block-policy'", "code": "400", "provider_specific_fields": { "error": { "message": "Content blocked by guardrail pipeline 'block-policy'", "type": "guardrail_pipeline_error", "pipeline_context": { "policy": "block-policy", "step_results": [{ "guardrail": "passthrough-guard", "outcome": "fail", "action": "block" }] } } } } }Type
🐛 Bug Fix
Changes
The policy pipeline executor caught the guardrail's intervention exception and flattened it to a string, then the proxy's block handler threw that detail away and synthesized a generic
guardrail_pipeline_error. The fix carries the guardrail's original exception onPipelineExecutionResultand re-raises it verbatim when a step blocks, enriching it with the blocking guardrail's name and mode the same way the direct path does. The generic pipeline error stays only as a fallback for a block with no underlying exception, such as a guardrail that could not be foundThe re-raise is gated so it never subverts a configured block.
_exception_changes_request_flowshort-circuitsSensitiveDataRouteExceptionandModifyResponseException, the two control-flow exceptions the proxy interprets as a reroute or a 200 passthrough; underon_fail: blockthose fall back to the generic pipeline block instead of escaping as the guardrail's alternate flowRegression coverage lives in the mapped test files.
test_pipeline_executor.pyasserts a blocking step exposes the guardrail's own exception on the result, andtest_guardrail_pipeline.pyasserts_handle_pipeline_resultand_maybe_execute_pipelinesre-raise that exception untouched, enrich it with guardrail name and mode, fall back to the generic error when no exception is present, and keep the configured block when the guardrail raised a reroute or passthrough exception. These tests fail on the pre-fix code and pass after