feat(guardrails): streaming text transformation + compliance mode-match fix - #32084
Conversation
Codecov Report❌ Patch coverage is
📢 Thoughts on this report? Let us know! |
Greptile SummaryThis PR adds opt-in
Confidence Score: 4/5The streaming transform feature is safe to merge, but the claimed ComplianceChecker mode-matching fix is missing and compliance checks remain broken for list-valued guardrail modes. The streaming transform implementation is thoroughly tested and defaults to the unchanged block_only path, so it carries little regression risk. However, the PR description explicitly claims to fix ComplianceChecker._get_guardrails_by_mode for list and dict modes — a fix absent from the diff. The equality check litellm/proxy/compliance_checks.py — _get_guardrails_by_mode still uses plain equality and needs to be updated to handle list and dict guardrail_mode values as described in the PR.
|
| Filename | Overview |
|---|---|
| litellm/proxy/guardrails/guardrail_hooks/unified_guardrail/unified_guardrail.py | Adds the full incremental_diff streaming transform path (~420 lines): chunk accumulation, synthetic-delta emission, fail-closed underflow guard, A2A in-stream error handling, and tool-call passthrough with block inspection. Logic is well-structured and unit-tested; default block_only behavior is unchanged. |
| litellm/llms/openai/chat/guardrail_translation/handler.py | Splits process_output_streaming_response into block-only and transform paths; adds _process_streaming_transform and _accumulate_string_content_by_choice_index. StreamTransformSink out-parameter keeps responses_so_far immutable across rounds. Implementation looks correct. |
| litellm/llms/base_llm/guardrail_translation/base_translation.py | Adds StreamTransformSink dataclass and optional stream_transform_sink parameter to process_output_streaming_response; base implementation ignores the sink. Changes are additive and backward-compatible. |
| litellm/types/proxy/guardrails/guardrail_hooks/generic_guardrail_api.py | Adds streaming_transform_mode field to GenericGuardrailAPIOptionalParams, stream_holdback_chars to GenericGuardrailAPIResponse, and the coerce_stream_holdback_value helper. from_dict parsing is robust (malformed values degrade to 0). |
| litellm/proxy/guardrails/guardrail_hooks/generic_guardrail_api/generic_guardrail_api.py | Wires streaming_transform_mode into GenericGuardrailAPI.init and propagates stream_holdback_chars from guardrail response to return_inputs. Changes are additive. |
| litellm/proxy/compliance_checks.py | NOT modified by this PR, but the PR description claims a mode-matching fix was applied here. _get_guardrails_by_mode still uses plain equality (g_mode == mode) which silently drops list-valued guardrail_mode entries, leaving compliance checks reporting NON-COMPLIANT for those guardrails. |
| tests/test_litellm/proxy/guardrails/guardrail_hooks/unified_guardrails/test_unified_guardrail.py | Adds ~620 lines of unit tests for the streaming transform path covering incremental_diff, holdback, underflow, tool-call passthrough, per-choice finish_reason, A2A error emission, and end-of-stream-only; all use mocks with no real network calls. |
| tests/test_litellm/proxy/guardrails/guardrail_hooks/test_generic_guardrail_api.py | Adds tests for streaming_transform_mode defaults, GenericGuardrailAPIResponse.from_dict parsing of stream_holdback_chars (including malformed values), and apply_guardrail holdback propagation. All mocked, no network calls. |
Reviews (3): Last reviewed commit: "fix(guardrails): strip content from tool..." | Re-trigger Greptile
f01dc11 to
6e7aee8
Compare
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: 3 · PR risk: 0/10 |
|
If you require an HTTP guardrail that transforms the text for the review, you can use this one: https://github.com/PalenaAI/palena-litellm-pseudonymizer |
|
please address the veria comments. cc: @yucheng-berri for review |
|
bugbot run |
|
Hi, thanks for the effort here! I have a couple of comments:
|
The compliance_checks (bug)fix is also related to this PR. The PR’s main objective is to implement the missing streaming support for text transformation guardrails. If we utilize such guardrails, it’s highly likely that we’ll apply them to both pre- and post-call processes. However, due to the compliance_checks bug, the compliance validator cannot effectively handle these use cases without this fix. Consequently, the UI will incorrectly mark every such request as non-compliant with GDPR and the EU AI Act. The compliance_checks function compares an array with a string. It works fine if only one mode is selected. However, if the user adds a guardrail with more than one mode, the compliance_checks string comparison will fail. |
|
@cursoragent review |
|
Unable to authenticate your request. Please make sure to connect your GitHub account to Cursor. Go to Cursor |
|
bugbot run |
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes using high effort and found 2 potential issues.
❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.
Reviewed by Cursor Bugbot for commit 22375d6. Configure here.
|
Thanks for getting back. I’m not comfortable approving yet though, since the live-proxy run is pending and I don’t see any follow-up proof. For a streaming guardrail feature, mock tests aren’t enough; we should see an actual proxy run showing the client only receives the transformed text and not the raw sensitive text. Also, can you add more targeted tests for ComplianceChecker as well. Thanks |
|
@yucheng-berri Thanks for getting back to me! I’ve taken care of your request about ComplianceChecker targeted tests and also resolved the issue BugBot pointed out. You’ll find some extra information below that might be helpful as you review it. However, since we’re a bit short on time, if this isn’t quite enough, I’ll need to create a custom build myself with these changes. Live proxy run — streaming transform, chunk-by-chunkRan against a real Assertions on the client-visible stream:
The very first chunk is already Fail-closed is also exercised: with the guardrail's detector unreachable, the proxy
|
|
Hi, thanks for adding the live test results. I have a couple of remaining concerns I’d like to understand:
Could you share your thinking on these cases? I’d like to understand whether these are intended limitations of Also, it would be great if the |
… streaming transform
…call passthrough, n>1 finish_reason)
3e725cb to
d895afa
Compare
|
@yucheng-berri Thanks for the careful review. My thinking on each: 1. Mid-stream underflow re-raise This matches the existing 2. Tool-call chunks skipping guardrail inspection You are right, that was a real gap versus One nuance worth flagging: like 3. Splitting compliance_checks Will do. I am moving the |
… text can't bypass the transform
|
merged in #33110, please follow up on this. Thanks! |
Thank you so much to @yucheng-berri and @mateo-berri |

