ec2b666284
Continuous Integration / Pre-commit Linter (push) Has been cancelled
Continuous Integration / Mypy Check (Python 3.10) (push) Has been cancelled
Continuous Integration / Mypy Check (Python 3.11) (push) Has been cancelled
Continuous Integration / Mypy Check (Python 3.12) (push) Has been cancelled
Continuous Integration / Mypy Check (Python 3.13) (push) Has been cancelled
Continuous Integration / Unit Tests (Python 3.10) (push) Has been cancelled
Continuous Integration / Unit Tests (Python 3.11) (push) Has been cancelled
Continuous Integration / Unit Tests (Python 3.12) (push) Has been cancelled
Continuous Integration / Unit Tests (Python 3.13) (push) Has been cancelled
Continuous Integration / Unit Tests (Python 3.14) (push) Has been cancelled
Continuous Integration / A2A v0.3 Tests (Python 3.10) (push) Has been cancelled
Continuous Integration / A2A v0.3 Tests (Python 3.11) (push) Has been cancelled
Continuous Integration / A2A v0.3 Tests (Python 3.12) (push) Has been cancelled
Copybara PR Handler / close-imported-pr (push) Has been cancelled
Continuous Integration / A2A v0.3 Tests (Python 3.13) (push) Has been cancelled
Continuous Integration / A2A v0.3 Tests (Python 3.14) (push) Has been cancelled
2350 lines
78 KiB
Python
2350 lines
78 KiB
Python
# Copyright 2026 Google LLC
|
|
#
|
|
# Licensed under the Apache License, Version 2.0 (the "License");
|
|
# you may not use this file except in compliance with the License.
|
|
# You may obtain a copy of the License at
|
|
#
|
|
# http://www.apache.org/licenses/LICENSE-2.0
|
|
#
|
|
# Unless required by applicable law or agreed to in writing, software
|
|
# distributed under the License is distributed on an "AS IS" BASIS,
|
|
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
# See the License for the specific language governing permissions and
|
|
# limitations under the License.
|
|
|
|
from unittest import mock
|
|
|
|
from google.adk.models.gemini_llm_connection import GeminiLlmConnection
|
|
from google.adk.utils.variant_utils import GoogleLLMVariant
|
|
from google.genai import types
|
|
import pytest
|
|
|
|
MODEL_VERSION = 'gemini-2.5-pro'
|
|
|
|
|
|
@pytest.fixture
|
|
def mock_gemini_session():
|
|
"""Mock Gemini session for testing."""
|
|
mock_session = mock.AsyncMock()
|
|
mock_session.session_id = 'test-session-id'
|
|
return mock_session
|
|
|
|
|
|
@pytest.fixture
|
|
def gemini_connection(mock_gemini_session):
|
|
"""GeminiLlmConnection instance with mocked session."""
|
|
return GeminiLlmConnection(
|
|
mock_gemini_session,
|
|
api_backend=GoogleLLMVariant.VERTEX_AI,
|
|
model_version=MODEL_VERSION,
|
|
)
|
|
|
|
|
|
@pytest.fixture
|
|
def gemini_api_connection(mock_gemini_session):
|
|
"""GeminiLlmConnection instance with mocked session for Gemini API."""
|
|
return GeminiLlmConnection(
|
|
mock_gemini_session,
|
|
api_backend=GoogleLLMVariant.GEMINI_API,
|
|
model_version=MODEL_VERSION,
|
|
)
|
|
|
|
|
|
@pytest.fixture
|
|
def test_blob():
|
|
"""Test blob for audio data."""
|
|
return types.Blob(data=b'\x00\xFF\x00\xFF', mime_type='audio/pcm')
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_send_realtime_default_behavior(
|
|
gemini_connection, mock_gemini_session, test_blob
|
|
):
|
|
"""Test send_realtime with default automatic_activity_detection value (True)."""
|
|
await gemini_connection.send_realtime(test_blob)
|
|
|
|
# Should call send once
|
|
mock_gemini_session.send_realtime_input.assert_called_once_with(
|
|
media=test_blob
|
|
)
|
|
# Should not call .send function
|
|
mock_gemini_session.send.assert_not_called()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_send_realtime_audio_uses_audio_channel_for_live_translate(
|
|
mock_gemini_session, test_blob
|
|
):
|
|
"""Live Translate models stream audio via the dedicated `audio=` channel."""
|
|
connection = GeminiLlmConnection(
|
|
mock_gemini_session,
|
|
api_backend=GoogleLLMVariant.GEMINI_API,
|
|
model_version='gemini-3.5-live-translate-preview',
|
|
)
|
|
|
|
await connection.send_realtime(test_blob)
|
|
|
|
mock_gemini_session.send_realtime_input.assert_called_once_with(
|
|
audio=test_blob
|
|
)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_send_history(gemini_connection, mock_gemini_session):
|
|
"""Test send_history method."""
|
|
history = [
|
|
types.Content(role='user', parts=[types.Part.from_text(text='Hello')]),
|
|
types.Content(
|
|
role='model', parts=[types.Part.from_text(text='Hi there!')]
|
|
),
|
|
]
|
|
|
|
await gemini_connection.send_history(history)
|
|
|
|
mock_gemini_session.send_client_content.assert_called_once()
|
|
call_args = mock_gemini_session.send_client_content.call_args[1]
|
|
assert 'turns' in call_args
|
|
assert call_args['turns'] == history
|
|
assert call_args['turn_complete'] is False # Last message is from model
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_send_content_text(gemini_connection, mock_gemini_session):
|
|
"""Test send_content with text content."""
|
|
content = types.Content(
|
|
role='user', parts=[types.Part.from_text(text='Hello')]
|
|
)
|
|
|
|
await gemini_connection.send_content(content)
|
|
|
|
mock_gemini_session.send.assert_called_once()
|
|
call_args = mock_gemini_session.send.call_args[1]
|
|
assert 'input' in call_args
|
|
assert call_args['input'].turns == [content]
|
|
assert call_args['input'].turn_complete is True
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_send_content_text_can_keep_turn_open(
|
|
gemini_connection, mock_gemini_session
|
|
):
|
|
content = types.Content(
|
|
role='user', parts=[types.Part.from_text(text='progress')]
|
|
)
|
|
|
|
await gemini_connection._send_content(content, partial=True)
|
|
|
|
mock_gemini_session.send.assert_called_once()
|
|
call_args = mock_gemini_session.send.call_args[1]
|
|
assert call_args['input'].turns == [content]
|
|
assert call_args['input'].turn_complete is False
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_send_content_function_response(
|
|
gemini_connection, mock_gemini_session
|
|
):
|
|
"""Test send_content with function response."""
|
|
function_response = types.FunctionResponse(
|
|
name='test_function', response={'result': 'success'}
|
|
)
|
|
content = types.Content(
|
|
role='user', parts=[types.Part(function_response=function_response)]
|
|
)
|
|
|
|
await gemini_connection.send_content(content)
|
|
|
|
mock_gemini_session.send_tool_response.assert_called_once()
|
|
call_args = mock_gemini_session.send_tool_response.call_args[1]
|
|
assert 'function_responses' in call_args
|
|
assert call_args['function_responses'] == [function_response]
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_close(gemini_connection, mock_gemini_session):
|
|
"""Test close method."""
|
|
await gemini_connection.close()
|
|
|
|
mock_gemini_session.close.assert_called_once()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.parametrize('tx_direction', ['input', 'output'])
|
|
async def test_receive_transcript_finished(
|
|
gemini_connection, mock_gemini_session, tx_direction
|
|
):
|
|
"""Test receive_transcript_finished for input and output transcription."""
|
|
|
|
finished_tx = types.Transcription(finished=True)
|
|
|
|
msg = mock.Mock()
|
|
msg.tool_call = None
|
|
msg.usage_metadata = None
|
|
msg.session_resumption_update = None
|
|
msg.go_away = None
|
|
msg.voice_activity = None
|
|
msg.server_content.model_turn = None
|
|
msg.server_content.interrupted = False
|
|
msg.server_content.turn_complete = False
|
|
msg.server_content.input_transcription = (
|
|
finished_tx if tx_direction == 'input' else None
|
|
)
|
|
msg.server_content.output_transcription = (
|
|
finished_tx if tx_direction == 'output' else None
|
|
)
|
|
msg.server_content.grounding_metadata = None
|
|
|
|
async def gen():
|
|
yield msg
|
|
|
|
mock_gemini_session.receive = mock.Mock(return_value=gen())
|
|
|
|
responses = []
|
|
async for r in gemini_connection.receive():
|
|
responses.append(r)
|
|
|
|
attr_name = f'{tx_direction}_transcription'
|
|
tx_resps = [r for r in responses if getattr(r, attr_name)]
|
|
assert tx_resps, f'Expected {tx_direction} transcription response'
|
|
|
|
transcription = getattr(tx_resps[0], attr_name)
|
|
assert transcription.finished is True
|
|
assert not transcription.text
|
|
|
|
|
|
async def test_receive_usage_metadata_and_server_content(
|
|
gemini_connection, mock_gemini_session
|
|
):
|
|
"""Test receive with usage metadata and server content in one message."""
|
|
usage_metadata = types.UsageMetadata(
|
|
prompt_token_count=10,
|
|
cached_content_token_count=5,
|
|
response_token_count=20,
|
|
total_token_count=35,
|
|
thoughts_token_count=2,
|
|
prompt_tokens_details=[
|
|
types.ModalityTokenCount(modality='text', token_count=10)
|
|
],
|
|
cache_tokens_details=[
|
|
types.ModalityTokenCount(modality='text', token_count=5)
|
|
],
|
|
response_tokens_details=[
|
|
types.ModalityTokenCount(modality='text', token_count=20)
|
|
],
|
|
)
|
|
mock_content = types.Content(
|
|
role='model', parts=[types.Part.from_text(text='response text')]
|
|
)
|
|
mock_server_content = mock.Mock()
|
|
mock_server_content.model_turn = mock_content
|
|
mock_server_content.interrupted = False
|
|
mock_server_content.input_transcription = None
|
|
mock_server_content.output_transcription = None
|
|
mock_server_content.turn_complete = False
|
|
mock_server_content.grounding_metadata = None
|
|
|
|
mock_message = mock.AsyncMock()
|
|
mock_message.usage_metadata = usage_metadata
|
|
mock_message.server_content = mock_server_content
|
|
mock_message.tool_call = None
|
|
mock_message.session_resumption_update = None
|
|
mock_message.go_away = None
|
|
mock_message.voice_activity = None
|
|
|
|
async def mock_receive_generator():
|
|
yield mock_message
|
|
|
|
receive_mock = mock.Mock(return_value=mock_receive_generator())
|
|
mock_gemini_session.receive = receive_mock
|
|
|
|
responses = [resp async for resp in gemini_connection.receive()]
|
|
|
|
assert responses
|
|
|
|
usage_response = next((r for r in responses if r.usage_metadata), None)
|
|
assert usage_response is not None
|
|
assert usage_response.model_version == MODEL_VERSION
|
|
content_response = next((r for r in responses if r.content), None)
|
|
assert content_response is not None
|
|
|
|
# The live API's `response_token_count`/`response_tokens_details` are remapped
|
|
# to `candidates_token_count`/`candidates_tokens_details`.
|
|
expected_usage = types.GenerateContentResponseUsageMetadata(
|
|
prompt_token_count=10,
|
|
cached_content_token_count=5,
|
|
candidates_token_count=20,
|
|
total_token_count=35,
|
|
thoughts_token_count=2,
|
|
prompt_tokens_details=[
|
|
types.ModalityTokenCount(modality='text', token_count=10)
|
|
],
|
|
cache_tokens_details=[
|
|
types.ModalityTokenCount(modality='text', token_count=5)
|
|
],
|
|
candidates_tokens_details=[
|
|
types.ModalityTokenCount(modality='text', token_count=20)
|
|
],
|
|
)
|
|
assert usage_response.usage_metadata == expected_usage
|
|
assert content_response.content == mock_content
|
|
|
|
|
|
async def test_receive_usage_metadata_remaps_output_tokens(
|
|
gemini_connection, mock_gemini_session
|
|
):
|
|
"""Test that live API output tokens are remapped to candidates_token_count."""
|
|
usage_metadata = types.UsageMetadata(
|
|
prompt_token_count=10,
|
|
cached_content_token_count=5,
|
|
response_token_count=20,
|
|
total_token_count=35,
|
|
thoughts_token_count=2,
|
|
tool_use_prompt_token_count=3,
|
|
prompt_tokens_details=[
|
|
types.ModalityTokenCount(modality='text', token_count=10)
|
|
],
|
|
cache_tokens_details=[
|
|
types.ModalityTokenCount(modality='text', token_count=5)
|
|
],
|
|
response_tokens_details=[
|
|
types.ModalityTokenCount(modality='text', token_count=20)
|
|
],
|
|
)
|
|
|
|
mock_message = mock.AsyncMock()
|
|
mock_message.usage_metadata = usage_metadata
|
|
mock_message.server_content = None
|
|
mock_message.tool_call = None
|
|
mock_message.session_resumption_update = None
|
|
mock_message.go_away = None
|
|
mock_message.voice_activity = None
|
|
|
|
async def mock_receive_generator():
|
|
yield mock_message
|
|
|
|
receive_mock = mock.Mock(return_value=mock_receive_generator())
|
|
mock_gemini_session.receive = receive_mock
|
|
|
|
responses = [resp async for resp in gemini_connection.receive()]
|
|
|
|
usage_response = next((r for r in responses if r.usage_metadata), None)
|
|
assert usage_response is not None
|
|
result = usage_response.usage_metadata
|
|
assert isinstance(result, types.GenerateContentResponseUsageMetadata)
|
|
# Output tokens are remapped from response_* to candidates_*.
|
|
assert result.candidates_token_count == 20
|
|
assert result.candidates_tokens_details == [
|
|
types.ModalityTokenCount(modality='text', token_count=20)
|
|
]
|
|
# Shared fields are carried over unchanged.
|
|
assert result.prompt_token_count == 10
|
|
assert result.cached_content_token_count == 5
|
|
assert result.total_token_count == 35
|
|
assert result.thoughts_token_count == 2
|
|
assert result.tool_use_prompt_token_count == 3
|
|
assert result.prompt_tokens_details == [
|
|
types.ModalityTokenCount(modality='text', token_count=10)
|
|
]
|
|
assert result.cache_tokens_details == [
|
|
types.ModalityTokenCount(modality='text', token_count=5)
|
|
]
|
|
|
|
|
|
async def test_receive_populates_live_session_id(
|
|
gemini_connection, mock_gemini_session
|
|
):
|
|
"""Test that receive populates live_session_id in LlmResponse."""
|
|
mock_message = mock.AsyncMock()
|
|
mock_message.usage_metadata = None
|
|
mock_message.server_content = None
|
|
mock_message.tool_call = None
|
|
mock_message.session_resumption_update = None
|
|
mock_message.go_away = None
|
|
mock_message.voice_activity = None
|
|
|
|
mock_server_content = mock.Mock()
|
|
mock_server_content.model_turn = types.Content(
|
|
role='model', parts=[types.Part.from_text(text='text')]
|
|
)
|
|
mock_server_content.interrupted = False
|
|
mock_server_content.input_transcription = None
|
|
mock_server_content.output_transcription = None
|
|
mock_server_content.turn_complete = False
|
|
mock_server_content.grounding_metadata = None
|
|
|
|
mock_message.server_content = mock_server_content
|
|
|
|
async def mock_receive_generator():
|
|
yield mock_message
|
|
|
|
mock_gemini_session.receive = mock.Mock(return_value=mock_receive_generator())
|
|
|
|
responses = [resp async for resp in gemini_connection.receive()]
|
|
|
|
assert responses
|
|
for resp in responses:
|
|
assert resp.live_session_id == 'test-session-id'
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.parametrize(
|
|
'conn_fixture',
|
|
['gemini_api_connection', 'gemini_connection'],
|
|
)
|
|
async def test_receive_transcript_finished_on_interrupt(
|
|
conn_fixture,
|
|
mock_gemini_session,
|
|
request,
|
|
):
|
|
"""Test receive finishes transcription on interrupt signal."""
|
|
connection = request.getfixturevalue(conn_fixture)
|
|
|
|
message1 = mock.Mock()
|
|
message1.usage_metadata = None
|
|
message1.server_content = mock.Mock()
|
|
message1.server_content.model_turn = None
|
|
message1.server_content.interrupted = False
|
|
message1.server_content.input_transcription = types.Transcription(
|
|
text='Hello', finished=False
|
|
)
|
|
message1.server_content.output_transcription = None
|
|
message1.server_content.turn_complete = False
|
|
message1.server_content.generation_complete = False
|
|
message1.server_content.grounding_metadata = None
|
|
message1.tool_call = None
|
|
message1.session_resumption_update = None
|
|
message1.go_away = None
|
|
message1.voice_activity = None
|
|
|
|
message2 = mock.Mock()
|
|
message2.usage_metadata = None
|
|
message2.server_content = mock.Mock()
|
|
message2.server_content.model_turn = None
|
|
message2.server_content.interrupted = False
|
|
message2.server_content.input_transcription = None
|
|
message2.server_content.output_transcription = types.Transcription(
|
|
text='How can', finished=False
|
|
)
|
|
message2.server_content.turn_complete = False
|
|
message2.server_content.generation_complete = False
|
|
message2.server_content.grounding_metadata = None
|
|
message2.tool_call = None
|
|
message2.session_resumption_update = None
|
|
message2.go_away = None
|
|
message2.voice_activity = None
|
|
|
|
message3 = mock.Mock()
|
|
message3.usage_metadata = None
|
|
message3.server_content = mock.Mock()
|
|
message3.server_content.model_turn = None
|
|
message3.server_content.interrupted = True
|
|
message3.server_content.input_transcription = None
|
|
message3.server_content.output_transcription = None
|
|
message3.server_content.turn_complete = False
|
|
message3.server_content.generation_complete = False
|
|
message3.server_content.grounding_metadata = None
|
|
message3.tool_call = None
|
|
message3.session_resumption_update = None
|
|
message3.go_away = None
|
|
message3.voice_activity = None
|
|
|
|
async def mock_receive_generator():
|
|
yield message1
|
|
yield message2
|
|
yield message3
|
|
|
|
receive_mock = mock.Mock(return_value=mock_receive_generator())
|
|
mock_gemini_session.receive = receive_mock
|
|
|
|
responses = [resp async for resp in connection.receive()]
|
|
|
|
assert len(responses) == 5
|
|
assert responses[4].interrupted is True
|
|
|
|
assert responses[0].input_transcription.text == 'Hello'
|
|
assert responses[0].input_transcription.finished is False
|
|
assert responses[0].partial is True
|
|
assert responses[1].output_transcription.text == 'How can'
|
|
assert responses[1].output_transcription.finished is False
|
|
assert responses[1].partial is True
|
|
assert responses[2].input_transcription.text == 'Hello'
|
|
assert responses[2].input_transcription.finished is True
|
|
assert responses[2].partial is False
|
|
assert responses[3].output_transcription.text == 'How can'
|
|
assert responses[3].output_transcription.finished is True
|
|
assert responses[3].partial is False
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.parametrize(
|
|
'conn_fixture',
|
|
['gemini_api_connection', 'gemini_connection'],
|
|
)
|
|
async def test_receive_transcript_finished_on_generation_complete(
|
|
conn_fixture,
|
|
mock_gemini_session,
|
|
request,
|
|
):
|
|
"""Test receive finishes transcription on generation_complete signal."""
|
|
connection = request.getfixturevalue(conn_fixture)
|
|
|
|
message1 = mock.Mock()
|
|
message1.usage_metadata = None
|
|
message1.server_content = mock.Mock()
|
|
message1.server_content.model_turn = None
|
|
message1.server_content.interrupted = False
|
|
message1.server_content.input_transcription = types.Transcription(
|
|
text='Hello', finished=False
|
|
)
|
|
message1.server_content.output_transcription = None
|
|
message1.server_content.turn_complete = False
|
|
message1.server_content.generation_complete = False
|
|
message1.server_content.grounding_metadata = None
|
|
message1.tool_call = None
|
|
message1.session_resumption_update = None
|
|
message1.go_away = None
|
|
message1.voice_activity = None
|
|
|
|
message2 = mock.Mock()
|
|
message2.usage_metadata = None
|
|
message2.server_content = mock.Mock()
|
|
message2.server_content.model_turn = None
|
|
message2.server_content.interrupted = False
|
|
message2.server_content.input_transcription = None
|
|
message2.server_content.output_transcription = types.Transcription(
|
|
text='How can', finished=False
|
|
)
|
|
message2.server_content.turn_complete = False
|
|
message2.server_content.generation_complete = False
|
|
message2.server_content.grounding_metadata = None
|
|
message2.tool_call = None
|
|
message2.session_resumption_update = None
|
|
message2.go_away = None
|
|
message2.voice_activity = None
|
|
|
|
message3 = mock.Mock()
|
|
message3.usage_metadata = None
|
|
message3.server_content = mock.Mock()
|
|
message3.server_content.model_turn = None
|
|
message3.server_content.interrupted = False
|
|
message3.server_content.input_transcription = None
|
|
message3.server_content.output_transcription = None
|
|
message3.server_content.turn_complete = False
|
|
message3.server_content.generation_complete = True
|
|
message3.server_content.grounding_metadata = None
|
|
message3.tool_call = None
|
|
message3.session_resumption_update = None
|
|
message3.go_away = None
|
|
message3.voice_activity = None
|
|
|
|
async def mock_receive_generator():
|
|
yield message1
|
|
yield message2
|
|
yield message3
|
|
|
|
receive_mock = mock.Mock(return_value=mock_receive_generator())
|
|
mock_gemini_session.receive = receive_mock
|
|
|
|
responses = [resp async for resp in connection.receive()]
|
|
|
|
assert len(responses) == 4
|
|
|
|
assert responses[0].input_transcription.text == 'Hello'
|
|
assert responses[0].input_transcription.finished is False
|
|
assert responses[0].partial is True
|
|
assert responses[1].output_transcription.text == 'How can'
|
|
assert responses[1].output_transcription.finished is False
|
|
assert responses[1].partial is True
|
|
assert responses[2].input_transcription.text == 'Hello'
|
|
assert responses[2].input_transcription.finished is True
|
|
assert responses[2].partial is False
|
|
assert responses[3].output_transcription.text == 'How can'
|
|
assert responses[3].output_transcription.finished is True
|
|
assert responses[3].partial is False
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.parametrize(
|
|
'conn_fixture',
|
|
['gemini_api_connection', 'gemini_connection'],
|
|
)
|
|
async def test_receive_transcript_finished_on_turn_complete(
|
|
conn_fixture,
|
|
mock_gemini_session,
|
|
request,
|
|
):
|
|
"""Test receive finishes transcription on interrupt or complete signals."""
|
|
connection = request.getfixturevalue(conn_fixture)
|
|
|
|
message1 = mock.Mock()
|
|
message1.usage_metadata = None
|
|
message1.server_content = mock.Mock()
|
|
message1.server_content.model_turn = None
|
|
message1.server_content.interrupted = False
|
|
message1.server_content.input_transcription = types.Transcription(
|
|
text='Hello', finished=False
|
|
)
|
|
message1.server_content.output_transcription = None
|
|
message1.server_content.turn_complete = False
|
|
message1.server_content.generation_complete = False
|
|
message1.server_content.grounding_metadata = None
|
|
message1.tool_call = None
|
|
message1.session_resumption_update = None
|
|
message1.go_away = None
|
|
message1.voice_activity = None
|
|
|
|
message2 = mock.Mock()
|
|
message2.usage_metadata = None
|
|
message2.server_content = mock.Mock()
|
|
message2.server_content.model_turn = None
|
|
message2.server_content.interrupted = False
|
|
message2.server_content.input_transcription = None
|
|
message2.server_content.output_transcription = types.Transcription(
|
|
text='How can', finished=False
|
|
)
|
|
message2.server_content.turn_complete = False
|
|
message2.server_content.generation_complete = False
|
|
message2.server_content.grounding_metadata = None
|
|
message2.tool_call = None
|
|
message2.session_resumption_update = None
|
|
message2.go_away = None
|
|
message2.voice_activity = None
|
|
|
|
message3 = mock.Mock()
|
|
message3.usage_metadata = None
|
|
message3.server_content = mock.Mock()
|
|
message3.server_content.model_turn = None
|
|
message3.server_content.interrupted = False
|
|
message3.server_content.input_transcription = None
|
|
message3.server_content.output_transcription = None
|
|
message3.server_content.turn_complete = True
|
|
message3.server_content.generation_complete = False
|
|
message3.server_content.grounding_metadata = None
|
|
message3.tool_call = None
|
|
message3.session_resumption_update = None
|
|
message3.go_away = None
|
|
message3.voice_activity = None
|
|
|
|
async def mock_receive_generator():
|
|
yield message1
|
|
yield message2
|
|
yield message3
|
|
|
|
receive_mock = mock.Mock(return_value=mock_receive_generator())
|
|
mock_gemini_session.receive = receive_mock
|
|
|
|
responses = [resp async for resp in connection.receive()]
|
|
|
|
assert len(responses) == 5
|
|
assert responses[4].turn_complete is True
|
|
|
|
assert responses[0].input_transcription.text == 'Hello'
|
|
assert responses[0].input_transcription.finished is False
|
|
assert responses[0].partial is True
|
|
assert responses[1].output_transcription.text == 'How can'
|
|
assert responses[1].output_transcription.finished is False
|
|
assert responses[1].partial is True
|
|
assert responses[2].input_transcription.text == 'Hello'
|
|
assert responses[2].input_transcription.finished is True
|
|
assert responses[2].partial is False
|
|
assert responses[3].output_transcription.text == 'How can'
|
|
assert responses[3].output_transcription.finished is True
|
|
assert responses[3].partial is False
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_receive_handles_input_transcription_fragments(
|
|
gemini_connection, mock_gemini_session
|
|
):
|
|
"""Test receive handles input transcription fragments correctly."""
|
|
message1 = mock.Mock()
|
|
message1.usage_metadata = None
|
|
message1.server_content = mock.Mock()
|
|
message1.server_content.model_turn = None
|
|
message1.server_content.interrupted = False
|
|
message1.server_content.input_transcription = types.Transcription(
|
|
text='Hello', finished=False
|
|
)
|
|
message1.server_content.output_transcription = None
|
|
message1.server_content.turn_complete = False
|
|
message1.server_content.generation_complete = False
|
|
message1.server_content.grounding_metadata = None
|
|
message1.tool_call = None
|
|
message1.session_resumption_update = None
|
|
message1.go_away = None
|
|
message1.voice_activity = None
|
|
|
|
message2 = mock.Mock()
|
|
message2.usage_metadata = None
|
|
message2.server_content = mock.Mock()
|
|
message2.server_content.model_turn = None
|
|
message2.server_content.interrupted = False
|
|
message2.server_content.input_transcription = types.Transcription(
|
|
text=' world', finished=False
|
|
)
|
|
message2.server_content.output_transcription = None
|
|
message2.server_content.turn_complete = False
|
|
message2.server_content.generation_complete = False
|
|
message2.server_content.grounding_metadata = None
|
|
message2.tool_call = None
|
|
message2.session_resumption_update = None
|
|
message2.go_away = None
|
|
message2.voice_activity = None
|
|
|
|
message3 = mock.Mock()
|
|
message3.usage_metadata = None
|
|
message3.server_content = mock.Mock()
|
|
message3.server_content.model_turn = None
|
|
message3.server_content.interrupted = False
|
|
message3.server_content.input_transcription = types.Transcription(
|
|
text=None, finished=True
|
|
)
|
|
message3.server_content.output_transcription = None
|
|
message3.server_content.turn_complete = False
|
|
message3.server_content.generation_complete = False
|
|
message3.server_content.grounding_metadata = None
|
|
message3.tool_call = None
|
|
message3.session_resumption_update = None
|
|
message3.go_away = None
|
|
message3.voice_activity = None
|
|
|
|
async def mock_receive_generator():
|
|
yield message1
|
|
yield message2
|
|
yield message3
|
|
|
|
receive_mock = mock.Mock(return_value=mock_receive_generator())
|
|
mock_gemini_session.receive = receive_mock
|
|
|
|
responses = [resp async for resp in gemini_connection.receive()]
|
|
|
|
assert len(responses) == 3
|
|
assert responses[0].input_transcription.text == 'Hello'
|
|
assert responses[0].input_transcription.finished is False
|
|
assert responses[0].partial is True
|
|
assert responses[1].input_transcription.text == ' world'
|
|
assert responses[1].input_transcription.finished is False
|
|
assert responses[1].partial is True
|
|
assert responses[2].input_transcription.text == 'Hello world'
|
|
assert responses[2].input_transcription.finished is True
|
|
assert responses[2].partial is False
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_receive_handles_output_transcription_fragments(
|
|
gemini_connection, mock_gemini_session
|
|
):
|
|
"""Test receive handles output transcription fragments correctly."""
|
|
message1 = mock.Mock()
|
|
message1.usage_metadata = None
|
|
message1.server_content = mock.Mock()
|
|
message1.server_content.model_turn = None
|
|
message1.server_content.interrupted = False
|
|
message1.server_content.input_transcription = None
|
|
message1.server_content.output_transcription = types.Transcription(
|
|
text='How can', finished=False
|
|
)
|
|
message1.server_content.turn_complete = False
|
|
message1.server_content.generation_complete = False
|
|
message1.server_content.grounding_metadata = None
|
|
message1.tool_call = None
|
|
message1.session_resumption_update = None
|
|
message1.go_away = None
|
|
message1.voice_activity = None
|
|
|
|
message2 = mock.Mock()
|
|
message2.usage_metadata = None
|
|
message2.server_content = mock.Mock()
|
|
message2.server_content.model_turn = None
|
|
message2.server_content.interrupted = False
|
|
message2.server_content.input_transcription = None
|
|
message2.server_content.output_transcription = types.Transcription(
|
|
text=' I help?', finished=False
|
|
)
|
|
message2.server_content.turn_complete = False
|
|
message2.server_content.generation_complete = False
|
|
message2.server_content.grounding_metadata = None
|
|
message2.tool_call = None
|
|
message2.session_resumption_update = None
|
|
message2.go_away = None
|
|
message2.voice_activity = None
|
|
|
|
message3 = mock.Mock()
|
|
message3.usage_metadata = None
|
|
message3.server_content = mock.Mock()
|
|
message3.server_content.model_turn = None
|
|
message3.server_content.interrupted = False
|
|
message3.server_content.input_transcription = None
|
|
message3.server_content.output_transcription = types.Transcription(
|
|
text=None, finished=True
|
|
)
|
|
message3.server_content.turn_complete = False
|
|
message3.server_content.generation_complete = False
|
|
message3.server_content.grounding_metadata = None
|
|
message3.tool_call = None
|
|
message3.session_resumption_update = None
|
|
message3.go_away = None
|
|
message3.voice_activity = None
|
|
|
|
async def mock_receive_generator():
|
|
yield message1
|
|
yield message2
|
|
yield message3
|
|
|
|
receive_mock = mock.Mock(return_value=mock_receive_generator())
|
|
mock_gemini_session.receive = receive_mock
|
|
|
|
responses = [resp async for resp in gemini_connection.receive()]
|
|
|
|
assert len(responses) == 3
|
|
assert responses[0].output_transcription.text == 'How can'
|
|
assert responses[0].output_transcription.finished is False
|
|
assert responses[0].partial is True
|
|
assert responses[1].output_transcription.text == ' I help?'
|
|
assert responses[1].output_transcription.finished is False
|
|
assert responses[1].partial is True
|
|
assert responses[2].output_transcription.text == 'How can I help?'
|
|
assert responses[2].output_transcription.finished is True
|
|
assert responses[2].partial is False
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.parametrize(
|
|
'audio_part',
|
|
[
|
|
types.Part(
|
|
inline_data=types.Blob(data=b'\x00\xFF', mime_type='audio/pcm')
|
|
),
|
|
types.Part(
|
|
file_data=types.FileData(
|
|
file_uri='artifact://app/user/session/_adk_live/audio.pcm#1',
|
|
mime_type='audio/pcm',
|
|
)
|
|
),
|
|
],
|
|
)
|
|
async def test_send_history_filters_audio(mock_gemini_session, audio_part):
|
|
"""Test that audio parts (inline or file_data) are filtered out."""
|
|
connection = GeminiLlmConnection(
|
|
mock_gemini_session, api_backend=GoogleLLMVariant.VERTEX_AI
|
|
)
|
|
history = [
|
|
types.Content(
|
|
role='user',
|
|
parts=[audio_part],
|
|
),
|
|
types.Content(
|
|
role='model', parts=[types.Part.from_text(text='I heard you')]
|
|
),
|
|
]
|
|
|
|
await connection.send_history(history)
|
|
|
|
mock_gemini_session.send_client_content.assert_called_once()
|
|
call_args = mock_gemini_session.send_client_content.call_args[1]
|
|
sent_contents = call_args['turns']
|
|
# Only the model response should be sent (user audio filtered out)
|
|
assert len(sent_contents) == 1
|
|
assert sent_contents[0].role == 'model'
|
|
assert sent_contents[0].parts == [types.Part.from_text(text='I heard you')]
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_send_history_keeps_image_data(mock_gemini_session):
|
|
"""Test that image data is NOT filtered out."""
|
|
connection = GeminiLlmConnection(
|
|
mock_gemini_session, api_backend=GoogleLLMVariant.VERTEX_AI
|
|
)
|
|
image_blob = types.Blob(data=b'\x89PNG\r\n', mime_type='image/png')
|
|
history = [
|
|
types.Content(
|
|
role='user',
|
|
parts=[types.Part(inline_data=image_blob)],
|
|
),
|
|
types.Content(
|
|
role='model', parts=[types.Part.from_text(text='Nice image!')]
|
|
),
|
|
]
|
|
|
|
await connection.send_history(history)
|
|
|
|
mock_gemini_session.send_client_content.assert_called_once()
|
|
call_args = mock_gemini_session.send_client_content.call_args[1]
|
|
sent_contents = call_args['turns']
|
|
# Both contents should be sent (image is not filtered)
|
|
assert len(sent_contents) == 2
|
|
assert sent_contents[0].parts[0].inline_data == image_blob
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_send_history_mixed_content_filters_only_audio(
|
|
mock_gemini_session,
|
|
):
|
|
"""Test that mixed content keeps non-audio parts."""
|
|
connection = GeminiLlmConnection(
|
|
mock_gemini_session, api_backend=GoogleLLMVariant.VERTEX_AI
|
|
)
|
|
history = [
|
|
types.Content(
|
|
role='user',
|
|
parts=[
|
|
types.Part(
|
|
inline_data=types.Blob(
|
|
data=b'\x00\xFF', mime_type='audio/wav'
|
|
)
|
|
),
|
|
types.Part.from_text(text='transcribed text'),
|
|
],
|
|
),
|
|
]
|
|
|
|
await connection.send_history(history)
|
|
|
|
mock_gemini_session.send_client_content.assert_called_once()
|
|
call_args = mock_gemini_session.send_client_content.call_args[1]
|
|
sent_contents = call_args['turns']
|
|
# Content should be sent but only with the text part
|
|
assert len(sent_contents) == 1
|
|
assert len(sent_contents[0].parts) == 1
|
|
assert sent_contents[0].parts[0].text == 'transcribed text'
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_send_history_all_audio_content_not_sent(mock_gemini_session):
|
|
"""Test that content with only audio parts is completely removed."""
|
|
connection = GeminiLlmConnection(
|
|
mock_gemini_session, api_backend=GoogleLLMVariant.VERTEX_AI
|
|
)
|
|
history = [
|
|
types.Content(
|
|
role='user',
|
|
parts=[
|
|
types.Part(
|
|
inline_data=types.Blob(
|
|
data=b'\x00\xFF', mime_type='audio/pcm'
|
|
)
|
|
),
|
|
types.Part(
|
|
file_data=types.FileData(
|
|
file_uri='artifact://audio.pcm#1',
|
|
mime_type='audio/wav',
|
|
)
|
|
),
|
|
],
|
|
),
|
|
]
|
|
|
|
await connection.send_history(history)
|
|
|
|
# No content should be sent since all parts are audio
|
|
mock_gemini_session.send.assert_not_called()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_send_history_empty_history_not_sent(mock_gemini_session):
|
|
"""Test that empty history does not call send."""
|
|
connection = GeminiLlmConnection(
|
|
mock_gemini_session, api_backend=GoogleLLMVariant.VERTEX_AI
|
|
)
|
|
|
|
await connection.send_history([])
|
|
|
|
mock_gemini_session.send.assert_not_called()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.parametrize(
|
|
'audio_mime_type',
|
|
['audio/pcm', 'audio/wav', 'audio/mp3', 'audio/ogg'],
|
|
)
|
|
async def test_send_history_filters_various_audio_mime_types(
|
|
mock_gemini_session,
|
|
audio_mime_type,
|
|
):
|
|
"""Test that various audio mime types are all filtered."""
|
|
connection = GeminiLlmConnection(
|
|
mock_gemini_session, api_backend=GoogleLLMVariant.VERTEX_AI
|
|
)
|
|
history = [
|
|
types.Content(
|
|
role='user',
|
|
parts=[
|
|
types.Part(
|
|
inline_data=types.Blob(data=b'', mime_type=audio_mime_type)
|
|
)
|
|
],
|
|
),
|
|
]
|
|
|
|
await connection.send_history(history)
|
|
|
|
# No content should be sent since the only part is audio
|
|
mock_gemini_session.send.assert_not_called()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_send_history_gemini_31_turn_complete(mock_gemini_session):
|
|
"""Verify Gemini 3.1 Live history seeding sets turn_complete based on history[-1].role == 'user'."""
|
|
conn = GeminiLlmConnection(
|
|
mock_gemini_session,
|
|
api_backend=GoogleLLMVariant.GEMINI_API,
|
|
model_version='gemini-3.1-flash-live-preview',
|
|
)
|
|
mock_gemini_session.send_client_content = mock.AsyncMock()
|
|
|
|
# Last turn is model -> turn_complete=False
|
|
mock_contents_model = [
|
|
types.Content(role='user', parts=[types.Part.from_text(text='hi')]),
|
|
types.Content(role='model', parts=[types.Part.from_text(text='hello')]),
|
|
]
|
|
await conn.send_history(mock_contents_model)
|
|
|
|
mock_gemini_session.send_client_content.assert_called_once_with(
|
|
turns=mock_contents_model,
|
|
turn_complete=False,
|
|
)
|
|
|
|
# Last turn is user -> turn_complete=True
|
|
mock_gemini_session.send_client_content.reset_mock()
|
|
mock_contents_user = [
|
|
types.Content(role='user', parts=[types.Part.from_text(text='hi')]),
|
|
]
|
|
await conn.send_history(mock_contents_user)
|
|
|
|
mock_gemini_session.send_client_content.assert_called_once_with(
|
|
turns=mock_contents_user,
|
|
turn_complete=True,
|
|
)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_send_history_vertex_ai_no_collapse(mock_gemini_session):
|
|
"""Verify history is sent without collapsing on Vertex AI backend."""
|
|
conn = GeminiLlmConnection(
|
|
mock_gemini_session,
|
|
api_backend=GoogleLLMVariant.VERTEX_AI,
|
|
model_version='gemini-3.1-flash-live-preview',
|
|
)
|
|
mock_gemini_session.send_client_content = mock.AsyncMock()
|
|
|
|
# Last turn is model -> turn_complete=False
|
|
mock_contents_model = [
|
|
types.Content(role='user', parts=[types.Part.from_text(text='hi')]),
|
|
types.Content(role='model', parts=[types.Part.from_text(text='hello')]),
|
|
]
|
|
await conn.send_history(mock_contents_model)
|
|
|
|
mock_gemini_session.send_client_content.assert_called_once_with(
|
|
turns=mock_contents_model,
|
|
turn_complete=False,
|
|
)
|
|
|
|
# Last turn is user -> turn_complete=True
|
|
mock_gemini_session.send_client_content.reset_mock()
|
|
mock_contents_user = [
|
|
types.Content(role='user', parts=[types.Part.from_text(text='hi')]),
|
|
types.Content(role='model', parts=[types.Part.from_text(text='hello')]),
|
|
types.Content(
|
|
role='user', parts=[types.Part.from_text(text='how are you?')]
|
|
),
|
|
]
|
|
await conn.send_history(mock_contents_user)
|
|
|
|
mock_gemini_session.send_client_content.assert_called_once_with(
|
|
turns=mock_contents_user,
|
|
turn_complete=True,
|
|
)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_send_history_turn_complete_determined_by_filtered_content(
|
|
mock_gemini_session,
|
|
):
|
|
"""Verify turn_complete is determined by the last element of filtered content instead of unfiltered history."""
|
|
conn = GeminiLlmConnection(
|
|
mock_gemini_session,
|
|
api_backend=GoogleLLMVariant.GEMINI_API,
|
|
model_version='gemini-3.1-flash-live-preview',
|
|
)
|
|
mock_gemini_session.send_client_content = mock.AsyncMock()
|
|
|
|
# Scenario: Last turn in history is a user audio turn (gets filtered out).
|
|
# The remaining last turn is model's turn -> turn_complete should be False.
|
|
audio_part = types.Part(
|
|
inline_data=types.Blob(data=b'\x00\xFF', mime_type='audio/pcm')
|
|
)
|
|
history_with_final_audio_user_turn = [
|
|
types.Content(role='user', parts=[types.Part.from_text(text='hi')]),
|
|
types.Content(role='model', parts=[types.Part.from_text(text='hello')]),
|
|
types.Content(role='user', parts=[audio_part]),
|
|
]
|
|
|
|
await conn.send_history(history_with_final_audio_user_turn)
|
|
|
|
expected_contents = [
|
|
types.Content(role='user', parts=[types.Part.from_text(text='hi')]),
|
|
types.Content(role='model', parts=[types.Part.from_text(text='hello')]),
|
|
]
|
|
mock_gemini_session.send_client_content.assert_called_once_with(
|
|
turns=expected_contents,
|
|
turn_complete=False,
|
|
)
|
|
|
|
# Scenario: Last turn in history is a model audio turn (gets filtered out).
|
|
# The remaining last turn is user's turn -> turn_complete should be True.
|
|
mock_gemini_session.send_client_content.reset_mock()
|
|
history_with_final_audio_model_turn = [
|
|
types.Content(role='user', parts=[types.Part.from_text(text='hi')]),
|
|
types.Content(role='model', parts=[audio_part]),
|
|
]
|
|
|
|
await conn.send_history(history_with_final_audio_model_turn)
|
|
|
|
expected_contents = [
|
|
types.Content(role='user', parts=[types.Part.from_text(text='hi')]),
|
|
]
|
|
mock_gemini_session.send_client_content.assert_called_once_with(
|
|
turns=expected_contents,
|
|
turn_complete=True,
|
|
)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_receive_grounding_metadata_standalone(
|
|
gemini_connection, mock_gemini_session
|
|
):
|
|
"""Test receive handles standalone grounding metadata correctly."""
|
|
grounding_metadata = types.GroundingMetadata(
|
|
web_search_queries=['stock price of google'],
|
|
search_entry_point=types.SearchEntryPoint(
|
|
rendered_content='<p>Google</p>'
|
|
),
|
|
)
|
|
mock_server_content = mock.create_autospec(
|
|
types.LiveServerContent, instance=True
|
|
)
|
|
mock_server_content.model_turn = None
|
|
mock_server_content.grounding_metadata = grounding_metadata
|
|
mock_server_content.turn_complete = False
|
|
mock_server_content.interrupted = False
|
|
mock_server_content.input_transcription = None
|
|
mock_server_content.output_transcription = None
|
|
mock_server_content.generation_complete = False
|
|
|
|
mock_message = mock.create_autospec(types.LiveServerMessage, instance=True)
|
|
mock_message.usage_metadata = None
|
|
mock_message.server_content = mock_server_content
|
|
mock_message.tool_call = None
|
|
mock_message.session_resumption_update = None
|
|
mock_message.go_away = None
|
|
mock_message.voice_activity = None
|
|
|
|
async def mock_receive_generator():
|
|
yield mock_message
|
|
|
|
receive_mock = mock.Mock(return_value=mock_receive_generator())
|
|
mock_gemini_session.receive = receive_mock
|
|
|
|
responses = [resp async for resp in gemini_connection.receive()]
|
|
|
|
assert len(responses) == 1
|
|
assert responses[0].grounding_metadata == grounding_metadata
|
|
assert responses[0].content is None
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_receive_grounding_metadata_with_content(
|
|
gemini_connection, mock_gemini_session
|
|
):
|
|
"""Test receive handles grounding metadata attached to regular content."""
|
|
grounding_metadata = types.GroundingMetadata(
|
|
web_search_queries=['stock price of google'],
|
|
search_entry_point=types.SearchEntryPoint(
|
|
rendered_content='<p>Google</p>'
|
|
),
|
|
)
|
|
mock_content = types.Content(
|
|
role='model', parts=[types.Part.from_text(text='response text')]
|
|
)
|
|
mock_server_content = mock.create_autospec(
|
|
types.LiveServerContent, instance=True
|
|
)
|
|
mock_server_content.model_turn = mock_content
|
|
mock_server_content.grounding_metadata = grounding_metadata
|
|
mock_server_content.turn_complete = False
|
|
mock_server_content.interrupted = False
|
|
mock_server_content.input_transcription = None
|
|
mock_server_content.output_transcription = None
|
|
mock_server_content.generation_complete = False
|
|
|
|
mock_message = mock.create_autospec(types.LiveServerMessage, instance=True)
|
|
mock_message.usage_metadata = None
|
|
mock_message.server_content = mock_server_content
|
|
mock_message.tool_call = None
|
|
mock_message.session_resumption_update = None
|
|
mock_message.go_away = None
|
|
mock_message.voice_activity = None
|
|
|
|
async def mock_receive_generator():
|
|
yield mock_message
|
|
|
|
receive_mock = mock.Mock(return_value=mock_receive_generator())
|
|
mock_gemini_session.receive = receive_mock
|
|
|
|
responses = [resp async for resp in gemini_connection.receive()]
|
|
|
|
assert len(responses) == 1
|
|
assert responses[0].grounding_metadata == grounding_metadata
|
|
assert responses[0].content == mock_content
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_receive_tool_call_and_grounding_metadata_with_native_audio(
|
|
mock_gemini_session,
|
|
):
|
|
"""Test receive handles tool call followed by grounding metadata."""
|
|
connection = GeminiLlmConnection(
|
|
mock_gemini_session,
|
|
api_backend=GoogleLLMVariant.VERTEX_AI,
|
|
model_version='gemini-live-2.5-flash-native-audio',
|
|
)
|
|
|
|
# 1. Message with tool call (e.g., enterprise_web_search)
|
|
mock_tool_call_msg = mock.create_autospec(
|
|
types.LiveServerMessage, instance=True
|
|
)
|
|
mock_tool_call_msg.usage_metadata = None
|
|
mock_tool_call_msg.server_content = None
|
|
mock_tool_call_msg.session_resumption_update = None
|
|
mock_tool_call_msg.go_away = None
|
|
mock_tool_call_msg.voice_activity = None
|
|
|
|
function_call = types.FunctionCall(
|
|
name='enterprise_web_search',
|
|
args={'query': 'Google stock price today'},
|
|
)
|
|
mock_tool_call = mock.create_autospec(types.LiveServerToolCall, instance=True)
|
|
mock_tool_call.function_calls = [function_call]
|
|
mock_tool_call_msg.tool_call = mock_tool_call
|
|
|
|
# 2. Message with grounding metadata and audio content (native audio model)
|
|
grounding_metadata = types.GroundingMetadata(
|
|
web_search_queries=['Google stock price today'],
|
|
search_entry_point=types.SearchEntryPoint(
|
|
rendered_content='<p>Google</p>'
|
|
),
|
|
)
|
|
audio_blob = types.Blob(data=b'\x00\xFF', mime_type='audio/pcm')
|
|
mock_content = types.Content(
|
|
role='model', parts=[types.Part(inline_data=audio_blob)]
|
|
)
|
|
|
|
mock_server_content = mock.create_autospec(
|
|
types.LiveServerContent, instance=True
|
|
)
|
|
mock_server_content.model_turn = mock_content
|
|
mock_server_content.grounding_metadata = grounding_metadata
|
|
mock_server_content.turn_complete = False
|
|
mock_server_content.interrupted = False
|
|
mock_server_content.input_transcription = None
|
|
mock_server_content.output_transcription = None
|
|
mock_server_content.generation_complete = False
|
|
|
|
mock_metadata_msg = mock.create_autospec(
|
|
types.LiveServerMessage, instance=True
|
|
)
|
|
mock_metadata_msg.usage_metadata = None
|
|
mock_metadata_msg.server_content = mock_server_content
|
|
mock_metadata_msg.tool_call = None
|
|
mock_metadata_msg.session_resumption_update = None
|
|
mock_metadata_msg.go_away = None
|
|
mock_metadata_msg.voice_activity = None
|
|
|
|
# 3. Message with turn_complete
|
|
mock_turn_complete_content = mock.create_autospec(
|
|
types.LiveServerContent, instance=True
|
|
)
|
|
mock_turn_complete_content.model_turn = None
|
|
mock_turn_complete_content.grounding_metadata = None
|
|
mock_turn_complete_content.turn_complete = True
|
|
mock_turn_complete_content.interrupted = False
|
|
mock_turn_complete_content.input_transcription = None
|
|
mock_turn_complete_content.output_transcription = None
|
|
mock_turn_complete_content.generation_complete = False
|
|
|
|
mock_turn_complete_msg = mock.create_autospec(
|
|
types.LiveServerMessage, instance=True
|
|
)
|
|
mock_turn_complete_msg.usage_metadata = None
|
|
mock_turn_complete_msg.server_content = mock_turn_complete_content
|
|
mock_turn_complete_msg.tool_call = None
|
|
mock_turn_complete_msg.session_resumption_update = None
|
|
mock_turn_complete_msg.go_away = None
|
|
mock_turn_complete_msg.voice_activity = None
|
|
|
|
async def mock_receive_generator():
|
|
yield mock_tool_call_msg
|
|
yield mock_metadata_msg
|
|
yield mock_turn_complete_msg
|
|
|
|
receive_mock = mock.Mock(return_value=mock_receive_generator())
|
|
mock_gemini_session.receive = receive_mock
|
|
|
|
responses = [resp async for resp in connection.receive()]
|
|
|
|
assert len(responses) == 3
|
|
|
|
# First response: the audio content and grounding metadata
|
|
assert responses[0].grounding_metadata == grounding_metadata
|
|
assert responses[0].content == mock_content
|
|
assert responses[0].content is not None
|
|
assert responses[0].content.parts is not None
|
|
assert responses[0].content.parts[0].inline_data == audio_blob
|
|
|
|
# Second response: the tool call, buffered until turn_complete
|
|
assert responses[1].content is not None
|
|
assert responses[1].content.parts is not None
|
|
assert responses[1].content.parts[0].function_call is not None
|
|
assert (
|
|
responses[1].content.parts[0].function_call.name
|
|
== 'enterprise_web_search'
|
|
)
|
|
assert responses[1].content.parts[0].function_call.args == {
|
|
'query': 'Google stock price today'
|
|
}
|
|
assert responses[1].grounding_metadata is None
|
|
|
|
# Third response: the turn_complete
|
|
assert responses[2].turn_complete is True
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_receive_multiple_tool_calls_buffered_until_turn_complete(
|
|
gemini_connection, mock_gemini_session
|
|
):
|
|
"""Test receive buffers multiple tool call messages until turn complete."""
|
|
# First tool call message
|
|
mock_tool_call_msg1 = mock.create_autospec(
|
|
types.LiveServerMessage, instance=True
|
|
)
|
|
mock_tool_call_msg1.usage_metadata = None
|
|
mock_tool_call_msg1.server_content = None
|
|
mock_tool_call_msg1.session_resumption_update = None
|
|
mock_tool_call_msg1.go_away = None
|
|
mock_tool_call_msg1.voice_activity = None
|
|
|
|
function_call1 = types.FunctionCall(
|
|
name='tool_1',
|
|
args={'arg': 'value1'},
|
|
)
|
|
mock_tool_call1 = mock.create_autospec(
|
|
types.LiveServerToolCall, instance=True
|
|
)
|
|
mock_tool_call1.function_calls = [function_call1]
|
|
mock_tool_call_msg1.tool_call = mock_tool_call1
|
|
|
|
# Second tool call message
|
|
mock_tool_call_msg2 = mock.create_autospec(
|
|
types.LiveServerMessage, instance=True
|
|
)
|
|
mock_tool_call_msg2.usage_metadata = None
|
|
mock_tool_call_msg2.server_content = None
|
|
mock_tool_call_msg2.session_resumption_update = None
|
|
mock_tool_call_msg2.go_away = None
|
|
mock_tool_call_msg2.voice_activity = None
|
|
|
|
function_call2 = types.FunctionCall(
|
|
name='tool_2',
|
|
args={'arg': 'value2'},
|
|
)
|
|
mock_tool_call2 = mock.create_autospec(
|
|
types.LiveServerToolCall, instance=True
|
|
)
|
|
mock_tool_call2.function_calls = [function_call2]
|
|
mock_tool_call_msg2.tool_call = mock_tool_call2
|
|
|
|
# Turn complete message
|
|
mock_turn_complete_content = mock.create_autospec(
|
|
types.LiveServerContent, instance=True
|
|
)
|
|
mock_turn_complete_content.model_turn = None
|
|
mock_turn_complete_content.grounding_metadata = None
|
|
mock_turn_complete_content.turn_complete = True
|
|
mock_turn_complete_content.interrupted = False
|
|
mock_turn_complete_content.input_transcription = None
|
|
mock_turn_complete_content.output_transcription = None
|
|
|
|
mock_turn_complete_msg = mock.create_autospec(
|
|
types.LiveServerMessage, instance=True
|
|
)
|
|
mock_turn_complete_msg.usage_metadata = None
|
|
mock_turn_complete_msg.server_content = mock_turn_complete_content
|
|
mock_turn_complete_msg.tool_call = None
|
|
mock_turn_complete_msg.session_resumption_update = None
|
|
mock_turn_complete_msg.go_away = None
|
|
mock_turn_complete_msg.voice_activity = None
|
|
|
|
async def mock_receive_generator():
|
|
yield mock_tool_call_msg1
|
|
yield mock_tool_call_msg2
|
|
yield mock_turn_complete_msg
|
|
|
|
receive_mock = mock.Mock(return_value=mock_receive_generator())
|
|
mock_gemini_session.receive = receive_mock
|
|
|
|
responses = [resp async for resp in gemini_connection.receive()]
|
|
|
|
# Expected: One LlmResponse with both tool calls, then one with turn_complete
|
|
assert len(responses) == 2
|
|
|
|
# First response: single LlmResponse carrying both function calls
|
|
assert responses[0].content is not None
|
|
parts = responses[0].content.parts
|
|
assert len(parts) == 2
|
|
assert parts[0].function_call.name == 'tool_1'
|
|
assert parts[0].function_call.args == {'arg': 'value1'}
|
|
assert parts[1].function_call.name == 'tool_2'
|
|
assert parts[1].function_call.args == {'arg': 'value2'}
|
|
|
|
# Second response: turn_complete True
|
|
assert responses[1].turn_complete is True
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_receive_tool_calls_yielded_immediately_for_gemini_3_1(
|
|
mock_gemini_session,
|
|
):
|
|
"""Test that tool calls are yielded immediately for Gemini 3.1."""
|
|
connection = GeminiLlmConnection(
|
|
mock_gemini_session,
|
|
api_backend=GoogleLLMVariant.VERTEX_AI,
|
|
model_version='gemini-3.1-flash-live-preview',
|
|
)
|
|
|
|
mock_tool_call_msg = mock.create_autospec(
|
|
types.LiveServerMessage, instance=True
|
|
)
|
|
mock_tool_call_msg.usage_metadata = None
|
|
mock_tool_call_msg.server_content = None
|
|
mock_tool_call_msg.session_resumption_update = None
|
|
mock_tool_call_msg.go_away = None
|
|
mock_tool_call_msg.voice_activity = None
|
|
|
|
function_call = types.FunctionCall(
|
|
name='test_tool',
|
|
args={'arg': 'value'},
|
|
)
|
|
mock_tool_call = mock.create_autospec(types.LiveServerToolCall, instance=True)
|
|
mock_tool_call.function_calls = [function_call]
|
|
mock_tool_call_msg.tool_call = mock_tool_call
|
|
|
|
async def mock_receive_generator():
|
|
yield mock_tool_call_msg
|
|
|
|
receive_mock = mock.Mock(return_value=mock_receive_generator())
|
|
mock_gemini_session.receive = receive_mock
|
|
|
|
responses = []
|
|
async for resp in connection.receive():
|
|
responses.append(resp)
|
|
break
|
|
|
|
assert len(responses) == 1
|
|
assert responses[0].content is not None
|
|
assert responses[0].content.parts[0].function_call.name == 'test_tool'
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_receive_go_away(gemini_connection, mock_gemini_session):
|
|
"""Test receive yields go_away message."""
|
|
mock_go_away = types.LiveServerGoAway(timeLeft='10s')
|
|
mock_msg = mock.MagicMock()
|
|
mock_msg.usage_metadata = None
|
|
mock_msg.server_content = None
|
|
mock_msg.tool_call = None
|
|
mock_msg.session_resumption_update = None
|
|
mock_msg.go_away = mock_go_away
|
|
mock_msg.voice_activity = None
|
|
|
|
async def mock_receive_generator():
|
|
yield mock_msg
|
|
|
|
receive_mock = mock.Mock(return_value=mock_receive_generator())
|
|
mock_gemini_session.receive = receive_mock
|
|
|
|
responses = [resp async for resp in gemini_connection.receive()]
|
|
|
|
assert len(responses) == 1
|
|
assert responses[0].go_away == mock_go_away
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_receive_aggregates_thoughts_separately(
|
|
gemini_connection, mock_gemini_session
|
|
):
|
|
"""Test receive aggregates thoughts and regular text separately."""
|
|
|
|
part1 = types.Part.from_text(text='thought 1')
|
|
part1.thought = True
|
|
message1 = types.LiveServerMessage(
|
|
server_content=types.LiveServerContent(
|
|
model_turn=types.Content(role='model', parts=[part1]),
|
|
)
|
|
)
|
|
|
|
part2 = types.Part.from_text(text=' thought 2')
|
|
part2.thought = True
|
|
message2 = types.LiveServerMessage(
|
|
server_content=types.LiveServerContent(
|
|
model_turn=types.Content(role='model', parts=[part2]),
|
|
)
|
|
)
|
|
|
|
part3 = types.Part.from_text(text='answer')
|
|
part3.thought = False
|
|
message3 = types.LiveServerMessage(
|
|
server_content=types.LiveServerContent(
|
|
model_turn=types.Content(role='model', parts=[part3]),
|
|
)
|
|
)
|
|
|
|
message4 = types.LiveServerMessage(
|
|
server_content=types.LiveServerContent(
|
|
turn_complete=True,
|
|
)
|
|
)
|
|
|
|
async def mock_receive_generator():
|
|
yield message1
|
|
yield message2
|
|
yield message3
|
|
yield message4
|
|
|
|
receive_mock = mock.Mock(return_value=mock_receive_generator())
|
|
mock_gemini_session.receive = receive_mock
|
|
|
|
responses = [resp async for resp in gemini_connection.receive()]
|
|
|
|
# Expected responses:
|
|
# 1. Message 1 (partial thought)
|
|
# 2. Message 2 (partial thought)
|
|
# 3. Aggregated thought (full)
|
|
# 4. Message 3 (partial answer)
|
|
# 5. Aggregated answer (full)
|
|
# 6. Turn complete message
|
|
|
|
assert len(responses) == 6
|
|
|
|
# Check partials
|
|
assert responses[0].content.parts[0].text == 'thought 1'
|
|
assert responses[0].partial is True
|
|
assert responses[1].content.parts[0].text == ' thought 2'
|
|
assert responses[1].partial is True
|
|
|
|
# Check aggregated thought
|
|
assert responses[2].content.parts[0].text == 'thought 1 thought 2'
|
|
assert responses[2].content.parts[0].thought is True
|
|
assert responses[2].partial is False
|
|
|
|
# Check partial answer
|
|
assert responses[3].content.parts[0].text == 'answer'
|
|
assert responses[3].partial is True
|
|
|
|
# Check aggregated answer
|
|
assert responses[4].content.parts[0].text == 'answer'
|
|
assert not getattr(responses[4].content.parts[0], 'thought', False)
|
|
assert responses[4].partial is False
|
|
|
|
# Check turn complete
|
|
assert responses[5].turn_complete is True
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_receive_video_content(gemini_connection, mock_gemini_session):
|
|
"""Test receive with video content."""
|
|
mock_content = types.Content(
|
|
role='model',
|
|
parts=[
|
|
types.Part(
|
|
inline_data=types.Blob(data=b'video_data', mime_type='video/mp4')
|
|
)
|
|
],
|
|
)
|
|
mock_server_content = mock.Mock()
|
|
mock_server_content.model_turn = mock_content
|
|
mock_server_content.interrupted = False
|
|
mock_server_content.input_transcription = None
|
|
mock_server_content.output_transcription = None
|
|
mock_server_content.turn_complete = False
|
|
mock_server_content.grounding_metadata = None
|
|
|
|
mock_message = mock.AsyncMock()
|
|
mock_message.usage_metadata = None
|
|
mock_message.server_content = mock_server_content
|
|
mock_message.tool_call = None
|
|
mock_message.session_resumption_update = None
|
|
mock_message.go_away = None
|
|
mock_message.voice_activity = None
|
|
|
|
async def mock_receive_generator():
|
|
yield mock_message
|
|
|
|
receive_mock = mock.Mock(return_value=mock_receive_generator())
|
|
mock_gemini_session.receive = receive_mock
|
|
|
|
responses = [resp async for resp in gemini_connection.receive()]
|
|
|
|
assert responses
|
|
content_response = next((r for r in responses if r.content), None)
|
|
assert content_response is not None
|
|
assert content_response.content == mock_content
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_receive_grounding_metadata_pending(
|
|
gemini_connection, mock_gemini_session
|
|
):
|
|
"""Test that grounding metadata in partial chunks is pending and yielded on full text."""
|
|
grounding_metadata = types.GroundingMetadata(
|
|
web_search_queries=['stock price of google'],
|
|
)
|
|
|
|
def make_msg(
|
|
text: str | None = None,
|
|
g_meta: types.GroundingMetadata | None = None,
|
|
tc: bool = False,
|
|
) -> mock.Mock:
|
|
msg = mock.Mock(
|
|
usage_metadata=None,
|
|
tool_call=None,
|
|
session_resumption_update=None,
|
|
go_away=None,
|
|
voice_activity=None,
|
|
)
|
|
msg.server_content = mock.Mock(
|
|
interrupted=False,
|
|
input_transcription=None,
|
|
output_transcription=None,
|
|
generation_complete=False,
|
|
turn_complete=tc,
|
|
grounding_metadata=g_meta,
|
|
model_turn=types.Content(
|
|
role='model', parts=[types.Part.from_text(text=text)]
|
|
)
|
|
if text
|
|
else None,
|
|
)
|
|
return msg
|
|
|
|
msg1 = make_msg(text='hello', g_meta=grounding_metadata)
|
|
msg2 = make_msg(text=' world')
|
|
msg3 = make_msg(tc=True)
|
|
|
|
async def gen():
|
|
yield msg1
|
|
yield msg2
|
|
yield msg3
|
|
|
|
mock_gemini_session.receive = mock.Mock(return_value=gen())
|
|
|
|
responses = [resp async for resp in gemini_connection.receive()]
|
|
|
|
# Expected responses:
|
|
# 1. Msg 1 partial (hello) with grounding_metadata
|
|
# 2. Msg 2 partial ( world) without grounding_metadata
|
|
# 3. Full text response (hello world) with PENDING grounding_metadata
|
|
# 4. Turn complete response without grounding_metadata (already cleared)
|
|
assert len(responses) == 4
|
|
|
|
assert responses[0].content.parts[0].text == 'hello'
|
|
assert responses[0].partial is True
|
|
assert responses[0].grounding_metadata == grounding_metadata
|
|
|
|
assert responses[1].content.parts[0].text == ' world'
|
|
assert responses[1].partial is True
|
|
assert responses[1].grounding_metadata is None
|
|
|
|
assert responses[2].content.parts[0].text == 'hello world'
|
|
assert responses[2].partial is False
|
|
assert responses[2].grounding_metadata == grounding_metadata
|
|
|
|
assert responses[3].turn_complete is True
|
|
assert responses[3].grounding_metadata is None
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_receive_populates_turn_complete_reason(
|
|
gemini_connection, mock_gemini_session
|
|
):
|
|
"""Test that receive populates turn_complete_reason in LlmResponse."""
|
|
mock_server_content = mock.create_autospec(
|
|
types.LiveServerContent, instance=True
|
|
)
|
|
mock_server_content.model_turn = None
|
|
mock_server_content.grounding_metadata = None
|
|
mock_server_content.turn_complete = True
|
|
mock_server_content.interrupted = False
|
|
mock_server_content.input_transcription = None
|
|
mock_server_content.output_transcription = None
|
|
mock_server_content.generation_complete = False
|
|
mock_server_content.turn_complete_reason = (
|
|
types.TurnCompleteReason.RESPONSE_REJECTED
|
|
)
|
|
|
|
mock_message = mock.create_autospec(types.LiveServerMessage, instance=True)
|
|
mock_message.usage_metadata = None
|
|
mock_message.server_content = mock_server_content
|
|
mock_message.tool_call = None
|
|
mock_message.session_resumption_update = None
|
|
mock_message.go_away = None
|
|
mock_message.voice_activity = None
|
|
|
|
async def mock_receive_generator():
|
|
yield mock_message
|
|
|
|
mock_gemini_session.receive = mock.Mock(return_value=mock_receive_generator())
|
|
|
|
responses = [resp async for resp in gemini_connection.receive()]
|
|
|
|
assert len(responses) == 1
|
|
assert responses[0].turn_complete is True
|
|
assert (
|
|
responses[0].turn_complete_reason
|
|
== types.TurnCompleteReason.RESPONSE_REJECTED
|
|
)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_receive_populates_turn_complete_reason_standalone_grounding(
|
|
gemini_connection, mock_gemini_session
|
|
):
|
|
"""Test that receive populates turn_complete_reason in LlmResponse for standalone grounding metadata."""
|
|
mock_server_content = mock.create_autospec(
|
|
types.LiveServerContent, instance=True
|
|
)
|
|
mock_server_content.model_turn = None
|
|
mock_server_content.grounding_metadata = types.GroundingMetadata()
|
|
mock_server_content.turn_complete = False
|
|
mock_server_content.interrupted = False
|
|
mock_server_content.input_transcription = None
|
|
mock_server_content.output_transcription = None
|
|
mock_server_content.generation_complete = False
|
|
mock_server_content.turn_complete_reason = (
|
|
types.TurnCompleteReason.RESPONSE_REJECTED
|
|
)
|
|
|
|
mock_message = mock.create_autospec(types.LiveServerMessage, instance=True)
|
|
mock_message.usage_metadata = None
|
|
mock_message.server_content = mock_server_content
|
|
mock_message.tool_call = None
|
|
mock_message.session_resumption_update = None
|
|
mock_message.go_away = None
|
|
mock_message.voice_activity = None
|
|
|
|
async def mock_receive_generator():
|
|
yield mock_message
|
|
|
|
mock_gemini_session.receive = mock.Mock(return_value=mock_receive_generator())
|
|
|
|
responses = [resp async for resp in gemini_connection.receive()]
|
|
|
|
assert len(responses) == 1
|
|
assert responses[0].grounding_metadata is not None
|
|
assert responses[0].turn_complete is None
|
|
assert (
|
|
responses[0].turn_complete_reason
|
|
== types.TurnCompleteReason.RESPONSE_REJECTED
|
|
)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_receive_populates_turn_complete_reason_with_content(
|
|
gemini_connection, mock_gemini_session
|
|
):
|
|
"""Test that receive populates turn_complete_reason in LlmResponse when model turn has content parts."""
|
|
mock_content = types.Content(
|
|
role='model',
|
|
parts=[types.Part.from_text(text='hello')],
|
|
)
|
|
mock_server_content = mock.create_autospec(
|
|
types.LiveServerContent, instance=True
|
|
)
|
|
mock_server_content.model_turn = mock_content
|
|
mock_server_content.grounding_metadata = None
|
|
mock_server_content.turn_complete = False
|
|
mock_server_content.interrupted = False
|
|
mock_server_content.input_transcription = None
|
|
mock_server_content.output_transcription = None
|
|
mock_server_content.generation_complete = False
|
|
mock_server_content.turn_complete_reason = (
|
|
types.TurnCompleteReason.RESPONSE_REJECTED
|
|
)
|
|
|
|
mock_message = mock.create_autospec(types.LiveServerMessage, instance=True)
|
|
mock_message.usage_metadata = None
|
|
mock_message.server_content = mock_server_content
|
|
mock_message.tool_call = None
|
|
mock_message.session_resumption_update = None
|
|
mock_message.go_away = None
|
|
mock_message.voice_activity = None
|
|
|
|
async def mock_receive_generator():
|
|
yield mock_message
|
|
|
|
mock_gemini_session.receive = mock.Mock(return_value=mock_receive_generator())
|
|
|
|
responses = [resp async for resp in gemini_connection.receive()]
|
|
|
|
assert len(responses) == 1
|
|
assert responses[0].content == mock_content
|
|
assert (
|
|
responses[0].turn_complete_reason
|
|
== types.TurnCompleteReason.RESPONSE_REJECTED
|
|
)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_receive_grounding_metadata_default_gemini_3_1(
|
|
mock_gemini_session,
|
|
):
|
|
"""Verify grounding_metadata defaults to empty GroundingMetadata for Gemini 3.1."""
|
|
conn = GeminiLlmConnection(
|
|
mock_gemini_session,
|
|
model_version='gemini-3.1-flash-live-preview',
|
|
)
|
|
|
|
def make_msg(
|
|
text: str | None = None,
|
|
tc: bool = False,
|
|
tool_call: types.LiveServerToolCall | None = None,
|
|
) -> mock.Mock:
|
|
msg = mock.create_autospec(types.LiveServerMessage, instance=True)
|
|
msg.usage_metadata = None
|
|
msg.tool_call = tool_call
|
|
msg.session_resumption_update = None
|
|
msg.go_away = None
|
|
msg.voice_activity = None
|
|
msg.server_content = mock.Mock()
|
|
msg.server_content.interrupted = False
|
|
msg.server_content.input_transcription = None
|
|
msg.server_content.output_transcription = None
|
|
msg.server_content.generation_complete = False
|
|
msg.server_content.turn_complete = tc
|
|
msg.server_content.grounding_metadata = None
|
|
msg.server_content.model_turn = (
|
|
types.Content(role='model', parts=[types.Part.from_text(text=text)])
|
|
if text
|
|
else None
|
|
)
|
|
return msg
|
|
|
|
# 1. Content event
|
|
msg1 = make_msg(text='hello')
|
|
# 2. Tool call event (yields immediately for Gemini 3.1)
|
|
function_call = types.FunctionCall(name='foo', args={})
|
|
tool_call = mock.create_autospec(types.LiveServerToolCall, instance=True)
|
|
tool_call.function_calls = [function_call]
|
|
msg2 = make_msg(tool_call=tool_call)
|
|
# 3. Turn complete event
|
|
msg3 = make_msg(tc=True)
|
|
|
|
async def mock_receive_generator():
|
|
yield msg1
|
|
yield msg2
|
|
yield msg3
|
|
|
|
mock_gemini_session.receive = mock.Mock(return_value=mock_receive_generator())
|
|
|
|
responses = [resp async for resp in conn.receive()]
|
|
|
|
# Expected:
|
|
# responses[0] -> partial content response for msg1 (has no grounding_metadata)
|
|
# responses[1] -> full text response for msg1 (has no grounding_metadata)
|
|
# responses[2] -> tool call response for msg2 (has no grounding_metadata)
|
|
# responses[3] -> turn_complete response for msg3 (has grounding_metadata)
|
|
assert len(responses) == 4
|
|
|
|
assert responses[0].content.parts[0].text == 'hello'
|
|
assert responses[0].grounding_metadata is None
|
|
assert responses[0].partial is True
|
|
|
|
assert responses[1].content.parts[0].text == 'hello'
|
|
assert responses[1].grounding_metadata is None
|
|
assert responses[1].partial is False
|
|
|
|
assert responses[2].content.parts[0].function_call.name == 'foo'
|
|
assert responses[2].grounding_metadata is None
|
|
|
|
assert responses[3].turn_complete is True
|
|
assert isinstance(responses[3].grounding_metadata, types.GroundingMetadata)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_receive_grounding_metadata_default_non_gemini_3_1(
|
|
mock_gemini_session,
|
|
):
|
|
"""Verify grounding_metadata stays None for non-Gemini 3.1 models."""
|
|
conn = GeminiLlmConnection(
|
|
mock_gemini_session,
|
|
model_version='gemini-2.5-flash-live',
|
|
)
|
|
|
|
def make_msg(text: str | None = None, tc: bool = False) -> mock.Mock:
|
|
msg = mock.create_autospec(types.LiveServerMessage, instance=True)
|
|
msg.usage_metadata = None
|
|
msg.tool_call = None
|
|
msg.session_resumption_update = None
|
|
msg.go_away = None
|
|
msg.voice_activity = None
|
|
msg.server_content = mock.Mock()
|
|
msg.server_content.interrupted = False
|
|
msg.server_content.input_transcription = None
|
|
msg.server_content.output_transcription = None
|
|
msg.server_content.generation_complete = False
|
|
msg.server_content.turn_complete = tc
|
|
msg.server_content.grounding_metadata = None
|
|
msg.server_content.model_turn = (
|
|
types.Content(role='model', parts=[types.Part.from_text(text=text)])
|
|
if text
|
|
else None
|
|
)
|
|
return msg
|
|
|
|
msg1 = make_msg(text='hello')
|
|
msg2 = make_msg(tc=True)
|
|
|
|
async def mock_receive_generator():
|
|
yield msg1
|
|
yield msg2
|
|
|
|
mock_gemini_session.receive = mock.Mock(return_value=mock_receive_generator())
|
|
|
|
responses = [resp async for resp in conn.receive()]
|
|
|
|
assert len(responses) == 3
|
|
|
|
assert responses[0].content.parts[0].text == 'hello'
|
|
assert responses[0].grounding_metadata is None
|
|
assert responses[0].partial is True
|
|
|
|
assert responses[1].content.parts[0].text == 'hello'
|
|
assert responses[1].grounding_metadata is None
|
|
assert responses[1].partial is False
|
|
|
|
assert responses[2].turn_complete is True
|
|
assert responses[2].grounding_metadata is None
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_receive_input_transcription_gemini_3_1(
|
|
mock_gemini_session,
|
|
):
|
|
"""Verify input_transcription yields finished=True immediately for Gemini 3.1."""
|
|
conn = GeminiLlmConnection(
|
|
mock_gemini_session,
|
|
model_version='gemini-3.1-flash-live-preview',
|
|
)
|
|
|
|
def make_msg(
|
|
input_text=None, output_text=None, output_finished=False, tc=False
|
|
):
|
|
msg = mock.create_autospec(types.LiveServerMessage, instance=True)
|
|
msg.usage_metadata = None
|
|
msg.tool_call = None
|
|
msg.session_resumption_update = None
|
|
msg.go_away = None
|
|
msg.voice_activity = None
|
|
msg.server_content = mock.Mock()
|
|
msg.server_content.interrupted = False
|
|
msg.server_content.input_transcription = (
|
|
types.Transcription(text=input_text, finished=False)
|
|
if input_text
|
|
else None
|
|
)
|
|
msg.server_content.output_transcription = (
|
|
types.Transcription(text=output_text, finished=output_finished)
|
|
if output_text
|
|
else None
|
|
)
|
|
msg.server_content.generation_complete = False
|
|
msg.server_content.turn_complete = tc
|
|
msg.server_content.grounding_metadata = None
|
|
msg.server_content.model_turn = None
|
|
return msg
|
|
|
|
msg1 = make_msg(input_text='Hello')
|
|
msg2 = make_msg(output_text='Hi there!', output_finished=True)
|
|
msg3 = make_msg(tc=True)
|
|
|
|
async def mock_receive_generator():
|
|
yield msg1
|
|
yield msg2
|
|
yield msg3
|
|
|
|
mock_gemini_session.receive = mock.Mock(return_value=mock_receive_generator())
|
|
|
|
responses = [resp async for resp in conn.receive()]
|
|
|
|
assert len(responses) == 4
|
|
|
|
assert responses[0].input_transcription.text == 'Hello'
|
|
assert responses[0].input_transcription.finished is True
|
|
assert responses[0].partial is False
|
|
|
|
assert responses[1].output_transcription.text == 'Hi there!'
|
|
assert responses[1].output_transcription.finished is False
|
|
assert responses[1].partial is True
|
|
|
|
assert responses[2].output_transcription.text == 'Hi there!'
|
|
assert responses[2].output_transcription.finished is True
|
|
assert responses[2].partial is False
|
|
|
|
assert responses[3].turn_complete is True
|
|
|
|
|
|
def _create_mock_receive_message(
|
|
model_turn: types.Content | None = None,
|
|
grounding_metadata: types.GroundingMetadata | None = None,
|
|
interrupted: bool = False,
|
|
turn_complete: bool = False,
|
|
tool_call: types.LiveServerToolCall | mock.Mock | None = None,
|
|
) -> mock.Mock:
|
|
"""Helper to create a mock message from the Gemini API."""
|
|
mock_server_content = mock.Mock()
|
|
mock_server_content.model_turn = model_turn
|
|
mock_server_content.interrupted = interrupted
|
|
mock_server_content.input_transcription = None
|
|
mock_server_content.output_transcription = None
|
|
mock_server_content.turn_complete = turn_complete
|
|
mock_server_content.generation_complete = False
|
|
mock_server_content.grounding_metadata = grounding_metadata
|
|
|
|
mock_message = mock.Mock()
|
|
mock_message.usage_metadata = None
|
|
mock_message.server_content = mock_server_content
|
|
mock_message.tool_call = tool_call
|
|
mock_message.session_resumption_update = None
|
|
mock_message.go_away = None
|
|
mock_message.voice_activity = None
|
|
return mock_message
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_receive_extracts_grounding_metadata(
|
|
gemini_connection, mock_gemini_session
|
|
):
|
|
"""Test that grounding_metadata is extracted and included in LlmResponse."""
|
|
mock_content = types.Content(
|
|
role='model', parts=[types.Part.from_text(text='response text')]
|
|
)
|
|
mock_grounding_metadata = types.GroundingMetadata(
|
|
retrieval_queries=['test query'],
|
|
web_search_queries=['web search query'],
|
|
)
|
|
|
|
mock_message = _create_mock_receive_message(
|
|
model_turn=mock_content,
|
|
grounding_metadata=mock_grounding_metadata,
|
|
turn_complete=True,
|
|
)
|
|
|
|
async def mock_receive_generator():
|
|
yield mock_message
|
|
|
|
receive_mock = mock.Mock(return_value=mock_receive_generator())
|
|
mock_gemini_session.receive = receive_mock
|
|
|
|
responses = [resp async for resp in gemini_connection.receive()]
|
|
|
|
assert responses
|
|
# The last response (turn_complete) should have the grounding metadata
|
|
turn_complete_response = next((r for r in responses if r.turn_complete), None)
|
|
assert turn_complete_response is not None
|
|
assert turn_complete_response.grounding_metadata == mock_grounding_metadata
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_receive_grounding_metadata_reset_after_tool_call(
|
|
gemini_connection, mock_gemini_session
|
|
):
|
|
"""Test grounding_metadata reset after tool_call."""
|
|
mock_grounding_metadata = types.GroundingMetadata(
|
|
retrieval_queries=['test query'],
|
|
)
|
|
|
|
message1 = _create_mock_receive_message(
|
|
grounding_metadata=mock_grounding_metadata
|
|
)
|
|
|
|
mock_function_call = types.FunctionCall(
|
|
name='test_function', args={'param': 'value'}
|
|
)
|
|
mock_tool_call = mock.Mock()
|
|
mock_tool_call.function_calls = [mock_function_call]
|
|
message2 = _create_mock_receive_message(tool_call=mock_tool_call)
|
|
message2.server_content = None
|
|
|
|
message3 = _create_mock_receive_message(turn_complete=True)
|
|
|
|
async def mock_receive_generator():
|
|
yield message1
|
|
yield message2
|
|
yield message3
|
|
|
|
receive_mock = mock.Mock(return_value=mock_receive_generator())
|
|
mock_gemini_session.receive = receive_mock
|
|
|
|
responses = [resp async for resp in gemini_connection.receive()]
|
|
|
|
# If Gemini 3.1, it yields immediately. If not, it buffers.
|
|
# But in both cases, the tool call response should have the grounding metadata
|
|
# and the subsequent turn_complete should NOT have it (reset).
|
|
tool_call_response = next(
|
|
(r for r in responses if r.content and r.content.parts[0].function_call),
|
|
None,
|
|
)
|
|
assert tool_call_response is not None
|
|
assert tool_call_response.grounding_metadata == mock_grounding_metadata
|
|
|
|
turn_complete_response = next((r for r in responses if r.turn_complete), None)
|
|
assert turn_complete_response is not None
|
|
assert turn_complete_response.grounding_metadata is None
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_receive_grounding_metadata_accumulates_across_messages(
|
|
gemini_connection, mock_gemini_session
|
|
):
|
|
"""Test grounding_metadata accumulated across messages."""
|
|
grounding1 = types.GroundingMetadata(
|
|
retrieval_queries=['query1'],
|
|
)
|
|
grounding2 = types.GroundingMetadata(
|
|
retrieval_queries=['query2'],
|
|
grounding_chunks=[
|
|
types.GroundingChunk(
|
|
web=types.GroundingChunkWeb(uri='https://example.com')
|
|
)
|
|
],
|
|
)
|
|
|
|
mock_content1 = types.Content(
|
|
role='model', parts=[types.Part.from_text(text='part1')]
|
|
)
|
|
message1 = _create_mock_receive_message(
|
|
model_turn=mock_content1, grounding_metadata=grounding1
|
|
)
|
|
|
|
mock_content2 = types.Content(
|
|
role='model', parts=[types.Part.from_text(text=' part2')]
|
|
)
|
|
message2 = _create_mock_receive_message(
|
|
model_turn=mock_content2, grounding_metadata=grounding2
|
|
)
|
|
|
|
message3 = _create_mock_receive_message(turn_complete=True)
|
|
|
|
async def mock_receive_generator():
|
|
yield message1
|
|
yield message2
|
|
yield message3
|
|
|
|
receive_mock = mock.Mock(return_value=mock_receive_generator())
|
|
mock_gemini_session.receive = receive_mock
|
|
|
|
responses = [resp async for resp in gemini_connection.receive()]
|
|
|
|
assert len(responses) == 4
|
|
assert responses[2].content.parts[0].text == 'part1 part2'
|
|
merged = responses[2].grounding_metadata
|
|
assert merged is not None
|
|
assert merged.retrieval_queries == ['query1', 'query2']
|
|
assert len(merged.grounding_chunks) == 1
|
|
assert merged.grounding_chunks[0].web.uri == 'https://example.com'
|
|
|
|
assert responses[3].turn_complete is True
|
|
assert responses[3].grounding_metadata is None
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_receive_interrupted_with_pending_text_preserves_flag(
|
|
gemini_connection, mock_gemini_session
|
|
):
|
|
"""Test interrupted flag when flushing pending text."""
|
|
mock_grounding_metadata = types.GroundingMetadata(
|
|
retrieval_queries=['test query'],
|
|
)
|
|
|
|
mock_content1 = types.Content(
|
|
role='model', parts=[types.Part.from_text(text='partial')]
|
|
)
|
|
message1 = _create_mock_receive_message(
|
|
model_turn=mock_content1, grounding_metadata=mock_grounding_metadata
|
|
)
|
|
|
|
mock_content2 = types.Content(
|
|
role='model', parts=[types.Part.from_text(text=' text')]
|
|
)
|
|
message2 = _create_mock_receive_message(model_turn=mock_content2)
|
|
|
|
message3 = _create_mock_receive_message(interrupted=True)
|
|
|
|
async def mock_receive_generator():
|
|
yield message1
|
|
yield message2
|
|
yield message3
|
|
|
|
receive_mock = mock.Mock(return_value=mock_receive_generator())
|
|
mock_gemini_session.receive = receive_mock
|
|
|
|
responses = [resp async for resp in gemini_connection.receive()]
|
|
|
|
full_text_responses = [
|
|
r for r in responses if r.content and not r.partial and r.interrupted
|
|
]
|
|
assert (
|
|
len(full_text_responses) > 0
|
|
), 'Should have interrupted full text response'
|
|
|
|
assert full_text_responses[0].content.parts[0].text == 'partial text'
|
|
assert full_text_responses[0].grounding_metadata == mock_grounding_metadata
|
|
assert full_text_responses[0].interrupted is True
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_receive_grounding_metadata_accumulates_deduplicates_and_shifts_indices(
|
|
gemini_connection, mock_gemini_session
|
|
):
|
|
"""Test grounding_metadata deduplicates queries and shifts support indices."""
|
|
grounding1 = types.GroundingMetadata(
|
|
retrieval_queries=['query1'],
|
|
grounding_chunks=[
|
|
types.GroundingChunk(
|
|
web=types.GroundingChunkWeb(uri='https://example.com/1')
|
|
)
|
|
],
|
|
grounding_supports=[
|
|
types.GroundingSupport(
|
|
segment=types.Segment(start_index=0, end_index=5, text='hello'),
|
|
grounding_chunk_indices=[0],
|
|
)
|
|
],
|
|
)
|
|
grounding2 = types.GroundingMetadata(
|
|
retrieval_queries=['query1', 'query2'], # 'query1' is duplicate
|
|
grounding_chunks=[
|
|
types.GroundingChunk(
|
|
web=types.GroundingChunkWeb(uri='https://example.com/2')
|
|
)
|
|
],
|
|
grounding_supports=[
|
|
types.GroundingSupport(
|
|
segment=types.Segment(start_index=6, end_index=11, text='world'),
|
|
grounding_chunk_indices=[0], # index should scale to 1 in merged
|
|
)
|
|
],
|
|
)
|
|
|
|
mock_content1 = types.Content(
|
|
role='model', parts=[types.Part.from_text(text='hello')]
|
|
)
|
|
message1 = _create_mock_receive_message(
|
|
model_turn=mock_content1, grounding_metadata=grounding1
|
|
)
|
|
|
|
mock_content2 = types.Content(
|
|
role='model', parts=[types.Part.from_text(text=' world')]
|
|
)
|
|
message2 = _create_mock_receive_message(
|
|
model_turn=mock_content2, grounding_metadata=grounding2
|
|
)
|
|
|
|
message3 = _create_mock_receive_message(turn_complete=True)
|
|
|
|
async def mock_receive_generator():
|
|
yield message1
|
|
yield message2
|
|
yield message3
|
|
|
|
receive_mock = mock.Mock(return_value=mock_receive_generator())
|
|
mock_gemini_session.receive = receive_mock
|
|
|
|
responses = [resp async for resp in gemini_connection.receive()]
|
|
|
|
# Find the full text response (yielding accumulated)
|
|
full_text_resp = responses[2]
|
|
assert full_text_resp.content.parts[0].text == 'hello world'
|
|
merged = full_text_resp.grounding_metadata
|
|
assert merged is not None
|
|
# query1 should only appear once
|
|
assert merged.retrieval_queries == ['query1', 'query2']
|
|
# both chunks should be present
|
|
assert len(merged.grounding_chunks) == 2
|
|
assert merged.grounding_chunks[0].web.uri == 'https://example.com/1'
|
|
assert merged.grounding_chunks[1].web.uri == 'https://example.com/2'
|
|
# grounding supports indices:
|
|
assert len(merged.grounding_supports) == 2
|
|
# first support index stays 0
|
|
assert merged.grounding_supports[0].grounding_chunk_indices == [0]
|
|
# second support index shifted to 1
|
|
assert merged.grounding_supports[1].grounding_chunk_indices == [1]
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_receive_incomplete_grounding_logs_warning_only_on_turn_complete(
|
|
gemini_connection, mock_gemini_session, caplog
|
|
):
|
|
"""Test that incomplete grounding metadata warns at turn_complete but not midway."""
|
|
grounding1 = types.GroundingMetadata(
|
|
retrieval_queries=['query1'],
|
|
)
|
|
mock_content1 = types.Content(
|
|
role='model', parts=[types.Part.from_text(text='hello')]
|
|
)
|
|
message1 = _create_mock_receive_message(
|
|
model_turn=mock_content1, grounding_metadata=grounding1
|
|
)
|
|
message2 = _create_mock_receive_message(turn_complete=True)
|
|
|
|
async def mock_receive_generator():
|
|
yield message1
|
|
yield message2
|
|
|
|
receive_mock = mock.Mock(return_value=mock_receive_generator())
|
|
mock_gemini_session.receive = receive_mock
|
|
|
|
with caplog.at_level('WARNING'):
|
|
responses = [resp async for resp in gemini_connection.receive()]
|
|
|
|
# We received two messages. The warning should be logged because retrieval_queries was present
|
|
# but no grounding chunks were received when turn completed.
|
|
incomplete_warnings = [
|
|
record
|
|
for record in caplog.records
|
|
if 'Incomplete grounding_metadata received' in record.message
|
|
]
|
|
assert len(incomplete_warnings) == 1
|
|
assert 'query1' in incomplete_warnings[0].message
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_receive_voice_activity(gemini_connection, mock_gemini_session):
|
|
"""Test receive yields voice_activity message."""
|
|
mock_vad = types.VoiceActivity(
|
|
voice_activity_type=types.VoiceActivityType.ACTIVITY_START,
|
|
audio_offset='1.5s',
|
|
)
|
|
message = _create_mock_receive_message()
|
|
message.voice_activity = mock_vad
|
|
|
|
async def mock_receive_generator():
|
|
yield message
|
|
|
|
receive_mock = mock.Mock(return_value=mock_receive_generator())
|
|
mock_gemini_session.receive = receive_mock
|
|
|
|
responses = [resp async for resp in gemini_connection.receive()]
|
|
|
|
assert len(responses) == 1
|
|
assert responses[0].voice_activity == mock_vad
|