Skip to content

fix(proxy): post-call guardrail response not captured for logging - #23910

Merged
5 commits merged into
BerriAI:mainfrom
michelligabriele:fix/guardrail-post-call-logging
Mar 23, 2026
Merged

fix(proxy): post-call guardrail response not captured for logging#23910
5 commits merged into
BerriAI:mainfrom
michelligabriele:fix/guardrail-post-call-logging

Conversation

@michelligabriele

Copy link
Copy Markdown
Contributor

Post-call OpenAI Moderation guardrail results were not reaching downstream logging callbacks (Langfuse, Datadog, custom loggers). Two independent bugs:

  1. process_output_response() created a throwaway request_data dict, so guardrail info written by the @log_guardrail_information decorator was lost before reaching StandardLoggingPayload. Fixed by threading the real request_data from the unified guardrail dispatcher through all 13 BaseTranslation handlers, with litellm_metadata injection preserved for third-party guardrails that read it during post-call (Zscaler, Prompt Security, Generic Guardrail API, Gray Swan).

  2. The @log_guardrail_information decorator reduced the full moderation API response (categories, scores, flagged status) to the string "allow". Fixed by overriding _process_response and _process_error on OpenAIModerationGuardrail to stash and log the full response (following the established Model Armor pattern). Uses .pop() for cleanup so internal keys don't leak to downstream loggers.

Relevant issues

Related to #13690 (Bedrock guardrail response logging — same class of issue) and #13321 (Bedrock Guardrail Error Response Incomplete).

Supersedes #23554 (same fixes, cleaner implementation addressing all review feedback).

Pre-Submission checklist

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

  • I have Added testing in the tests/test_litellm/ directory, Adding at least 1 test is a hard requirement - see details
  • My PR passes all unit tests on make test-unit
  • 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

CI (LiteLLM team)

CI status guideline:

  • 50-55 passing tests: main is stable with minor issues.
  • 45-49 passing tests: acceptable but needs attention
  • <= 40 passing tests: unstable; be careful with your merges and assess the risk.
  • Branch creation CI run

@vercel

vercel Bot commented Mar 17, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
litellm Ready Ready Preview, Comment Mar 23, 2026 2:45pm

Request Review

@codspeed-hq

codspeed-hq Bot commented Mar 17, 2026

Copy link
Copy Markdown
Contributor

Merging this PR will not alter performance

✅ 16 untouched benchmarks


Comparing michelligabriele:fix/guardrail-post-call-logging (fa7ccf0) with main (c89496f)

Open in CodSpeed

@greptile-apps

greptile-apps Bot commented Mar 17, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR correctly fixes two independent bugs that prevented post-call OpenAI Moderation guardrail results from reaching downstream logging callbacks (Langfuse, Datadog, custom loggers).

Bug 1 — Throwaway request_data in process_output_response: The unified guardrail dispatcher now passes the real per-request data dict as request_data to all 13 BaseTranslation handler implementations. Previously, each handler created a fresh local dict, so any guardrail metadata written by @log_guardrail_information was written into an object that was immediately discarded. The fix is threaded through all handlers with backward-compatible Optional[dict] = None defaults and litellm_metadata injection preserved for third-party guardrails (Zscaler, Prompt Security, etc.).

