Skip to content

fix(guardrails): stop reporting a no-op guardrail as applied on passthrough - #34411

Merged
tin-berri merged 1 commit into
litellm_internal_stagingfrom
litellm_lit4650_passthrough_guardrail_log
Jul 24, 2026
Merged

fix(guardrails): stop reporting a no-op guardrail as applied on passthrough#34411
tin-berri merged 1 commit into
litellm_internal_stagingfrom
litellm_lit4650_passthrough_guardrail_log

Conversation

@tin-berri

@tin-berri tin-berri commented Jul 23, 2026

Copy link
Copy Markdown
Contributor

TLDR

Problem this solves:

  • Passthrough spend logs report a compression guardrail as succeeded when it never ran
  • guardrail_status, guardrail_information, and applied_guardrails all list a no-op guardrail

How it solves it:

  • Flag guardrails that log their own execution so the framework does not fake an entry
  • List a guardrail as applied only when it actually recorded a run

Relevant issues

  • Passthrough requests dispatch headroom's apply_guardrail, which no-ops because the passthrough translation supplies only texts and no structured_messages
  • The @log_guardrail_information decorator then synthesized an "allow"/"success" guardrail entry, and the unified hook added the guardrail to applied_guardrails, so spend logs claimed compression succeeded
  • A guardrail that manages its own logging now controls whether it is reported, so a no-op is logged as not_run with no entry

Linear ticket

Resolves LIT-4650

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 received a Greptile Confidence Score of at least 4/5 before requesting a maintainer review

Screenshots / Proof of Fix

Config: a headroom guardrail with default_on: true plus a generic passthrough endpoint. A downstream logger captures the StandardLoggingPayload that a Pub/Sub consumer would receive. The compression service is never reached on passthrough because headroom no-ops before calling it.

Passthrough request (real Anthropic upstream), identical before and after:

curl -sS http://127.0.0.1:4650/anthropic-passthrough/v1/messages \
  -H "Authorization: Bearer sk-1234" -H "Content-Type: application/json" \
  -d '{"model":"claude-haiku-4-5-20251001","max_tokens":32,"messages":[{"role":"user","content":"Say hello in exactly three words"}]}'
# -> HTTP 200; no x-litellm-applied-guardrails header (compression did not run)

Before, on litellm_internal_staging (0a4333580f), the spend log falsely reports success:

{
  "call_type": "pass_through_endpoint",
  "status_fields.guardrail_status": "success",
  "metadata.applied_guardrails": ["headroom-compression"],
  "guardrail_information": [
    {
      "guardrail_name": "headroom-compression",
      "guardrail_provider": null,
      "guardrail_mode": "pre_call",
      "guardrail_response": "allow",
      "guardrail_status": "success",
      "duration": 1e-05
    }
  ]
}

After, on this branch (cb9b86dff7), the same request reports the guardrail as not run:

{
  "call_type": "pass_through_endpoint",
  "status_fields.guardrail_status": "not_run",
  "metadata.applied_guardrails": null,
  "guardrail_information": null
}

The working path is unchanged. A /v1/messages request where headroom actually compresses (real 1.76ms call to the compression service) still reports the guardrail correctly and still emits the x-litellm-applied-guardrails header:

{
  "call_type": "anthropic_messages",
  "status_fields.guardrail_status": "success",
  "metadata.applied_guardrails": ["headroom-compression"],
  "guardrail_information": [
    {
      "guardrail_name": "headroom-compression",
      "guardrail_provider": "headroom",
      "guardrail_mode": "pre_call",
      "guardrail_response": {"tokens_before": 1000, "tokens_after": 200, "compression_ratio": 0.2},
      "guardrail_status": "success",
      "duration": 0.00176
    }
  ]
}

A fail_open attempt (compression service unreachable, request forwarded uncompressed) is reported as a failure, not not_run and not success, so a consumer can tell compression was attempted and did not happen:

{
  "call_type": "anthropic_messages",
  "status_fields.guardrail_status": "guardrail_failed_to_respond",
  "metadata.applied_guardrails": ["headroom-compression"],
  "guardrail_information": [
    {
      "guardrail_name": "headroom-compression",
      "guardrail_provider": "headroom",
      "guardrail_mode": "pre_call",
      "guardrail_response": {"error": "headroom compression unavailable; request forwarded uncompressed"},
      "guardrail_status": "guardrail_failed_to_respond",
      "duration": 0.00152
    }
  ]
}

Type

🐛 Bug Fix

Changes

  • Add records_own_guardrail_information on CustomGuardrail; HeadroomGuardrail sets it since it records its own entry only when it compresses
  • The @log_guardrail_information decorator skips its synthetic "allow"/"success" entry for such a guardrail, so a no-op early return is not logged as a run; genuine failures are still recorded
  • The unified guardrail hook no longer auto-adds a self-logging guardrail to applied_guardrails; such a guardrail marks itself only when it actually runs, so HeadroomGuardrail adds itself where it records (on a successful compression and on a fail_open failure) and stays absent on a no-op
  • HeadroomGuardrail records a guardrail_failed_to_respond entry on the fail_open path so an attempted-but-failed compression is logged as a failure rather than dropped

Final Attestation

  • The tests check the right things, including the edge cases, and regressions in the respective real-world customer use-cases are not possible after this PR

@greptile-apps

greptile-apps Bot commented Jul 23, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR corrects Headroom guardrail reporting for passthrough and fail-open requests

  • Adds an opt-in flag for guardrails that manage their own execution records
  • Prevents unified hooks from automatically marking self-logging guardrails as applied
  • Records Headroom compression successes and fail-open failures while leaving no-op paths unreported
  • Adds regression coverage for passthrough, successful compression, and fail-open behavior

Confidence Score: 5/5

The PR appears safe to merge

No blocking failure remains

Important Files Changed

Filename Overview
litellm/integrations/custom_guardrail.py Adds the self-logging capability and suppresses synthetic success records when enabled
litellm/proxy/guardrails/guardrail_hooks/headroom/headroom.py Marks Headroom as self-logging and explicitly records successful and fail-open compression attempts
litellm/proxy/guardrails/guardrail_hooks/unified_guardrail/unified_guardrail.py Defers applied-guardrail bookkeeping to guardrails that declare ownership of their execution records
tests/test_litellm/integrations/test_custom_guardrail.py Verifies that self-logging no-op guardrails do not receive synthetic success entries
tests/test_litellm/proxy/guardrails/guardrail_hooks/test_headroom.py Covers Headroom no-op, successful compression, and fail-open reporting behavior
tests/test_litellm/proxy/guardrails/guardrail_hooks/unified_guardrails/test_unified_guardrail.py Verifies unified hooks defer applied-guardrail tracking only for self-logging guardrails

Reviews (3): Last reviewed commit: "fix(guardrails): stop reporting a no-op ..." | Re-trigger Greptile

Comment thread litellm/proxy/guardrails/guardrail_hooks/headroom/headroom.py
@codecov

codecov Bot commented Jul 23, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.

📢 Thoughts on this report? Let us know!

@codspeed-hq

codspeed-hq Bot commented Jul 23, 2026

Copy link
Copy Markdown
Contributor

Merging this PR will not alter performance

✅ 31 untouched benchmarks


Comparing litellm_lit4650_passthrough_guardrail_log (9777e95) with litellm_internal_staging (7263aa0)1

Open in CodSpeed

Footnotes

  1. No successful run was found on litellm_internal_staging (3eaf7b1) during the generation of this report, so 7263aa0 was used instead as the comparison base. There might be some changes unrelated to this pull request in this report.

@tin-berri
tin-berri force-pushed the litellm_lit4650_passthrough_guardrail_log branch from cb9b86d to c43b0b7 Compare July 23, 2026 20:20
@tin-berri

Copy link
Copy Markdown
Contributor Author

Good catch on the fail_open P1, it was a real gap in the design and is fixed in the latest commit.

