From 2519b429ea4184c5fc53219e9be0961ff0b59c14 Mon Sep 17 00:00:00 2001 From: mateo-berri <277851410+mateo-berri@users.noreply.github.com> Date: Tue, 10 Nov 2026 02:45:57 -0800 Subject: [PATCH] fix(speech): send a number or boolean deployment header as its config text through the TTS bridge A header value has to be text, and the merge base never sent deployment headers, so failing like chat turned a TTS deployment with a number in extra_headers from 200 into 500. Null, list and map values still fail like chat --- .../speech_to_completion_bridge/handler.py | 13 ++++++- tests/unit/test_main.py | 39 ++++++++----------- 2 files changed, 29 insertions(+), 23 deletions(-) diff --git a/litellm/endpoints/speech/speech_to_completion_bridge/handler.py b/litellm/endpoints/speech/speech_to_completion_bridge/handler.py index c168bc398b4..84c9ce90215 100644 --- a/litellm/endpoints/speech/speech_to_completion_bridge/handler.py +++ b/litellm/endpoints/speech/speech_to_completion_bridge/handler.py @@ -2,6 +2,7 @@ Handler for transforming /chat/completions api requests to litellm.responses requests """ +import json from collections.abc import Coroutine, Mapping from typing import TYPE_CHECKING, Final @@ -17,6 +18,16 @@ if TYPE_CHECKING: from litellm.types.utils import CacheControlsHint +def _header_text(value: object) -> object: + return json.dumps(value) if isinstance(value, bool | int | float) else value + + +def _headers_as_text(extra_headers: Mapping[str, object] | None) -> Mapping[str, object] | None: + if extra_headers is None: + return None + return {name: _header_text(value) for name, value in extra_headers.items()} + + class SpeechToCompletionBridgeHandlerInputKwargs(TypedDict): model: str input: str @@ -117,7 +128,7 @@ class SpeechToCompletionBridgeHandler: litellm_params={ **litellm_params, "api_base": api_base, - "extra_headers": extra_headers, + "extra_headers": _headers_as_text(extra_headers), "timeout": timeout, **cache_controls, }, diff --git a/tests/unit/test_main.py b/tests/unit/test_main.py index 6af71ed3137..cd72bcd25f4 100644 --- a/tests/unit/test_main.py +++ b/tests/unit/test_main.py @@ -6379,33 +6379,28 @@ def test_speech_gemini_tts_keeps_cached_audio_only_for_the_request_ttl( assert route.call_count == 2 -def test_speech_gemini_tts_fails_like_chat_on_a_non_string_deployment_header(respx_mock: respx.MockRouter): +def test_speech_gemini_tts_sends_number_and_boolean_deployment_headers_as_their_config_text( + 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, - ) + litellm.speech( + model=f"gemini/{GEMINI_TTS_MODEL}", + input="hello", + voice="Kore", + api_key="fake-gemini-key", + extra_headers={"x-gateway-key": "gateway-secret", "x-org-id": 12345, "x-ratio": 1.5, "x-audit": True}, + ) - 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 + sent: Final = route.calls.last.request.headers + assert (sent["x-gateway-key"], sent["x-org-id"], sent["x-ratio"], sent["x-audit"]) == ( + "gateway-secret", + "12345", + "1.5", + "true", + ) async def test_aspeech_gemini_tts_waits_for_a_stalled_upstream_without_holding_a_worker_thread(