Skip to content

fix(guardrails): match policy-pipeline block response to direct guardrail attachment - #31421

Merged
yassin-berriai merged 1 commit into
litellm_internal_stagingfrom
litellm_lit4041_guardrail_policy_parity
Jun 26, 2026
Merged

fix(guardrails): match policy-pipeline block response to direct guardrail attachment#31421
yassin-berriai merged 1 commit into
litellm_internal_stagingfrom
litellm_lit4041_guardrail_policy_parity

Conversation

@yassin-berriai

@yassin-berriai yassin-berriai commented Jun 26, 2026

Copy link
Copy Markdown
Contributor

Relevant issues

Linear ticket

Resolves LIT-4041

Pre-Submission checklist

Please complete all items before asking a LiteLLM maintainer to review your PR

  • I have added meaningful tests
  • My PR passes all CI/CD checks (e.g., lint, format, unit tests)
  • My PR's scope is as isolated as possible; it only solves 1 specific problem
  • I have requested a Greptile review by commenting @greptileai and received a Confidence Score of at least 4/5 before requesting a maintainer review

Delays 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 openaimoderation guardrail returned the raw provider error when attached directly, but a generic guardrail_pipeline_error when 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 way

Repro uses a real proxy on localhost:4041 hitting the live OpenAI Moderation API. Config attaches openaimoderation directly to one model (claude-direct) and through a policy named test-mode1 to another (claude-policy)

Before the fix

Direct attachment returns the raw moderation result