Relevant issues
Linear ticket
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
Pending a live-proxy run. To exercise it end to end, point a
generic_guardrail_apiguardrail withstreaming_transform_mode: incremental_diffat a real HTTP guardrail service that returnsaction: GUARDRAIL_INTERVENEDwith modifiedtexts(and optionallystream_holdback_chars), then stream a/v1/chat/completionsrequest against a real provider and confirm the client receives the transformed deltas whose concatenation equals the guardrailed textType
🆕 New Feature
🐛 Bug Fix
Changes
This PR contains two changes. The primary one is the streaming text-transformation feature; the second is a small, self-contained fix to
ComplianceCheckerthat surfaced while testing the feature against a mode-based compliance setup. They are described separately below and can be split into their own commits if a reviewer would prefer thatStreaming text transformation (feature)
LiteLLM's HTTP guardrail protocol is bidirectional: a guardrail service can return
action: GUARDRAIL_INTERVENEDwith a modifiedtextslist, and the non-streamingchat/completionspost-call path already writes those texts back before returning to the client. The streaming path did not propagate them. It accumulated the mutated text into the response accumulator but always yielded the raw pre-mutation chunk, so any guardrail whose job is to rewrite text (PII masking, pseudonym reversal, redaction, translation) could not affect a streamed response over HTTP; only the in-processcustom_guardrailon_stream_*hooks couldThis adds an opt-in
streaming_transform_modewith two values.block_onlyis the default and preserves today's behavior exactly.incremental_diffwithholds the raw chunks and instead emits the guardrailed accumulated text as new deltas, computed by diffing the mutated text against what has already been sent to the clientHow it works:
streaming_transform_modelives onGenericGuardrailAPIOptionalParams, is read fromguardrail_configandoptional_paramslike the siblingstreaming_*flags, and is forwarded throughinitialize_guardrail. On each sampled round the guardrail runs over the raw accumulated text (the accumulator is never mutated in place, so a rewrite guardrail always sees consistent input), keyed byStreamingChoices.indexson > 1completions stay correct, and the hook emits only the newly guardrailed portion per choice. A newstream_holdback_charsfield on the guardrail response (indexed liketexts) lets the guardrail withhold a number of trailing chars until the next round, so a pseudonym like "Thomas We" is never emitted before "Weber" fully arrives; the framework carries the holdback forward and forces it to 0 on the end-of-stream flush. The final flush chunk carries each choice's ownfinish_reasonfrom the last raw chunk to preserve OpenAI wire semantics, and a terminal chunk is guardrailed once (by the flush) rather than twiceThe path fails closed rather than silently leaking. If a transform is not a forward extension of what was already streamed (shorter than, or rewriting, the emitted prefix), the framework raises
HTTPException(400, stream_transform_underflow)because emitted bytes cannot be retracted; for A2A call types this surfaces as an in-stream JSON-RPC error. Synthetic chunks also droptool_calls, since v1 does not transform streamed tool calls and passing the raw upstream ones through would bypass the guardrailThe guardrailed text and requested holdback travel from the handler back to the hook through a typed
StreamTransformSinkout-parameter onprocess_output_streaming_response, rather than by mutatingresponses_so_far(which must stay a correct raw accumulator) or changing the method's return type (several existing handler tests assert on it)Scope: this v1 targets the OpenAI chat completions streaming path with string
delta.contentonly. Non-OpenAI translation handlers (Anthropic, Gemini), list-of-blocks content, and image or tool-call streaming transforms remainblock_onlyand are candidates for follow-up PRs.incremental_diffis gated to the OpenAI chat handler via the resolved request route; any other surface logs a warning and falls back toblock_onlyComplianceChecker mode matching (fix)
A guardrail's
guardrail_modecan be astr, alist, or a tag-baseddict, but the mode matcher compared it to the target mode with==, so a guardrail configured withmode: [pre_call, post_call]matched no mode and every mode-based compliance check reported NON-COMPLIANT. Mode matching now handles all of those shapesTests
Unit tests cover
block_onlydropping rewrites,incremental_diffemitting transformed deltas whose concatenation equals the full guardrailed text, holdback boundary behavior with no loss or duplication, the fail-closed underflow and prefix-rewrite cases, per-choicefinish_reasonpreservation forn > 1, tool_calls being dropped from synthetic chunks, the guardrail always receiving the raw accumulated text, index-based accumulation for non-zero choice indices, a terminal chunk not being guardrailed twice, the end-of-stream-only single-chunk path, unsupported-route fallback, andstream_holdback_charsparsing (including malformed values degrading to 0) onGenericGuardrailAPIResponse.from_dict.make lintpasses with zero net budget additionsDocs: a follow-up should add a "Streaming text transformations" section to the generic guardrail API docs page documenting
streaming_transform_mode,stream_holdback_chars, the diff-based emission semantics, and the fail-closed ruleNote
Medium Risk
Touches live streaming response paths and guardrail wire semantics; misconfiguration or guardrail rewrites that shrink/revise already-emitted text can error mid-stream, though defaults preserve prior behavior.
Overview
Adds opt-in
streaming_transform_mode: incremental_diffso HTTP guardrails can stream rewritten text (PII masking, redaction, etc.) on OpenAI chat completions: raw chunks are withheld, guardrails run on the raw accumulator viaStreamTransformSink, and the client gets synthetic deltas diffed against what was already sent, with optionalstream_holdback_charsfor word-boundary safety. Defaultblock_onlyis unchanged. The path fails closed on non-forward transforms (stream_transform_underflow), drops streamedtool_callsfrom synthetic chunks, and falls back to block-only when the route isn’t OpenAI chat streaming.Separately fixes
ComplianceCheckerso guardrails logged withguardrail_modeas a list or tag-based dict count toward mode-based compliance (previously list modes never matched).Reviewed by Cursor Bugbot for commit 22375d6. Bugbot is set up for automated code reviews on this repo. Configure here.