Bug 2 — Full moderation response reduced to "allow": OpenAIModerationGuardrail now overrides _process_response and _process_error (following the established Model Armor pattern). apply_guardrail stashes moderation_response.model_dump() into request_data["metadata"]["_openai_moderation_response"] before calling _check_moderation_result. The overridden hooks then pop this key (cleaning it up so it doesn't leak to downstream loggers) and pass the full dict to add_standard_logging_guardrail_information_to_request_data.

Key observations:

  • The or {} guard in apply_guardrail and both overridden hooks correctly handles request_data["metadata"] = None.
  • The litellm_logging.py and proxy/utils.py changes are purely cosmetic Black reformatting with no functional effect.
  • All 5 new tests in test_moderations.py and the new streaming test use mocks exclusively — no real network calls.
  • The mock signature update in test_unified_guardrail.py (request_data=None added) correctly tracks the base class interface change and does not weaken any assertions.
  • One minor edge case exists in the streaming handlers (anthropic, openai/responses): when request_data is None the fallback {} is falsy, so the @log_guardrail_information decorator creates a second independent {} and the stash is unreachable. This only affects the SDK/direct-call path — the proxy always passes a non-empty dict.

Confidence Score: 4/5

  • Safe to merge for the proxy path; two P2 style notes on SDK-path edge cases do not affect correctness in production.
  • Both fixes are correctly implemented and follow established patterns. The stash-anchor-pop flow in apply_guardrail / _process_response / _process_error is sound, metadata-None handling is properly guarded, all new tests use mocks and cover the main scenarios, and no existing assertions are weakened. Score is 4 rather than 5 because the falsy-empty-dict edge case in the SDK path means the full moderation response can be silently dropped to "allow" when request_data is None in the streaming handlers, and _process_error technically risks suppressing the original exception if add_standard_logging_guardrail_information_to_request_data itself throws.
  • litellm/proxy/guardrails/guardrail_hooks/openai/moderations.py and litellm/llms/anthropic/chat/guardrail_translation/handler.py (and the three similar call-sites in litellm/llms/openai/responses/guardrail_translation/handler.py) warrant a quick read for the two P2 comments above.

Important Files Changed

Filename Overview
litellm/proxy/guardrails/guardrail_hooks/openai/moderations.py Core of bug-fix #2: adds apply_guardrail stashing of full moderation API response, and overrides _process_response/_process_error to recover and log it via the Model Armor pattern. Implementation is correct; or {} guard safely handles metadata=None. One minor edge case: the stash can be lost when apply_guardrail is called with a falsy empty dict (SDK/direct-call path only, not proxy path).
litellm/proxy/guardrails/guardrail_hooks/unified_guardrail/unified_guardrail.py Core of bug-fix #1: three call sites in async_post_call_success_hook and async_post_call_streaming_iterator_hook now pass request_data=data (the real per-request dict) instead of a throwaway, ensuring guardrail info written by the decorator is visible to downstream logging.
litellm/llms/base_llm/guardrail_translation/base_translation.py Interface change: process_output_response and process_output_streaming_response abstract methods each gain a new optional request_data: Optional[dict] = None parameter, making the API backward-compatible. All 13 concrete handler subclasses are updated consistently.
litellm/llms/openai/chat/guardrail_translation/handler.py Uses the real request_data when provided (proxy path) and falls back to a local dict when None (SDK path). litellm_metadata injection is preserved for third-party guardrails via if "litellm_metadata" not in request_data guard. Both non-streaming and streaming paths are updated.
litellm/llms/anthropic/chat/guardrail_translation/handler.py Consistent pattern with OpenAI chat handler. Streaming path previously passed hard-coded request_data={} to apply_guardrail; now threads the real dict through, or falls back to {} when request_data is None.
litellm/litellm_core_utils/litellm_logging.py Pure cosmetic reformatting — Black-style multi-line assignments rewritten to parenthesised single-line form throughout. No functional changes whatsoever.
tests/test_litellm/proxy/guardrails/guardrail_hooks/openai/test_moderations.py Five new tests added (all mock-based, no real network calls): safe-content full response logging, harmful-content full response logging, post-call request_data passthrough, and two edge cases for metadata=None in _process_response/_process_error. Good coverage of the two bug fixes.
tests/test_litellm/proxy/guardrails/guardrail_hooks/openai/test_openai_moderation_streaming.py Adds streaming end-of-stream request_data passthrough test that verifies guardrail info lands in the real request_data dict (not a throwaway) after the streaming iterator hook completes. Mock-based, no real network calls.
tests/test_litellm/proxy/guardrails/guardrail_hooks/unified_guardrails/test_unified_guardrail.py Two modifications: adds request_data=None to an inline mock's signature to match the updated base class interface, and reformats inline list/dict literals. No assertions weakened.
litellm/proxy/utils.py Purely cosmetic reformatting of multi-line dict-access assignments and SQL heredocs. No functional changes.

Sequence Diagram

sequenceDiagram
    participant LB as LiteLLM Proxy
    participant UG as UnifiedLLMGuardrails
    participant HN as BaseTranslation Handler<br/>(e.g. OpenAIChatCompletionsHandler)
    participant OM as OpenAIModerationGuardrail
    participant Dec as @log_guardrail_information<br/>decorator
    participant SL as StandardLoggingPayload<br/>(Langfuse / DD / OTEL)

    Note over LB,SL: BUG 1 FIX: Real request_data threaded through

    LB->>UG: async_post_call_success_hook(data=request_data, response)
    UG->>HN: process_output_response(response, guardrail, request_data=data)
    Note over HN: Old: request_data = {} (throwaway)<br/>New: request_data = passed-through real dict
    HN->>Dec: apply_guardrail(inputs, request_data=request_data)
    
    Note over Dec,OM: BUG 2 FIX: Full moderation response preserved

    Dec->>OM: apply_guardrail body executes
    OM->>OM: async_make_request(text)
    OM->>OM: stash: request_data["metadata"]["_openai_moderation_response"] = full_response
    
    alt Content not flagged
        OM-->>Dec: return inputs
        Dec->>OM: _process_response(request_data=request_data)
        OM->>OM: metadata.pop("_openai_moderation_response", "allow") → full_response
        OM->>OM: add_standard_logging_guardrail_information_to_request_data(full_response, ...)
        OM-->>HN: return inputs
        HN-->>UG: return response
        UG-->>SL: request_data["metadata"]["standard_logging_guardrail_information"] = [full_response]
    else Content flagged (HTTPException 400)
        OM->>Dec: raise HTTPException
        Dec->>OM: _process_error(e, request_data=request_data)
        OM->>OM: metadata.pop("_openai_moderation_response", e) → full_response
        OM->>OM: add_standard_logging_guardrail_information_to_request_data(full_response, "guardrail_intervened")
        OM->>Dec: raise e
        Dec->>UG: propagate HTTPException
    end
Loading

Comments Outside Diff (2)

  1. litellm/llms/anthropic/chat/guardrail_translation/handler.py, line 628 (link)

    Falsy empty-dict silently drops stashed moderation response in SDK path

    When request_data is None, request_data if request_data is not None else {} passes a fresh empty {} to apply_guardrail. Because {} is falsy, the @log_guardrail_information decorator evaluates kwargs.get("request_data") or {} as a second fresh {} — a different object. apply_guardrail stashes _openai_moderation_response in the first {}, but _process_response pops from the second {} and falls back to "allow", silently discarding the full moderation response.

    This only affects the direct SDK / non-proxy path (the proxy always passes a non-empty dict). The same pattern appears in litellm/llms/openai/responses/guardrail_translation/handler.py at three apply_guardrail call-sites (lines 934, 943, and 952).

    A safer fallback would make it explicit:

    Using request_data or {} is identical to request_data if request_data is not None else {} but makes it clear the caller accepts the falsy-empty behaviour. Alternatively, a non-empty sentinel (e.g. {"_sdk_path": True}) would let the decorator resolve to the same reference; this would fully fix the SDK path as well.

  2. litellm/proxy/guardrails/guardrail_hooks/openai/moderations.py, line 315-322 (link)

    _process_error raises before returning a value — callers cannot use the return value

    _process_error always ends with raise e, so return self._process_error(...) in the @log_guardrail_information decorator never evaluates to a value. This is functionally correct (the exception propagates), but the method's declared return type implicitly promises a value. The base class _process_error has the same shape, so this is not a regression — just worth noting for clarity.

    If add_standard_logging_guardrail_information_to_request_data itself raises (unlikely but possible), it would suppress e and surface the internal error instead. A defensive approach would be:

    try:
        self.add_standard_logging_guardrail_information_to_request_data(...)
    finally:
        raise e

    This is a low-priority style note, consistent with the Model Armor pattern the PR follows.

    Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

Reviews (7): Last reviewed commit: "fix(test): add request_data param to tes..." | Re-trigger Greptile

Comment on lines +257 to +264
metadata = (
request_data.get("metadata", {}) if isinstance(request_data, dict) else {}
)

# .pop() cleans up the internal key so it doesn't leak to downstream
# loggers. Falls back to "allow" when no moderation call was made
# (e.g. no text to moderate — early return in apply_guardrail).
guardrail_response = metadata.pop("_openai_moderation_response", "allow")

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 metadata.pop() raises AttributeError when request_data["metadata"] is None

dict.get(key, default) returns the stored value even when it is None; the default only applies when the key is absent. So when request_data = {"metadata": None}, request_data.get("metadata", {}) returns None, and the subsequent .pop() call raises AttributeError: 'NoneType' object has no attribute 'pop'.

The base-class method add_standard_logging_guardrail_information_to_request_data already guards against this case (it resets None to {}), but both overrides perform .pop() before that guard runs.

The same issue exists in _process_error at line 301.

# Current (fragile):
metadata = (
    request_data.get("metadata", {}) if isinstance(request_data, dict) else {}
)
guardrail_response = metadata.pop("_openai_moderation_response", "allow")
Suggested change
metadata = (
request_data.get("metadata", {}) if isinstance(request_data, dict) else {}
)
# .pop() cleans up the internal key so it doesn't leak to downstream
# loggers. Falls back to "allow" when no moderation call was made
# (e.g. no text to moderate — early return in apply_guardrail).
guardrail_response = metadata.pop("_openai_moderation_response", "allow")
metadata = (
request_data.get("metadata") or {}
if isinstance(request_data, dict)
else {}
)
# .pop() cleans up the internal key so it doesn't leak to downstream
# loggers. Falls back to "allow" when no moderation call was made
# (e.g. no text to moderate — early return in apply_guardrail).
guardrail_response = metadata.pop("_openai_moderation_response", "allow")

@michelligabriele
michelligabriele force-pushed the fix/guardrail-post-call-logging branch from 0bc16b0 to 0f13c99 Compare March 17, 2026 22:47
Comment on lines +231 to +233
if isinstance(request_data, dict):
metadata = request_data.setdefault("metadata", {})
metadata["_openai_moderation_response"] = moderation_response.model_dump()

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 setdefault does not guard against metadata = None

dict.setdefault(key, default) only sets the key when it is absent. When request_data = {"metadata": None} the key is already present, so setdefault returns None — and the immediately following item-assignment metadata["_openai_moderation_response"] = ... raises TypeError: 'NoneType' object does not support item assignment.

This causes the guardrail to fail entirely (a TypeError bubbles up instead of the content-moderation result), which is the same class of defect that was fixed in _process_response / _process_error with or {}. The same guard is needed here:

# Current (fragile):
if isinstance(request_data, dict):
    metadata = request_data.setdefault("metadata", {})
    metadata["_openai_moderation_response"] = moderation_response.model_dump()
Suggested change
if isinstance(request_data, dict):
metadata = request_data.setdefault("metadata", {})
metadata["_openai_moderation_response"] = moderation_response.model_dump()
# Stash full moderation response in request_data for logging
# (Model Armor pattern — per-request dict avoids race conditions)
if isinstance(request_data, dict):
existing = request_data.get("metadata")
if not isinstance(existing, dict):
request_data["metadata"] = {}
request_data["metadata"]["_openai_moderation_response"] = moderation_response.model_dump()

This mirrors the or {} guard already used in _process_response (line 258) and _process_error (line 297), and the null-reset already performed in add_standard_logging_guardrail_information_to_request_data (custom_guardrail.py:627-628).

…ed for logging

Two independent bugs prevented post-call OpenAI Moderation guardrail
results from reaching downstream logging callbacks (Langfuse, Datadog).

Bug 1: process_output_response() created a throwaway request_data dict,
so guardrail info written by @log_guardrail_information was discarded.
Fixed by threading the real request_data from the unified guardrail
dispatcher through all 13 BaseTranslation handlers, with litellm_metadata
injection preserved for third-party guardrails (Zscaler, Prompt Security).
Also extended to process_output_streaming_response for consistency.

Bug 2: The @log_guardrail_information decorator collapsed the full
moderation API response (categories, scores, flagged status) to "allow".
Fixed by overriding _process_response/_process_error on
OpenAIModerationGuardrail to stash and log the full response, following
the established Model Armor pattern.
…kward compat, test coverage

- Pass request_data to end-of-stream process_output_streaming_response call
- Restore inputs.update() in OCR handler for third-party guardrail providers
- Add streaming end-to-end test for guardrail logging passthrough
@michelligabriele
michelligabriele force-pushed the fix/guardrail-post-call-logging branch from 05d8d7c to fa7ccf0 Compare March 23, 2026 14:43
@ghost
ghost enabled auto-merge March 23, 2026 15:15
@ghost
ghost self-requested a review March 23, 2026 16:21
@ghost
ghost disabled auto-merge March 23, 2026 16:21
@ghost
ghost merged commit 63425b4 into BerriAI:main Mar 23, 2026
38 of 39 checks passed
fzowl pushed a commit to fzowl/litellm that referenced this pull request Jun 24, 2026
…post-call-logging

fix(proxy): post-call guardrail response not captured for logging
This pull request was closed.
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.

1 participant