Skip to content

fix(realtime): guardrails with pre_call/post_call mode now work on realtime WebSocket - #22161

Merged
ishaan-jaff merged 3 commits into
mainfrom
worktree-radiant-weaving-forest
Feb 26, 2026
Merged

fix(realtime): guardrails with pre_call/post_call mode now work on realtime WebSocket#22161
ishaan-jaff merged 3 commits into
mainfrom
worktree-radiant-weaving-forest

Conversation

@ishaan-jaff

Copy link
Copy Markdown
Contributor

Relevant issues

Pre-Submission checklist

  • I have Added testing in the tests/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

CI (LiteLLM team)

  • Branch creation CI run
    Link:

  • CI run for the last commit
    Link:

  • Merge / cherry-pick CI run
    Links:

Type

🐛 Bug Fix

Changes

Guardrails configured with mode: pre_call or mode: post_call were silently ignored on the /v1/realtime WebSocket endpoint. Only guardrails with mode: realtime_input_transcription worked. This meant the standard content filter setup (e.g. email blocker with mode: pre_call, default_on: true) had no effect on realtime sessions.

What changed:

  • _has_realtime_guardrails() and run_realtime_guardrails() now check pre_call, post_call, and realtime_input_transcription — so any guardrail mode works
  • When a guardrail blocks text in conversation.item.create, the proxy now returns an {"type": "error", "error": {"type": "guardrail_violation", ...}} event directly to the WebSocket consumer, instead of asking the LLM to speak the error message via response.create
  • The blocked item is not forwarded to the backend; the client's subsequent response.create is swallowed
  • RealTimeStreaming takes an optional request_data param (passed from the OpenAI handler as {"litellm_metadata": ...}) so guardrail metadata (e.g. explicit guardrail lists) flows through correctly

Tests added (tests/test_litellm/litellm_core_utils/test_realtime_streaming.py):

  • test_realtime_text_input_guardrail_blocks_and_returns_error — verifies that a pre_call guardrail blocks conversation.item.create text, sends an error event to the client, and does not forward the item to the backend
  • test_realtime_text_input_guardrail_uses_pre_call_mode — verifies _has_realtime_guardrails() returns True for a pre_call guardrail
  • Updated test_realtime_guardrail_blocks_prompt_injection to match the new direct-error behavior (was checking that the LLM was asked to speak the error)

…altime WebSocket; return error directly to consumer
@vercel

vercel Bot commented Feb 26, 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 Feb 26, 2026 7:35am

Request Review

@greptile-apps

greptile-apps Bot commented Feb 26, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR fixes realtime WebSocket guardrails so that guardrails configured with mode: pre_call or mode: post_call now work on the /v1/realtime endpoint — previously only mode: realtime_input_transcription was recognized. The error handling is improved: blocked content now returns a structured {"type": "error", "error": {"type": "guardrail_violation", ...}} event directly to the client WebSocket instead of asking the LLM to speak the error message.

  • Broadened guardrail detection: _has_realtime_guardrails() now checks pre_call, post_call, and realtime_input_transcription modes; a new _has_audio_transcription_guardrails() method specifically gates the VAD session.update injection to only realtime_input_transcription guardrails
  • Direct error events: Blocked text now returns a guardrail_violation error to the client WebSocket, replacing the previous response.create + TTS workaround
  • Metadata plumbing: litellm_metadata (with guardrail lists) is now passed through to RealTimeStreaming via request_data for OpenAI, Azure, and XAI providers
  • Comprehensive tests added: 4 new test cases cover text input blocking, pre_call mode detection, audio guardrail session.update injection, and pre_call-only no-injection behavior

Confidence Score: 4/5

  • This PR is safe to merge with one semantic concern around post_call guardrails being applied to user input
  • The changes are well-structured with good separation between audio-path and text-path guardrail logic. Tests are thorough and mock-only. The main concern is that post_call guardrails (designed for response checking) are now also applied to user input text in the realtime context, which could cause unexpected behavior for guardrail authors. The provider metadata plumbing gaps (Bedrock, provider_config path) were already flagged in prior reviews.
  • litellm/litellm_core_utils/realtime_streaming.py — the run_realtime_guardrails method applies post_call guardrails to user input, which is a semantic mismatch worth reviewing

Important Files Changed

Filename Overview
litellm/litellm_core_utils/realtime_streaming.py Core change: broadened guardrail detection to include pre_call/post_call modes, split _has_realtime_guardrails from _has_audio_transcription_guardrails, changed error handling from LLM-spoken warnings to direct WebSocket error events. Well-structured with deduplication tracking via _already_run set.
litellm/llms/openai/realtime/handler.py Added litellm_metadata parameter to async_realtime and passes it through to RealTimeStreaming as request_data. Minimal, clean change.
litellm/llms/azure/realtime/handler.py Added user_api_key_dict and litellm_metadata parameters to async_realtime and passes them through to RealTimeStreaming. Import reorder is cosmetic. Clean change.
litellm/realtime_api/main.py Added _build_litellm_metadata helper and plumbed litellm_metadata through to Azure, OpenAI, and XAI handlers. Bedrock and provider_config path still missing this plumbing.
tests/test_litellm/litellm_core_utils/test_realtime_streaming.py Comprehensive test additions: text input guardrail blocking, pre_call mode detection, audio-only session.update injection, and pre_call-only no-injection. All use mocks, no real network calls. Existing tests updated for new error event behavior.

Flowchart

%%{init: {'theme': 'neutral'}}%%
flowchart TD
    A[Client WebSocket] -->|conversation.item.create| B{Has text content?}
    B -->|Yes| C[run_realtime_guardrails]
    B -->|No| D[Forward to Backend]
    C -->|Blocked| E["Send error event to client\n{type: guardrail_violation}"]
    C -->|Clean| D
    D --> F[Backend LLM]

    F -->|session.created| G{_has_audio_transcription_guardrails?}
    G -->|Yes| H["Forward session.created to client\nthen inject session.update\n{create_response: false}"]
    G -->|No| I[Forward session.created to client]

    F -->|transcription.completed| J[run_realtime_guardrails]
    J -->|Blocked| E
    J -->|Clean| K["Send response.create to Backend"]

    style E fill:#f96,stroke:#333
    style H fill:#ff9,stroke:#333
    style C fill:#9cf,stroke:#333
    style J fill:#9cf,stroke:#333
Loading

Last reviewed commit: 1d2445d

@greptile-apps greptile-apps 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.

5 files reviewed, 4 comments

Edit Code Review Agent Settings | Greptile

}
)
)
for event in events:

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.

Removal of create_response: false injection may cause double responses for audio/VAD path

The previous code injected session.update with create_response: false into the backend session when guardrails were registered. This prevented the LLM from auto-responding to VAD speech completions before the guardrail had a chance to run.

With this code removed, when server VAD detects speech completion, the backend will auto-generate a response (create_response defaults to true). Then, after the transcription arrives and guardrails pass, the proxy also sends a manual response.create (lines 383-384 in the provider_config path, and lines 415-417 in the raw path). This could result in two LLM responses for each clean audio transcription.

For the new text-input (conversation.item.create) path this isn't an issue since the client explicitly controls response.create. But for the existing audio transcription guardrail flow, this removal appears to be a regression. Consider keeping the create_response: false injection for audio-based guardrails, or gating the proxy's manual response.create on whether auto-response is already enabled.

Comment thread litellm/realtime_api/main.py Outdated
Comment on lines +153 to +157
# Build metadata for guardrail checking.
_litellm_metadata: dict = {**(kwargs.get("litellm_metadata") or {})}
_guardrails = (kwargs.get("metadata") or {}).get("guardrails") or kwargs.get("guardrails") or []
if _guardrails:
_litellm_metadata["guardrails"] = _guardrails

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.

Guardrail metadata only plumbed for OpenAI provider

The litellm_metadata (with guardrails list) is only constructed and passed in the openai branch. Other providers that use RealTimeStreaming (Azure, XAI) also support guardrails via the same class, but they don't receive litellm_metadata/request_data. This means non-default_on guardrails (those that need explicit guardrails: [...] in the request) won't be picked up for those providers.

If this is intentional (OpenAI-only for now), it might be worth a code comment. Otherwise, consider extracting the metadata build to a shared helper before the provider branches so all providers benefit.

Comment on lines +471 to +472
if msg_type == "response.create" and self._swallow_next_response_create:
self._swallow_next_response_create = False

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.

_swallow_next_response_create can lose track when multiple items are blocked consecutively

If the client sends two conversation.item.create messages that are both blocked in a row (without an intervening response.create), the flag is set to True twice. The next single response.create clears it, but the second response.create (for the second blocked item) will pass through to the backend, potentially triggering a response for content that was blocked.

Consider using a counter instead of a boolean:

Suggested change
if msg_type == "response.create" and self._swallow_next_response_create:
self._swallow_next_response_create = False
if msg_type == "response.create" and self._swallow_next_response_create:
self._swallow_next_response_create -= 1

(with corresponding change to set self._swallow_next_response_create += 1 on block, and initializing as 0 instead of False).

- Restore session.update injection for audio/VAD path, but only when
  realtime_input_transcription guardrails are configured (not pre_call).
  Forward session.created to the client first so no error arrives before
  the client sees the session.
- Change _swallow_next_response_create bool to int counter so consecutive
  blocked items are handled correctly.
- Extract _build_litellm_metadata() helper to eliminate duplicated
  metadata-building logic across OpenAI/Azure/XAI provider branches.
- Plumb litellm_metadata and user_api_key_dict to Azure and XAI handlers
  so guardrails work for those providers too.
- Add tests for session.update injection, no-inject for pre_call-only,
  and consecutive-block counter.
When an item is blocked, the error event is already sent to the client.
The subsequent response.create from the client is fine to forward through —
the LLM may respond to previous context which is acceptable behavior.
Removing the swallow counter eliminates unnecessary state tracking.
@ishaan-jaff

Copy link
Copy Markdown
Contributor Author

@greptile review again

Comment on lines +289 to 306
_realtime_event_types = [
GuardrailEventHooks.realtime_input_transcription,
GuardrailEventHooks.pre_call,
GuardrailEventHooks.post_call,
]
_check_data = {**self.request_data, "transcript": transcript}
_already_run: set = set()

for callback in litellm.callbacks:
if not isinstance(callback, CustomGuardrail):
continue
if (
callback.should_run_guardrail(
data={"transcript": transcript},
event_type=GuardrailEventHooks.realtime_input_transcription,
)
is not True
if id(callback) in _already_run:
continue
if not any(
callback.should_run_guardrail(data=_check_data, event_type=et)
for et in _realtime_event_types
):
continue

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.

post_call guardrails run on user input, not LLM output

run_realtime_guardrails checks all three event types (pre_call, post_call, realtime_input_transcription) when deciding whether to run a guardrail. This means a guardrail configured with mode: post_call — which is semantically meant to run on the LLM's response — will instead be called on the user's input text during both the conversation.item.create path and the audio transcription path.

A post_call guardrail author may have written detection logic that only makes sense for response content (e.g., checking for hallucinations, formatting violations, or PII in the assistant's reply). Running that same apply_guardrail on user input text could produce false positives or false negatives.

Consider either:

  1. Only matching pre_call and realtime_input_transcription for the input-side check (since those are semantically about guarding input), or
  2. Documenting that post_call guardrails will also gate user input in the realtime context so guardrail authors can account for it.

@ishaan-jaff
ishaan-jaff merged commit 819581f into main Feb 26, 2026
30 of 35 checks passed
Sameerlite pushed a commit that referenced this pull request Mar 3, 2026
…altime WebSocket (#22161)

* fix(realtime): guardrails with pre_call/post_call mode now work on realtime WebSocket; return error directly to consumer

* fix(realtime guardrails): address code review feedback

- Restore session.update injection for audio/VAD path, but only when
  realtime_input_transcription guardrails are configured (not pre_call).
  Forward session.created to the client first so no error arrives before
  the client sees the session.
- Change _swallow_next_response_create bool to int counter so consecutive
  blocked items are handled correctly.
- Extract _build_litellm_metadata() helper to eliminate duplicated
  metadata-building logic across OpenAI/Azure/XAI provider branches.
- Plumb litellm_metadata and user_api_key_dict to Azure and XAI handlers
  so guardrails work for those providers too.
- Add tests for session.update injection, no-inject for pre_call-only,
  and consecutive-block counter.

* simplify: remove response.create swallowing after guardrail block

When an item is blocked, the error event is already sent to the client.
The subsequent response.create from the client is fine to forward through —
the LLM may respond to previous context which is acceptable behavior.
Removing the swallow counter eliminates unnecessary state tracking.
fzowl pushed a commit to fzowl/litellm that referenced this pull request Jun 24, 2026
…altime WebSocket (BerriAI#22161)

* fix(realtime): guardrails with pre_call/post_call mode now work on realtime WebSocket; return error directly to consumer

* fix(realtime guardrails): address code review feedback

- Restore session.update injection for audio/VAD path, but only when
  realtime_input_transcription guardrails are configured (not pre_call).
  Forward session.created to the client first so no error arrives before
  the client sees the session.
- Change _swallow_next_response_create bool to int counter so consecutive
  blocked items are handled correctly.
- Extract _build_litellm_metadata() helper to eliminate duplicated
  metadata-building logic across OpenAI/Azure/XAI provider branches.
- Plumb litellm_metadata and user_api_key_dict to Azure and XAI handlers
  so guardrails work for those providers too.
- Add tests for session.update injection, no-inject for pre_call-only,
  and consecutive-block counter.

* simplify: remove response.create swallowing after guardrail block

When an item is blocked, the error event is already sent to the client.
The subsequent response.create from the client is fine to forward through —
the LLM may respond to previous context which is acceptable behavior.
Removing the swallow counter eliminates unnecessary state tracking.
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