fix(realtime): guardrails with pre_call/post_call mode now work on realtime WebSocket - #22161
Conversation
…altime WebSocket; return error directly to consumer
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
Greptile SummaryThis PR fixes realtime WebSocket guardrails so that guardrails configured with
Confidence Score: 4/5
|
| 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
Last reviewed commit: 1d2445d
| } | ||
| ) | ||
| ) | ||
| for event in events: |
There was a problem hiding this comment.
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.
| # 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 |
There was a problem hiding this comment.
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.
| if msg_type == "response.create" and self._swallow_next_response_create: | ||
| self._swallow_next_response_create = False |
There was a problem hiding this comment.
_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:
| 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.
|
@greptile review again |
| _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 |
There was a problem hiding this comment.
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:
- Only matching
pre_callandrealtime_input_transcriptionfor the input-side check (since those are semantically about guarding input), or - Documenting that
post_callguardrails will also gate user input in the realtime context so guardrail authors can account for it.
…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.
…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.
Relevant issues
Pre-Submission checklist
tests/litellm/directory, Adding at least 1 test is a hard requirement - see detailsmake test-unitCI (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_callormode: post_callwere silently ignored on the/v1/realtimeWebSocket endpoint. Only guardrails withmode: realtime_input_transcriptionworked. This meant the standard content filter setup (e.g. email blocker withmode: pre_call, default_on: true) had no effect on realtime sessions.What changed:
_has_realtime_guardrails()andrun_realtime_guardrails()now checkpre_call,post_call, andrealtime_input_transcription— so any guardrail mode worksconversation.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 viaresponse.createresponse.createis swallowedRealTimeStreamingtakes an optionalrequest_dataparam (passed from the OpenAI handler as{"litellm_metadata": ...}) so guardrail metadata (e.g. explicit guardrail lists) flows through correctlyTests added (
tests/test_litellm/litellm_core_utils/test_realtime_streaming.py):test_realtime_text_input_guardrail_blocks_and_returns_error— verifies that apre_callguardrail blocksconversation.item.createtext, sends an error event to the client, and does not forward the item to the backendtest_realtime_text_input_guardrail_uses_pre_call_mode— verifies_has_realtime_guardrails()returns True for apre_callguardrailtest_realtime_guardrail_blocks_prompt_injectionto match the new direct-error behavior (was checking that the LLM was asked to speak the error)