From 84791fc3f5543b9c7ccf19dbeac92e47caad1f7b Mon Sep 17 00:00:00 2001 From: mateo-berri <277851410+mateo-berri@users.noreply.github.com> Date: Fri, 22 May 2026 23:54:40 +0000 Subject: [PATCH] fix(gemini realtime): tolerate sibling-only frames (e.g. standalone usageMetadata) A Gemini Live frame that contains only metadata keys outside _KNOWN_GEMINI_TOP_LEVEL_KEYS (e.g. a bare {"usageMetadata": {...}} emitted between turns) leaves returned_message empty after the transform loop and was tripping the 'Unknown message type' guard, which raised ValueError and terminated the WebSocket session. Treat such frames as no-ops and return the unchanged state instead. --- .../llms/gemini/realtime/transformation.py | 17 ++++++++ .../test_gemini_realtime_transformation.py | 40 +++++++++++++++++++ 2 files changed, 57 insertions(+) diff --git a/litellm/llms/gemini/realtime/transformation.py b/litellm/llms/gemini/realtime/transformation.py index 3f09c19d3d2..9a13e4f96d5 100644 --- a/litellm/llms/gemini/realtime/transformation.py +++ b/litellm/llms/gemini/realtime/transformation.py @@ -1528,6 +1528,23 @@ class GeminiRealtimeConfig(BaseRealtimeConfig): else: raise ValueError(f"Unknown openai event: {openai_event}") if len(returned_message) == 0: + # A frame whose only top-level keys are sibling metadata (e.g. + # a standalone ``{"usageMetadata": {...}}`` emitted by Gemini + # Live between turns) is not an error — there is just nothing + # to forward to the OpenAI-shaped client. Returning the + # unchanged state keeps the WebSocket alive; raising would + # terminate the session for a benign no-op frame. + if not any(key in _KNOWN_GEMINI_TOP_LEVEL_KEYS for key in json_message): + return { + "response": returned_message, + "current_output_item_id": current_output_item_id, + "current_response_id": current_response_id, + "current_delta_chunks": current_delta_chunks, + "current_conversation_id": current_conversation_id, + "current_item_chunks": current_item_chunks, + "current_delta_type": current_delta_type, + "session_configuration_request": session_configuration_request, + } if isinstance(message, bytes): message_str = message.decode("utf-8", errors="replace") else: 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 c386845ce90..1d0ae30f275 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 @@ -1145,3 +1145,43 @@ def test_gemini_tool_call_id_to_name_evicts_oldest_when_capped(): "call_6", "call_7", ] + + +def test_gemini_standalone_usage_metadata_does_not_crash_websocket(): + """A Gemini frame containing only sibling metadata (e.g. a standalone + ``usageMetadata`` block emitted between turns) must not trip the + ``Unknown message type`` guard — that would terminate the WebSocket + session on a benign no-op frame.""" + config = GeminiRealtimeConfig() + logging_obj = MagicMock() + logging_obj.litellm_trace_id = "trace_usage_only" + + result = config.transform_realtime_response( + json.dumps( + { + "usageMetadata": { + "promptTokenCount": 12, + "responseTokenCount": 34, + "totalTokenCount": 46, + } + } + ), + "gemini-2.5-flash", + logging_obj, + realtime_response_transform_input={ + "session_configuration_request": None, + "current_output_item_id": "item_existing", + "current_response_id": "resp_existing", + "current_conversation_id": "conv_existing", + "current_delta_chunks": [], + "current_item_chunks": [], + "current_delta_type": None, + }, + ) + + assert result["response"] == [] + # State must be returned unchanged so subsequent frames continue the + # in-flight response correctly. + assert result["current_output_item_id"] == "item_existing" + assert result["current_response_id"] == "resp_existing" + assert result["current_conversation_id"] == "conv_existing"