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.
This commit is contained in:
mateo-berri 2026-07-16 15:24:55 -07:00
parent 5961c173e1
commit 154aeeba27
2 changed files with 43 additions and 1 deletions

View file

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

View file

@ -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",