diff --git a/litellm/llms/gemini/realtime/transformation.py b/litellm/llms/gemini/realtime/transformation.py index c31bf4578a0..48ae79bfbe9 100644 --- a/litellm/llms/gemini/realtime/transformation.py +++ b/litellm/llms/gemini/realtime/transformation.py @@ -1276,6 +1276,7 @@ class GeminiRealtimeConfig(BaseRealtimeConfig): else: server_content_handled = False + tool_call_handled = False for key, value in json_message.items(): # Skip sibling metadata keys (e.g. ``usageMetadata``) that can # accompany a primary payload like ``toolCall`` or ``serverContent``. @@ -1306,21 +1307,13 @@ class GeminiRealtimeConfig(BaseRealtimeConfig): # Handle toolCall from Gemini. If the payload has no function # calls, emit nothing — an orphaned response.created/done pair # with no output items would confuse OpenAI-compatible clients. - # Return rather than ``continue`` so a toolCall that is the - # only key in the message doesn't leave ``returned_message`` - # empty and trip the "Unknown message type" guard below - # (which would terminate the WebSocket session). + # Mark the key as intentionally consumed (mirroring + # ``server_content_handled``) so any sibling keys in the same + # frame are still processed by the rest of the loop and the + # post-loop guard doesn't treat the no-op as fatal. if not value.get("functionCalls"): - 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, - } + tool_call_handled = True + continue if current_conversation_id is None: current_conversation_id = f"conv_{uuid.uuid4()}" @@ -1565,6 +1558,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 == "toolCall" and tool_call_handled) ] if not unhandled_known_keys: return { 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 d1e596f1359..122eb3fe69a 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 @@ -870,6 +870,41 @@ def test_gemini_empty_tool_call_does_not_crash_websocket(): assert result["current_output_item_id"] is None +def test_gemini_empty_tool_call_with_sibling_usage_metadata_does_not_crash(): + """A toolCall with empty functionCalls alongside a sibling key (e.g. + ``usageMetadata``) must still be handled as a benign no-op: the empty + toolCall is consumed and the metadata sibling is skipped, without + raising ``Unknown message type``.""" + config = GeminiRealtimeConfig() + logging_obj = MagicMock() + logging_obj.litellm_trace_id = "trace_empty_tool_call_with_sibling" + + result = config.transform_realtime_response( + json.dumps( + { + "toolCall": {"functionCalls": []}, + "usageMetadata": {"totalTokenCount": 7}, + } + ), + "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"] == [] + # In-flight response IDs must survive the benign no-op. + assert result["current_response_id"] == "resp_existing" + assert result["current_output_item_id"] == "item_existing" + + def test_gemini_function_call_output_includes_name(): """Verify function_call_output includes name field from stored mapping.""" config = GeminiRealtimeConfig()