Merge pull request #38819 from BerriAI/litellm_fix_gemini_tts_response_format

fix(speech): stop forwarding response_format as a chat param for Gemini TTS
This commit is contained in:
Mateo Wang 2026-08-31 15:56:18 -07:00 committed by GitHub
commit d60e77ae8c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 109 additions and 24 deletions

View file

@ -103,6 +103,7 @@ jobs:
tests/test_litellm/completion_extras
tests/test_litellm/compression
tests/test_litellm/containers
tests/test_litellm/endpoints
tests/test_litellm/experimental_mcp_client
tests/test_litellm/models
tests/test_litellm/repositories

View file

@ -1,10 +1,14 @@
from collections.abc import Mapping
from types import MappingProxyType
from typing import TYPE_CHECKING, Final, cast
from typing_extensions import NotRequired, ReadOnly, TypedDict
from litellm.constants import OPENAI_CHAT_COMPLETION_PARAMS
if TYPE_CHECKING:
from litellm import Logging as LiteLLMLoggingObj
from litellm.types.llms.openai import HttpxBinaryResponseContent
from litellm.types.llms.openai import ChatCompletionUserMessage, HttpxBinaryResponseContent
from litellm.types.utils import ModelResponse
@ -16,7 +20,42 @@ def _completion_response_cost(model_response: "ModelResponse") -> float | None:
return response_cost if isinstance(response_cost, float) else None
GEMINI_TTS_CHAT_AUDIO_FORMAT: Final = "pcm16"
class ChatAudioParam(TypedDict):
voice: ReadOnly[str]
format: ReadOnly[NotRequired[str]]
class SpeechToCompletionBridgeTransformationHandler:
def _chat_completion_params(self, optional_params: Mapping[str, object]) -> Mapping[str, object]:
return MappingProxyType(
{
param: value
for param, value in optional_params.items()
if param in OPENAI_CHAT_COMPLETION_PARAMS and param != "response_format"
}
)
def _chat_audio_format(self, model: str, optional_params: Mapping[str, object]) -> str | None:
if self._is_gemini_tts_model(model):
return GEMINI_TTS_CHAT_AUDIO_FORMAT
response_format: Final = optional_params.get("response_format")
return response_format if isinstance(response_format, str) else None
def _chat_audio_param(
self, model: str, voice: str | Mapping[str, object] | None, optional_params: Mapping[str, object]
) -> ChatAudioParam | None:
if not isinstance(voice, str):
return None
audio_format: Final = self._chat_audio_format(model, optional_params)
if audio_format is None:
voice_only: Final[ChatAudioParam] = {"voice": voice}
return voice_only
audio: Final[ChatAudioParam] = {"voice": voice, "format": audio_format}
return audio
def transform_request(
self,
model: str,
@ -28,36 +67,19 @@ class SpeechToCompletionBridgeTransformationHandler:
litellm_logging_obj: "LiteLLMLoggingObj",
custom_llm_provider: str,
) -> dict:
passed_optional_params: Final = {}
for op in optional_params:
if op in OPENAI_CHAT_COMPLETION_PARAMS:
passed_optional_params[op] = optional_params[op]
if voice is not None:
if isinstance(voice, str):
passed_optional_params["audio"] = {"voice": voice}
if "response_format" in optional_params:
passed_optional_params["audio"]["format"] = optional_params["response_format"]
return_kwargs = {
user_message: Final[ChatCompletionUserMessage] = {"role": "user", "content": input}
return_kwargs: Final = {
"model": model,
"messages": [
{
"role": "user",
"content": input,
}
],
"messages": [user_message],
"modalities": ["audio"],
**passed_optional_params,
**self._chat_completion_params(optional_params),
"audio": self._chat_audio_param(model, voice, optional_params),
**litellm_params,
"headers": headers,
"litellm_logging_obj": litellm_logging_obj,
"custom_llm_provider": custom_llm_provider,
}
# filter out None values
return_kwargs = {k: v for k, v in return_kwargs.items() if v is not None}
return return_kwargs
return {k: v for k, v in return_kwargs.items() if v is not None}
def _convert_pcm16_to_wav(self, pcm_data: bytes, sample_rate: int = 24000, channels: int = 1) -> bytes:
"""

View file

View file

@ -0,0 +1,62 @@
from typing import Final
from unittest.mock import MagicMock
import pytest
import litellm
from litellm.constants import OPENAI_CHAT_COMPLETION_PARAMS
from litellm.endpoints.speech.speech_to_completion_bridge.transformation import (
SpeechToCompletionBridgeTransformationHandler,
)
GEMINI_TTS_MODEL: Final = "gemini-3.1-flash-tts-preview"
def _bridge_request(response_format: str | None) -> dict:
optional_params: Final = (
{"temperature": 0.4} if response_format is None else {"temperature": 0.4, "response_format": response_format}
)
return SpeechToCompletionBridgeTransformationHandler().transform_request(
model=GEMINI_TTS_MODEL,
input="Hello from LiteLLM",
voice="Kore",
optional_params=optional_params,
litellm_params={},
headers={},
litellm_logging_obj=MagicMock(),
custom_llm_provider="gemini",
)
@pytest.mark.parametrize("response_format", ["wav", "mp3", "pcm", None])
def test_gemini_tts_request_keeps_speech_response_format_out_of_chat_params(response_format: str | None) -> None:
request: Final = _bridge_request(response_format)
assert "response_format" not in request
assert request["audio"] == {"voice": "Kore", "format": "pcm16"}
assert request["temperature"] == 0.4
assert request["modalities"] == ["audio"]
gemini_params: Final = litellm.get_optional_params(
model=GEMINI_TTS_MODEL,
custom_llm_provider="gemini",
**{param: value for param, value in request.items() if param in OPENAI_CHAT_COMPLETION_PARAMS},
)
assert gemini_params["speechConfig"] == {"voiceConfig": {"prebuiltVoiceConfig": {"voiceName": "Kore"}}}
assert "responseMimeType" not in gemini_params
def test_non_gemini_request_forwards_speech_response_format_as_audio_format() -> None:
request: Final = SpeechToCompletionBridgeTransformationHandler().transform_request(
model="gpt-4o-audio-preview",
input="Hello from LiteLLM",
voice="alloy",
optional_params={"response_format": "wav"},
litellm_params={},
headers={},
litellm_logging_obj=MagicMock(),
custom_llm_provider="openai",
)
assert "response_format" not in request
assert request["audio"] == {"voice": "alloy", "format": "wav"}