From 6fc9555f83b8130a0ffd1e18f38b165eebbebe75 Mon Sep 17 00:00:00 2001 From: Julien Parenti Date: Fri, 6 Nov 2026 09:03:17 +0100 Subject: [PATCH] fix(vertex_ai): honor vertex_location for the Cloud Text-to-Speech endpoint (#50255) Chirp and other Cloud TTS models now call us-texttospeech or eu-texttospeech.googleapis.com when the deployment's vertex_location is a US or EU location, keep the global host otherwise, and an explicit api_base still wins. Gemini TTS accepts more US and EU regions. --- .../text_to_speech/transformation.py | 48 +++++++++-- .../text_to_speech/test_transformation.py | 82 ++++++++++++++++++- 2 files changed, 122 insertions(+), 8 deletions(-) diff --git a/litellm/llms/vertex_ai/text_to_speech/transformation.py b/litellm/llms/vertex_ai/text_to_speech/transformation.py index c0082bc5ca6..a674ca4130e 100644 --- a/litellm/llms/vertex_ai/text_to_speech/transformation.py +++ b/litellm/llms/vertex_ai/text_to_speech/transformation.py @@ -55,17 +55,45 @@ _LyriaVoice: TypeAlias = str | dict | None _JSON_OBJECT: Final = TypeAdapter(Mapping[str, object], config=ConfigDict(hide_input_in_errors=True)) _JSON_ARRAY: Final = TypeAdapter(tuple[object, ...], config=ConfigDict(hide_input_in_errors=True)) _STR: Final = TypeAdapter(str, config=ConfigDict(hide_input_in_errors=True)) -_GEMINI_CLOUD_TTS_LOCATIONS: Final = MappingProxyType( +_CLOUD_TTS_LOCATIONS: Final = MappingProxyType( { **dict.fromkeys( - ("us", "us-central1", "us-east1", "us-east4", "us-east5", "us-south1", "us-west1", "us-west4"), "us" + ( + "us", + "us-central1", + "us-east1", + "us-east4", + "us-east5", + "us-south1", + "us-west1", + "us-west2", + "us-west3", + "us-west4", + "us-west5", + ), + "us", ), **dict.fromkeys( - ("eu", "europe-central2", "europe-north1", "europe-southwest1", "europe-west1", "europe-west4"), "eu" + ( + "eu", + "europe-central2", + "europe-north1", + "europe-north2", + "europe-southwest1", + "europe-west1", + "europe-west3", + "europe-west4", + "europe-west8", + "europe-west9", + "europe-west10", + "europe-west12", + ), + "eu", ), "northamerica-northeast1": "northamerica-northeast1", } ) +_CLOUD_TTS_MULTI_REGIONS: Final = frozenset(("us", "eu")) _DEFAULT_GEMINI_TTS_VOICE: Final = "Kore" @@ -563,7 +591,11 @@ class VertexAITextToSpeechConfig(BaseTextToSpeechConfig, VertexBase): # Resolve Vertex AI credentials using VertexBase helpers vertex_credentials: Final = self.safe_get_vertex_ai_credentials(litellm_params_dict) vertex_project: Final = self.safe_get_vertex_ai_project(litellm_params_dict) - vertex_location: Final = self.safe_get_vertex_ai_location(litellm_params_dict) + vertex_location: Final = ( + self.safe_get_vertex_ai_location(litellm_params_dict) + if self._is_gemini_tts_model(model) + else self.explicit_vertex_ai_location(litellm_params_dict) + ) mapped_voice, mapped_params = ( self.map_openai_params( @@ -732,7 +764,11 @@ class VertexAITextToSpeechConfig(BaseTextToSpeechConfig, VertexBase): if api_base: return api_base - return self.TTS_API_URL + location: Final = _JSON_OBJECT.validate_python(litellm_params).get("vertex_location") + cloud_tts_location: Final = _CLOUD_TTS_LOCATIONS.get(location) if isinstance(location, str) else None + if cloud_tts_location not in _CLOUD_TTS_MULTI_REGIONS: + return self.TTS_API_URL + return f"https://{cloud_tts_location}-texttospeech.googleapis.com/v1/text:synthesize" def _get_gemini_tts_url(self, model: str, api_base: str | None, litellm_params: Mapping[str, object]) -> str: if api_base: @@ -748,7 +784,7 @@ class VertexAITextToSpeechConfig(BaseTextToSpeechConfig, VertexBase): vertex_location: Final = location if isinstance(location, str) else self.get_default_vertex_location() if vertex_location == "global": return self.TTS_API_URL - cloud_tts_location: Final = _GEMINI_CLOUD_TTS_LOCATIONS.get(vertex_location) + cloud_tts_location: Final = _CLOUD_TTS_LOCATIONS.get(vertex_location) if cloud_tts_location is None: raise UnsupportedParamsError( message=( diff --git a/tests/unit/llms/vertex_ai/text_to_speech/test_transformation.py b/tests/unit/llms/vertex_ai/text_to_speech/test_transformation.py index b3d6d5b952f..59c781c5a59 100644 --- a/tests/unit/llms/vertex_ai/text_to_speech/test_transformation.py +++ b/tests/unit/llms/vertex_ai/text_to_speech/test_transformation.py @@ -102,6 +102,61 @@ class TestVertexAITextToSpeechConfig: assert url == custom_url + @pytest.mark.parametrize( + ("vertex_location", "expected_host"), + [ + ("eu", "eu-texttospeech.googleapis.com"), + ("europe-west4", "eu-texttospeech.googleapis.com"), + ("europe-west1", "eu-texttospeech.googleapis.com"), + ("europe-west3", "eu-texttospeech.googleapis.com"), + ("europe-west8", "eu-texttospeech.googleapis.com"), + ("europe-west9", "eu-texttospeech.googleapis.com"), + ("europe-west12", "eu-texttospeech.googleapis.com"), + ("us", "us-texttospeech.googleapis.com"), + ("us-central1", "us-texttospeech.googleapis.com"), + ("us-west2", "us-texttospeech.googleapis.com"), + ("us-west5", "us-texttospeech.googleapis.com"), + ], + ) + def test_get_complete_url_uses_regional_endpoint_for_vertex_location( + self, vertex_location: str, expected_host: str + ) -> None: + config: Final = VertexAITextToSpeechConfig() + + url: Final = config.get_complete_url( + model="vertex_ai/chirp", + api_base=None, + litellm_params={"vertex_location": vertex_location}, + ) + + assert url == f"https://{expected_host}/v1/text:synthesize" + + @pytest.mark.parametrize( + "vertex_location", ["global", "europe-west2", "asia-northeast1", "northamerica-northeast1", None] + ) + def test_get_complete_url_keeps_global_endpoint_without_a_regional_match(self, vertex_location: str | None) -> None: + config: Final = VertexAITextToSpeechConfig() + + url: Final = config.get_complete_url( + model="vertex_ai/chirp", + api_base=None, + litellm_params={"vertex_location": vertex_location}, + ) + + assert url == "https://texttospeech.googleapis.com/v1/text:synthesize" + + def test_get_complete_url_api_base_wins_over_vertex_location(self) -> None: + config: Final = VertexAITextToSpeechConfig() + + custom_url: Final = "https://custom-tts-endpoint.example.com/v1/synthesize" + url: Final = config.get_complete_url( + model="vertex_ai/chirp", + api_base=custom_url, + litellm_params={"vertex_location": "eu"}, + ) + + assert url == custom_url + @patch.object( # test-quality-ok: isolates provider credentials while testing request serialization VertexAITextToSpeechConfig, "_ensure_access_token" ) @@ -1332,8 +1387,7 @@ def test_litellm_speech_vertex_ai_chirp(mock_get_token, mock_ensure_token, mock_ mock_post.assert_called_once() call_kwargs = mock_post.call_args.kwargs - # Verify the URL is the Google Cloud TTS API - assert call_kwargs["url"] == "https://texttospeech.googleapis.com/v1/text:synthesize" + assert call_kwargs["url"] == "https://us-texttospeech.googleapis.com/v1/text:synthesize" # Verify request body structure assert "json" in call_kwargs @@ -1863,9 +1917,13 @@ async def test_aspeech_vertex_ai_gemini_tts_mp3_with_a_numeric_speaking_rate_ret ( ("us-central1", "https://us-texttospeech.googleapis.com/v1/text:synthesize"), ("us-east5", "https://us-texttospeech.googleapis.com/v1/text:synthesize"), + ("us-west3", "https://us-texttospeech.googleapis.com/v1/text:synthesize"), + ("us-west5", "https://us-texttospeech.googleapis.com/v1/text:synthesize"), ("us", "https://us-texttospeech.googleapis.com/v1/text:synthesize"), ("europe-west4", "https://eu-texttospeech.googleapis.com/v1/text:synthesize"), ("europe-north1", "https://eu-texttospeech.googleapis.com/v1/text:synthesize"), + ("europe-west9", "https://eu-texttospeech.googleapis.com/v1/text:synthesize"), + ("europe-west10", "https://eu-texttospeech.googleapis.com/v1/text:synthesize"), ("eu", "https://eu-texttospeech.googleapis.com/v1/text:synthesize"), ("northamerica-northeast1", "https://northamerica-northeast1-texttospeech.googleapis.com/v1/text:synthesize"), ("global", _SYNTHESIZE_URL), @@ -1914,6 +1972,26 @@ async def test_aspeech_vertex_ai_gemini_tts_mp3_rejects_a_region_cloud_tts_does_ assert not respx_mock.calls +@pytest.mark.asyncio +async def test_aspeech_vertex_ai_chirp_keeps_the_global_host_when_only_vertexai_location_is_set( + respx_mock: respx.MockRouter, google_token_endpoint: responses.RequestsMock, monkeypatch: pytest.MonkeyPatch +) -> None: + monkeypatch.setenv("VERTEXAI_LOCATION", "europe-west4") + route: Final = respx_mock.post(_SYNTHESIZE_URL).mock( + return_value=httpx.Response(200, json={"audioContent": base64.b64encode(b"global-audio").decode()}) + ) + + response: Final = await litellm.aspeech( + model="vertex_ai/chirp", + input="Bonjour", + voice="fr-FR-Chirp3-HD-Aoede", + vertex_credentials=_AUTHORIZED_USER, + ) + + assert route.call_count == 1 + assert response.content == b"global-audio" + + @pytest.mark.asyncio async def test_router_gemini_tts_mp3_unknown_voice_is_a_bad_request_that_keeps_the_deployment_serving( respx_mock: respx.MockRouter, google_token_endpoint: responses.RequestsMock