From 86cd49d476769c7727a3cdacc1aeef787e97ec89 Mon Sep 17 00:00:00 2001 From: mateo-berri <277851410+mateo-berri@users.noreply.github.com> Date: Thu, 29 Oct 2026 00:02:44 -0700 Subject: [PATCH] fix(speech): use the deployment's api_base and timeout for Gemini and Vertex AI TTS Gemini and Vertex AI Gemini TTS go through the speech-to-completion bridge, which built its completion call from params that never carried api_base or timeout. A deployment with a gateway api_base sent /v1/audio/speech straight to Google, and a caller's timeout fell back to the 600 second default Both bridge call sites now pass the deployment's api_base and the caller's timeout to the handler, and None keeps today's behavior --- .../speech_to_completion_bridge/handler.py | 6 +- litellm/main.py | 4 ++ .../text_to_speech/test_transformation.py | 24 ++++++++ tests/unit/test_main.py | 61 +++++++++++++++++++ 4 files changed, 94 insertions(+), 1 deletion(-) diff --git a/litellm/endpoints/speech/speech_to_completion_bridge/handler.py b/litellm/endpoints/speech/speech_to_completion_bridge/handler.py index faf2daecf5f..961c209fc5b 100644 --- a/litellm/endpoints/speech/speech_to_completion_bridge/handler.py +++ b/litellm/endpoints/speech/speech_to_completion_bridge/handler.py @@ -9,6 +9,8 @@ from typing_extensions import TypedDict from litellm._internal_context import internal_sub_call if TYPE_CHECKING: + import httpx + from litellm import LiteLLMLoggingObj from litellm.types.llms.openai import HttpxBinaryResponseContent @@ -87,6 +89,8 @@ class SpeechToCompletionBridgeHandler: headers: dict, logging_obj: "LiteLLMLoggingObj", custom_llm_provider: str, + api_base: str | None, + timeout: "float | httpx.Timeout | None", ) -> "HttpxBinaryResponseContent": received_args: Final = locals() from litellm import completion @@ -106,7 +110,7 @@ class SpeechToCompletionBridgeHandler: model=model, input=input, optional_params=optional_params, - litellm_params=litellm_params, + litellm_params={**litellm_params, "api_base": api_base, "timeout": timeout}, headers=headers, litellm_logging_obj=logging_obj, custom_llm_provider=custom_llm_provider, diff --git a/litellm/main.py b/litellm/main.py index 41c4302499e..e62ca56fe24 100644 --- a/litellm/main.py +++ b/litellm/main.py @@ -8937,6 +8937,8 @@ def speech( headers=headers or {}, logging_obj=logging_obj, custom_llm_provider=custom_llm_provider, + api_base=api_base, + timeout=timeout, ) # Vertex AI Text-to-Speech (Google Cloud TTS) @@ -8988,6 +8990,8 @@ def speech( headers=headers or {}, logging_obj=logging_obj, custom_llm_provider=custom_llm_provider, + api_base=api_base, + timeout=timeout, ) elif custom_llm_provider == "runwayml": from litellm.llms.runwayml.text_to_speech.transformation import ( 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..118ee3b0929 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 @@ -1702,6 +1702,30 @@ async def test_aspeech_vertex_ai_gemini_tts_formats_the_bridge_serves_stay_on_ge assert _PCM_AUDIO in response.content +@pytest.mark.parametrize("custom_llm_provider", ("vertex_ai", "vertex_ai_beta")) +@pytest.mark.asyncio +async def test_aspeech_vertex_ai_gemini_tts_routes_to_configured_api_base( + respx_mock: respx.MockRouter, google_token_endpoint: responses.RequestsMock, custom_llm_provider: str +) -> None: + gateway_route: Final = respx_mock.post( + "https://vertex.gateway.internal/v1/projects/test-project/locations/global" + "/publishers/google/models/gemini-3.8-flash-tts:generateContent" + ).mock(return_value=httpx.Response(200, json=_GENERATE_CONTENT_RESPONSE)) + + response: Final = await litellm.aspeech( + model=f"{custom_llm_provider}/gemini-3.8-flash-tts", + input="Say hello", + voice="Kore", + vertex_credentials=_AUTHORIZED_USER, + vertex_location="global", + api_base="https://vertex.gateway.internal", + ) + + assert gateway_route.call_count == 1 + assert gateway_route.calls.last.request.headers["authorization"] == "Bearer minted-google-token" + assert _PCM_AUDIO in response.content + + @pytest.mark.asyncio async def test_aspeech_vertex_ai_gemini_tts_mp3_reaches_cloud_tts_for_any_gemini_tts_model( respx_mock: respx.MockRouter, google_token_endpoint: responses.RequestsMock diff --git a/tests/unit/test_main.py b/tests/unit/test_main.py index f38bbcf4404..7d01a215de9 100644 --- a/tests/unit/test_main.py +++ b/tests/unit/test_main.py @@ -5454,6 +5454,67 @@ def test_speech_mistral_routes_to_configured_api_base(respx_mock: respx.MockRout assert response.content == audio_bytes +GEMINI_TTS_MODEL: Final = "gemini-3.8-flash-tts" +GEMINI_GATEWAY_BASE: Final = "https://gemini.gateway.internal/v1beta" +GEMINI_DEFAULT_GENERATE_CONTENT_URL: Final = ( + rf"https://generativelanguage\.googleapis\.com/[^/]+/models/{GEMINI_TTS_MODEL}:generateContent" +) + + +@pytest.mark.parametrize("endpoint_param", ["api_base", "base_url"]) +@pytest.mark.parametrize("sync_mode", [True, False]) +async def test_speech_gemini_tts_routes_to_configured_api_base( + respx_mock: respx.MockRouter, monkeypatch: pytest.MonkeyPatch, endpoint_param: str, sync_mode: bool +): + monkeypatch.setattr(litellm, "disable_aiohttp_transport", True) + gateway_route: Final = respx_mock.post(f"{GEMINI_GATEWAY_BASE}/models/{GEMINI_TTS_MODEL}:generateContent").mock( + return_value=httpx.Response(200, json=_gemini_tts_generate_content_response()) + ) + speech_kwargs: Final = { + "model": f"gemini/{GEMINI_TTS_MODEL}", + "input": "hello from litellm", + "voice": "Kore", + "api_key": "fake-gemini-key", + endpoint_param: GEMINI_GATEWAY_BASE, + } + + response: Final = litellm.speech(**speech_kwargs) if sync_mode else await litellm.aspeech(**speech_kwargs) + + assert gateway_route.call_count == 1 + assert gateway_route.calls.last.request.headers["x-goog-api-key"] == "fake-gemini-key" + assert b"pcm-audio-bytes" in response.content + + +def test_speech_gemini_tts_applies_the_caller_timeout(respx_mock: respx.MockRouter): + route: Final = respx_mock.post(url__regex=GEMINI_DEFAULT_GENERATE_CONTENT_URL).mock( + return_value=httpx.Response(200, json=_gemini_tts_generate_content_response()) + ) + + litellm.speech( + model=f"gemini/{GEMINI_TTS_MODEL}", input="hello", voice="Kore", api_key="fake-gemini-key", timeout=7.5 + ) + + assert route.calls.last.request.extensions["timeout"] == httpx.Timeout(7.5).as_dict() + + +def test_speech_gemini_tts_without_a_timeout_waits_as_long_as_the_same_chat_completion(respx_mock: respx.MockRouter): + route: Final = respx_mock.post(url__regex=GEMINI_DEFAULT_GENERATE_CONTENT_URL).mock( + return_value=httpx.Response(200, json=_gemini_tts_generate_content_response()) + ) + + litellm.speech(model=f"gemini/{GEMINI_TTS_MODEL}", input="hello", voice="Kore", api_key="fake-gemini-key") + litellm.completion( + model=f"gemini/{GEMINI_TTS_MODEL}", + messages=[{"role": "user", "content": "hello"}], + modalities=["audio"], + audio={"voice": "Kore", "format": "pcm16"}, + api_key="fake-gemini-key", + ) + + speech_call, chat_call = route.calls + assert speech_call.request.extensions["timeout"] == chat_call.request.extensions["timeout"] + + FOUNDRY_HOST: Final = "https://my-project.services.ai.azure.com"