-
-
Notifications
You must be signed in to change notification settings - Fork 11.6k
fix(realtime): guardrails with pre_call/post_call mode now work on realtime WebSocket #22161
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -43,6 +43,7 @@ def __init__( | |
| provider_config: Optional[BaseRealtimeConfig] = None, | ||
| model: str = "", | ||
| user_api_key_dict: Optional[Any] = None, | ||
| request_data: Optional[Dict] = None, | ||
| ): | ||
| self.websocket = websocket | ||
| self.backend_ws = backend_ws | ||
|
|
@@ -68,6 +69,7 @@ def __init__( | |
| self.current_delta_type: Optional[ALL_DELTA_TYPES] = None | ||
| self.session_configuration_request: Optional[str] = None | ||
| self.user_api_key_dict = user_api_key_dict | ||
| self.request_data: Dict = request_data or {} | ||
|
|
||
| def _should_store_message( | ||
| self, | ||
|
|
@@ -231,14 +233,40 @@ async def _send_to_backend(self, message: str) -> None: | |
| await self.backend_ws.send(message) | ||
|
|
||
| def _has_realtime_guardrails(self) -> bool: | ||
| """Return True if any callback is registered for realtime_input_transcription.""" | ||
| """Return True if any callback is registered for realtime guardrail event types.""" | ||
| from litellm.integrations.custom_guardrail import CustomGuardrail | ||
| from litellm.types.guardrails import GuardrailEventHooks | ||
|
|
||
| _realtime_event_types = [ | ||
| GuardrailEventHooks.realtime_input_transcription, | ||
| GuardrailEventHooks.pre_call, | ||
| GuardrailEventHooks.post_call, | ||
| ] | ||
| return any( | ||
| isinstance(cb, CustomGuardrail) | ||
| and any( | ||
| cb.should_run_guardrail( | ||
| data=self.request_data, | ||
| event_type=et, | ||
| ) | ||
| for et in _realtime_event_types | ||
| ) | ||
| for cb in litellm.callbacks | ||
| ) | ||
|
|
||
| def _has_audio_transcription_guardrails(self) -> bool: | ||
| """Return True if any callback needs to run on audio transcriptions (VAD path). | ||
|
|
||
| When this returns True, we inject a session.update to disable the LLM's | ||
| auto-response so the guardrail can gate it first. | ||
| """ | ||
| from litellm.integrations.custom_guardrail import CustomGuardrail | ||
| from litellm.types.guardrails import GuardrailEventHooks | ||
|
|
||
| return any( | ||
| isinstance(cb, CustomGuardrail) | ||
| and cb.should_run_guardrail( | ||
| data={}, | ||
| data=self.request_data, | ||
| event_type=GuardrailEventHooks.realtime_input_transcription, | ||
| ) | ||
| for cb in litellm.callbacks | ||
|
|
@@ -258,17 +286,25 @@ async def run_realtime_guardrails( | |
| from litellm.integrations.custom_guardrail import CustomGuardrail | ||
| from litellm.types.guardrails import GuardrailEventHooks | ||
|
|
||
| _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 | ||
| _already_run.add(id(callback)) | ||
| try: | ||
| await callback.apply_guardrail( | ||
| inputs={"texts": [transcript], "images": []}, | ||
|
|
@@ -293,20 +329,15 @@ async def run_realtime_guardrails( | |
| safe_msg = str(detail) | ||
| else: | ||
| safe_msg = str(e) or "I'm sorry, that request was blocked by the content filter." | ||
| # Cancel any in-flight response before speaking the warning. | ||
| # This handles the race where create_response fired before we could intercept. | ||
| await self._send_to_backend(json.dumps({"type": "response.cancel"})) | ||
| # Ask the model to speak the warning — TTS audio plays naturally in the client | ||
| await self._send_to_backend( | ||
| # Return the error directly to the WebSocket consumer. | ||
| await self.websocket.send_text( | ||
| json.dumps( | ||
| { | ||
| "type": "response.create", | ||
| "response": { | ||
| "modalities": ["text", "audio"], | ||
| "instructions": ( | ||
| f"Say exactly and only: \"{safe_msg}\". " | ||
| "Do not add anything else." | ||
| ), | ||
| "type": "error", | ||
| "error": { | ||
| "type": "guardrail_violation", | ||
| "message": safe_msg, | ||
| "code": "content_policy_violation", | ||
| }, | ||
| } | ||
| ) | ||
|
|
@@ -348,25 +379,25 @@ async def _handle_provider_config_message(self, raw_response) -> None: | |
| if isinstance(transformed_response, list) | ||
| else [transformed_response] | ||
| ) | ||
| for event in events: | ||
| ## GUARDRAIL: inject create_response=false on session.created | ||
| if isinstance(event, dict) and event.get("type") == "session.created": | ||
| if self._has_realtime_guardrails(): | ||
| await self._send_to_backend( | ||
| json.dumps( | ||
| { | ||
| "type": "session.update", | ||
| "session": { | ||
| "turn_detection": { | ||
| "type": "server_vad", | ||
| "create_response": False, | ||
| } | ||
| }, | ||
| } | ||
| ) | ||
| ) | ||
| for event in events: | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Removal of The previous code injected With this code removed, when server VAD detects speech completion, the backend will auto-generate a response ( For the new text-input ( |
||
| event_str = json.dumps(event) | ||
| ## For audio/VAD guardrail path: forward session.created first, then inject. | ||
| if ( | ||
| isinstance(event, dict) | ||
| and event.get("type") == "session.created" | ||
| and self._has_audio_transcription_guardrails() | ||
| ): | ||
| self.store_message(event_str) | ||
| await self.websocket.send_text(event_str) | ||
| await self._send_to_backend( | ||
| json.dumps( | ||
| { | ||
| "type": "session.update", | ||
| "session": {"turn_detection": {"create_response": False}}, | ||
| } | ||
| ) | ||
| ) | ||
| continue | ||
| ## GUARDRAIL: run on transcription events in provider_config path too | ||
| if ( | ||
| isinstance(event, dict) | ||
|
|
@@ -397,27 +428,26 @@ async def _handle_raw_backend_message(self, raw_response) -> bool: | |
| try: | ||
| event_obj = json.loads(raw_response) | ||
|
|
||
| if event_obj.get("type") == "session.created": | ||
| # If any realtime guardrails are registered, proactively | ||
| # set create_response=false so the LLM never auto-responds | ||
| # before our guardrail has a chance to run. | ||
| if self._has_realtime_guardrails(): | ||
| await self._send_to_backend( | ||
| json.dumps( | ||
| { | ||
| "type": "session.update", | ||
| "session": { | ||
| "turn_detection": { | ||
| "type": "server_vad", | ||
| "create_response": False, | ||
| } | ||
| }, | ||
| } | ||
| ) | ||
| ) | ||
| verbose_logger.debug( | ||
| "[realtime guardrail] injected create_response=false into session" | ||
| # For audio/VAD guardrail path: once the session is ready, tell the backend | ||
| # not to auto-respond after VAD detects end-of-speech. We send the | ||
| # session.created to the client FIRST so the client is always in sync, then | ||
| # inject the session.update so a potential error from the backend doesn't | ||
| # arrive before the client sees session.created. | ||
| if ( | ||
| event_obj.get("type") == "session.created" | ||
| and self._has_audio_transcription_guardrails() | ||
| ): | ||
| self.store_message(raw_response) | ||
| await self.websocket.send_text(raw_response) | ||
| await self._send_to_backend( | ||
| json.dumps( | ||
| { | ||
| "type": "session.update", | ||
| "session": {"turn_detection": {"create_response": False}}, | ||
| } | ||
| ) | ||
| ) | ||
| return True | ||
|
|
||
| if ( | ||
| event_obj.get("type") | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
post_callguardrails run on user input, not LLM outputrun_realtime_guardrailschecks all three event types (pre_call,post_call,realtime_input_transcription) when deciding whether to run a guardrail. This means a guardrail configured withmode: 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 theconversation.item.createpath and the audio transcription path.A
post_callguardrail 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 sameapply_guardrailon user input text could produce false positives or false negatives.Consider either:
pre_callandrealtime_input_transcriptionfor the input-side check (since those are semantically about guarding input), orpost_callguardrails will also gate user input in the realtime context so guardrail authors can account for it.