From 0defeccced1ef80d87b940b83faa064b83b4d81c Mon Sep 17 00:00:00 2001 From: Liang Wu <18244712+wuliang229@users.noreply.github.com> Date: Fri, 7 Aug 2026 21:43:51 +0000 Subject: [PATCH] fix(live): Port safety_settings forwarding for the Live API to v1 Safety settings configured via `LlmAgent.generate_content_config` were silently dropped on the Live (bidiGenerateContent) path. `Gemini.connect()` copied only `system_instruction` and `tools` from `LlmRequest.config` into `LlmRequest.live_connect_config`, so `safetySettings` never reached the server on either the Vertex AI or the Gemini API backend. The non-live path was unaffected, because it forwards the whole `GenerateContentConfig` to `generate_content`. `RunConfig` exposes no safety settings either, and `before_model_callback` does not run on the live path, so there was no supported way to configure safety for a live agent. Safety settings are now forwarded to `live_connect_config` when the agent supplies them. A value already set on `live_connect_config` continues to take precedence. This has been the behavior since the first release; it is not a regression. BEHAVIOR CHANGE: agents that set `safety_settings` in `generate_content_config` and run under `run_live()` will now have those settings applied. Review the safety configuration of live agents before upgrading. Backport of 0a6d05da3b6ce912fa6f53eef1d97f638522817c from main. The upstream commit also touched the `thinking_config` forwarding block, which does not exist on v1 (it came from a separate change that was never ported); that hunk was dropped so this backport is scoped to safety settings only. --- src/google/adk/models/google_llm.py | 11 ++ tests/unittests/models/test_google_llm.py | 144 ++++++++++++++++++++++ 2 files changed, 155 insertions(+) diff --git a/src/google/adk/models/google_llm.py b/src/google/adk/models/google_llm.py index d5923ffd254..afc6fb47504 100644 --- a/src/google/adk/models/google_llm.py +++ b/src/google/adk/models/google_llm.py @@ -453,6 +453,17 @@ async def connect(self, llm_request: LlmRequest) -> BaseLlmConnection: ' backend. Please use Vertex AI backend.' ) llm_request.live_connect_config.tools = llm_request.config.tools + # Safety settings are configured via LlmAgent.generate_content_config, which + # only populates llm_request.config. Forward them so live runs honor the + # same safety configuration as non-live runs. An explicitly provided + # live_connect_config value takes precedence. + if ( + llm_request.config.safety_settings is not None + and llm_request.live_connect_config.safety_settings is None + ): + llm_request.live_connect_config.safety_settings = ( + llm_request.config.safety_settings + ) logger.debug('Connecting to live with llm_request:%s', llm_request) logger.debug('Live connect config: %s', llm_request.live_connect_config) async with self._live_api_client.aio.live.connect( diff --git a/tests/unittests/models/test_google_llm.py b/tests/unittests/models/test_google_llm.py index 7f7ce39895f..439b6d1468d 100644 --- a/tests/unittests/models/test_google_llm.py +++ b/tests/unittests/models/test_google_llm.py @@ -852,6 +852,150 @@ async def __aexit__(self, *args): ) +@pytest.mark.asyncio +async def test_connect_forwards_safety_settings(gemini_llm, llm_request): + """Live sessions receive safety_settings from generate_content_config.""" + safety_settings = [ + types.SafetySetting( + category=types.HarmCategory.HARM_CATEGORY_DANGEROUS_CONTENT, + threshold=types.HarmBlockThreshold.BLOCK_LOW_AND_ABOVE, + ), + types.SafetySetting( + category=types.HarmCategory.HARM_CATEGORY_HARASSMENT, + threshold=types.HarmBlockThreshold.BLOCK_ONLY_HIGH, + ), + ] + llm_request.config.safety_settings = safety_settings + llm_request.live_connect_config = types.LiveConnectConfig() + + 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() + + async with gemini_llm.connect(llm_request) as connection: + mock_live_client.aio.live.connect.assert_called_once() + config_arg = mock_live_client.aio.live.connect.call_args.kwargs["config"] + + assert config_arg.safety_settings == safety_settings + assert isinstance(connection, GeminiLlmConnection) + + +@pytest.mark.asyncio +async def test_connect_keeps_existing_live_safety_settings( + gemini_llm, llm_request +): + """An explicit live_connect_config.safety_settings is not overwritten.""" + live_safety_settings = [ + types.SafetySetting( + category=types.HarmCategory.HARM_CATEGORY_HATE_SPEECH, + threshold=types.HarmBlockThreshold.BLOCK_NONE, + ), + ] + llm_request.config.safety_settings = [ + types.SafetySetting( + category=types.HarmCategory.HARM_CATEGORY_DANGEROUS_CONTENT, + threshold=types.HarmBlockThreshold.BLOCK_LOW_AND_ABOVE, + ), + ] + llm_request.live_connect_config = types.LiveConnectConfig( + safety_settings=live_safety_settings + ) + + 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() + + async with gemini_llm.connect(llm_request): + config_arg = mock_live_client.aio.live.connect.call_args.kwargs["config"] + + assert config_arg.safety_settings == live_safety_settings + + +@pytest.mark.asyncio +async def test_connect_keeps_empty_live_safety_settings( + gemini_llm, llm_request +): + """An explicit empty live_connect_config.safety_settings is not overwritten. + + An empty list means "send no safety settings" and is distinct from None, + which means "not configured here". + """ + llm_request.config.safety_settings = [ + types.SafetySetting( + category=types.HarmCategory.HARM_CATEGORY_DANGEROUS_CONTENT, + threshold=types.HarmBlockThreshold.BLOCK_LOW_AND_ABOVE, + ), + ] + llm_request.live_connect_config = types.LiveConnectConfig(safety_settings=[]) + + 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() + + async with gemini_llm.connect(llm_request): + config_arg = mock_live_client.aio.live.connect.call_args.kwargs["config"] + + assert config_arg.safety_settings is not None + assert len(config_arg.safety_settings) == 0 + + +@pytest.mark.asyncio +async def test_connect_safety_settings_remain_none_when_unset( + gemini_llm, llm_request +): + """No safety_settings anywhere leaves the live config untouched.""" + llm_request.live_connect_config = types.LiveConnectConfig() + + 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() + + async with gemini_llm.connect(llm_request): + config_arg = mock_live_client.aio.live.connect.call_args.kwargs["config"] + + assert config_arg.safety_settings is None + + @pytest.mark.parametrize( ( "api_backend, "