fix(gemini realtime): preserve sibling keys on empty toolCall no-op

Replace the early return on `functionCalls` empty/absent with a
`continue` plus a `tool_call_handled` flag that mirrors the existing
`server_content_handled` pattern. The post-loop guard already
distinguishes intentionally-consumed known keys from genuinely-unknown
messages, so adding `toolCall` to that exclusion list lets the loop
continue iterating over any sibling top-level keys in the same Gemini
frame instead of short-circuiting on the first empty toolCall.

In practice Gemini's protobuf places `toolCall`/`serverContent`/
`setupComplete` in a `oneof` so the only realistic sibling is
`usageMetadata` (already filtered as unknown-top-level), but the
uniform handling avoids silently discarding any future sibling key
should the wire format grow.
This commit is contained in:
mateo-berri 2026-05-23 01:58:30 +00:00
parent 70e1169989
commit 0e13cfabef
No known key found for this signature in database
2 changed files with 43 additions and 14 deletions

View file

@ -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 {

View file

@ -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()