diff --git a/litellm/llms/gemini/realtime/transformation.py b/litellm/llms/gemini/realtime/transformation.py index f4e2941670a..86d610b7ea1 100644 --- a/litellm/llms/gemini/realtime/transformation.py +++ b/litellm/llms/gemini/realtime/transformation.py @@ -391,7 +391,7 @@ class GeminiRealtimeConfig(BaseRealtimeConfig): return None @staticmethod - def _normalize_session_payload_for_mapping(session: dict) -> dict: + def _normalize_session_payload_for_mapping(session: dict) -> dict[str, object]: """Normalize GA-remapped session fields back to their beta keys. ``map_openai_params`` only recognises the flat OpenAI-beta key names @@ -472,6 +472,39 @@ class GeminiRealtimeConfig(BaseRealtimeConfig): ) return setup + def _warn_session_update_dropped(self, session_payload: Mapping[str, object], *, provider: str) -> None: + """Report, once per session, that a later ``session.update`` reached nothing. + + Live accepts ``setup`` as the first-and-only client message, so a second one + closes the socket with 1007 and the update has to be dropped. That is + unavoidable; being quiet about it is not. This used to log at debug, which on + a proxy means the fields never arrive and nothing says so -- the caller sees + the model running on provider defaults and no instructions, voice or persona + of theirs (#44825). + + Warned once because a GA client sends several ``session.update`` messages + while configuring; later drops stay at debug. + """ + dropped: Final = sorted(key for key in session_payload if key != "type") + if getattr(self, "_session_update_drop_warned", False): + verbose_logger.debug( + "%s: dropping a further session.update (setup already sent): %s", + provider, + dropped, + ) + return + self._session_update_drop_warned = True + verbose_logger.warning( + "%s: dropping session.update because setup was already sent, so these " + "fields do not reach the model: %s. Live accepts setup as the " + "first-and-only client message. To have the client's first " + "session.update carry them instead, set litellm.gemini_live_defer_setup " + "= True (env LITELLM_GEMINI_LIVE_DEFER_SETUP=true), which defers setup " + "until that update arrives. Further drops in this session log at debug.", + provider, + dropped, + ) + def _handle_session_update( self, json_message: _OpenAIRealtimeClientEvent, @@ -527,7 +560,7 @@ class GeminiRealtimeConfig(BaseRealtimeConfig): "suppress the model's auto-response mid-session." ) else: - verbose_logger.debug("Gemini Realtime: Ignoring session.update (setup already sent)") + self._warn_session_update_dropped(session_payload, provider="Gemini Realtime") return [] def _handle_conversation_item(self, json_message: _OpenAIRealtimeClientEvent) -> list[str]: diff --git a/litellm/llms/vertex_ai/realtime/transformation.py b/litellm/llms/vertex_ai/realtime/transformation.py index 55f1cf58cbe..bd29d5ac6e3 100644 --- a/litellm/llms/vertex_ai/realtime/transformation.py +++ b/litellm/llms/vertex_ai/realtime/transformation.py @@ -17,7 +17,7 @@ Auth: OAuth2 Bearer token (not an API key). """ import json -from collections.abc import Awaitable, Callable +from collections.abc import Awaitable, Callable, Mapping from typing import Final from litellm import verbose_logger @@ -238,7 +238,10 @@ class VertexAIRealtimeConfig(GeminiRealtimeConfig): "Vertex AI in non-deferred mode." ) else: - verbose_logger.debug("Vertex AI Realtime: Ignoring session.update (setup already sent)") + # Annotated rather than passed straight from json.loads, whose Any + # would reach the helper's Mapping parameter as an unknown type. + dropped_session: Final[Mapping[str, object]] = json_message.get("session") or {} + self._warn_session_update_dropped(dropped_session, provider="Vertex AI Realtime") return [] return super().transform_realtime_request(message, model, session_configuration_request) diff --git a/tests/unit/llms/gemini/realtime/test_gemini_realtime_transformation.py b/tests/unit/llms/gemini/realtime/test_gemini_realtime_transformation.py index 017f279d6c4..39dd79f7b2c 100644 --- a/tests/unit/llms/gemini/realtime/test_gemini_realtime_transformation.py +++ b/tests/unit/llms/gemini/realtime/test_gemini_realtime_transformation.py @@ -2235,3 +2235,34 @@ def test_gemini_realtime_response_done_reports_no_grounding_when_none_ran(): assert input_details.get("web_search_requests") is None assert input_details.get("google_maps_grounding_requests") is None + + +def test_gemini_warns_once_when_dropping_a_session_update(caplog): + """The Gemini path reports a dropped session.update the same way Vertex does. + + Both drop it for the same reason -- Live accepts setup as the first-and-only + client message -- so both should say so rather than log at debug and let the + caller discover their instructions never arrived (#44825). + """ + import logging + + cfg = GeminiRealtimeConfig() + session_update = json.dumps({"type": "session.update", "session": {"instructions": "Be concise."}}) + already_set_up = json.dumps({"setup": {"model": "x"}}) + + with caplog.at_level(logging.WARNING, logger="LiteLLM"): + for _ in range(2): + cfg.transform_realtime_request( + session_update, + "gemini-live-2.5-flash-preview-native-audio-09-2025", + session_configuration_request=already_set_up, + ) + + warnings = [ + record + for record in caplog.records + if "Gemini Realtime" in record.message and "session.update" in record.message + ] + assert len(warnings) == 1 + assert "instructions" in warnings[0].message + assert "gemini_live_defer_setup" in warnings[0].message diff --git a/tests/unit/llms/vertex_ai/realtime/test_vertex_ai_realtime_transformation.py b/tests/unit/llms/vertex_ai/realtime/test_vertex_ai_realtime_transformation.py index 0bfff870d15..16827f414f5 100644 --- a/tests/unit/llms/vertex_ai/realtime/test_vertex_ai_realtime_transformation.py +++ b/tests/unit/llms/vertex_ai/realtime/test_vertex_ai_realtime_transformation.py @@ -418,9 +418,19 @@ def test_vertex_warns_when_dropping_guardrail_turn_detection_update(caplog): ) -def test_vertex_does_not_warn_when_dropping_non_guardrail_session_update(caplog): - """A subsequent session.update without ``create_response: False`` is a - routine drop and should stay at debug level (no warning).""" +def test_vertex_warns_once_when_dropping_a_session_update(caplog): + """A dropped session.update is reported, and reported once. + + This inverts what the test here previously asserted. It held that dropping a + subsequent session.update was "a routine drop" that should stay at debug -- + with ``{"instructions": "Be concise."}`` as its own fixture, which is exactly + the case reported in #44825: the caller's instructions never reach the model + and nothing on a default proxy log says so. + + The drop itself is unavoidable (Live rejects a second setup with 1007). Only + the silence changes. It warns once because a GA client sends several + session.updates while configuring; the rest stay at debug. + """ import logging cfg = VertexAIRealtimeConfig( @@ -429,20 +439,28 @@ def test_vertex_does_not_warn_when_dropping_non_guardrail_session_update(caplog) session_update = { "type": "session.update", - "session": {"instructions": "Be concise."}, + "session": {"instructions": "Be concise.", "voice": "Puck"}, } + already_set_up = json.dumps({"setup": {"model": "x"}}) with caplog.at_level(logging.WARNING, logger="LiteLLM"): - cfg.transform_realtime_request( - json.dumps(session_update), - "gemini-live-2.5-flash-preview-native-audio-09-2025", - session_configuration_request=json.dumps({"setup": {"model": "x"}}), - ) + for _ in range(3): + cfg.transform_realtime_request( + json.dumps(session_update), + "gemini-live-2.5-flash-preview-native-audio-09-2025", + session_configuration_request=already_set_up, + ) - assert not any( - "Vertex AI Realtime" in record.message and "session.update" in record.message + warnings = [ + record for record in caplog.records - ) + if "Vertex AI Realtime" in record.message and "session.update" in record.message + ] + assert len(warnings) == 1, "three drops, one warning" + # the operator needs to know what was lost and what to do about it + assert "instructions" in warnings[0].message + assert "voice" in warnings[0].message + assert "gemini_live_defer_setup" in warnings[0].message @pytest.mark.asyncio