feat: Granular Per Agent Speech Configuration

Merge https://github.com/google/adk-python/pull/3170

Addresses Feature Request: #3116

This PR adds a `speech_config` to the **LLM Agent configuration** for the **live use case**. When an **asynchronous LLM** call is made to the **Gemini Live API**, it prioritizes the most specific agent configuration's speech_config. If that is null, it then uses the run configuration's speech_config. Unit tests have been added to verify this behavior.

COPYBARA_INTEGRATE_REVIEW=https://github.com/google/adk-python/pull/3170 from qyuo:bidi_agent_speech_config af1bd277d4f95c4a7d9aa0b16828ba3de826ce08
PiperOrigin-RevId: 822305427
This commit is contained in:
Luis Pabon
2025-10-21 15:44:00 -07:00
committed by Copybara-Service
parent 2a901d12f4
commit 409df1378f
4 changed files with 229 additions and 3 deletions
+186
View File
@@ -1858,3 +1858,189 @@ def test_build_request_log_fallback_to_repr_on_all_failures(monkeypatch):
# Should still succeed using repr()
assert "Config:" in log_output
assert "GenerateContentConfig" in log_output
@pytest.mark.asyncio
async def test_connect_uses_gemini_speech_config_when_request_is_none(
gemini_llm, llm_request
):
"""Tests that Gemini's speech_config is used when live_connect_config's is None."""
# Arrange: Set a speech_config on the Gemini instance with the voice "Kore"
gemini_llm.speech_config = types.SpeechConfig(
voice_config=types.VoiceConfig(
prebuilt_voice_config=types.PrebuiltVoiceConfig(
voice_name="Kore",
)
)
)
llm_request.live_connect_config = (
types.LiveConnectConfig()
) # speech_config is None
mock_live_session = mock.AsyncMock()
with mock.patch.object(gemini_llm, "_live_api_client") as mock_live_client:
class MockLiveConnect:
async def __aenter__(self):
return mock_live_session
async def __aexit__(self, *args):
pass
mock_live_client.aio.live.connect.return_value = MockLiveConnect()
# Act
async with gemini_llm.connect(llm_request) as connection:
# Assert
mock_live_client.aio.live.connect.assert_called_once()
call_args = mock_live_client.aio.live.connect.call_args
config_arg = call_args.kwargs["config"]
# Verify the speech_config from the Gemini instance was used
assert config_arg.speech_config is not None
assert (
config_arg.speech_config.voice_config.prebuilt_voice_config.voice_name
== "Kore"
)
assert isinstance(connection, GeminiLlmConnection)
@pytest.mark.asyncio
async def test_connect_uses_request_speech_config_when_gemini_is_none(
gemini_llm, llm_request
):
"""Tests that request's speech_config is used when Gemini's is None."""
# Arrange: Set a speech_config on the request instance with the voice "Kore"
gemini_llm.speech_config = None
request_speech_config = types.SpeechConfig(
voice_config=types.VoiceConfig(
prebuilt_voice_config=types.PrebuiltVoiceConfig(
voice_name="Kore",
)
)
)
llm_request.live_connect_config = types.LiveConnectConfig(
speech_config=request_speech_config
)
mock_live_session = mock.AsyncMock()
with mock.patch.object(gemini_llm, "_live_api_client") as mock_live_client:
class MockLiveConnect:
async def __aenter__(self):
return mock_live_session
async def __aexit__(self, *args):
pass
mock_live_client.aio.live.connect.return_value = MockLiveConnect()
# Act
async with gemini_llm.connect(llm_request) as connection:
# Assert
mock_live_client.aio.live.connect.assert_called_once()
call_args = mock_live_client.aio.live.connect.call_args
config_arg = call_args.kwargs["config"]
# Verify the speech_config from the request instance was used
assert config_arg.speech_config is not None
assert (
config_arg.speech_config.voice_config.prebuilt_voice_config.voice_name
== "Kore"
)
assert isinstance(connection, GeminiLlmConnection)
@pytest.mark.asyncio
async def test_connect_request_gemini_config_overrides_speech_config(
gemini_llm, llm_request
):
"""Tests that live_connect_config's speech_config is preserved even if Gemini has one."""
# Arrange: Set different speech_configs on both the Gemini instance ("Puck") and the request ("Zephyr")
gemini_llm.speech_config = types.SpeechConfig(
voice_config=types.VoiceConfig(
prebuilt_voice_config=types.PrebuiltVoiceConfig(
voice_name="Puck",
)
)
)
request_speech_config = types.SpeechConfig(
voice_config=types.VoiceConfig(
prebuilt_voice_config=types.PrebuiltVoiceConfig(
voice_name="Zephyr",
)
)
)
llm_request.live_connect_config = types.LiveConnectConfig(
speech_config=request_speech_config
)
mock_live_session = mock.AsyncMock()
with mock.patch.object(gemini_llm, "_live_api_client") as mock_live_client:
class MockLiveConnect:
async def __aenter__(self):
return mock_live_session
async def __aexit__(self, *args):
pass
mock_live_client.aio.live.connect.return_value = MockLiveConnect()
# Act
async with gemini_llm.connect(llm_request) as connection:
# Assert
mock_live_client.aio.live.connect.assert_called_once()
call_args = mock_live_client.aio.live.connect.call_args
config_arg = call_args.kwargs["config"]
# Verify the speech_config from the request ("Zephyr") was overwritten by Gemini's speech_config ("Puck")
assert config_arg.speech_config is not None
assert (
config_arg.speech_config.voice_config.prebuilt_voice_config.voice_name
== "Puck"
)
assert isinstance(connection, GeminiLlmConnection)
@pytest.mark.asyncio
async def test_connect_speech_config_remains_none_when_both_are_none(
gemini_llm, llm_request
):
"""Tests that speech_config is None when neither Gemini nor the request has it."""
# Arrange: Ensure both Gemini instance and request have no speech_config
gemini_llm.speech_config = None
llm_request.live_connect_config = (
types.LiveConnectConfig()
) # speech_config is None
mock_live_session = mock.AsyncMock()
with mock.patch.object(gemini_llm, "_live_api_client") as mock_live_client:
class MockLiveConnect:
async def __aenter__(self):
return mock_live_session
async def __aexit__(self, *args):
pass
mock_live_client.aio.live.connect.return_value = MockLiveConnect()
# Act
async with gemini_llm.connect(llm_request) as connection:
# Assert
mock_live_client.aio.live.connect.assert_called_once()
call_args = mock_live_client.aio.live.connect.call_args
config_arg = call_args.kwargs["config"]
# Verify the final speech_config is still None
assert config_arg.speech_config is None
assert isinstance(connection, GeminiLlmConnection)