fix(gemini-realtime): keep the client's voice on Vertex AI native-audio Live

Vertex AI Live accepts a speechConfig on setup for native-audio models, so
stripping it silently discarded the voice a client asked for. Confirmed against
a live BidiGenerateContent session on gemini-live-2.5-flash-native-audio and on
gemini-live-2.5-flash-preview-native-audio-09-2025: both return setupComplete
with speechConfig present.

The strip stays in place for Google AI Studio, which was never verified to
accept it, via an overridable predicate matching the existing
_include_function_response_id pattern. The responseModalities TEXT to AUDIO
coercion is unchanged, since Vertex does reject TEXT on these models.
This commit is contained in:
Marty Sullivan 2026-08-13 16:59:15 -04:00 committed by Yucheng Zhu
parent ace28fd97a
commit 5d8769bbaf
3 changed files with 59 additions and 3 deletions

View file

@ -92,6 +92,10 @@ class GeminiRealtimeConfig(BaseRealtimeConfig):
"""Google AI Studio Gemini 3.5+ accepts ``id`` on functionResponses; Vertex AI rejects it."""
return True
def _strip_native_audio_speech_config(self) -> bool:
"""Google AI Studio native-audio Live rejects ``speechConfig`` on setup; Vertex AI accepts it."""
return True
@staticmethod
def _usage_detail_alias(details: Any, defaults: dict[str, int]) -> dict[str, Any]:
if not isinstance(details, dict):
@ -382,8 +386,7 @@ class GeminiRealtimeConfig(BaseRealtimeConfig):
without_text: Final = [modality for modality in normalized if modality != "TEXT"]
return without_text if without_text else ["AUDIO"]
@staticmethod
def _finalize_gemini_live_setup(model: str, setup: dict[str, Any]) -> dict[str, Any]:
def _finalize_gemini_live_setup(self, model: str, setup: dict[str, Any]) -> dict[str, Any]:
"""Drop fields Gemini Live native-audio rejects on ``setup``."""
generation_config: Final = setup.get("generationConfig")
if isinstance(generation_config, dict):
@ -392,7 +395,7 @@ class GeminiRealtimeConfig(BaseRealtimeConfig):
generation_config["responseModalities"] = GeminiRealtimeConfig._coerce_response_modalities(
model, modalities
)
if GeminiRealtimeConfig._is_native_audio_model(model):
if self._strip_native_audio_speech_config() and GeminiRealtimeConfig._is_native_audio_model(model):
generation_config.pop("speechConfig", None)
return setup

View file

@ -35,6 +35,9 @@ class VertexAIRealtimeConfig(GeminiRealtimeConfig):
def _include_function_response_id(self) -> bool:
return False
def _strip_native_audio_speech_config(self) -> bool:
return False
# ------------------------------------------------------------------
# URL
# ------------------------------------------------------------------

View file

@ -462,3 +462,53 @@ def test_vertex_function_call_output_omits_id():
assert "id" not in function_response
assert function_response["name"] == "terminate_call"
assert function_response["response"] == {"status": "ok"}
def test_vertex_native_audio_keeps_requested_voice(patch_native_audio_cost_map_entry):
"""Regression: Vertex Live accepts speechConfig on native audio, so the client's voice must survive.
Stripping it silently dropped voice selection for every Vertex native-audio
session. TEXT is still coerced away, which Vertex does reject.
"""
cfg = VertexAIRealtimeConfig(
access_token="tok", project="my-proj", location="us-central1"
)
session_update = {
"type": "session.update",
"session": {
"output_modalities": ["text"],
"audio": {"output": {"voice": "Aoede"}},
},
}
messages = cfg.transform_realtime_request(
json.dumps(session_update),
_NATIVE_AUDIO_MODEL,
session_configuration_request=None,
)
generation_config = json.loads(messages[0])["setup"]["generationConfig"]
assert generation_config["speechConfig"]["voiceConfig"]["prebuiltVoiceConfig"]["voiceName"] == "Aoede"
assert generation_config["responseModalities"] == ["AUDIO"]
def test_google_ai_studio_native_audio_still_strips_voice(patch_native_audio_cost_map_entry):
"""Google AI Studio's own native-audio Live is untouched: only Vertex was verified to accept speechConfig."""
from litellm.llms.gemini.realtime.transformation import GeminiRealtimeConfig
messages = GeminiRealtimeConfig().transform_realtime_request(
json.dumps(
{
"type": "session.update",
"session": {
"output_modalities": ["audio"],
"audio": {"output": {"voice": "Aoede"}},
},
}
),
_NATIVE_AUDIO_MODEL,
session_configuration_request=None,
)
generation_config = json.loads(messages[0])["setup"]["generationConfig"]
assert "speechConfig" not in generation_config