From 154aeeba27cff0716c6a8b61808b55106eff0d6d Mon Sep 17 00:00:00 2001 From: mateo-berri <277851410+mateo-berri@users.noreply.github.com> Date: Thu, 16 Jul 2026 15:24:55 -0700 Subject: [PATCH] fix(gemini): tolerate generationComplete with no content deltas in realtime bridge A Gemini Live generationComplete arriving before any modelTurn deltas (toolCall-only or empty generations) raised ValueError('Unexpected delta type: None'), and the caller dropped the entire backend frame. This fired on every gemini/vertex realtime tool-call turn and intermittently stalled the vertex server-VAD path (LIT-4482). map_openai_event now returns None for that case and the transform marks the frame handled instead of raising. --- .../llms/gemini/realtime/transformation.py | 13 +++++++- .../test_gemini_realtime_transformation.py | 31 +++++++++++++++++++ 2 files changed, 43 insertions(+), 1 deletion(-) diff --git a/litellm/llms/gemini/realtime/transformation.py b/litellm/llms/gemini/realtime/transformation.py index bc2145fd832..ccf759930ec 100644 --- a/litellm/llms/gemini/realtime/transformation.py +++ b/litellm/llms/gemini/realtime/transformation.py @@ -1103,7 +1103,11 @@ class GeminiRealtimeConfig(BaseRealtimeConfig): key: str, value: Any, current_delta_type: Optional[ALL_DELTA_TYPES], - ) -> Union[OpenAIRealtimeEventTypes, ResponsesAPIStreamEvents]: + ) -> Union[OpenAIRealtimeEventTypes, ResponsesAPIStreamEvents, None]: + """Map a Gemini top-level key to the OpenAI event it produces. Returns None + for a ``generationComplete`` with no preceding content deltas (a toolCall-only + or empty generation): there is no text/audio "done" to emit for it, and + raising here used to make the caller drop the entire backend frame.""" if isinstance(value, dict): model_turn_event = value.get("modelTurn") generation_complete_event = value.get("generationComplete") @@ -1114,6 +1118,8 @@ class GeminiRealtimeConfig(BaseRealtimeConfig): if model_turn_event: # check if model turn event openai_event = self.map_model_turn_event(model_turn_event) elif generation_complete_event: + if current_delta_type is None: + return None openai_event = self.map_generation_complete_event(delta_type=current_delta_type) else: # Check if this key or any nested key matches our mapping. Use a @@ -1237,6 +1243,7 @@ class GeminiRealtimeConfig(BaseRealtimeConfig): server_content_handled = False tool_call_handled = False + generation_complete_handled = False for key, value in list(json_message.items()): # snapshot: handlers may mutate json_message # Skip sibling metadata keys (e.g. ``usageMetadata``) that can # accompany a primary payload like ``toolCall`` or ``serverContent``. @@ -1255,6 +1262,9 @@ class GeminiRealtimeConfig(BaseRealtimeConfig): value=value, current_delta_type=current_delta_type, ) + if openai_event is None: + generation_complete_handled = True + continue if openai_event == OpenAIRealtimeEventTypes.SESSION_CREATED: transformed_message = self.transform_session_created_event( @@ -1498,6 +1508,7 @@ class GeminiRealtimeConfig(BaseRealtimeConfig): for key in json_message if key in _KNOWN_GEMINI_TOP_LEVEL_KEYS and not (key == "serverContent" and server_content_handled) + and not (key == "serverContent" and generation_complete_handled) and not (key == "toolCall" and tool_call_handled) ] standalone_usage_metadata = json_message.get("usageMetadata") diff --git a/tests/test_litellm/llms/gemini/realtime/test_gemini_realtime_transformation.py b/tests/test_litellm/llms/gemini/realtime/test_gemini_realtime_transformation.py index 02004d5c8a8..3c738dbc8b0 100644 --- a/tests/test_litellm/llms/gemini/realtime/test_gemini_realtime_transformation.py +++ b/tests/test_litellm/llms/gemini/realtime/test_gemini_realtime_transformation.py @@ -311,6 +311,37 @@ def test_gemini_realtime_transformation_generation_complete(): assert contains_audio_done_event, "Expected audio done event" +def test_gemini_realtime_transformation_generation_complete_without_deltas(): + config = GeminiRealtimeConfig() + + session_configuration_request_str = json.dumps( + { + "setup": { + "model": "gemini-1.5-flash", + "generationConfig": {"responseModalities": ["AUDIO"]}, + } + } + ) + + result = config.transform_realtime_response( + json.dumps({"serverContent": {"generationComplete": True}}), + "gemini-1.5-flash", + MagicMock(), + realtime_response_transform_input={ + "session_configuration_request": session_configuration_request_str, + "current_output_item_id": None, + "current_response_id": None, + "current_conversation_id": None, + "current_delta_chunks": [], + "current_item_chunks": [], + "current_delta_type": None, + }, + ) + + assert result["response"] == [] + assert result["current_delta_type"] is None + + def test_gemini_3_1_flash_live_preview_model_cost_map_entry(): for key in ( "gemini-3.1-flash-live-preview",