Root cause framing: the records_own_guardrail_information flag makes a guardrail authoritative for its own spend-log entry, so the decorator stops synthesizing a default entry for it. For that contract to hold, the guardrail has to record an entry for every outcome where it actually runs. Headroom recorded success but the fail_open path (compression attempted, service unreachable, request forwarded uncompressed) returned without recording, so with the flag on it read as not_run. The pre-PR behavior logged that same case as "allow"/"success", which is also misleading, so neither the old code nor the intermediate was right; the correct outcome is a failure status

Fix: headroom now records a guardrail_failed_to_respond entry on the fail_open return. fail_closed is still recorded by the decorator's error path (which the flag does not gate), and a genuine no-op stays not_run

Pinned by test_apply_guardrail_transport_error_fail_open_forwards_uncompressed, which now asserts the recorded entry is guardrail_failed_to_respond; mutation-checked, it fails without the new record. Live-verified on a proxy: a fail_open request against an unreachable compression service produces status_fields.guardrail_status = "guardrail_failed_to_respond" in the StandardLoggingPayload, not not_run

@tin-berri

Copy link
Copy Markdown
Contributor Author

@greptileai

@tin-berri

Copy link
Copy Markdown
Contributor Author

bugbot run

@cursor cursor Bot left a comment

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.

✅ Bugbot reviewed your changes and found no new issues!

Comment @cursor review or bugbot run to trigger another review on this PR

Reviewed by Cursor Bugbot for commit c43b0b7. Configure here.

…hrough

On passthrough requests the shared guardrail plumbing still dispatches
headroom's pre_call apply_guardrail, but the passthrough translation hands it
only `texts` and no `structured_messages`, so it early-returns a no-op. The
@log_guardrail_information decorator then synthesized an "allow"/"success"
StandardLoggingGuardrailInformation entry, and the unified hook added the
guardrail to applied_guardrails, so spend logs reported the compression
guardrail as succeeded even though nothing ran.

Add a records_own_guardrail_information flag for guardrails that log their own
execution (headroom). The decorator skips the synthetic success entry for them,
and the unified hook lists such a guardrail in applied_guardrails only when it
actually recorded a run. A guardrail that owns its logging must record every
outcome it runs, so headroom now records a guardrail_failed_to_respond entry on
the fail_open path (compression attempted, service unreachable, request
forwarded uncompressed) instead of leaving it unlogged; fail_closed is still
recorded by the decorator's error path, and a genuine no-op stays not_run.
@tin-berri
tin-berri force-pushed the litellm_lit4650_passthrough_guardrail_log branch from c43b0b7 to 9777e95 Compare July 24, 2026 23:37
@tin-berri

Copy link
Copy Markdown
Contributor Author

Rebased onto current litellm_internal_staging to clear a conflict with #33770 (the run_in_parallel guardrail work).

That PR replaced the count-of-recorded-entries self-record check with a concurrency-safe _guardrail_self_recorded ContextVar and removed _count_recorded_guardrail_entries, so this was a design reconciliation rather than a textual merge. Two changes:

The decorator now reads self.records_own_guardrail_information or _guardrail_self_recorded.get(). The flag is still needed because the ContextVar alone does not cover the no-op case: a guardrail that records nothing leaves the flag false and would still be auto-synthesized

The applied_guardrails gate no longer counts entries in the shared request_data (which #33770 deliberately moved away from for concurrency). Instead the unified hook does not auto-mark a self-logging guardrail, and headroom marks itself where it records, on a successful compression and on the fail_open failure. A no-op stays absent

Behavior is unchanged from the previous revision and re-verified live: passthrough no-op reports not_run with no entry, a real /v1/messages compression reports success, and a fail_open attempt against an unreachable service reports guardrail_failed_to_respond, each with the guardrail present in applied_guardrails only when it actually ran

@tin-berri

Copy link
Copy Markdown
Contributor Author

@greptileai

@tin-berri
tin-berri merged commit d389f83 into litellm_internal_staging Jul 24, 2026
79 of 81 checks passed
@tin-berri
tin-berri deleted the litellm_lit4650_passthrough_guardrail_log branch July 24, 2026 23:52
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.

2 participants