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
This commit is contained in:
mateo-berri 2026-11-09 23:54:01 -08:00
parent d18b0d8289
commit 176808e268
4 changed files with 61 additions and 4 deletions

View file

@ -90,7 +90,7 @@ class SpeechToCompletionBridgeHandler:
logging_obj: "LiteLLMLoggingObj", logging_obj: "LiteLLMLoggingObj",
custom_llm_provider: str, custom_llm_provider: str,
api_base: str | None, api_base: str | None,
extra_headers: Mapping[str, str] | None, extra_headers: Mapping[str, object] | None,
timeout: "float | httpx.Timeout | None", timeout: "float | httpx.Timeout | None",
cache_controls: "CacheControlsHint", cache_controls: "CacheControlsHint",
) -> "HttpxBinaryResponseContent": ) -> "HttpxBinaryResponseContent":

View file

@ -88,7 +88,7 @@ class SpeechToCompletionBridgeTransformationHandler:
headers: dict, headers: dict,
litellm_logging_obj: "LiteLLMLoggingObj", litellm_logging_obj: "LiteLLMLoggingObj",
custom_llm_provider: str, custom_llm_provider: str,
) -> dict: ) -> Mapping[str, object]:
self._validate_response_format(model, custom_llm_provider, optional_params) self._validate_response_format(model, custom_llm_provider, optional_params)
user_message: Final[ChatCompletionUserMessage] = {"role": "user", "content": input} user_message: Final[ChatCompletionUserMessage] = {"role": "user", "content": input}
return_kwargs: Final = { return_kwargs: Final = {

View file

@ -4874,7 +4874,8 @@ class BaseUrlRoutingHint(TypedDict, total=False):
class CacheControlsHint(TypedDict, total=False): class CacheControlsHint(TypedDict, total=False):
cache: ReadOnly[OnErrorOmit[Mapping[str, object] | None]] cache: ReadOnly[OnErrorOmit[Mapping[str, object] | None]]
caching: ReadOnly[OnErrorOmit[JsonValue]] caching: ReadOnly[OnErrorOmit[JsonValue]]
ttl: ReadOnly[OnErrorOmit[JsonValue]]
class DeploymentHeadersHint(TypedDict, total=False): class DeploymentHeadersHint(TypedDict, total=False):
extra_headers: ReadOnly[OnErrorOmit[Mapping[str, str] | None]] extra_headers: ReadOnly[OnErrorOmit[Mapping[str, object] | None]]

View file

@ -36,6 +36,7 @@ from litellm import (
from litellm import main as litellm_main from litellm import main as litellm_main
from litellm.caching.base_cache import BaseCache from litellm.caching.base_cache import BaseCache
from litellm.caching.caching import Cache from litellm.caching.caching import Cache
from litellm.caching.in_memory_cache import InMemoryCache
from litellm.constants import CONTROL_OPTIONS_KEY from litellm.constants import CONTROL_OPTIONS_KEY
from litellm.integrations.custom_logger import CustomLogger from litellm.integrations.custom_logger import CustomLogger
from litellm.integrations.custom_prompt_management import CustomPromptManagement 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( @pytest.mark.parametrize(
"malformed_controls", "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( def test_speech_gemini_tts_without_a_cache_ignores_malformed_controls(
respx_mock: respx.MockRouter, 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") 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" FOUNDRY_HOST: Final = "https://my-project.services.ai.azure.com"