mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-09 22:31:41 +00:00
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:
parent
70e1169989
commit
0e13cfabef
2 changed files with 43 additions and 14 deletions
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue