FazBrowse GitHub Viewer | Trending |
URL:
| Home
Tools: [Download Repo ZIP]   [Original HTTPS Page]

test: add API-mode live module integration tests · googleapis/python-genai@fec0cb4 · GitHub

Repository navigation

Commit fec0cb4

Browse files
authored andcommitted
test: add API-mode live module integration tests
FUTURE_COPYBARA_INTEGRATE_REVIEW=#2918 from googleapis:release-please--branches--main 184ea0b PiperOrigin-RevId: 956850144
1 parent ca2b081 commit fec0cb4

3 files changed

Lines changed: 244 additions & 0 deletions

File tree

‎google/genai/tests/live_api/__init__.py‎

Whitespace-only changes.
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
"""Fixtures for the api-mode live tests.
2+
3+
These tests deliberately do not go through pytest_helper.setup(): the live
4+
module is a bidirectional WebSocket session, which cannot be expressed as the
5+
request/response table the shared corpus is built on, and which the replay
6+
client cannot record. setup() would also emit a _test_table.json that the other
7+
five SDKs' harnesses would try to execute.
8+
9+
The `client` fixture in the parent conftest still applies, and in --mode=api it
10+
yields a client that talks to the real backend.
11+
"""
12+
13+
import pytest
14+
15+
16+
@pytest.fixture
17+
def http_options():
18+
"""Required by the parent `client` fixture.
19+
20+
Normally injected by pytest_helper.setup(); live tests use the SDK defaults.
21+
"""
22+
return None
Lines changed: 222 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,222 @@
1+
# Copyright 2026 Google LLC
2+
#
3+
# Licensed under the Apache License, Version 2.0 (the "License");
4+
# you may not use this file except in compliance with the License.
5+
# You may obtain a copy of the License at
6+
#
7+
# http://www.apache.org/licenses/LICENSE-2.0
8+
#
9+
# Unless required by applicable law or agreed to in writing, software
10+
# distributed under the License is distributed on an "AS IS" BASIS,
11+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
# See the License for the specific language governing permissions and
13+
# limitations under the License.
14+
#
15+
16+
"""API-mode integration tests for the live (bidirectional WebSocket) module.
17+
18+
These run only against the real backend. The live module opens a WebSocket and
19+
never goes through BaseApiClient._request, so ReplayApiClient cannot record or
20+
replay it -- see go/genai-sdk:integration-testing. In --mode=api the `client`
21+
fixture yields a client that calls the real API and writes no replay, which is
22+
exactly what these need.
23+
24+
They are deliberately not part of tests/shared: a live session is multi-step and
25+
cannot be expressed as the request/response table that corpus is built on, and
26+
emitting a _test_table.json for it would make the other SDKs' harnesses try to
27+
execute a case they have no implementation for.
28+
"""
29+
30+
import asyncio
31+
32+
import pytest
33+
34+
from .. import pytest_helper
35+
from ... import types
36+
37+
pytestmark = pytest.mark.skipif(
38+
f"not ({pytest_helper.is_api_mode})",
39+
reason=(
40+
'Live tests open a real WebSocket to the backend; there is no replay'
41+
' support for WebSocket traffic, so they only run in --mode=api.'
42+
),
43+
)
44+
45+
pytest_plugins = ('pytest_asyncio',)
46+
47+
# The only live model family currently served. It is audio-native and rejects a
48+
# TEXT response modality outright ("The requested combination of response
49+
# modalities (TEXT) is not supported by the model"), so these tests request
50+
# AUDIO and turn on output transcription to get an assertable text signal.
51+
LIVE_MODEL = 'gemini-3.1-flash-live-preview'
52+
53+
# A live turn is an open-ended stream with no built-in deadline. Without this
54+
# bound a wedged receive would hang the nightly rather than fail it.
55+
_TURN_TIMEOUT_SECONDS = 90
56+
57+
58+
def _base_config(**overrides) -> types.LiveConnectConfig:
59+
config = {
60+
'response_modalities': ['AUDIO'],
61+
'output_audio_transcription': types.AudioTranscriptionConfig(),
62+
}
63+
config.update(overrides)
64+
return types.LiveConnectConfig(**config)
65+
66+
67+
class _Turn:
68+
"""Everything a single model turn produced."""
69+
70+
def __init__(self):
71+
self.audio_bytes = 0
72+
self.transcript = ''
73+
self.tool_calls = []
74+
75+
76+
async def _receive_turn(session) -> _Turn:
77+
"""Drains exactly one model turn, or the tool call that interrupts it."""
78+
turn = _Turn()
79+
transcript_parts = []
80+
81+
async def _drain():
82+
async for message in session.receive():
83+
if message.tool_call and message.tool_call.function_calls:
84+
turn.tool_calls.extend(message.tool_call.function_calls)
85+
return
86+
server_content = message.server_content
87+
if not server_content:
88+
continue
89+
if (
90+
server_content.output_transcription
91+
and server_content.output_transcription.text
92+
):
93+
transcript_parts.append(server_content.output_transcription.text)
94+
if server_content.model_turn:
95+
for part in server_content.model_turn.parts or []:
96+
if part.inline_data and part.inline_data.data:
97+
turn.audio_bytes += len(part.inline_data.data)
98+
if server_content.turn_complete:
99+
return
100+
101+
await asyncio.wait_for(_drain(), timeout=_TURN_TIMEOUT_SECONDS)
102+
turn.transcript = ''.join(transcript_parts)
103+
return turn
104+
105+
106+
def _skip_if_quota_exhausted(error: Exception) -> None:
107+
"""Mirrors pytest_helper's api-mode 429 handling.
108+
109+
A quota response still proves the SDK built the request, authenticated,
110+
reached the live endpoint and parsed the error, so it is not a regression.
111+
See go/genai-sdk:integration-testing section 4.4.
112+
"""
113+
if getattr(error, 'code', None) == 429:
114+
pytest.skip(f'Resource exhausted (429). Skipping instead of failing: {error}')
115+
116+
117+
async def _say(session, text: str) -> None:
118+
await session.send_client_content(
119+
turns=types.Content(role='user', parts=[types.Part(text=text)]),
120+
turn_complete=True,
121+
)
122+
123+
124+
@pytest.mark.asyncio
125+
async def test_text_input(client):
126+
"""A single text turn produces audio output and a matching transcription."""
127+
try:
128+
async with client.aio.live.connect(
129+
model=LIVE_MODEL, config=_base_config()
130+
) as session:
131+
await _say(session, 'Say hello.')
132+
turn = await _receive_turn(session)
133+
134+
assert turn.audio_bytes > 0, 'expected audio output from the model'
135+
assert turn.transcript.strip(), 'expected an output transcription'
136+
except Exception as e: # pylint: disable=broad-except
137+
_skip_if_quota_exhausted(e)
138+
raise
139+
140+
141+
@pytest.mark.asyncio
142+
async def test_multi_turn(client):
143+
"""A second turn in the same session can see the first turn's context."""
144+
try:
145+
async with client.aio.live.connect(
146+
model=LIVE_MODEL, config=_base_config()
147+
) as session:
148+
await _say(session, 'Remember the number 42. Just acknowledge it.')
149+
first = await _receive_turn(session)
150+
assert first.transcript.strip(), 'expected a response to the first turn'
151+
152+
await _say(session, 'What number did I ask you to remember?')
153+
second = await _receive_turn(session)
154+
155+
assert second.audio_bytes > 0, 'expected audio output on the second turn'
156+
assert '42' in second.transcript, (
157+
'the second turn should recall context from the first; transcript was'
158+
f' {second.transcript!r}'
159+
)
160+
except Exception as e: # pylint: disable=broad-except
161+
_skip_if_quota_exhausted(e)
162+
raise
163+
164+
165+
@pytest.mark.asyncio
166+
async def test_function_calling(client):
167+
"""The model requests a declared tool, and the session accepts its result."""
168+
turn_on_the_lights = types.FunctionDeclaration(
169+
name='turn_on_the_lights',
170+
description='Turns the lights on in the room.',
171+
parameters=types.Schema(type=types.Type.OBJECT, properties={}),
172+
)
173+
config = _base_config(
174+
tools=[types.Tool(function_declarations=[turn_on_the_lights])]
175+
)
176+
177+
try:
178+
async with client.aio.live.connect(
179+
model=LIVE_MODEL, config=config
180+
) as session:
181+
await _say(session, 'Please turn on the lights.')
182+
turn = await _receive_turn(session)
183+
184+
assert turn.tool_calls, 'expected the model to request the tool'
185+
call = turn.tool_calls[0]
186+
assert call.name == 'turn_on_the_lights'
187+
assert call.id, 'a Gemini API tool call must carry an id'
188+
189+
await session.send_tool_response(
190+
function_responses=[
191+
types.FunctionResponse(
192+
id=call.id, name=call.name, response={'result': 'ok'}
193+
)
194+
]
195+
)
196+
follow_up = await _receive_turn(session)
197+
assert follow_up.transcript.strip(), (
198+
'expected the model to respond after the tool result'
199+
)
200+
except Exception as e: # pylint: disable=broad-except
201+
_skip_if_quota_exhausted(e)
202+
raise
203+
204+
205+
@pytest.mark.asyncio
206+
async def test_send_tool_response_without_id_raises(client):
207+
"""The Gemini API backend requires an id on every FunctionResponse."""
208+
try:
209+
async with client.aio.live.connect(
210+
model=LIVE_MODEL, config=_base_config()
211+
) as session:
212+
with pytest.raises(ValueError, match='must have an `id` field'):
213+
await session.send_tool_response(
214+
function_responses=[
215+
types.FunctionResponse(
216+
name='turn_on_the_lights', response={'result': 'ok'}
217+
)
218+
]
219+
)
220+
except Exception as e: # pylint: disable=broad-except
221+
_skip_if_quota_exhausted(e)
222+
raise

0 commit comments

Comments
 (0)

Back | FazBrowse Home | New Git URL