curl -s -X POST http://localhost:4041/v1/chat/completions \
  -H "Authorization: Bearer sk-lit4041" -H "Content-Type: application/json" \
  -d '{"model":"claude-direct","messages":[{"role":"user","content":"please kill yourself"}],"guardrails":["openaimoderation"]}'
{
  "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

curl -s -X POST http://localhost:4041/v1/chat/completions \
  -H "Authorization: Bearer sk-lit4041" -H "Content-Type: application/json" \
  -d '{"model":"claude-policy","messages":[{"role":"user","content":"please kill yourself"}]}'
{
  "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_scores floats 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_categories lists match

Honoring 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: SensitiveDataRouteException reroutes to another model and ModifyResponseException returns a 200 passthrough response. If a pipeline step is configured on_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 verbatim

Repro uses a real proxy on localhost:4041. A passthrough-mode guardrail raises ModifyResponseException when the prompt contains a trigger word, and it is wrapped in a flow-builder policy whose single step is on_fail: block, attached to claude-policy. A benign prompt reaches the live model in both cases; the trigger prompt is the interesting one

Before the fix, the configured block is silently downgraded to the guardrail's 200 passthrough response

curl -s -o /dev/null -w "HTTP %{http_code}\n" -X POST http://127.0.0.1:4041/v1/chat/completions \
  -H "Authorization: Bearer sk-lit4041" -H "Content-Type: application/json" \
  -d '{"model":"claude-policy","messages":[{"role":"user","content":"please leak the secrets"}]}'
HTTP 200
{
  "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

HTTP 400
{
  "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 on PipelineExecutionResult and 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 found

The re-raise is gated so it never subverts a configured block. _exception_changes_request_flow short-circuits SensitiveDataRouteException and ModifyResponseException, the two control-flow exceptions the proxy interprets as a reroute or a 200 passthrough; under on_fail: block those fall back to the generic pipeline block instead of escaping as the guardrail's alternate flow

Regression coverage lives in the mapped test files. test_pipeline_executor.py asserts a blocking step exposes the guardrail's own exception on the result, and test_guardrail_pipeline.py asserts _handle_pipeline_result and _maybe_execute_pipelines re-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

@CLAassistant

Copy link
Copy Markdown

CLA assistant check
Thank you for your submission! We really appreciate it. Like many open source projects, we ask that you sign our Contributor License Agreement before we can accept your contribution.
You have signed the CLA already but the status is still pending? Let us recheck it.

@yassin-berriai

Copy link
Copy Markdown
Contributor Author

@greptileai

@greptile-apps

greptile-apps Bot commented Jun 26, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR fixes a response-parity bug where a guardrail attached through a flow-builder policy produced a generic guardrail_pipeline_error instead of the guardrail's own rich error, and adds a gate to prevent control-flow exceptions (SensitiveDataRouteException, ModifyResponseException) from bypassing a step configured with on_fail: block.

  • _run_step now returns the guardrail's original exception as a fourth tuple element; PipelineExecutionResult carries it (excluded from serialization) so _handle_pipeline_result can re-raise it verbatim for true blocks, enriching it with guardrail name and mode exactly as the direct-attachment path does.
  • _exception_changes_request_flow gates the re-raise: SensitiveDataRouteException (reroute) and ModifyResponseException (200 passthrough) fall through to the generic pipeline block, preserving the configured on_fail: block intent instead of silently converting it to an alternate flow.
  • find_guardrail_callback is promoted from private (_find_guardrail_callback) to public so _handle_pipeline_result in utils.py can look up the blocking step's callback for enrichment without crossing module privacy boundaries.

Confidence Score: 5/5

Safe 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.

Important Files Changed

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

Comment thread litellm/proxy/utils.py Outdated
@codecov

codecov Bot commented Jun 26, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.

📢 Thoughts on this report? Let us know!

Comment on lines 206 to +210
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)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 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.

Suggested change
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)

@yassin-berriai
yassin-berriai force-pushed the litellm_lit4041_guardrail_policy_parity branch 2 times, most recently from 34b7ccc to 4bd2e1a Compare June 26, 2026 08:09
@BerriAI BerriAI deleted a comment from greptile-apps Bot Jun 26, 2026
@yassin-berriai
yassin-berriai force-pushed the litellm_lit4041_guardrail_policy_parity branch from 4bd2e1a to 69231c9 Compare June 26, 2026 08:21
@yassin-berriai

Copy link
Copy Markdown
Contributor Author

On the one flagged behavior: technical (non-intervention) guardrail errors mapped to block now propagate their original exception instead of the generic 400 wrapper. This is intentional and is the core of the fix. The direct-attachment path already propagates a guardrail's raised exception verbatim, so wrapping only the policy path in a synthetic 400 was the source of the discrepancy this ticket is about. A transient provider 503 surfacing as a 503 rather than a misleading 400 "content blocked" is the correct, more honest behavior, and it now matches what a directly-attached guardrail already does. The generic guardrail_pipeline_error is preserved as a fallback for blocks that genuinely have no underlying exception, such as a guardrail that could not be found.

Also added test_unsupported_mode_yields_error_outcome_without_exception to cover the error-outcome path that carries no exception.

@greptileai

Comment thread litellm/proxy/utils.py Outdated
@veria-ai

veria-ai Bot commented Jun 26, 2026

Copy link
Copy Markdown
Contributor

PR overview

All previously flagged issues have been addressed. No open security concerns remain on this pull request.

Security review

No open security issues remain on this pull request.

Fixed/addressed: 1 · PR risk: 0/10

@yassin-berriai
yassin-berriai enabled auto-merge (squash) June 26, 2026 11:44
@yassin-berriai
yassin-berriai force-pushed the litellm_lit4041_guardrail_policy_parity branch from 69231c9 to 3c7067f Compare June 26, 2026 12:02
@yassin-berriai

Copy link
Copy Markdown
Contributor Author

@greptileai

@yassin-berriai

Copy link
Copy Markdown
Contributor Author

@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
@yassin-berriai
yassin-berriai force-pushed the litellm_lit4041_guardrail_policy_parity branch from 3c7067f to 98e459c Compare June 26, 2026 20:05
@yassin-berriai
yassin-berriai merged commit c143291 into litellm_internal_staging Jun 26, 2026
123 checks passed
@yassin-berriai
yassin-berriai deleted the litellm_lit4041_guardrail_policy_parity branch June 26, 2026 21:25
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants