Add (relatively spartan) reconnection logic to GeminiMultimodalLiveLLMService, leveraging the Gemini Live session resumption mechanism
This commit is contained in:
@@ -14,6 +14,7 @@ voice transcription, streaming responses, and tool usage.
|
|||||||
import base64
|
import base64
|
||||||
import io
|
import io
|
||||||
import json
|
import json
|
||||||
|
import random
|
||||||
import time
|
import time
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from enum import Enum
|
from enum import Enum
|
||||||
@@ -98,6 +99,7 @@ try:
|
|||||||
Part,
|
Part,
|
||||||
ProactivityConfig,
|
ProactivityConfig,
|
||||||
RealtimeInputConfig,
|
RealtimeInputConfig,
|
||||||
|
SessionResumptionConfig,
|
||||||
SlidingWindow,
|
SlidingWindow,
|
||||||
SpeechConfig,
|
SpeechConfig,
|
||||||
StartSensitivity,
|
StartSensitivity,
|
||||||
@@ -618,6 +620,12 @@ class GeminiMultimodalLiveLLMService(LLMService):
|
|||||||
)
|
)
|
||||||
self._vad_params = params.vad
|
self._vad_params = params.vad
|
||||||
|
|
||||||
|
# Reconnection tracking
|
||||||
|
self._consecutive_failures = 0
|
||||||
|
self._max_consecutive_failures = 3
|
||||||
|
self._connection_established_threshold = 10.0 # seconds
|
||||||
|
self._connection_start_time = None
|
||||||
|
|
||||||
self._settings = {
|
self._settings = {
|
||||||
"frequency_penalty": params.frequency_penalty,
|
"frequency_penalty": params.frequency_penalty,
|
||||||
"max_tokens": params.max_tokens,
|
"max_tokens": params.max_tokens,
|
||||||
@@ -645,6 +653,9 @@ class GeminiMultimodalLiveLLMService(LLMService):
|
|||||||
self._search_result_buffer = ""
|
self._search_result_buffer = ""
|
||||||
self._accumulated_grounding_metadata = None
|
self._accumulated_grounding_metadata = None
|
||||||
|
|
||||||
|
# Session resumption
|
||||||
|
self._session_resumption_handle: Optional[str] = None
|
||||||
|
|
||||||
def _create_client(self, api_key: str, http_options: Optional[HttpOptions] = None):
|
def _create_client(self, api_key: str, http_options: Optional[HttpOptions] = None):
|
||||||
self._client = Client(api_key=api_key, http_options=http_options)
|
self._client = Client(api_key=api_key, http_options=http_options)
|
||||||
|
|
||||||
@@ -850,14 +861,19 @@ class GeminiMultimodalLiveLLMService(LLMService):
|
|||||||
else:
|
else:
|
||||||
await self.push_frame(frame, direction)
|
await self.push_frame(frame, direction)
|
||||||
|
|
||||||
async def _connect(self):
|
async def _connect(self, session_resumption_handle: Optional[str] = None):
|
||||||
"""Establish client connection to Gemini Live API."""
|
"""Establish client connection to Gemini Live API."""
|
||||||
if self._session:
|
if self._session:
|
||||||
# Here we assume that if we have a client, we are connected. We
|
# Here we assume that if we have a client, we are connected. We
|
||||||
# handle disconnections in the send/recv code paths.
|
# handle disconnections in the send/recv code paths.
|
||||||
return
|
return
|
||||||
|
|
||||||
logger.info("Connecting to Gemini service")
|
if session_resumption_handle:
|
||||||
|
logger.info(
|
||||||
|
f"Connecting to Gemini service with session_resumption_handle: {session_resumption_handle}"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
logger.info("Connecting to Gemini service")
|
||||||
try:
|
try:
|
||||||
# Assemble basic configuration
|
# Assemble basic configuration
|
||||||
config = LiveConnectConfig(
|
config = LiveConnectConfig(
|
||||||
@@ -879,6 +895,7 @@ class GeminiMultimodalLiveLLMService(LLMService):
|
|||||||
),
|
),
|
||||||
input_audio_transcription=AudioTranscriptionConfig(),
|
input_audio_transcription=AudioTranscriptionConfig(),
|
||||||
output_audio_transcription=AudioTranscriptionConfig(),
|
output_audio_transcription=AudioTranscriptionConfig(),
|
||||||
|
session_resumption=SessionResumptionConfig(handle=session_resumption_handle),
|
||||||
)
|
)
|
||||||
|
|
||||||
# Add context window compression to configuration, if enabled
|
# Add context window compression to configuration, if enabled
|
||||||
@@ -965,12 +982,18 @@ class GeminiMultimodalLiveLLMService(LLMService):
|
|||||||
async with self._client.aio.live.connect(model=self._model_name, config=config) as session:
|
async with self._client.aio.live.connect(model=self._model_name, config=config) as session:
|
||||||
logger.info("Connected to Gemini service")
|
logger.info("Connected to Gemini service")
|
||||||
|
|
||||||
|
# Mark connection start time
|
||||||
|
self._connection_start_time = time.time()
|
||||||
|
|
||||||
await self._handle_session_ready(session)
|
await self._handle_session_ready(session)
|
||||||
|
|
||||||
while True:
|
while True:
|
||||||
try:
|
try:
|
||||||
turn = self._session.receive()
|
turn = self._session.receive()
|
||||||
async for message in turn:
|
async for message in turn:
|
||||||
|
# Reset failure counter if connection has been stable
|
||||||
|
self._check_and_reset_failure_counter()
|
||||||
|
|
||||||
if message.server_content and message.server_content.model_turn:
|
if message.server_content and message.server_content.model_turn:
|
||||||
await self._handle_msg_model_turn(message)
|
await self._handle_msg_model_turn(message)
|
||||||
elif (
|
elif (
|
||||||
@@ -988,13 +1011,67 @@ class GeminiMultimodalLiveLLMService(LLMService):
|
|||||||
await self._handle_msg_grounding_metadata(message)
|
await self._handle_msg_grounding_metadata(message)
|
||||||
elif message.tool_call:
|
elif message.tool_call:
|
||||||
await self._handle_msg_tool_call(message)
|
await self._handle_msg_tool_call(message)
|
||||||
|
elif message.session_resumption_update:
|
||||||
|
self._handle_msg_resumption_update(message)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
if not self._disconnecting:
|
if not self._disconnecting:
|
||||||
await self.push_error(
|
should_reconnect = await self._handle_connection_error(e)
|
||||||
ErrorFrame(error=f"{self} Error in receive loop: {e}", fatal=True)
|
if should_reconnect:
|
||||||
)
|
await self._reconnect()
|
||||||
|
return # Exit this connection handler, _reconnect will start a new one
|
||||||
break
|
break
|
||||||
|
|
||||||
|
def _check_and_reset_failure_counter(self):
|
||||||
|
"""Check if connection has been stable long enough to reset the failure counter.
|
||||||
|
|
||||||
|
If the connection has been active for longer than the established threshold
|
||||||
|
and there are accumulated failures, reset the counter to 0.
|
||||||
|
"""
|
||||||
|
if (
|
||||||
|
self._connection_start_time
|
||||||
|
and self._consecutive_failures > 0
|
||||||
|
and time.time() - self._connection_start_time >= self._connection_established_threshold
|
||||||
|
):
|
||||||
|
logger.info(
|
||||||
|
f"Connection stable for {self._connection_established_threshold}s, "
|
||||||
|
f"resetting failure counter from {self._consecutive_failures} to 0"
|
||||||
|
)
|
||||||
|
self._consecutive_failures = 0
|
||||||
|
|
||||||
|
async def _handle_connection_error(self, error: Exception) -> bool:
|
||||||
|
"""Handle a connection error and determine if reconnection should be attempted.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
error: The exception that caused the connection error.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if reconnection should be attempted, False if a fatal error should be pushed.
|
||||||
|
"""
|
||||||
|
self._consecutive_failures += 1
|
||||||
|
logger.warning(
|
||||||
|
f"Connection error (failure {self._consecutive_failures}/{self._max_consecutive_failures}): {error}"
|
||||||
|
)
|
||||||
|
|
||||||
|
if self._consecutive_failures >= self._max_consecutive_failures:
|
||||||
|
logger.error(
|
||||||
|
f"Max consecutive failures ({self._max_consecutive_failures}) reached, "
|
||||||
|
"treating as fatal error"
|
||||||
|
)
|
||||||
|
await self.push_error(
|
||||||
|
ErrorFrame(error=f"{self} Error in receive loop: {error}", fatal=True)
|
||||||
|
)
|
||||||
|
return False
|
||||||
|
else:
|
||||||
|
logger.info(
|
||||||
|
f"Attempting reconnection ({self._consecutive_failures}/{self._max_consecutive_failures})"
|
||||||
|
)
|
||||||
|
return True
|
||||||
|
|
||||||
|
async def _reconnect(self):
|
||||||
|
"""Reconnect to Gemini Live API."""
|
||||||
|
await self._disconnect()
|
||||||
|
await self._connect(session_resumption_handle=self._session_resumption_handle)
|
||||||
|
|
||||||
async def _disconnect(self):
|
async def _disconnect(self):
|
||||||
"""Disconnect from Gemini Live API and clean up resources."""
|
"""Disconnect from Gemini Live API and clean up resources."""
|
||||||
logger.info("Disconnecting from Gemini service")
|
logger.info("Disconnecting from Gemini service")
|
||||||
@@ -1013,10 +1090,7 @@ class GeminiMultimodalLiveLLMService(LLMService):
|
|||||||
|
|
||||||
async def _send_user_audio(self, frame):
|
async def _send_user_audio(self, frame):
|
||||||
"""Send user audio frame to Gemini Live API."""
|
"""Send user audio frame to Gemini Live API."""
|
||||||
if self._audio_input_paused:
|
if self._audio_input_paused or self._disconnecting or not self._session:
|
||||||
return
|
|
||||||
|
|
||||||
if not self._session:
|
|
||||||
return
|
return
|
||||||
|
|
||||||
# Send all audio to Gemini
|
# Send all audio to Gemini
|
||||||
@@ -1051,7 +1125,7 @@ class GeminiMultimodalLiveLLMService(LLMService):
|
|||||||
Args:
|
Args:
|
||||||
text: The text to send as user input.
|
text: The text to send as user input.
|
||||||
"""
|
"""
|
||||||
if not self._session:
|
if self._disconnecting or not self._session:
|
||||||
return
|
return
|
||||||
|
|
||||||
try:
|
try:
|
||||||
@@ -1061,10 +1135,7 @@ class GeminiMultimodalLiveLLMService(LLMService):
|
|||||||
|
|
||||||
async def _send_user_video(self, frame):
|
async def _send_user_video(self, frame):
|
||||||
"""Send user video frame to Gemini Live API."""
|
"""Send user video frame to Gemini Live API."""
|
||||||
if self._video_input_paused:
|
if self._video_input_paused or self._disconnecting or not self._session:
|
||||||
return
|
|
||||||
|
|
||||||
if not self._session:
|
|
||||||
return
|
return
|
||||||
|
|
||||||
now = time.time()
|
now = time.time()
|
||||||
@@ -1085,6 +1156,9 @@ class GeminiMultimodalLiveLLMService(LLMService):
|
|||||||
|
|
||||||
async def _create_initial_response(self):
|
async def _create_initial_response(self):
|
||||||
"""Create initial response based on context history."""
|
"""Create initial response based on context history."""
|
||||||
|
if self._disconnecting:
|
||||||
|
return
|
||||||
|
|
||||||
if not self._session:
|
if not self._session:
|
||||||
self._run_llm_when_session_ready = True
|
self._run_llm_when_session_ready = True
|
||||||
return
|
return
|
||||||
@@ -1112,6 +1186,9 @@ class GeminiMultimodalLiveLLMService(LLMService):
|
|||||||
|
|
||||||
async def _create_single_response(self, messages_list):
|
async def _create_single_response(self, messages_list):
|
||||||
"""Create a single response from a list of messages."""
|
"""Create a single response from a list of messages."""
|
||||||
|
if self._disconnecting or not self._session:
|
||||||
|
return
|
||||||
|
|
||||||
# Create a throwaway context just for the purpose of getting messages
|
# Create a throwaway context just for the purpose of getting messages
|
||||||
# in the right format
|
# in the right format
|
||||||
context = GeminiMultimodalLiveContext.upgrade(OpenAILLMContext(messages=messages_list))
|
context = GeminiMultimodalLiveContext.upgrade(OpenAILLMContext(messages=messages_list))
|
||||||
@@ -1132,6 +1209,9 @@ class GeminiMultimodalLiveLLMService(LLMService):
|
|||||||
@traced_gemini_live(operation="llm_tool_result")
|
@traced_gemini_live(operation="llm_tool_result")
|
||||||
async def _tool_result(self, tool_result_message):
|
async def _tool_result(self, tool_result_message):
|
||||||
"""Send tool result back to the API."""
|
"""Send tool result back to the API."""
|
||||||
|
if self._disconnecting or not self._session:
|
||||||
|
return
|
||||||
|
|
||||||
# For now we're shoving the name into the tool_call_id field, so this
|
# For now we're shoving the name into the tool_call_id field, so this
|
||||||
# will work until we revisit that.
|
# will work until we revisit that.
|
||||||
id = tool_result_message.get("tool_call_id")
|
id = tool_result_message.get("tool_call_id")
|
||||||
@@ -1407,6 +1487,11 @@ class GeminiMultimodalLiveLLMService(LLMService):
|
|||||||
|
|
||||||
await self.start_llm_usage_metrics(tokens)
|
await self.start_llm_usage_metrics(tokens)
|
||||||
|
|
||||||
|
def _handle_msg_resumption_update(self, message: LiveServerMessage):
|
||||||
|
update = message.session_resumption_update
|
||||||
|
if update.resumable and update.new_handle:
|
||||||
|
self._session_resumption_handle = update.new_handle
|
||||||
|
|
||||||
async def _handle_send_error(self, error: Exception):
|
async def _handle_send_error(self, error: Exception):
|
||||||
# In server-to-server contexts, a WebSocket error should be quite rare.
|
# In server-to-server contexts, a WebSocket error should be quite rare.
|
||||||
# Given how hard it is to recover from a send-side error with proper
|
# Given how hard it is to recover from a send-side error with proper
|
||||||
|
|||||||
Reference in New Issue
Block a user