From 176808e2689f9fe5132541b38d89a4b98df54b5d Mon Sep 17 00:00:00 2001 From: mateo-berri <277851410+mateo-berri@users.noreply.github.com> Date: Mon, 9 Nov 2026 23:54:01 -0800 Subject: [PATCH] fix(speech): forward a top-level ttl and pass deployment headers as is through the TTS bridge Picked from 9dafd85341 without its awaited aspeech path. 1.108.x has no internal sub-call scope around the bridge's inner chat call, so awaiting acompletion there writes the speech spend row under the provider's response id instead of the call id --- .../speech_to_completion_bridge/handler.py | 2 +- .../transformation.py | 2 +- litellm/types/utils.py | 3 +- tests/unit/test_main.py | 58 ++++++++++++++++++- 4 files changed, 61 insertions(+), 4 deletions(-) diff --git a/litellm/endpoints/speech/speech_to_completion_bridge/handler.py b/litellm/endpoints/speech/speech_to_completion_bridge/handler.py index 6cacf057515..8b6e6a84045 100644 --- a/litellm/endpoints/speech/speech_to_completion_bridge/handler.py +++ b/litellm/endpoints/speech/speech_to_completion_bridge/handler.py @@ -90,7 +90,7 @@ class SpeechToCompletionBridgeHandler: logging_obj: "LiteLLMLoggingObj", custom_llm_provider: str, api_base: str | None, - extra_headers: Mapping[str, str] | None, + extra_headers: Mapping[str, object] | None, timeout: "float | httpx.Timeout | None", cache_controls: "CacheControlsHint", ) -> "HttpxBinaryResponseContent": diff --git a/litellm/endpoints/speech/speech_to_completion_bridge/transformation.py b/litellm/endpoints/speech/speech_to_completion_bridge/transformation.py index 2ed140c0208..12931251393 100644 --- a/litellm/endpoints/speech/speech_to_completion_bridge/transformation.py +++ b/litellm/endpoints/speech/speech_to_completion_bridge/transformation.py @@ -88,7 +88,7 @@ class SpeechToCompletionBridgeTransformationHandler: headers: dict, litellm_logging_obj: "LiteLLMLoggingObj", custom_llm_provider: str, - ) -> dict: + ) -> Mapping[str, object]: self._validate_response_format(model, custom_llm_provider, optional_params) user_message: Final[ChatCompletionUserMessage] = {"role": "user", "content": input} return_kwargs: Final = { diff --git a/litellm/types/utils.py b/litellm/types/utils.py index 8f9e3386777..2f95800fad5 100644 --- a/litellm/types/utils.py +++ b/litellm/types/utils.py @@ -4874,7 +4874,8 @@ class BaseUrlRoutingHint(TypedDict, total=False): class CacheControlsHint(TypedDict, total=False): cache: ReadOnly[OnErrorOmit[Mapping[str, object] | None]] caching: ReadOnly[OnErrorOmit[JsonValue]] + ttl: ReadOnly[OnErrorOmit[JsonValue]] class DeploymentHeadersHint(TypedDict, total=False): - extra_headers: ReadOnly[OnErrorOmit[Mapping[str, str] | None]] + extra_headers: ReadOnly[OnErrorOmit[Mapping[str, object] | None]] diff --git a/tests/unit/test_main.py b/tests/unit/test_main.py index 51aeb792724..4126c5c30df 100644 --- a/tests/unit/test_main.py +++ b/tests/unit/test_main.py @@ -36,6 +36,7 @@ from litellm import ( from litellm import main as litellm_main from litellm.caching.base_cache import BaseCache from litellm.caching.caching import Cache +from litellm.caching.in_memory_cache import InMemoryCache from litellm.constants import CONTROL_OPTIONS_KEY from litellm.integrations.custom_logger import CustomLogger from litellm.integrations.custom_prompt_management import CustomPromptManagement @@ -4905,7 +4906,7 @@ def test_speech_gemini_tts_honors_the_request_cache_controls( @pytest.mark.parametrize( "malformed_controls", - [{"caching": "yes"}, {"caching": 1}, {"cache": {1: True}}, {"extra_headers": {"x-gateway-key": 5}}], + [{"caching": "yes"}, {"caching": 1}, {"cache": {1: True}}], ) def test_speech_gemini_tts_without_a_cache_ignores_malformed_controls( respx_mock: respx.MockRouter, @@ -4929,6 +4930,61 @@ def test_speech_gemini_tts_without_a_cache_ignores_malformed_controls( assert response.content.startswith(b"RIFF") +@pytest.mark.parametrize("ttl", [60, "60"]) +def test_speech_gemini_tts_keeps_cached_audio_only_for_the_request_ttl( + respx_mock: respx.MockRouter, monkeypatch: pytest.MonkeyPatch, ttl: int | str +): + now: Final = [0.0] + cache: Final = Cache(type="local") + cache.cache = InMemoryCache(clock=lambda: now[0]) + monkeypatch.setattr(litellm, "cache", cache) + route: Final = respx_mock.post(url__regex=GEMINI_DEFAULT_GENERATE_CONTENT_URL).mock( + return_value=httpx.Response(200, json=_gemini_tts_generate_content_response()) + ) + + def speak() -> None: + litellm.speech( + model=f"gemini/{GEMINI_TTS_MODEL}", input="hello", voice="Kore", api_key="fake-gemini-key", ttl=ttl + ) + + speak() + now[0] = 30.0 + speak() + assert route.call_count == 1 + now[0] = 61.0 + speak() + assert route.call_count == 2 + + +def test_speech_gemini_tts_fails_like_chat_on_a_non_string_deployment_header(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()) + ) + deployment_headers: Final = {"x-gateway-key": "gateway-secret", "x-org-id": 12345} + + with pytest.raises(litellm.APIConnectionError) as chat_error: + 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", + extra_headers=deployment_headers, + ) + with pytest.raises(litellm.APIConnectionError) as speech_error: + litellm.speech( + model=f"gemini/{GEMINI_TTS_MODEL}", + input="hello", + voice="Kore", + api_key="fake-gemini-key", + extra_headers=deployment_headers, + ) + + assert "Header value must be str or bytes" in str(chat_error.value) + assert "Header value must be str or bytes" in str(speech_error.value) + assert route.call_count == 0 + + FOUNDRY_HOST: Final = "https://my-project.services.ai.azure.com"