fix(vertex_ai): honor vertex_location for the Cloud Text-to-Speech endpoint (#50255)
Some checks are pending
LiteLLM Rust / rust-lint (push) Waiting to run
LiteLLM Rust / rust-test (push) Waiting to run
LiteLLM Rust / rust-wheel (push) Waiting to run

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.
This commit is contained in:
Julien Parenti 2026-11-06 09:03:17 +01:00 • committed by GitHub
parent 848c267b76
commit 6fc9555f83
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 122 additions and 8 deletions

View file

@ -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=(

View file

@ -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