1561 lines
68 KiB
Python
1561 lines
68 KiB
Python
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import contextlib
|
|
import json
|
|
import os
|
|
import time
|
|
import weakref
|
|
from collections.abc import Iterator
|
|
from dataclasses import dataclass, field
|
|
from typing import Literal
|
|
|
|
import google.auth.credentials
|
|
from google.auth._default_async import default_async
|
|
from google.genai import Client as GenAIClient, types
|
|
from google.genai.live import AsyncSession
|
|
from livekit import rtc
|
|
from livekit.agents import APIConnectionError, LanguageCode, llm, utils
|
|
from livekit.agents.metrics import RealtimeModelMetrics
|
|
from livekit.agents.metrics.base import Metadata
|
|
from livekit.agents.types import (
|
|
DEFAULT_API_CONNECT_OPTIONS,
|
|
NOT_GIVEN,
|
|
APIConnectOptions,
|
|
NotGivenOr,
|
|
)
|
|
from livekit.agents.utils import audio as audio_utils, images, is_given
|
|
from livekit.plugins.google.realtime.api_proto import ClientEvents, LiveAPIModels, Voice
|
|
|
|
from ..log import logger
|
|
from ..utils import create_function_response, create_tools_config, get_tool_results_for_realtime
|
|
from ..version import __version__
|
|
|
|
INPUT_AUDIO_SAMPLE_RATE = 16000
|
|
INPUT_AUDIO_CHANNELS = 1
|
|
OUTPUT_AUDIO_SAMPLE_RATE = 24000
|
|
OUTPUT_AUDIO_CHANNELS = 1
|
|
|
|
DEFAULT_IMAGE_ENCODE_OPTIONS = images.EncodeOptions(
|
|
format="JPEG",
|
|
quality=75,
|
|
resize_options=images.ResizeOptions(width=1024, height=1024, strategy="scale_aspect_fit"),
|
|
)
|
|
|
|
lk_google_debug = int(os.getenv("LK_GOOGLE_DEBUG", 0))
|
|
|
|
# stop rejecting tool calls after this many in a row to avoid a loop (tool_choice="none")
|
|
MAX_TOOL_CALL_REJECTIONS = 3
|
|
|
|
# Known VertexAI models for the Live API
|
|
# See: https://docs.cloud.google.com/vertex-ai/generative-ai/docs/live-api
|
|
KNOWN_VERTEXAI_MODELS: frozenset[str] = frozenset(
|
|
{
|
|
"gemini-live-2.5-flash-native-audio",
|
|
}
|
|
)
|
|
|
|
# Known Gemini API models for the Live API
|
|
# See: https://ai.google.dev/gemini-api/docs/models#gemini-2.5-flash-live
|
|
KNOWN_GEMINI_API_MODELS: frozenset[str] = frozenset(
|
|
{
|
|
"gemini-3.1-flash-live-preview",
|
|
"gemini-2.5-flash-native-audio-preview-12-2025",
|
|
}
|
|
)
|
|
|
|
|
|
def _validate_model_api_match(model: str, use_vertexai: bool) -> None:
|
|
"""
|
|
Validate that the model name matches the API being used.
|
|
|
|
Raises ValueError if a known model is used with the wrong API configuration.
|
|
|
|
Args:
|
|
model: The model name being used
|
|
use_vertexai: Whether VertexAI is enabled
|
|
"""
|
|
if use_vertexai and model in KNOWN_GEMINI_API_MODELS:
|
|
raise ValueError(
|
|
f"Model '{model}' is a Gemini API model, but vertexai=True. "
|
|
f"Use a VertexAI model (e.g., 'gemini-live-2.5-flash-native-audio') "
|
|
f"or set vertexai=False."
|
|
)
|
|
|
|
if not use_vertexai and model in KNOWN_VERTEXAI_MODELS:
|
|
raise ValueError(
|
|
f"Model '{model}' is a VertexAI model, but vertexai=False. "
|
|
f"Use a Gemini API model (e.g., 'gemini-2.5-flash-native-audio-preview-12-2025') "
|
|
f"or set vertexai=True."
|
|
)
|
|
|
|
|
|
def _get_1008_error_hint(error_message: str) -> str | None:
|
|
"""
|
|
Generate a hint for WebSocket 1008 policy violation errors.
|
|
|
|
This provides a generic hint when the connection fails with a 1008 error,
|
|
which often indicates the model name doesn't match the API being used.
|
|
|
|
Args:
|
|
error_message: The error message from the WebSocket exception
|
|
|
|
Returns:
|
|
A helpful hint string, or None if not a 1008 error
|
|
"""
|
|
if "1008" not in error_message and "policy violation" not in error_message.lower():
|
|
return None
|
|
|
|
return (
|
|
"\n\nHint: A 1008 policy violation error often indicates that the model name "
|
|
"doesn't match the API being used. VertexAI models typically start with "
|
|
"'gemini-live-', while Gemini API models start with 'gemini-2.' or similar. "
|
|
"Please verify your model name matches your API configuration."
|
|
)
|
|
|
|
|
|
@dataclass
|
|
class InputTranscription:
|
|
item_id: str
|
|
transcript: str
|
|
|
|
|
|
@dataclass
|
|
class _RealtimeOptions:
|
|
model: LiveAPIModels | str
|
|
api_key: str | None
|
|
voice: Voice | str
|
|
language: NotGivenOr[LanguageCode]
|
|
response_modalities: list[types.Modality]
|
|
vertexai: bool
|
|
project: str | None
|
|
location: str | None
|
|
candidate_count: int
|
|
temperature: NotGivenOr[float]
|
|
max_output_tokens: NotGivenOr[int]
|
|
top_p: NotGivenOr[float]
|
|
top_k: NotGivenOr[int]
|
|
presence_penalty: NotGivenOr[float]
|
|
frequency_penalty: NotGivenOr[float]
|
|
instructions: NotGivenOr[str]
|
|
input_audio_transcription: types.AudioTranscriptionConfig | None
|
|
output_audio_transcription: types.AudioTranscriptionConfig | None
|
|
image_encode_options: NotGivenOr[images.EncodeOptions]
|
|
conn_options: APIConnectOptions
|
|
http_options: NotGivenOr[types.HttpOptions]
|
|
media_resolution: NotGivenOr[types.MediaResolution] = NOT_GIVEN
|
|
enable_affective_dialog: NotGivenOr[bool] = NOT_GIVEN
|
|
proactivity: NotGivenOr[bool] = NOT_GIVEN
|
|
realtime_input_config: NotGivenOr[types.RealtimeInputConfig] = NOT_GIVEN
|
|
context_window_compression: NotGivenOr[types.ContextWindowCompressionConfig] = NOT_GIVEN
|
|
api_version: NotGivenOr[str] = NOT_GIVEN
|
|
tool_behavior: NotGivenOr[types.Behavior] = NOT_GIVEN
|
|
tool_response_scheduling: NotGivenOr[types.FunctionResponseScheduling] = NOT_GIVEN
|
|
tool_choice: NotGivenOr[llm.ToolChoice | None] = NOT_GIVEN
|
|
thinking_config: NotGivenOr[types.ThinkingConfig] = NOT_GIVEN
|
|
session_resumption: NotGivenOr[types.SessionResumptionConfig] = NOT_GIVEN
|
|
credentials: google.auth.credentials.Credentials | None = None
|
|
|
|
|
|
@dataclass
|
|
class _ResponseGeneration:
|
|
message_ch: utils.aio.Chan[llm.MessageGeneration]
|
|
function_ch: utils.aio.Chan[llm.FunctionCall]
|
|
|
|
input_id: str
|
|
response_id: str
|
|
text_ch: utils.aio.Chan[str]
|
|
audio_ch: utils.aio.Chan[rtc.AudioFrame]
|
|
|
|
input_transcription: str = ""
|
|
output_text: str = ""
|
|
|
|
_created_timestamp: float = field(default_factory=time.time)
|
|
"""The timestamp when the generation is created"""
|
|
_first_token_timestamp: float | None = None
|
|
"""The timestamp when the first audio token is received"""
|
|
_completed_timestamp: float | None = None
|
|
"""The timestamp when the generation is completed"""
|
|
_done: bool = False
|
|
"""Whether the generation is done (set when the turn is complete)"""
|
|
|
|
def push_text(self, text: str) -> None:
|
|
if self.output_text:
|
|
self.output_text += text
|
|
else:
|
|
self.output_text = text
|
|
|
|
self.text_ch.send_nowait(text)
|
|
|
|
|
|
class RealtimeModel(llm.RealtimeModel):
|
|
def __init__(
|
|
self,
|
|
*,
|
|
instructions: NotGivenOr[str] = NOT_GIVEN,
|
|
model: NotGivenOr[LiveAPIModels | str] = NOT_GIVEN,
|
|
api_key: NotGivenOr[str] = NOT_GIVEN,
|
|
voice: Voice | str = "Puck",
|
|
language: NotGivenOr[str] = NOT_GIVEN,
|
|
modalities: NotGivenOr[list[types.Modality]] = NOT_GIVEN,
|
|
vertexai: NotGivenOr[bool] = NOT_GIVEN,
|
|
project: NotGivenOr[str] = NOT_GIVEN,
|
|
location: NotGivenOr[str] = NOT_GIVEN,
|
|
candidate_count: int = 1,
|
|
temperature: NotGivenOr[float] = NOT_GIVEN,
|
|
max_output_tokens: NotGivenOr[int] = NOT_GIVEN,
|
|
top_p: NotGivenOr[float] = NOT_GIVEN,
|
|
top_k: NotGivenOr[int] = NOT_GIVEN,
|
|
presence_penalty: NotGivenOr[float] = NOT_GIVEN,
|
|
frequency_penalty: NotGivenOr[float] = NOT_GIVEN,
|
|
input_audio_transcription: NotGivenOr[types.AudioTranscriptionConfig | None] = NOT_GIVEN,
|
|
output_audio_transcription: NotGivenOr[types.AudioTranscriptionConfig | None] = NOT_GIVEN,
|
|
image_encode_options: NotGivenOr[images.EncodeOptions] = NOT_GIVEN,
|
|
enable_affective_dialog: NotGivenOr[bool] = NOT_GIVEN,
|
|
proactivity: NotGivenOr[bool] = NOT_GIVEN,
|
|
realtime_input_config: NotGivenOr[types.RealtimeInputConfig] = NOT_GIVEN,
|
|
context_window_compression: NotGivenOr[types.ContextWindowCompressionConfig] = NOT_GIVEN,
|
|
tool_behavior: NotGivenOr[types.Behavior] = NOT_GIVEN,
|
|
tool_response_scheduling: NotGivenOr[types.FunctionResponseScheduling] = NOT_GIVEN,
|
|
session_resumption: NotGivenOr[types.SessionResumptionConfig] = NOT_GIVEN,
|
|
api_version: NotGivenOr[str] = NOT_GIVEN,
|
|
conn_options: APIConnectOptions = DEFAULT_API_CONNECT_OPTIONS,
|
|
http_options: NotGivenOr[types.HttpOptions] = NOT_GIVEN,
|
|
media_resolution: NotGivenOr[types.MediaResolution] = NOT_GIVEN,
|
|
thinking_config: NotGivenOr[types.ThinkingConfig] = NOT_GIVEN,
|
|
credentials: google.auth.credentials.Credentials | None = None,
|
|
) -> None:
|
|
"""
|
|
Initializes a RealtimeModel instance for interacting with Google's Realtime API.
|
|
|
|
Environment Requirements:
|
|
- For VertexAI: Set the `GOOGLE_APPLICATION_CREDENTIALS` environment variable to the path of the service account key file or use any of the other Google Cloud auth methods.
|
|
The Google Cloud project and location can be set via `project` and `location` arguments or the environment variables
|
|
`GOOGLE_CLOUD_PROJECT` and `GOOGLE_CLOUD_LOCATION`. By default, the project is inferred from the service account key file,
|
|
and the location defaults to "us-central1".
|
|
- For Google Gemini API: Set the `api_key` argument or the `GOOGLE_API_KEY` environment variable.
|
|
|
|
Args:
|
|
instructions (str, optional): Initial system instructions for the model. Defaults to "".
|
|
api_key (str, optional): Google Gemini API key. If None, will attempt to read from the environment variable GOOGLE_API_KEY.
|
|
modalities (list[Modality], optional): Modalities to use, such as ["TEXT", "AUDIO"]. Defaults to ["AUDIO"].
|
|
model (str, optional): The name of the model to use. Defaults to "gemini-2.5-flash-native-audio-preview-12-2025" or "gemini-live-2.5-flash-native-audio" (vertexai).
|
|
voice (api_proto.Voice, optional): Voice setting for audio outputs. Defaults to "Puck".
|
|
language (str, optional): The language(BCP-47 Code) to use for the API. supported languages - https://ai.google.dev/gemini-api/docs/live#supported-languages
|
|
temperature (float, optional): Sampling temperature for response generation. Defaults to 0.8.
|
|
vertexai (bool, optional): Whether to use VertexAI for the API. Defaults to False.
|
|
project (str, optional): The project id to use for the API. Defaults to None. (for vertexai)
|
|
location (str, optional): The location to use for the API. Defaults to None. (for vertexai)
|
|
candidate_count (int, optional): The number of candidate responses to generate. Defaults to 1.
|
|
top_p (float, optional): The top-p value for response generation
|
|
top_k (int, optional): The top-k value for response generation
|
|
presence_penalty (float, optional): The presence penalty for response generation
|
|
frequency_penalty (float, optional): The frequency penalty for response generation
|
|
input_audio_transcription (AudioTranscriptionConfig | None, optional): The configuration for input audio transcription. Defaults to None.)
|
|
output_audio_transcription (AudioTranscriptionConfig | None, optional): The configuration for output audio transcription. Defaults to AudioTranscriptionConfig().
|
|
image_encode_options (images.EncodeOptions, optional): The configuration for image encoding. Defaults to DEFAULT_ENCODE_OPTIONS.
|
|
media_resolution (MediaResolution, optional): The media resolution for the session. Defaults to None.
|
|
enable_affective_dialog (bool, optional): Whether to enable affective dialog. Defaults to False.
|
|
proactivity (bool, optional): Whether to enable proactive audio. Defaults to False.
|
|
realtime_input_config (RealtimeInputConfig, optional): The configuration for realtime input. Defaults to None.
|
|
context_window_compression (ContextWindowCompressionConfig, optional): The configuration for context window compression. Defaults to None.
|
|
tool_behavior (Behavior, optional): The behavior for tool call. Default behavior is BLOCK in Gemini Realtime API.
|
|
tool_response_scheduling (FunctionResponseScheduling, optional): The scheduling for tool response. Default scheduling is WHEN_IDLE.
|
|
session_resumption (SessionResumptionConfig, optional): The configuration for session resumption. Defaults to None.
|
|
thinking_config (ThinkingConfig, optional): Native audio thinking configuration.
|
|
conn_options (APIConnectOptions, optional): The configuration for the API connection. Defaults to DEFAULT_API_CONNECT_OPTIONS.
|
|
|
|
Raises:
|
|
ValueError: If the API key is required but not found.
|
|
""" # noqa: E501
|
|
if not is_given(input_audio_transcription):
|
|
input_audio_transcription = types.AudioTranscriptionConfig()
|
|
if not is_given(output_audio_transcription):
|
|
output_audio_transcription = types.AudioTranscriptionConfig()
|
|
|
|
server_turn_detection = True
|
|
if (
|
|
is_given(realtime_input_config)
|
|
and realtime_input_config.automatic_activity_detection
|
|
and realtime_input_config.automatic_activity_detection.disabled
|
|
):
|
|
server_turn_detection = False
|
|
modalities = modalities if is_given(modalities) else [types.Modality.AUDIO]
|
|
use_vertexai = (
|
|
vertexai
|
|
if is_given(vertexai)
|
|
else os.environ.get("GOOGLE_GENAI_USE_VERTEXAI", "0").lower() in ["true", "1"]
|
|
)
|
|
if not is_given(model):
|
|
model = (
|
|
"gemini-live-2.5-flash-native-audio"
|
|
if use_vertexai
|
|
else "gemini-2.5-flash-native-audio-preview-12-2025"
|
|
)
|
|
|
|
mutable = "3.1" not in model
|
|
super().__init__(
|
|
capabilities=llm.RealtimeCapabilities(
|
|
message_truncation=False,
|
|
turn_detection=server_turn_detection,
|
|
user_transcription=input_audio_transcription is not None,
|
|
auto_tool_reply_generation=True,
|
|
audio_output=types.Modality.AUDIO in modalities,
|
|
manual_function_calls=False,
|
|
mutable_chat_context=mutable,
|
|
mutable_instructions=mutable,
|
|
mutable_tools=False,
|
|
per_response_tool_choice=False,
|
|
)
|
|
)
|
|
|
|
gemini_api_key = api_key if is_given(api_key) else os.environ.get("GOOGLE_API_KEY")
|
|
gcp_project = project if is_given(project) else os.environ.get("GOOGLE_CLOUD_PROJECT")
|
|
gcp_location: str | None = (
|
|
location
|
|
if is_given(location)
|
|
else os.environ.get("GOOGLE_CLOUD_LOCATION") or "us-central1"
|
|
)
|
|
|
|
if use_vertexai:
|
|
if not gcp_project:
|
|
_, gcp_project = default_async( # type: ignore
|
|
scopes=["https://www.googleapis.com/auth/cloud-platform"]
|
|
)
|
|
if not gcp_project or not gcp_location:
|
|
raise ValueError(
|
|
"Project is required for VertexAI via project kwarg or GOOGLE_CLOUD_PROJECT environment variable" # noqa: E501
|
|
)
|
|
gemini_api_key = None # VertexAI does not require an API key
|
|
else:
|
|
gcp_project = None
|
|
gcp_location = None
|
|
if credentials is not None:
|
|
logger.warning(
|
|
"'credentials' is only applicable to VertexAI and will be ignored for the Gemini API"
|
|
)
|
|
credentials = None
|
|
if not gemini_api_key:
|
|
raise ValueError(
|
|
"API key is required for Google API either via api_key or GOOGLE_API_KEY environment variable" # noqa: E501
|
|
)
|
|
|
|
# Validate model/API compatibility for known models
|
|
_validate_model_api_match(model, use_vertexai)
|
|
|
|
if "3.1" in model:
|
|
logger.warning(
|
|
f"'{model}' has limited mid-session update support. instructions, chat "
|
|
"context, and tool updates will not be applied until the next session."
|
|
)
|
|
|
|
self._opts = _RealtimeOptions(
|
|
model=model,
|
|
api_key=gemini_api_key,
|
|
voice=voice,
|
|
response_modalities=modalities,
|
|
vertexai=use_vertexai,
|
|
project=gcp_project,
|
|
location=gcp_location,
|
|
candidate_count=candidate_count,
|
|
temperature=temperature,
|
|
max_output_tokens=max_output_tokens,
|
|
top_p=top_p,
|
|
top_k=top_k,
|
|
presence_penalty=presence_penalty,
|
|
frequency_penalty=frequency_penalty,
|
|
instructions=instructions,
|
|
input_audio_transcription=input_audio_transcription,
|
|
output_audio_transcription=output_audio_transcription,
|
|
language=LanguageCode(language) if isinstance(language, str) else language,
|
|
image_encode_options=image_encode_options,
|
|
enable_affective_dialog=enable_affective_dialog,
|
|
proactivity=proactivity,
|
|
realtime_input_config=realtime_input_config,
|
|
context_window_compression=context_window_compression,
|
|
api_version=api_version,
|
|
tool_behavior=tool_behavior,
|
|
tool_response_scheduling=tool_response_scheduling,
|
|
conn_options=conn_options,
|
|
http_options=http_options,
|
|
media_resolution=media_resolution,
|
|
thinking_config=thinking_config,
|
|
session_resumption=session_resumption,
|
|
credentials=credentials,
|
|
)
|
|
|
|
self._sessions = weakref.WeakSet[RealtimeSession]()
|
|
|
|
@property
|
|
def model(self) -> str:
|
|
return self._opts.model
|
|
|
|
@property
|
|
def provider(self) -> str:
|
|
if self._opts.vertexai:
|
|
return "Vertex AI"
|
|
else:
|
|
return "Gemini"
|
|
|
|
def session(self) -> RealtimeSession:
|
|
sess = RealtimeSession(self)
|
|
self._sessions.add(sess)
|
|
return sess
|
|
|
|
def update_options(
|
|
self,
|
|
*,
|
|
voice: NotGivenOr[str] = NOT_GIVEN,
|
|
temperature: NotGivenOr[float] = NOT_GIVEN,
|
|
tool_behavior: NotGivenOr[types.Behavior] = NOT_GIVEN,
|
|
tool_response_scheduling: NotGivenOr[types.FunctionResponseScheduling] = NOT_GIVEN,
|
|
) -> None:
|
|
"""
|
|
Update the options for the RealtimeModel.
|
|
|
|
Args:
|
|
voice (str, optional): The voice to use for the session.
|
|
temperature (float, optional): The temperature to use for the session.
|
|
tools (list[LLMTool], optional): The tools to use for the session.
|
|
"""
|
|
if is_given(voice):
|
|
self._opts.voice = voice
|
|
|
|
if is_given(temperature):
|
|
self._opts.temperature = temperature
|
|
|
|
if is_given(tool_behavior):
|
|
self._opts.tool_behavior = tool_behavior
|
|
|
|
if is_given(tool_response_scheduling):
|
|
self._opts.tool_response_scheduling = tool_response_scheduling
|
|
|
|
for sess in self._sessions:
|
|
sess.update_options(
|
|
voice=self._opts.voice,
|
|
temperature=self._opts.temperature,
|
|
tool_behavior=self._opts.tool_behavior,
|
|
tool_response_scheduling=self._opts.tool_response_scheduling,
|
|
)
|
|
|
|
async def aclose(self) -> None:
|
|
pass
|
|
|
|
|
|
class RealtimeSession(llm.RealtimeSession):
|
|
def __init__(self, realtime_model: RealtimeModel) -> None:
|
|
super().__init__(realtime_model)
|
|
self._opts = realtime_model._opts
|
|
self._tools = llm.ToolContext.empty()
|
|
self._chat_ctx = llm.ChatContext.empty()
|
|
self._msg_ch = utils.aio.Chan[ClientEvents]()
|
|
self._input_resampler: rtc.AudioResampler | None = None
|
|
|
|
# 50ms chunks
|
|
self._bstream = audio_utils.AudioByteStream(
|
|
INPUT_AUDIO_SAMPLE_RATE,
|
|
INPUT_AUDIO_CHANNELS,
|
|
samples_per_channel=INPUT_AUDIO_SAMPLE_RATE // 20,
|
|
)
|
|
|
|
api_version = self._opts.api_version
|
|
if (
|
|
not api_version
|
|
and (self._opts.enable_affective_dialog or self._opts.proactivity)
|
|
and not self._opts.vertexai
|
|
):
|
|
api_version = "v1alpha"
|
|
|
|
http_options = self._opts.http_options or types.HttpOptions(
|
|
timeout=int(self._opts.conn_options.timeout * 1000)
|
|
)
|
|
if api_version:
|
|
http_options.api_version = api_version
|
|
if not http_options.headers:
|
|
http_options.headers = {}
|
|
http_options.headers["x-goog-api-client"] = f"livekit-agents/{__version__}"
|
|
|
|
self._client = GenAIClient(
|
|
api_key=self._opts.api_key,
|
|
vertexai=self._opts.vertexai,
|
|
project=self._opts.project,
|
|
location=self._opts.location,
|
|
credentials=self._opts.credentials,
|
|
http_options=http_options,
|
|
)
|
|
|
|
self._main_atask = asyncio.create_task(self._main_task(), name="gemini-realtime-session")
|
|
|
|
self._current_generation: _ResponseGeneration | None = None
|
|
self._active_session: AsyncSession | None = None
|
|
# indicates if the underlying session should end
|
|
self._session_should_close = asyncio.Event()
|
|
self._response_created_futures: dict[str, asyncio.Future[llm.GenerationCreatedEvent]] = {}
|
|
self._pending_generation_fut: asyncio.Future[llm.GenerationCreatedEvent] | None = None
|
|
# number of tool calls rejected in the current tool_choice="none" turn; non-zero also
|
|
# means we're draining that turn's trailing events (which have no generation to attach
|
|
# to). reset when the next generation starts.
|
|
self._rejected_tool_calls = 0
|
|
|
|
self._session_resumption_handle: str | None = (
|
|
self._opts.session_resumption.handle
|
|
if is_given(self._opts.session_resumption)
|
|
else None
|
|
)
|
|
|
|
self._in_user_activity = False
|
|
self._session_lock = asyncio.Lock()
|
|
self._num_retries = 0
|
|
# error recorded by the recv/send tasks so _main_task can bound retries
|
|
# and surface it through the "error" event
|
|
self._session_error: Exception | None = None
|
|
|
|
async def _close_active_session(self) -> None:
|
|
async with self._session_lock:
|
|
if self._active_session:
|
|
try:
|
|
await self._active_session.close()
|
|
except Exception as e:
|
|
logger.warning(f"error closing Gemini session: {e}")
|
|
finally:
|
|
self._active_session = None
|
|
|
|
def _mark_restart_needed(self, on_error: bool = False) -> None:
|
|
if not self._session_should_close.is_set():
|
|
self._session_should_close.set()
|
|
# reset the msg_ch, do not send messages from previous session
|
|
if not on_error:
|
|
while not self._msg_ch.empty():
|
|
msg = self._msg_ch.recv_nowait()
|
|
if isinstance(msg, types.LiveClientContent) and msg.turn_complete is True:
|
|
logger.warning(
|
|
"discarding client content for turn completion, may cause generate_reply timeout",
|
|
extra={"content": str(msg)},
|
|
)
|
|
|
|
self._msg_ch = utils.aio.Chan[ClientEvents]()
|
|
|
|
def update_options(
|
|
self,
|
|
*,
|
|
voice: NotGivenOr[str] = NOT_GIVEN,
|
|
temperature: NotGivenOr[float] = NOT_GIVEN,
|
|
tool_choice: NotGivenOr[llm.ToolChoice | None] = NOT_GIVEN,
|
|
tool_behavior: NotGivenOr[types.Behavior] = NOT_GIVEN,
|
|
tool_response_scheduling: NotGivenOr[types.FunctionResponseScheduling] = NOT_GIVEN,
|
|
) -> None:
|
|
should_restart = False
|
|
if is_given(voice) and self._opts.voice != voice:
|
|
self._opts.voice = voice
|
|
should_restart = True
|
|
|
|
if is_given(temperature) and self._opts.temperature != temperature:
|
|
self._opts.temperature = temperature if is_given(temperature) else NOT_GIVEN
|
|
should_restart = True
|
|
|
|
if is_given(tool_behavior) and self._opts.tool_behavior != tool_behavior:
|
|
self._opts.tool_behavior = tool_behavior
|
|
should_restart = True
|
|
|
|
if (
|
|
is_given(tool_response_scheduling)
|
|
and self._opts.tool_response_scheduling != tool_response_scheduling
|
|
):
|
|
self._opts.tool_response_scheduling = tool_response_scheduling
|
|
# no need to restart
|
|
|
|
if is_given(tool_choice):
|
|
# no per-response tool_choice on Gemini; "none" is emulated by rejecting any tool
|
|
# call emitted during the turn (see _reject_tool_calls).
|
|
self._opts.tool_choice = tool_choice
|
|
if tool_choice == "none":
|
|
logger.warning(
|
|
"the Google Realtime API has no tool_choice='none'; tool calls emitted "
|
|
"this turn will be rejected so the model replies directly."
|
|
)
|
|
elif tool_choice not in (None, "auto"):
|
|
logger.warning(
|
|
f"tool_choice='{tool_choice}' is not supported by the Google Realtime API, "
|
|
"falling back to 'auto'."
|
|
)
|
|
|
|
if should_restart:
|
|
self._mark_restart_needed()
|
|
|
|
async def update_instructions(self, instructions: str) -> None:
|
|
if not is_given(self._opts.instructions) or self._opts.instructions != instructions:
|
|
self._opts.instructions = instructions
|
|
|
|
async with self._session_lock:
|
|
if not self._active_session:
|
|
# No active session yet — restart will pick up new instructions via _build_connect_config
|
|
self._mark_restart_needed()
|
|
return
|
|
|
|
if not self._realtime_model.capabilities.mutable_instructions:
|
|
return
|
|
|
|
# Active session exists — send mid-session system instruction update (no reconnect needed)
|
|
logger.debug("Updating instructions mid-session")
|
|
self._send_client_event(
|
|
types.LiveClientContent(
|
|
turns=[
|
|
types.Content(
|
|
parts=[types.Part(text=instructions)],
|
|
# Vertex AI ignores role=None or role="system" and only works with role="model".
|
|
# Gemini Live API (non-Vertex) errors on role="system"; role=None works as system role.
|
|
role="model" if self._opts.vertexai else None,
|
|
)
|
|
],
|
|
turn_complete=False,
|
|
)
|
|
)
|
|
|
|
async def update_chat_ctx(self, chat_ctx: llm.ChatContext) -> None:
|
|
# Check for system/developer messages that will be dropped
|
|
system_msg_count = sum(
|
|
1 for msg in chat_ctx.messages() if msg.role in ("system", "developer")
|
|
)
|
|
if system_msg_count > 0:
|
|
logger.warning(
|
|
f"Gemini Realtime model '{self._opts.model}' does not support 'system' or "
|
|
f"'developer' roles in chat history. Dropping {system_msg_count} system "
|
|
f"message(s) from chat context. Gemini Realtime only supports 'user' and "
|
|
f"'model' roles. Use update_instructions() to set system-level context instead."
|
|
)
|
|
|
|
chat_ctx = chat_ctx.copy(
|
|
exclude_handoff=True,
|
|
exclude_instructions=True,
|
|
exclude_empty_message=True,
|
|
exclude_config_update=True,
|
|
)
|
|
async with self._session_lock:
|
|
if not self._active_session:
|
|
self._chat_ctx = chat_ctx
|
|
return
|
|
|
|
diff_ops = llm.utils.compute_chat_ctx_diff(self._chat_ctx, chat_ctx)
|
|
|
|
if diff_ops.to_remove:
|
|
logger.warning("Gemini Live does not support removing messages")
|
|
|
|
append_ctx = llm.ChatContext.empty()
|
|
for _, item_id in diff_ops.to_create:
|
|
item = chat_ctx.get_by_id(item_id)
|
|
if item:
|
|
append_ctx.items.append(item)
|
|
|
|
if append_ctx.items:
|
|
tool_results = get_tool_results_for_realtime(
|
|
append_ctx,
|
|
vertexai=self._opts.vertexai,
|
|
tool_response_scheduling=self._opts.tool_response_scheduling,
|
|
)
|
|
if self._realtime_model.capabilities.mutable_chat_context:
|
|
turns_dict, _ = append_ctx.copy(exclude_function_call=True).to_provider_format(
|
|
format="google", inject_dummy_user_message=False
|
|
)
|
|
turns = [types.Content.model_validate(turn) for turn in turns_dict]
|
|
if turns:
|
|
self._send_client_event(
|
|
types.LiveClientContent(turns=turns, turn_complete=False)
|
|
)
|
|
if tool_results:
|
|
self._send_client_event(tool_results)
|
|
|
|
# since we don't have a view of the history on the server side, we'll assume
|
|
# the current state is accurate. this isn't perfect because removals aren't done.
|
|
self._chat_ctx = chat_ctx
|
|
|
|
async def update_tools(self, tools: list[llm.Tool]) -> None:
|
|
tool_ctx = llm.ToolContext(tools)
|
|
if self._tools == tool_ctx:
|
|
return
|
|
|
|
self._tools = tool_ctx
|
|
self._mark_restart_needed()
|
|
|
|
@property
|
|
def chat_ctx(self) -> llm.ChatContext:
|
|
return self._chat_ctx.copy()
|
|
|
|
@property
|
|
def tools(self) -> llm.ToolContext:
|
|
return self._tools.copy()
|
|
|
|
@property
|
|
def _manual_activity_detection(self) -> bool:
|
|
if (
|
|
is_given(self._opts.realtime_input_config)
|
|
and self._opts.realtime_input_config.automatic_activity_detection is not None
|
|
and self._opts.realtime_input_config.automatic_activity_detection.disabled
|
|
):
|
|
return True
|
|
return False
|
|
|
|
@property
|
|
def session_resumption_handle(self) -> str | None:
|
|
return self._session_resumption_handle
|
|
|
|
def push_audio(self, frame: rtc.AudioFrame) -> None:
|
|
for f in self._resample_audio(frame):
|
|
for nf in self._bstream.write(f.data.tobytes()):
|
|
realtime_input = types.LiveClientRealtimeInput(
|
|
audio=types.Blob(
|
|
data=nf.data.tobytes(),
|
|
mime_type=f"audio/pcm;rate={INPUT_AUDIO_SAMPLE_RATE}",
|
|
)
|
|
)
|
|
self._send_client_event(realtime_input)
|
|
|
|
def push_video(self, frame: rtc.VideoFrame) -> None:
|
|
encoded_data = images.encode(
|
|
frame, self._opts.image_encode_options or DEFAULT_IMAGE_ENCODE_OPTIONS
|
|
)
|
|
realtime_input = types.LiveClientRealtimeInput(
|
|
video=types.Blob(data=encoded_data, mime_type="image/jpeg")
|
|
)
|
|
self._send_client_event(realtime_input)
|
|
|
|
def _send_client_event(self, event: ClientEvents) -> None:
|
|
with contextlib.suppress(utils.aio.channel.ChanClosed):
|
|
self._msg_ch.send_nowait(event)
|
|
|
|
def generate_reply(
|
|
self,
|
|
*,
|
|
instructions: NotGivenOr[str] = NOT_GIVEN,
|
|
tool_choice: NotGivenOr[llm.ToolChoice] = NOT_GIVEN,
|
|
tools: NotGivenOr[list[llm.Tool]] = NOT_GIVEN,
|
|
) -> asyncio.Future[llm.GenerationCreatedEvent]:
|
|
if is_given(tools):
|
|
logger.warning("per-response tools is not supported by Google Realtime API, ignoring")
|
|
if not self._realtime_model.capabilities.mutable_chat_context:
|
|
logger.warning(
|
|
f"generate_reply is not compatible with '{self._opts.model}' and will be ignored."
|
|
)
|
|
fut = asyncio.Future[llm.GenerationCreatedEvent]()
|
|
fut.set_exception(
|
|
llm.RealtimeError(f"generate_reply is not compatible with '{self._opts.model}'")
|
|
)
|
|
return fut
|
|
if self._pending_generation_fut and not self._pending_generation_fut.done():
|
|
logger.warning(
|
|
"generate_reply called while another generation is pending, cancelling previous."
|
|
)
|
|
# clear the slot before cancelling so the done callback doesn't treat it
|
|
# as an external cancellation and signal the server.
|
|
old_fut = self._pending_generation_fut
|
|
self._pending_generation_fut = None
|
|
old_fut.cancel("Superseded by new generate_reply call")
|
|
|
|
fut = asyncio.Future[llm.GenerationCreatedEvent]()
|
|
self._pending_generation_fut = fut
|
|
|
|
if self._in_user_activity:
|
|
self._send_client_event(
|
|
types.LiveClientRealtimeInput(
|
|
activity_end=types.ActivityEnd(),
|
|
)
|
|
)
|
|
self._in_user_activity = False
|
|
|
|
# Gemini requires the last message to end with user's turn
|
|
# so we need to add a placeholder user turn in order to trigger a new generation
|
|
turns = []
|
|
if is_given(instructions):
|
|
turns.append(types.Content(parts=[types.Part(text=instructions)], role="model"))
|
|
turns.append(types.Content(parts=[types.Part(text=".")], role="user"))
|
|
self._send_client_event(types.LiveClientContent(turns=turns, turn_complete=True))
|
|
|
|
def _on_timeout() -> None:
|
|
if not fut.done():
|
|
fut.set_exception(
|
|
llm.RealtimeError(
|
|
"generate_reply timed out waiting for generation_created event."
|
|
)
|
|
)
|
|
if self._pending_generation_fut is fut:
|
|
self._pending_generation_fut = None
|
|
|
|
timeout_handle = asyncio.get_event_loop().call_later(5.0, _on_timeout)
|
|
|
|
def _on_fut_done(f: asyncio.Future[llm.GenerationCreatedEvent]) -> None:
|
|
timeout_handle.cancel()
|
|
is_current = self._pending_generation_fut is fut
|
|
if is_current:
|
|
self._pending_generation_fut = None
|
|
if f.cancelled() and is_current:
|
|
# external cancel: signal interrupt to Gemini via activity_start
|
|
self.interrupt()
|
|
|
|
fut.add_done_callback(_on_fut_done)
|
|
|
|
return fut
|
|
|
|
def start_user_activity(self) -> None:
|
|
if not self._manual_activity_detection:
|
|
return
|
|
|
|
if not self._in_user_activity:
|
|
self._in_user_activity = True
|
|
self._send_client_event(
|
|
types.LiveClientRealtimeInput(
|
|
activity_start=types.ActivityStart(),
|
|
)
|
|
)
|
|
|
|
def interrupt(self) -> None:
|
|
# Gemini Live treats activity start as interruption, so we rely on start_user_activity
|
|
# notifications to handle it
|
|
if (
|
|
self._opts.realtime_input_config
|
|
and self._opts.realtime_input_config.activity_handling
|
|
== types.ActivityHandling.NO_INTERRUPTION
|
|
):
|
|
return
|
|
self.start_user_activity()
|
|
|
|
def truncate(
|
|
self,
|
|
*,
|
|
message_id: str,
|
|
modalities: list[Literal["text", "audio"]],
|
|
audio_end_ms: int,
|
|
audio_transcript: NotGivenOr[str] = NOT_GIVEN,
|
|
) -> None:
|
|
logger.warning("truncate is not supported by the Google Realtime API.")
|
|
pass
|
|
|
|
async def aclose(self) -> None:
|
|
self._msg_ch.close()
|
|
self._session_should_close.set()
|
|
|
|
if self._main_atask:
|
|
await utils.aio.cancel_and_wait(self._main_atask)
|
|
|
|
await self._close_active_session()
|
|
|
|
if self._pending_generation_fut and not self._pending_generation_fut.done():
|
|
self._pending_generation_fut.cancel("Session closed")
|
|
|
|
for fut in self._response_created_futures.values():
|
|
if not fut.done():
|
|
fut.set_exception(llm.RealtimeError("Session closed before response created"))
|
|
self._response_created_futures.clear()
|
|
|
|
if self._current_generation:
|
|
self._mark_current_generation_done()
|
|
|
|
@utils.log_exceptions(logger=logger)
|
|
async def _main_task(self) -> None:
|
|
max_retries = self._opts.conn_options.max_retry
|
|
|
|
while not self._msg_ch.closed:
|
|
# previous session might not be closed yet, we'll do it here.
|
|
await self._close_active_session()
|
|
|
|
self._session_should_close.clear()
|
|
config = self._build_connect_config()
|
|
session = None
|
|
try:
|
|
logger.debug("connecting to Gemini Realtime API...")
|
|
t0 = time.perf_counter()
|
|
async with self._client.aio.live.connect(
|
|
model=self._opts.model, config=config
|
|
) as session:
|
|
self._report_connection_acquired(time.perf_counter() - t0)
|
|
async with self._session_lock:
|
|
self._active_session = session
|
|
|
|
# Check for system/developer messages in initial chat context
|
|
system_msg_count = sum(
|
|
1
|
|
for msg in self._chat_ctx.messages()
|
|
if msg.role in ("system", "developer")
|
|
)
|
|
if system_msg_count > 0:
|
|
logger.warning(
|
|
f"Gemini Realtime model '{self._opts.model}' does not support 'system' or "
|
|
f"'developer' roles in chat history. Dropping {system_msg_count} system "
|
|
f"message(s) from initial chat context during session initialization. "
|
|
f"Gemini Realtime only supports 'user' and 'model' roles. Use "
|
|
f"update_instructions() to set system-level context instead."
|
|
)
|
|
|
|
turns_dict, _ = self._chat_ctx.copy(
|
|
exclude_function_call=True,
|
|
exclude_handoff=True,
|
|
exclude_instructions=True,
|
|
exclude_empty_message=True,
|
|
exclude_config_update=True,
|
|
).to_provider_format(format="google", inject_dummy_user_message=False)
|
|
turns = [types.Content.model_validate(turn) for turn in turns_dict]
|
|
if turns:
|
|
await session.send_client_content(
|
|
turns=turns, # type: ignore
|
|
turn_complete=False,
|
|
)
|
|
|
|
# queue up existing chat context
|
|
send_task = asyncio.create_task(
|
|
self._send_task(session), name="gemini-realtime-send"
|
|
)
|
|
recv_task = asyncio.create_task(
|
|
self._recv_task(session), name="gemini-realtime-recv"
|
|
)
|
|
restart_wait_task = asyncio.create_task(
|
|
self._session_should_close.wait(), name="gemini-restart-wait"
|
|
)
|
|
|
|
done, pending = await asyncio.wait(
|
|
[send_task, recv_task, restart_wait_task],
|
|
return_when=asyncio.FIRST_COMPLETED,
|
|
)
|
|
|
|
for task in done:
|
|
if task is not restart_wait_task and task.exception():
|
|
logger.error(f"error in task {task.get_name()}: {task.exception()}")
|
|
raise task.exception() or Exception(f"{task.get_name()} failed")
|
|
|
|
if restart_wait_task not in done and self._msg_ch.closed:
|
|
break
|
|
|
|
for task in pending:
|
|
await utils.aio.cancel_and_wait(task)
|
|
|
|
# the recv/send tasks signal restart by setting _session_should_close
|
|
# rather than raising. propagate any error they recorded so the handler
|
|
# below can bound retries and surface it through the "error" event.
|
|
if self._session_error is not None:
|
|
err = self._session_error
|
|
self._session_error = None
|
|
raise err
|
|
|
|
except asyncio.CancelledError:
|
|
break
|
|
except Exception as e:
|
|
# Provide a hint for 1008 errors (often model/API mismatch for unknown models)
|
|
hint = _get_1008_error_hint(str(e))
|
|
if hint:
|
|
logger.error(f"Gemini Realtime API error: {e}{hint}", exc_info=e)
|
|
else:
|
|
logger.error(f"Gemini Realtime API error: {e}", exc_info=e)
|
|
|
|
if not self._msg_ch.closed:
|
|
# Gemini Live closes with 1007 ("Request contains an invalid argument")
|
|
# when the session context is exhausted. Reconnecting replays the same
|
|
# oversized chat context and fails identically, producing a tight retry
|
|
# loop, so treat it as fatal to the session instead of retrying.
|
|
if getattr(e, "code", None) == 1007 or "1007" in str(e):
|
|
logger.error(
|
|
"Gemini Live closed the session: context exhausted (1007). "
|
|
"Reconnecting would replay the same context and fail again; "
|
|
"terminating the session.",
|
|
exc_info=e,
|
|
)
|
|
self._emit_error(e, recoverable=False)
|
|
raise APIConnectionError(
|
|
message="Gemini Live session context exhausted (1007)"
|
|
) from e
|
|
|
|
# we shouldn't retry when it's not connected, usually this means incorrect
|
|
# parameters or setup
|
|
if not session or max_retries == 0:
|
|
self._emit_error(e, recoverable=False)
|
|
error_msg = "Failed to connect to Gemini Live"
|
|
if hint:
|
|
error_msg += hint
|
|
raise APIConnectionError(message=error_msg) from e
|
|
|
|
if self._num_retries == max_retries:
|
|
self._emit_error(e, recoverable=False)
|
|
error_msg = f"Failed to connect to Gemini Live after {max_retries} attempts"
|
|
if hint:
|
|
error_msg += hint
|
|
raise APIConnectionError(message=error_msg) from e
|
|
|
|
self._emit_error(e, recoverable=True)
|
|
retry_interval = self._opts.conn_options._interval_for_retry(self._num_retries)
|
|
logger.warning(
|
|
f"Gemini Realtime API connection failed, retrying in {retry_interval}s",
|
|
exc_info=e,
|
|
extra={"attempt": self._num_retries, "max_retries": max_retries},
|
|
)
|
|
await asyncio.sleep(retry_interval)
|
|
self._num_retries += 1
|
|
finally:
|
|
await self._close_active_session()
|
|
|
|
async def _send_task(self, session: AsyncSession) -> None:
|
|
try:
|
|
async for msg in self._msg_ch:
|
|
async with self._session_lock:
|
|
if self._session_should_close.is_set() or (
|
|
not self._active_session or self._active_session != session
|
|
):
|
|
break
|
|
if isinstance(msg, types.LiveClientContent):
|
|
await session.send_client_content(
|
|
turns=msg.turns, # type: ignore
|
|
turn_complete=msg.turn_complete if msg.turn_complete is not None else True,
|
|
)
|
|
elif isinstance(msg, types.LiveClientToolResponse) and msg.function_responses:
|
|
await session.send_tool_response(function_responses=msg.function_responses)
|
|
elif isinstance(msg, types.LiveClientRealtimeInput):
|
|
if msg.audio:
|
|
await session.send_realtime_input(audio=msg.audio)
|
|
elif msg.video:
|
|
await session.send_realtime_input(video=msg.video)
|
|
elif msg.text:
|
|
await session.send_realtime_input(text=msg.text)
|
|
elif msg.activity_start:
|
|
await session.send_realtime_input(activity_start=msg.activity_start)
|
|
elif msg.activity_end:
|
|
await session.send_realtime_input(activity_end=msg.activity_end)
|
|
else:
|
|
logger.warning(f"Warning: Received unhandled message type: {type(msg)}")
|
|
|
|
if lk_google_debug and isinstance(
|
|
msg,
|
|
(
|
|
types.LiveClientContent,
|
|
types.LiveClientToolResponse,
|
|
types.LiveClientRealtimeInput,
|
|
),
|
|
):
|
|
if not isinstance(msg, types.LiveClientRealtimeInput) or not (
|
|
msg.audio or msg.video or msg.text
|
|
):
|
|
logger.debug(
|
|
f">>> sent {type(msg).__name__}",
|
|
extra={"content": msg.model_dump(exclude_defaults=True)},
|
|
)
|
|
|
|
except Exception as e:
|
|
if not self._session_should_close.is_set():
|
|
logger.error(f"error in send task: {e}", exc_info=e)
|
|
self._session_error = e
|
|
self._mark_restart_needed(on_error=True)
|
|
finally:
|
|
logger.debug("send task finished.")
|
|
|
|
async def _recv_task(self, session: AsyncSession) -> None:
|
|
try:
|
|
while True:
|
|
async with self._session_lock:
|
|
if self._session_should_close.is_set() or (
|
|
not self._active_session or self._active_session != session
|
|
):
|
|
logger.debug("receive task: Session changed or closed, stopping receive.")
|
|
break
|
|
|
|
async for response in session.receive():
|
|
if lk_google_debug:
|
|
resp_copy = response.model_dump(exclude_defaults=True)
|
|
# remove audio from debugging logs
|
|
if (
|
|
(sc := resp_copy.get("server_content"))
|
|
and (mt := sc.get("model_turn"))
|
|
and (parts := mt.get("parts"))
|
|
):
|
|
for part in parts:
|
|
if part and part.get("inline_data"):
|
|
part["inline_data"] = "<audio>"
|
|
logger.debug("<<< received response", extra={"response": resp_copy})
|
|
|
|
if response.tool_call and self._opts.tool_choice == "none":
|
|
# reject without opening a generation, so the pending generate_reply
|
|
# stays bound to the model's eventual reply and tools stay suppressed
|
|
# for the whole turn.
|
|
self._reject_tool_calls(response.tool_call.function_calls or [])
|
|
continue
|
|
|
|
if not self._current_generation or self._current_generation._done:
|
|
if (sc := response.server_content) and sc.interrupted:
|
|
# two cases an interrupted event is sent without an active generation
|
|
# 1) the generation is done but playout is not finished (turn_complete -> interrupted)
|
|
# 2) the generation is not started (interrupted -> turn_complete)
|
|
# for both cases, we interrupt the agent if there is no pending generation from `generate_reply`
|
|
# for the second case, the pending generation will be stopped by `turn_complete` event coming later
|
|
if not self._pending_generation_fut:
|
|
self._handle_input_speech_started()
|
|
|
|
sc.interrupted = None
|
|
sc_copy = sc.model_dump(exclude_none=True)
|
|
if not sc_copy:
|
|
# ignore empty server content
|
|
response.server_content = None
|
|
if lk_google_debug:
|
|
logger.debug("ignoring empty server content")
|
|
|
|
if self._is_new_generation(response):
|
|
self._start_new_generation()
|
|
if lk_google_debug:
|
|
logger.debug(f"new generation started: {self._current_generation}")
|
|
|
|
if response.session_resumption_update:
|
|
if (
|
|
response.session_resumption_update.resumable
|
|
and response.session_resumption_update.new_handle
|
|
):
|
|
self._session_resumption_handle = (
|
|
response.session_resumption_update.new_handle
|
|
)
|
|
|
|
if response.server_content:
|
|
self._handle_server_content(response.server_content)
|
|
if response.tool_call:
|
|
self._handle_tool_calls(response.tool_call)
|
|
if response.tool_call_cancellation:
|
|
self._handle_tool_call_cancellation(response.tool_call_cancellation)
|
|
if response.usage_metadata:
|
|
self._handle_usage_metadata(response.usage_metadata)
|
|
if response.go_away:
|
|
self._handle_go_away(response.go_away)
|
|
|
|
if self._num_retries > 0:
|
|
self._num_retries = 0 # reset the retry counter
|
|
|
|
# TODO(dz): a server-side turn is complete
|
|
except Exception as e:
|
|
if not self._session_should_close.is_set():
|
|
logger.error(f"error in receive task: {e}", exc_info=e)
|
|
self._session_error = e
|
|
self._mark_restart_needed(on_error=True)
|
|
finally:
|
|
self._mark_current_generation_done()
|
|
|
|
def _build_connect_config(self) -> types.LiveConnectConfig:
|
|
temp = self._opts.temperature if is_given(self._opts.temperature) else None
|
|
|
|
tools_config = create_tools_config(
|
|
self._tools,
|
|
tool_behavior=self._opts.tool_behavior,
|
|
use_parameters_json_schema=False,
|
|
)
|
|
conf = types.LiveConnectConfig(
|
|
response_modalities=self._opts.response_modalities,
|
|
history_config=types.HistoryConfig(initial_history_in_client_content=True)
|
|
if not self._realtime_model.capabilities.mutable_chat_context
|
|
else None,
|
|
generation_config=types.GenerationConfig(
|
|
candidate_count=self._opts.candidate_count,
|
|
temperature=temp,
|
|
max_output_tokens=self._opts.max_output_tokens
|
|
if is_given(self._opts.max_output_tokens)
|
|
else None,
|
|
top_p=self._opts.top_p if is_given(self._opts.top_p) else None,
|
|
top_k=self._opts.top_k if is_given(self._opts.top_k) else None,
|
|
presence_penalty=self._opts.presence_penalty
|
|
if is_given(self._opts.presence_penalty)
|
|
else None,
|
|
frequency_penalty=self._opts.frequency_penalty
|
|
if is_given(self._opts.frequency_penalty)
|
|
else None,
|
|
thinking_config=self._opts.thinking_config
|
|
if is_given(self._opts.thinking_config)
|
|
else None,
|
|
media_resolution=self._opts.media_resolution
|
|
if is_given(self._opts.media_resolution)
|
|
else None,
|
|
),
|
|
system_instruction=types.Content(parts=[types.Part(text=self._opts.instructions)])
|
|
if is_given(self._opts.instructions)
|
|
else None,
|
|
speech_config=types.SpeechConfig(
|
|
voice_config=types.VoiceConfig(
|
|
prebuilt_voice_config=types.PrebuiltVoiceConfig(voice_name=self._opts.voice)
|
|
),
|
|
language_code=self._opts.language if is_given(self._opts.language) else None,
|
|
),
|
|
tools=tools_config,
|
|
input_audio_transcription=self._opts.input_audio_transcription,
|
|
output_audio_transcription=self._opts.output_audio_transcription,
|
|
session_resumption=types.SessionResumptionConfig(
|
|
handle=self._session_resumption_handle
|
|
),
|
|
)
|
|
|
|
if is_given(self._opts.proactivity):
|
|
conf.proactivity = types.ProactivityConfig(proactive_audio=self._opts.proactivity)
|
|
if is_given(self._opts.enable_affective_dialog):
|
|
conf.enable_affective_dialog = self._opts.enable_affective_dialog
|
|
if is_given(self._opts.realtime_input_config):
|
|
conf.realtime_input_config = self._opts.realtime_input_config
|
|
if is_given(self._opts.context_window_compression):
|
|
conf.context_window_compression = self._opts.context_window_compression
|
|
|
|
return conf
|
|
|
|
def _start_new_generation(self) -> None:
|
|
self._rejected_tool_calls = 0
|
|
if self._current_generation and not self._current_generation._done:
|
|
logger.warning("starting new generation while another is active. Finalizing previous.")
|
|
self._mark_current_generation_done()
|
|
|
|
response_id = utils.shortuuid("GR_")
|
|
self._current_generation = _ResponseGeneration(
|
|
message_ch=utils.aio.Chan[llm.MessageGeneration](),
|
|
function_ch=utils.aio.Chan[llm.FunctionCall](),
|
|
response_id=response_id,
|
|
input_id=utils.shortuuid("GI_"),
|
|
text_ch=utils.aio.Chan[str](),
|
|
audio_ch=utils.aio.Chan[rtc.AudioFrame](),
|
|
_created_timestamp=time.time(),
|
|
)
|
|
if not self._realtime_model.capabilities.audio_output:
|
|
self._current_generation.audio_ch.close()
|
|
|
|
msg_modalities = asyncio.Future[list[Literal["text", "audio"]]]()
|
|
msg_modalities.set_result(
|
|
["audio", "text"] if self._realtime_model.capabilities.audio_output else ["text"]
|
|
)
|
|
self._current_generation.message_ch.send_nowait(
|
|
llm.MessageGeneration(
|
|
message_id=response_id,
|
|
text_stream=self._current_generation.text_ch,
|
|
audio_stream=self._current_generation.audio_ch,
|
|
modalities=msg_modalities,
|
|
)
|
|
)
|
|
|
|
generation_event = llm.GenerationCreatedEvent(
|
|
message_stream=self._current_generation.message_ch,
|
|
function_stream=self._current_generation.function_ch,
|
|
user_initiated=False,
|
|
response_id=self._current_generation.response_id,
|
|
)
|
|
|
|
if self._pending_generation_fut and not self._pending_generation_fut.done():
|
|
generation_event.user_initiated = True
|
|
self._pending_generation_fut.set_result(generation_event)
|
|
self._pending_generation_fut = None
|
|
else:
|
|
# emit input_speech_started event before starting an agent initiated generation
|
|
# to interrupt the previous audio playout if any
|
|
self._handle_input_speech_started()
|
|
|
|
self.emit("generation_created", generation_event)
|
|
|
|
def _handle_server_content(self, server_content: types.LiveServerContent) -> None:
|
|
current_gen = self._current_generation
|
|
if not current_gen:
|
|
if self._rejected_tool_calls:
|
|
logger.debug(
|
|
"ignoring server content from a rejected tool call turn",
|
|
extra={"server_content": server_content.model_dump_json(exclude_none=True)},
|
|
)
|
|
else:
|
|
logger.warning("received server content but no active generation.")
|
|
return
|
|
|
|
if model_turn := server_content.model_turn:
|
|
for part in model_turn.parts or []:
|
|
if part.thought:
|
|
# bypass reasoning output
|
|
continue
|
|
if part.text:
|
|
current_gen.push_text(part.text)
|
|
if part.inline_data:
|
|
if not current_gen._first_token_timestamp:
|
|
current_gen._first_token_timestamp = time.time()
|
|
frame_data = part.inline_data.data
|
|
try:
|
|
if not isinstance(frame_data, bytes):
|
|
raise ValueError("frame_data is not bytes")
|
|
frame = rtc.AudioFrame(
|
|
data=frame_data,
|
|
sample_rate=OUTPUT_AUDIO_SAMPLE_RATE,
|
|
num_channels=OUTPUT_AUDIO_CHANNELS,
|
|
samples_per_channel=len(frame_data) // (2 * OUTPUT_AUDIO_CHANNELS),
|
|
)
|
|
current_gen.audio_ch.send_nowait(frame)
|
|
except ValueError as e:
|
|
logger.error(f"Error creating audio frame from Gemini data: {e}")
|
|
|
|
if input_transcription := server_content.input_transcription:
|
|
text = input_transcription.text
|
|
if text:
|
|
if current_gen.input_transcription == "":
|
|
# gemini would start with a space, which doesn't make sense
|
|
# at beginning of the transcript
|
|
text = text.lstrip()
|
|
current_gen.input_transcription += text
|
|
self.emit(
|
|
"input_audio_transcription_completed",
|
|
llm.InputTranscriptionCompleted(
|
|
item_id=current_gen.input_id,
|
|
transcript=current_gen.input_transcription,
|
|
is_final=False,
|
|
),
|
|
)
|
|
|
|
if output_transcription := server_content.output_transcription:
|
|
text = output_transcription.text
|
|
if text:
|
|
current_gen.push_text(text)
|
|
|
|
if server_content.generation_complete or server_content.turn_complete:
|
|
current_gen._completed_timestamp = time.time()
|
|
|
|
if server_content.interrupted and not self._pending_generation_fut:
|
|
# interrupt agent if there is no pending user initiated generation
|
|
self._handle_input_speech_started()
|
|
|
|
if server_content.turn_complete:
|
|
self._mark_current_generation_done()
|
|
|
|
def _mark_current_generation_done(self) -> None:
|
|
if not self._current_generation or self._current_generation._done:
|
|
return
|
|
|
|
# emit input_speech_stopped event after the generation is done
|
|
self._handle_input_speech_stopped()
|
|
|
|
gen = self._current_generation
|
|
|
|
# The only way we'd know that the transcription is complete is by when they are
|
|
# done with generation
|
|
if gen.input_transcription:
|
|
self.emit(
|
|
"input_audio_transcription_completed",
|
|
llm.InputTranscriptionCompleted(
|
|
item_id=gen.input_id,
|
|
transcript=gen.input_transcription,
|
|
is_final=True,
|
|
),
|
|
)
|
|
|
|
# since gemini doesn't give us a view of the chat history on the server side,
|
|
# we would handle it manually here
|
|
self._chat_ctx.add_message(
|
|
role="user",
|
|
content=gen.input_transcription,
|
|
id=gen.input_id,
|
|
)
|
|
|
|
if gen.output_text:
|
|
self._chat_ctx.add_message(
|
|
role="assistant",
|
|
content=gen.output_text,
|
|
id=gen.response_id,
|
|
)
|
|
|
|
if not gen.text_ch.closed:
|
|
if self._opts.output_audio_transcription is None:
|
|
# close the text data of transcription synchronizer
|
|
gen.text_ch.send_nowait("")
|
|
gen.text_ch.close()
|
|
if not gen.audio_ch.closed:
|
|
gen.audio_ch.close()
|
|
|
|
gen.function_ch.close()
|
|
gen.message_ch.close()
|
|
gen._done = True
|
|
if lk_google_debug:
|
|
logger.debug(f"generation done {gen}")
|
|
|
|
def _handle_input_speech_started(self) -> None:
|
|
self.emit("input_speech_started", llm.InputSpeechStartedEvent())
|
|
|
|
def _handle_input_speech_stopped(self) -> None:
|
|
self.emit(
|
|
"input_speech_stopped",
|
|
llm.InputSpeechStoppedEvent(user_transcription_enabled=False),
|
|
)
|
|
|
|
def _reject_tool_calls(self, function_calls: list[types.FunctionCall]) -> None:
|
|
if not function_calls:
|
|
return
|
|
|
|
self._rejected_tool_calls += 1
|
|
extra = {"functions": [fnc_call.name for fnc_call in function_calls]}
|
|
if self._rejected_tool_calls > MAX_TOOL_CALL_REJECTIONS:
|
|
# stop responding to break the loop; the user can still interrupt by voice
|
|
if self._rejected_tool_calls == MAX_TOOL_CALL_REJECTIONS + 1:
|
|
logger.error(
|
|
"model keeps calling tools despite tool_choice='none'; "
|
|
f"stopping after {MAX_TOOL_CALL_REJECTIONS} rejections to avoid a loop",
|
|
extra=extra,
|
|
)
|
|
return
|
|
|
|
logger.warning("rejecting tool call requested while tool_choice='none'", extra=extra)
|
|
responses = [
|
|
create_function_response(
|
|
llm.FunctionCallOutput(
|
|
name=fnc_call.name or "",
|
|
call_id=fnc_call.id or "",
|
|
output="Tool calls are disabled for this turn, respond to the user directly.",
|
|
is_error=True,
|
|
),
|
|
vertexai=self._opts.vertexai,
|
|
tool_response_scheduling=self._opts.tool_response_scheduling,
|
|
)
|
|
for fnc_call in function_calls
|
|
]
|
|
self._send_client_event(types.LiveClientToolResponse(function_responses=responses))
|
|
|
|
def _handle_tool_calls(self, tool_call: types.LiveServerToolCall) -> None:
|
|
if not self._current_generation:
|
|
logger.warning("received tool call but no active generation.")
|
|
return
|
|
|
|
gen = self._current_generation
|
|
for fnc_call in tool_call.function_calls or []:
|
|
arguments = json.dumps(fnc_call.args)
|
|
|
|
gen.function_ch.send_nowait(
|
|
llm.FunctionCall(
|
|
call_id=fnc_call.id or utils.shortuuid("fnc-call-"),
|
|
name=fnc_call.name,
|
|
arguments=arguments,
|
|
)
|
|
)
|
|
self._mark_current_generation_done()
|
|
|
|
def _handle_tool_call_cancellation(
|
|
self, tool_call_cancellation: types.LiveServerToolCallCancellation
|
|
) -> None:
|
|
logger.warning(
|
|
"server cancelled tool calls",
|
|
extra={"function_call_ids": tool_call_cancellation.ids},
|
|
)
|
|
|
|
def _handle_usage_metadata(self, usage_metadata: types.UsageMetadata) -> None:
|
|
current_gen = self._current_generation
|
|
if not current_gen:
|
|
if self._rejected_tool_calls:
|
|
logger.debug("ignoring usage metadata from a rejected tool call turn")
|
|
else:
|
|
logger.warning("no active generation to report metrics for")
|
|
return
|
|
|
|
ttft = (
|
|
current_gen._first_token_timestamp - current_gen._created_timestamp
|
|
if current_gen._first_token_timestamp
|
|
else -1
|
|
)
|
|
duration = (
|
|
current_gen._completed_timestamp or time.time()
|
|
) - current_gen._created_timestamp
|
|
|
|
def _token_details_map(
|
|
token_details: list[types.ModalityTokenCount] | None,
|
|
) -> dict[str, int]:
|
|
token_details_map = {"audio_tokens": 0, "text_tokens": 0, "image_tokens": 0}
|
|
if not token_details:
|
|
return token_details_map
|
|
|
|
for token_detail in token_details:
|
|
if not token_detail.token_count:
|
|
continue
|
|
|
|
if token_detail.modality == types.MediaModality.AUDIO:
|
|
token_details_map["audio_tokens"] += token_detail.token_count
|
|
elif token_detail.modality == types.MediaModality.TEXT:
|
|
token_details_map["text_tokens"] += token_detail.token_count
|
|
elif token_detail.modality == types.MediaModality.IMAGE:
|
|
token_details_map["image_tokens"] += token_detail.token_count
|
|
return token_details_map
|
|
|
|
metrics = RealtimeModelMetrics(
|
|
label=self._realtime_model.label,
|
|
request_id=current_gen.response_id,
|
|
timestamp=current_gen._created_timestamp,
|
|
duration=duration,
|
|
ttft=ttft,
|
|
cancelled=False,
|
|
input_tokens=usage_metadata.prompt_token_count or 0,
|
|
output_tokens=usage_metadata.response_token_count or 0,
|
|
total_tokens=usage_metadata.total_token_count or 0,
|
|
tokens_per_second=(usage_metadata.response_token_count or 0) / duration
|
|
if duration > 0
|
|
else 0,
|
|
input_token_details=RealtimeModelMetrics.InputTokenDetails(
|
|
**_token_details_map(usage_metadata.prompt_tokens_details),
|
|
cached_tokens=sum(
|
|
token_detail.token_count or 0
|
|
for token_detail in usage_metadata.cache_tokens_details or []
|
|
),
|
|
cached_tokens_details=RealtimeModelMetrics.CachedTokenDetails(
|
|
**_token_details_map(usage_metadata.cache_tokens_details),
|
|
),
|
|
),
|
|
output_token_details=RealtimeModelMetrics.OutputTokenDetails(
|
|
**_token_details_map(usage_metadata.response_tokens_details),
|
|
),
|
|
metadata=Metadata(
|
|
model_name=self._realtime_model.model, model_provider=self._realtime_model.provider
|
|
),
|
|
)
|
|
self.emit("metrics_collected", metrics)
|
|
|
|
def _handle_go_away(self, go_away: types.LiveServerGoAway) -> None:
|
|
logger.warning(
|
|
f"Gemini server indicates disconnection soon. Time left: {go_away.time_left}"
|
|
)
|
|
# TODO(dz): this isn't a seamless reconnection just yet
|
|
self._session_should_close.set()
|
|
|
|
def commit_audio(self) -> None:
|
|
logger.warning("commit_audio is not supported by Gemini Realtime API.")
|
|
|
|
def clear_audio(self) -> None:
|
|
logger.warning("clear_audio is not supported by Gemini Realtime API.")
|
|
|
|
def _resample_audio(self, frame: rtc.AudioFrame) -> Iterator[rtc.AudioFrame]:
|
|
if self._input_resampler:
|
|
if frame.sample_rate != self._input_resampler._input_rate:
|
|
# input audio changed to a different sample rate
|
|
self._input_resampler = None
|
|
|
|
if self._input_resampler is None and (
|
|
frame.sample_rate != INPUT_AUDIO_SAMPLE_RATE
|
|
or frame.num_channels != INPUT_AUDIO_CHANNELS
|
|
):
|
|
self._input_resampler = rtc.AudioResampler(
|
|
input_rate=frame.sample_rate,
|
|
output_rate=INPUT_AUDIO_SAMPLE_RATE,
|
|
num_channels=INPUT_AUDIO_CHANNELS,
|
|
)
|
|
|
|
if self._input_resampler:
|
|
# TODO(long): flush the resampler when the input source is changed
|
|
yield from self._input_resampler.push(frame)
|
|
else:
|
|
yield frame
|
|
|
|
def _emit_error(self, error: Exception, recoverable: bool) -> None:
|
|
self.emit(
|
|
"error",
|
|
llm.RealtimeModelError(
|
|
timestamp=time.time(),
|
|
label=self._realtime_model._label,
|
|
error=error,
|
|
recoverable=recoverable,
|
|
),
|
|
)
|
|
|
|
def _is_new_generation(self, resp: types.LiveServerMessage) -> bool:
|
|
if resp.tool_call:
|
|
return True
|
|
|
|
if (sc := resp.server_content) and (
|
|
sc.model_turn
|
|
or (
|
|
sc.output_transcription and sc.output_transcription and sc.output_transcription.text
|
|
)
|
|
or (sc.input_transcription and sc.input_transcription and sc.input_transcription.text)
|
|
# or (sc.generation_complete is not None)
|
|
# or (sc.turn_complete is not None)
|
|
):
|
|
# Some Gemini models send a `generation_complete` event after tool calls, but others do not.
|
|
# We mark the generation as done after a tool call and need to ignore any empty transcriptions or generation_complete events.
|
|
# This prevents new empty generations from starting and interrupting tool execution.
|
|
return True
|
|
|
|
return False
|