This commit is contained in:
Emerson Gomes 2026-10-06 14:44:03 +00:00 • committed by GitHub
commit 268ecfe89c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
27 changed files with 2061 additions and 357 deletions

View file

@ -375,7 +375,9 @@ def render(schema: JsonSchema) -> str:
def validation_errors(prices: dict, schema: JsonSchema) -> tuple:
validator = jsonschema.Draft202012Validator(schema, format_checker=jsonschema.Draft202012Validator.FORMAT_CHECKER)
validator = jsonschema.Draft202012Validator(
schema, format_checker=jsonschema.Draft202012Validator.FORMAT_CHECKER
)
return tuple(
f"{'.'.join(str(part) for part in error.absolute_path)}: {error.message}"
for error in validator.iter_errors(prices)

View file

@ -1026,7 +1026,11 @@ def get_usage_object(
(
completion_response.get("usage")
if isinstance(completion_response, dict)
else getattr(completion_response, "get", lambda x: None)("usage")
else (
completion_response.usage
if isinstance(completion_response, HttpxBinaryResponseContent)
else getattr(completion_response, "get", lambda x: None)("usage")
)
),
)
@ -1461,8 +1465,10 @@ def completion_cost(
verbose_logger.debug("selected model name for cost calculation: %s", model)
if completion_response is not None and (
isinstance(completion_response, BaseModel) or isinstance(completion_response, dict)
): # tts returns a custom class
isinstance(completion_response, (BaseModel, dict))
or isinstance(completion_response, HttpxBinaryResponseContent)
and completion_response.usage is not None
):
if isinstance(completion_response, dict):
usage_obj: dict | Usage | None = completion_response.get("usage", {})
else:

View file

@ -119,6 +119,7 @@ class SpeechToCompletionBridgeHandler:
if isinstance(result, ModelResponse):
return self.transformation_handler.transform_response(
model_response=result,
model=model,
response_format=requested_response_format if isinstance(requested_response_format, str) else None,
)
else:

View file

@ -30,6 +30,11 @@ class ChatAudioParam(TypedDict):
format: ReadOnly[NotRequired[str]]
class ChatSpeechConfigAudioParam(TypedDict):
speech_config: ReadOnly[Mapping[str, object]]
format: ReadOnly[str]
class SpeechToCompletionBridgeTransformationHandler:
def _validate_response_format(
self, model: str, custom_llm_provider: str, optional_params: Mapping[str, object]
@ -68,7 +73,17 @@ class SpeechToCompletionBridgeTransformationHandler:
def _chat_audio_param(
self, model: str, voice: str | Mapping[str, object] | None, optional_params: Mapping[str, object]
) -> ChatAudioParam | None:
) -> ChatAudioParam | ChatSpeechConfigAudioParam | None:
if isinstance(voice, Mapping) and self._is_gemini_tts_model(model):
from litellm.llms.vertex_ai.gemini.vertex_and_google_ai_studio_gemini import (
normalize_gemini_speech_config,
)
speech_audio: Final[ChatSpeechConfigAudioParam] = {
"speech_config": normalize_gemini_speech_config(voice),
"format": GEMINI_TTS_CHAT_AUDIO_FORMAT,
}
return speech_audio
if not isinstance(voice, str):
return None
audio_format: Final = self._chat_audio_format(model, optional_params)
@ -145,8 +160,9 @@ class SpeechToCompletionBridgeTransformationHandler:
return wav_header + pcm_data
def _is_gemini_tts_model(self, model: str) -> bool:
"""Check if the model is a Gemini TTS model that returns PCM16 data."""
return "gemini" in model.lower() and ("tts" in model.lower() or "preview-tts" in model.lower())
from litellm.utils import is_gemini_tts_model
return is_gemini_tts_model(model)
def _gemini_tts_response_body(self, decoded_audio: bytes, response_format: str | None) -> tuple[bytes, str]:
if response_format == GEMINI_TTS_RAW_RESPONSE_FORMAT:
@ -154,7 +170,7 @@ class SpeechToCompletionBridgeTransformationHandler:
return self._convert_pcm16_to_wav(decoded_audio), "audio/wav"
def transform_response(
self, model_response: "ModelResponse", response_format: str | None
self, model_response: "ModelResponse", model: str, response_format: str | None
) -> "HttpxBinaryResponseContent":
import base64
@ -168,7 +184,6 @@ class SpeechToCompletionBridgeTransformationHandler:
raise ValueError("No audio part found in the response")
decoded_audio: Final = base64.b64decode(audio_part.data)
model: Final = getattr(model_response, "model", "")
content, content_type = (
self._gemini_tts_response_body(decoded_audio, response_format)
if self._is_gemini_tts_model(model)

View file

@ -16,7 +16,10 @@ from litellm.llms.vertex_ai.common_utils import (
_build_vertex_schema,
supports_response_json_schema,
)
from litellm.llms.vertex_ai.gemini.vertex_and_google_ai_studio_gemini import VertexLLM
from litellm.llms.vertex_ai.gemini.vertex_and_google_ai_studio_gemini import (
VertexLLM,
normalize_gemini_speech_config,
)
from litellm.types.router import GenericLiteLLMParams
if TYPE_CHECKING:
@ -140,7 +143,10 @@ class GoogleGenAIConfig(BaseGoogleGenAIGenerateContentConfig, VertexLLM):
if is_supported:
# Always output in camelCase for Google GenAI API
output_key = param_camel if param != param_camel else param
_generate_content_config_dict[output_key] = value
if param_snake == "speech_config" and isinstance(value, dict):
_generate_content_config_dict[output_key] = normalize_gemini_speech_config(value)
else:
_generate_content_config_dict[output_key] = value
return _generate_content_config_dict
def validate_environment(

View file

@ -7,6 +7,7 @@ import time
from collections.abc import Callable, Mapping, Sequence
from copy import deepcopy
from functools import partial
from types import MappingProxyType
from typing import TYPE_CHECKING, Any, Final, Literal, Optional, Union, cast, get_args
import httpx
@ -133,6 +134,83 @@ def _served_model_name(model_version: object) -> str | None:
return model_version.split("@", 1)[0]
_GEMINI_SPEECH_CONFIG_KEY_MAP: Final[Mapping[str, str]] = MappingProxyType(
{
"multi_speaker_voice_config": "multiSpeakerVoiceConfig",
"speaker_voice_configs": "speakerVoiceConfigs",
"voice_config": "voiceConfig",
"prebuilt_voice_config": "prebuiltVoiceConfig",
"voice_name": "voiceName",
"language_code": "languageCode",
}
)
def _normalize_gemini_speech_config_item(value: Mapping[str, object]) -> dict[str, object]:
normalized: Final[dict[str, object]] = {}
pending: Final[ # mutable-ok: the explicit worklist must grow while avoiding recursive traversal
list[tuple[dict[str, object] | list[object], Mapping[object, object] | list[object]]]
] = [(normalized, value)]
while pending:
target, source = pending.pop()
items = source.items() if isinstance(source, Mapping) else enumerate(source)
for key, item in items:
if isinstance(source, Mapping):
if not isinstance(key, str):
continue
normalized_key: str | int = _GEMINI_SPEECH_CONFIG_KEY_MAP.get(key, key)
else:
normalized_key = len(target)
child: object
if isinstance(item, Mapping):
child = {}
elif isinstance(item, list):
child = []
else:
child = item
if isinstance(target, dict):
assert isinstance(normalized_key, str)
target[normalized_key] = child
else:
target.append(child)
if isinstance(child, (dict, list)) and isinstance(item, (Mapping, list)):
pending.append((child, item))
return normalized
def normalize_gemini_speech_config(
value: Mapping[str, object],
) -> dict[str, object]: # mutable-ok: provider request serialization requires a concrete dict
nested_config: Final = value.get("speechConfig", value.get("speech_config"))
config: Final = (
{
**{key: item for key, item in value.items() if key not in ("speechConfig", "speech_config", "format")},
**nested_config,
}
if isinstance(nested_config, Mapping)
else value
)
normalized: Final = _normalize_gemini_speech_config_item(
{key: item for key, item in config.items() if key != "format"}
)
voice_name: Final = next(
(normalized[key] for key in ("name", "voiceName", "voice") if isinstance(normalized.get(key), str)),
None,
)
if "voiceConfig" in normalized or "multiSpeakerVoiceConfig" in normalized:
return {key: item for key, item in normalized.items() if key not in ("name", "voiceName", "voice")}
if voice_name is None:
return normalized
return {
**{key: item for key, item in normalized.items() if key not in ("name", "voiceName", "voice", "modelName")},
"voiceConfig": {"prebuiltVoiceConfig": {"voiceName": voice_name}},
}
class VertexAIBaseConfig:
def get_mapped_special_auth_params(self) -> dict:
"""
@ -1035,12 +1113,6 @@ class VertexGeminiConfig(VertexAIBaseConfig, BaseConfig):
languageCode: "en-US",
}
"""
from litellm.types.llms.vertex_ai import (
PrebuiltVoiceConfig,
SpeechConfig,
VoiceConfig,
)
# Validate audio format - Gemini TTS only supports pcm16
audio_format: Final = value.get("format")
if audio_format is not None and audio_format != "pcm16":
@ -1050,18 +1122,36 @@ class VertexGeminiConfig(VertexAIBaseConfig, BaseConfig):
f"Please set audio format to 'pcm16'."
)
# Map OpenAI audio parameter to Gemini speech config
speech_config: Final[SpeechConfig] = {}
camel_speech_config: Final = value.get("speechConfig")
snake_speech_config: Final = value.get("speech_config")
speech_config_value: Final = (
camel_speech_config if isinstance(camel_speech_config, Mapping) else snake_speech_config
)
language_code: Final = value.get("language_code", value.get("languageCode"))
if isinstance(speech_config_value, Mapping):
return normalize_gemini_speech_config(value)
if "voice" in value:
prebuilt_voice_config: Final[PrebuiltVoiceConfig] = {"voiceName": value["voice"]}
voice_config: Final[VoiceConfig] = {"prebuiltVoiceConfig": prebuilt_voice_config}
speech_config["voiceConfig"] = voice_config
speech_config_without_language: Final[ # mutable-ok: provider request serialization requires a concrete dict
dict[str, object]
] = (
{
"voiceConfig": {
"prebuiltVoiceConfig": {
"voiceName": value["voice"],
}
}
}
if "voice" in value
else {}
)
if "language_code" in value:
speech_config["languageCode"] = value["language_code"]
if language_code is not None:
return {
**speech_config_without_language,
"languageCode": language_code,
}
return cast(dict, speech_config)
return speech_config_without_language
@staticmethod
def _apply_include_server_side_tool_invocations(
@ -1228,7 +1318,10 @@ class VertexGeminiConfig(VertexAIBaseConfig, BaseConfig):
optional_params["responseModalities"].append("AUDIO")
# Set default temperature to 1.0 for Gemini 3 models if not specified
if VertexGeminiConfig._is_gemini_3_or_newer(model):
if VertexGeminiConfig._is_gemini_3_or_newer(model) and not litellm.utils.is_gemini_tts_model(
model,
custom_llm_provider="vertex_ai",
):
if "temperature" not in optional_params:
optional_params["temperature"] = 1.0

View file

@ -38,34 +38,15 @@ class VertexAIGoogleGenAIConfig(GoogleGenAIConfig):
return default_headers
def _camel_to_snake(self, camel_str: str) -> str:
"""Convert camelCase to snake_case"""
import re
return re.sub(r"(?<!^)(?=[A-Z])", "_", camel_str).lower()
def map_generate_content_optional_params(
self,
generate_content_config_dict,
generate_content_config_dict: dict[str, Any], # mutable-ok: inherited provider interface accepts a request dict
model: str,
):
"""
Map Google GenAI parameters to provider-specific format.
Args:
generate_content_optional_params: Optional parameters for generate content
model: The model name
Returns:
Mapped parameters for the provider
"""
_generate_content_config_dict: Final[dict] = {}
for param, value in generate_content_config_dict.items():
camel_case_key = self._camel_to_snake(param)
_generate_content_config_dict[camel_case_key] = value
return _generate_content_config_dict
) -> dict[str, Any]: # mutable-ok: inherited provider interface returns a request dict
return super().map_generate_content_optional_params(
generate_content_config_dict=generate_content_config_dict,
model=model,
)
def transform_generate_content_request(
self,

View file

@ -6,9 +6,10 @@ Reference: https://cloud.google.com/text-to-speech/docs/reference/rest/v1/text/s
"""
import base64
import math
from collections.abc import Coroutine, Mapping
from types import MappingProxyType
from typing import TYPE_CHECKING, Any, Final, TypeAlias, Union
from typing import TYPE_CHECKING, Any, ClassVar, Final, TypeAlias, Union
import httpx
from pydantic import ConfigDict, TypeAdapter
@ -17,6 +18,7 @@ import litellm
from litellm.exceptions import UnsupportedParamsError
from litellm.litellm_core_utils.audio_utils.utils import (
DEFAULT_SPEECH_MEDIA_TYPE,
calculate_request_duration,
speech_media_type_from_audio_bytes,
)
from litellm.litellm_core_utils.url_utils import encode_url_path_segment
@ -33,6 +35,7 @@ from litellm.types.llms.vertex_ai import VERTEX_CREDENTIALS_TYPES
from litellm.types.llms.vertex_ai_text_to_speech import (
VertexTextToSpeechAudioConfig,
VertexTextToSpeechInput,
VertexTextToSpeechSpeakerVoiceConfig,
VertexTextToSpeechVoice,
)
@ -50,6 +53,44 @@ _JSON_OBJECT: Final = TypeAdapter(Mapping[str, object], config=ConfigDict(hide_i
_STR: Final = TypeAdapter(str, config=ConfigDict(hide_input_in_errors=True))
def _fallback_gemini_tts_audio_duration(audio: bytes, encoding: str, sample_rate: int) -> float | None:
if encoding == "PCM":
return len(audio) / (2 * sample_rate) if sample_rate > 0 else None
if encoding in ("ALAW", "MULAW") and not (audio[:4] == b"RIFF" and audio[8:12] == b"WAVE"):
return len(audio) / sample_rate if sample_rate > 0 else None
if encoding in ("LINEAR16", "ALAW", "MULAW") and audio[:4] == b"RIFF" and audio[8:12] == b"WAVE":
format_offset: Final = audio.find(b"fmt ", 12)
data_offset: Final = audio.find(b"data", 12)
if format_offset >= 0 and data_offset >= 0 and format_offset + 20 <= len(audio):
byte_rate: Final = int.from_bytes(audio[format_offset + 16 : format_offset + 20], "little")
data_size: Final = int.from_bytes(audio[data_offset + 4 : data_offset + 8], "little")
if byte_rate > 0 and data_size <= len(audio) - data_offset - 8:
return data_size / byte_rate
if encoding == "FLAC" and audio[:4] == b"fLaC" and len(audio) >= 42:
if audio[4] & 0x7F != 0 or int.from_bytes(audio[5:8], "big") != 34:
return None
stream_info: Final = int.from_bytes(audio[18:26], "big")
flac_sample_rate: Final = stream_info >> 44
total_samples: Final = stream_info & ((1 << 36) - 1)
return total_samples / flac_sample_rate if flac_sample_rate > 0 and total_samples > 0 else None
if encoding == "MP3" and speech_media_type_from_audio_bytes(audio) == "audio/mpeg":
return len(audio) / 4000
if encoding == "OGG_OPUS" and audio[:4] == b"OggS":
opus_header: Final = audio.find(b"OpusHead")
last_page: Final = audio.rfind(b"OggS")
if opus_header >= 0 and opus_header + 12 <= len(audio) and last_page + 14 <= len(audio):
pre_skip: Final = int.from_bytes(audio[opus_header + 10 : opus_header + 12], "little")
granule: Final = int.from_bytes(audio[last_page + 6 : last_page + 14], "little")
if pre_skip <= granule < 2**64 - 1:
return (granule - pre_skip) / 48000
return None
class VertexAITextToSpeechConfig(BaseTextToSpeechConfig, VertexBase):
"""
Configuration for Google Cloud/Vertex AI Text-to-Speech
@ -80,14 +121,27 @@ class VertexAITextToSpeechConfig(BaseTextToSpeechConfig, VertexBase):
}
# Response format mappings from OpenAI to Google Cloud audio encoding
FORMAT_MAPPINGS = {
"mp3": "MP3",
"opus": "OGG_OPUS",
"aac": "MP3", # Google doesn't have AAC, use MP3
"flac": "FLAC",
"wav": "LINEAR16",
"pcm": "LINEAR16",
}
FORMAT_MAPPINGS: ClassVar[Mapping[str, str]] = MappingProxyType(
{
"mp3": "MP3",
"opus": "OGG_OPUS",
"aac": "MP3", # Google doesn't have AAC, use MP3
"flac": "FLAC",
"wav": "LINEAR16",
"pcm": "LINEAR16",
}
)
GEMINI_FORMAT_MAPPINGS: ClassVar[Mapping[str, str]] = MappingProxyType(
{
**FORMAT_MAPPINGS,
"alaw": "ALAW",
"mulaw": "MULAW",
"ogg_opus": "OGG_OPUS",
"pcm": "LINEAR16",
"pcm16": "LINEAR16",
"linear16": "LINEAR16",
}
)
def __init__(self) -> None:
BaseTextToSpeechConfig.__init__(self)
@ -96,6 +150,7 @@ class VertexAITextToSpeechConfig(BaseTextToSpeechConfig, VertexBase):
def _map_voice_to_vertex_format(
self,
voice: str | dict | None,
model: str | None = None,
) -> tuple[str | None, dict | None]:
"""
Map voice to Vertex AI format.
@ -113,6 +168,9 @@ class VertexAITextToSpeechConfig(BaseTextToSpeechConfig, VertexBase):
if voice is None:
return None, None
if model is not None and self._is_gemini_tts_model(model):
return self._map_gemini_tts_voice_to_vertex_format(model=model, voice=voice)
if isinstance(voice, dict):
# Already in Vertex AI format
return None, voice
@ -141,6 +199,164 @@ class VertexAITextToSpeechConfig(BaseTextToSpeechConfig, VertexBase):
return voice_str, voice_dict
@staticmethod
def _is_gemini_tts_model(model: str) -> bool:
from litellm.utils import is_gemini_tts_model
return is_gemini_tts_model(model, custom_llm_provider="vertex_ai")
@staticmethod
def _get_str_value(
source: Mapping[str, object],
*keys: str,
) -> str | None:
for key in keys:
value = source.get(key)
if isinstance(value, str):
return value
return None
@staticmethod
def _get_dict_value(
source: Mapping[str, object],
*keys: str,
) -> dict[str, object] | None: # mutable-ok: nested provider payloads remain concrete dictionaries
for key in keys:
value = source.get(key)
if isinstance(value, dict):
return value
return None
def _extract_gemini_tts_speaker_configs(
self,
voice: Mapping[str, object],
) -> list[VertexTextToSpeechSpeakerVoiceConfig]:
speech_config: Final = self._get_dict_value(voice, "speechConfig", "speech_config") or voice
multi_speaker_config: Final = self._get_dict_value(
speech_config,
"multiSpeakerVoiceConfig",
"multi_speaker_voice_config",
)
if multi_speaker_config is None:
return []
raw_speaker_configs: Final = multi_speaker_config.get(
"speakerVoiceConfigs",
multi_speaker_config.get(
"speaker_voice_configs",
[],
),
)
if not isinstance(raw_speaker_configs, list):
return []
speaker_configs: Final[ # mutable-ok: validated payloads are accumulated for serialization
list[VertexTextToSpeechSpeakerVoiceConfig]
] = []
for raw_config in raw_speaker_configs:
if not isinstance(raw_config, dict):
continue
speaker_alias = self._get_str_value(
raw_config,
"speakerAlias",
"speaker_alias",
"speaker",
)
speaker_id = self._get_str_value(raw_config, "speakerId", "speaker_id")
if speaker_id is None:
voice_config = self._get_dict_value(raw_config, "voiceConfig", "voice_config")
if voice_config is not None:
prebuilt_voice_config = self._get_dict_value(
voice_config,
"prebuiltVoiceConfig",
"prebuilt_voice_config",
)
if prebuilt_voice_config is not None:
speaker_id = self._get_str_value(
prebuilt_voice_config,
"voiceName",
"voice_name",
)
if speaker_alias is not None and speaker_id is not None:
speaker_configs.append(
{
"speakerAlias": speaker_alias,
"speakerId": speaker_id,
}
)
return speaker_configs
def _extract_gemini_tts_voice_name(
self,
voice: Mapping[str, object],
) -> str | None:
voice_name: Final = self._get_str_value(voice, "name", "voiceName", "voice_name", "voice")
if voice_name is not None:
return voice_name
speech_config: Final = self._get_dict_value(voice, "speechConfig", "speech_config") or voice
nested_voice_name: Final = self._get_str_value(speech_config, "name", "voiceName", "voice_name", "voice")
if nested_voice_name is not None:
return nested_voice_name
voice_config: Final = self._get_dict_value(speech_config, "voiceConfig", "voice_config")
if voice_config is None:
return None
prebuilt_voice_config: Final = self._get_dict_value(
voice_config,
"prebuiltVoiceConfig",
"prebuilt_voice_config",
)
if prebuilt_voice_config is None:
return None
return self._get_str_value(prebuilt_voice_config, "voiceName", "voice_name")
def _map_gemini_tts_voice_to_vertex_format(
self,
model: str,
voice: str | Mapping[str, object],
) -> tuple[str | None, dict[str, object]]: # mutable-ok: provider request serialization requires a concrete dict
if isinstance(voice, str):
return voice, {
"languageCode": self.DEFAULT_LANGUAGE_CODE,
"modelName": model,
"name": voice,
}
speech_config: Final = self._get_dict_value(voice, "speechConfig", "speech_config") or voice
language_code: Final = (
self._get_str_value(voice, "languageCode", "language_code")
or self._get_str_value(speech_config, "languageCode", "language_code")
or self.DEFAULT_LANGUAGE_CODE
)
model_name: Final = model
speaker_configs: Final = self._extract_gemini_tts_speaker_configs(voice)
if speaker_configs:
return None, {
"languageCode": language_code,
"modelName": model_name,
"multiSpeakerVoiceConfig": {
"speakerVoiceConfigs": speaker_configs,
},
}
voice_name: Final = self._extract_gemini_tts_voice_name(voice)
if voice_name is not None:
return None, {
"languageCode": language_code,
"modelName": model_name,
"name": voice_name,
}
return None, {
**voice,
"languageCode": language_code,
"modelName": model_name,
}
@staticmethod
def _dispatch_voice_name(voice: str | Mapping[str, object] | None) -> str | None:
if isinstance(voice, str):
return voice
if not isinstance(voice, Mapping):
return None
name: Final = voice.get("name")
return name if isinstance(name, str) else None
def dispatch_text_to_speech(
self,
model: str,
@ -174,14 +390,16 @@ class VertexAITextToSpeechConfig(BaseTextToSpeechConfig, VertexBase):
vertex_project: Final = self.safe_get_vertex_ai_project(litellm_params_dict)
vertex_location: Final = self.safe_get_vertex_ai_location(litellm_params_dict)
# Convert voice to string if it's a dict (extract name)
# Actual voice mapping happens in map_openai_params
voice_str: str | None = None
if isinstance(voice, str):
voice_str = voice
elif isinstance(voice, dict):
# Extract voice name from dict if needed
voice_str = voice.get("name") if voice else None
mapped_voice, mapped_params = (
(self._dispatch_voice_name(voice), optional_params)
if "audioEncoding" in optional_params
else self.map_openai_params(
model=model,
voice=voice,
optional_params=optional_params,
kwargs=kwargs,
)
)
# Store credentials in litellm_params for use in transform methods
litellm_params_dict.update(
@ -197,9 +415,9 @@ class VertexAITextToSpeechConfig(BaseTextToSpeechConfig, VertexBase):
response: Final = base_llm_http_handler.text_to_speech_handler(
model=model,
input=input,
voice=voice_str,
voice=mapped_voice,
text_to_speech_provider_config=self,
text_to_speech_optional_params=optional_params,
text_to_speech_optional_params=mapped_params,
custom_llm_provider="vertex_ai",
litellm_params=litellm_params_dict,
logging_obj=logging_obj,
@ -247,15 +465,21 @@ class VertexAITextToSpeechConfig(BaseTextToSpeechConfig, VertexBase):
##########################################################
# Map voice using helper
##########################################################
mapped_voice_str, voice_dict = self._map_voice_to_vertex_format(voice)
mapped_voice_str, voice_dict = self._map_voice_to_vertex_format(
voice=voice,
model=model,
)
if voice_dict is not None:
mapped_params["vertex_voice_dict"] = voice_dict
# Map response format
if "response_format" in optional_params:
format_name: Final = optional_params["response_format"]
if format_name in self.FORMAT_MAPPINGS:
mapped_params["audioEncoding"] = self.FORMAT_MAPPINGS[format_name]
format_mappings: Final = (
self.GEMINI_FORMAT_MAPPINGS if self._is_gemini_tts_model(model) else self.FORMAT_MAPPINGS
)
if format_name in format_mappings:
mapped_params["audioEncoding"] = format_mappings[format_name]
else:
# Try to use it directly as Google Cloud format
mapped_params["audioEncoding"] = format_name
@ -412,7 +636,7 @@ class VertexAITextToSpeechConfig(BaseTextToSpeechConfig, VertexBase):
voice_dict: Final = litellm_params.get("vertex_voice_dict") or optional_params.get("vertex_voice_dict")
if voice_dict is not None and isinstance(voice_dict, dict):
vertex_voice = VertexTextToSpeechVoice(**voice_dict)
elif voice is not None and isinstance(voice, str):
elif voice is not None:
# Handle string voice (shouldn't normally happen if dispatch was called)
parts: Final = voice.split("-")
if len(parts) >= 2:
@ -484,8 +708,36 @@ class VertexAITextToSpeechConfig(BaseTextToSpeechConfig, VertexBase):
content=binary_data,
)
# Initialize the HttpxBinaryResponseContent instance
return HttpxBinaryResponseContent(response)
binary_response: Final = HttpxBinaryResponseContent(response)
if self._is_gemini_tts_model(model):
from litellm.types.utils import CompletionTokensDetailsWrapper, Usage
request_body: Final = logging_obj.model_call_details["additional_args"]["complete_input_dict"]["dict_body"]
input_data: Final = request_body["input"]
audio_config: Final = request_body["audioConfig"]
container_duration: Final = calculate_request_duration(binary_data)
sample_rate: Final = audio_config.get("sampleRateHertz") or 24000
duration: Final = (
_fallback_gemini_tts_audio_duration(binary_data, "FLAC", sample_rate)
if audio_config["audioEncoding"] == "FLAC"
else container_duration
if container_duration is not None
else _fallback_gemini_tts_audio_duration(binary_data, audio_config["audioEncoding"], sample_rate)
)
if duration is None:
raise ValueError("Cannot determine Gemini TTS output duration for cost calculation")
input_text: Final = " ".join(
value for value in (input_data.get("text"), input_data.get("ssml"), input_data.get("prompt")) if value
)
prompt_tokens: Final = litellm.token_counter(model=model, text=input_text)
audio_tokens: Final = math.ceil(duration * 25)
binary_response.usage = Usage(
prompt_tokens=prompt_tokens,
completion_tokens=audio_tokens,
total_tokens=prompt_tokens + audio_tokens,
completion_tokens_details=CompletionTokensDetailsWrapper(audio_tokens=audio_tokens),
)
return binary_response
class VertexAILyriaTextToSpeechConfig(VertexAITextToSpeechConfig):

View file

@ -8281,6 +8281,7 @@ def speech(
kwargs.pop("tags", [])
optional_params = {}
original_voice: Final = voice
if response_format is not None:
optional_params["response_format"] = response_format
if speed is not None:
@ -8532,8 +8533,13 @@ def speech(
generic_optional_params: Final = GenericLiteLLMParams.model_validate(kwargs)
# Handle Gemini models separately (they use speech_to_completion_bridge)
if "gemini" in model:
if (
litellm.utils.is_gemini_tts_model(
model,
custom_llm_provider=custom_llm_provider,
)
and response_format is None
):
from .endpoints.speech.speech_to_completion_bridge.handler import (
speech_to_completion_bridge_handler,
)
@ -8541,7 +8547,7 @@ def speech(
return speech_to_completion_bridge_handler.speech(
model=model,
input=input,
voice=voice,
voice=original_voice,
optional_params=optional_params,
litellm_params=litellm_params_dict,
headers=headers or {},

View file

@ -28630,6 +28630,9 @@
"mode": "chat",
"output_cost_per_audio_token": 2e-05,
"output_cost_per_token": 2e-05,
"provider_specific_entry": {
"gemini_tts": 1
},
"source": "https://cloud.google.com/gemini-enterprise-agent-platform/generative-ai/pricing",
"supported_modalities": [
"text"
@ -30749,6 +30752,9 @@
"output_cost_per_audio_token": 2e-05,
"output_cost_per_token": 2e-05,
"output_cost_per_token_batches": 1e-05,
"provider_specific_entry": {
"gemini_tts": 1
},
"rpm": 10000,
"source": "https://ai.google.dev/gemini-api/docs/pricing",
"supported_modalities": [
@ -50855,6 +50861,33 @@
"web_search_billing_unit": "per_query",
"google_maps_grounding_cost_per_query": 0.014
},
"vertex_ai/gemini-3.1-flash-tts-preview": {
"input_cost_per_token": 1e-06,
"input_cost_per_token_batches": 5e-07,
"litellm_provider": "vertex_ai-language-models",
"mode": "audio_speech",
"output_cost_per_audio_token": 2e-05,
"output_cost_per_token": 2e-05,
"source": "https://cloud.google.com/text-to-speech/pricing",
"max_input_tokens": 8192,
"max_output_tokens": 16384,
"max_tokens": 16384,
"supported_endpoints": [
"/v1/audio/speech"
],
"supported_modalities": [
"text"
],
"supported_output_modalities": [
"audio"
],
"supports_audio_input": false,
"supports_audio_output": true,
"supports_function_calling": false,
"supports_prompt_caching": false,
"tpm": 4000000,
"rpm": 10
},
"vertex_ai/gemini-3.1-flash-lite": {
"cache_read_input_audio_token_cost": 5e-08,
"deprecation_date": "2027-05-07",
@ -57784,6 +57817,32 @@
"supports_response_schema": false,
"supports_web_search": false
},
"gemini-3.1-flash-tts-preview": {
"input_cost_per_token": 1e-06,
"litellm_provider": "gemini",
"max_input_tokens": 8192,
"max_output_tokens": 16384,
"max_tokens": 16384,
"mode": "audio_speech",
"output_cost_per_audio_token": 2e-05,
"output_cost_per_token": 2e-05,
"source": "https://cloud.google.com/text-to-speech/pricing",
"supported_endpoints": [
"/v1/audio/speech"
],
"supported_modalities": [
"text"
],
"supported_output_modalities": [
"audio"
],
"supports_audio_input": false,
"supports_audio_output": true,
"supports_function_calling": false,
"supports_prompt_caching": false,
"tpm": 4000000,
"rpm": 10
},
"gemini-flash-latest": {
"cache_read_input_token_cost": 7.5e-08,
"input_cost_per_token": 7.5e-07,
@ -70542,22 +70601,61 @@
"output_cost_per_token": 2e-06,
"source": "https://cloud.google.com/gemini-enterprise-agent-platform/generative-ai/pricing"
},
"vertex_ai/gemini-2.5-flash-tts": {
"input_cost_per_token": 5e-07,
"input_cost_per_token_batches": 2.5e-07,
"litellm_provider": "vertex_ai-language-models",
"mode": "audio_speech",
"output_cost_per_audio_token": 1e-05,
"output_cost_per_token": 1e-05,
"source": "https://cloud.google.com/text-to-speech/pricing",
"supported_endpoints": [
"/v1/audio/speech"
],
"supported_modalities": [
"text"
],
"supported_output_modalities": [
"audio"
],
"supports_audio_output": true
},
"vertex_ai/gemini-2.5-pro-tts": {
"input_cost_per_token": 1e-06,
"input_cost_per_token_batches": 5e-07,
"litellm_provider": "vertex_ai-language-models",
"mode": "audio_speech",
"output_cost_per_audio_token": 2e-05,
"output_cost_per_token": 2e-05,
"source": "https://cloud.google.com/text-to-speech/pricing",
"supported_endpoints": [
"/v1/audio/speech"
],
"supported_modalities": [
"text"
],
"supported_output_modalities": [
"audio"
],
"supports_audio_output": true
},
"vertex_ai/gemini-2.5-flash-preview-tts": {
"input_cost_per_token": 5e-07,
"input_cost_per_token_batches": 2.5e-07,
"litellm_provider": "vertex_ai",
"litellm_provider": "vertex_ai-language-models",
"mode": "audio_speech",
"output_cost_per_audio_token": 1e-05,
"output_cost_per_token": 1e-05,
"source": "https://cloud.google.com/gemini-enterprise-agent-platform/generative-ai/pricing"
},
"vertex_ai/gemini-3.1-flash-tts-preview": {
"input_cost_per_token": 1e-06,
"input_cost_per_token_batches": 5e-07,
"vertex_ai/gemini-3.1-flash-live-preview": {
"input_cost_per_audio_token": 3e-06,
"input_cost_per_second": 8.33333333333e-05,
"input_cost_per_token": 7.5e-07,
"litellm_provider": "vertex_ai",
"mode": "audio_speech",
"output_cost_per_audio_token": 2e-05,
"output_cost_per_token": 2e-05,
"mode": "realtime",
"output_cost_per_audio_token": 1.2e-05,
"output_cost_per_token": 4.5e-06,
"source": "https://cloud.google.com/gemini-enterprise-agent-platform/generative-ai/pricing"
},
"vertex_ai/gemini-3.5-transcribe": {
@ -78729,24 +78827,6 @@
"image"
]
},
"vertex_ai/gemini-2.5-flash-tts": {
"input_cost_per_token": 5e-07,
"input_cost_per_token_batches": 2.5e-07,
"litellm_provider": "vertex_ai",
"mode": "audio_speech",
"output_cost_per_audio_token": 1e-05,
"output_cost_per_token": 1e-05,
"source": "https://cloud.google.com/gemini-enterprise-agent-platform/generative-ai/pricing"
},
"vertex_ai/gemini-2.5-pro-tts": {
"input_cost_per_token": 1e-06,
"input_cost_per_token_batches": 5e-07,
"litellm_provider": "vertex_ai",
"mode": "audio_speech",
"output_cost_per_audio_token": 2e-05,
"output_cost_per_token": 2e-05,
"source": "https://cloud.google.com/gemini-enterprise-agent-platform/generative-ai/pricing"
},
"anthropic.claude-mythos-5-1": {
"litellm_provider": "bedrock_converse",
"max_input_tokens": 1000000,

View file

@ -12716,12 +12716,30 @@ async def audio_speech(
custom_headers.update(callback_headers)
requested_format: Final = data.get("response_format")
requested_format_str: Final = requested_format if isinstance(requested_format, str) else None
extra_response_format_media_types: Final = MappingProxyType(
{
"pcm16": "audio/wav",
"linear16": "audio/wav",
"alaw": "audio/x-alaw-basic",
"mulaw": "audio/basic",
}
)
extra_media_type: Final = (
extra_response_format_media_types.get(requested_format_str.lower())
if requested_format_str is not None
else None
)
upstream_content_type: Final = (
response.response.headers.get("content-type") if isinstance(response, HttpxBinaryResponseContent) else None
)
media_type: Final = resolve_speech_media_type(
upstream_content_type=upstream_content_type,
response_format=requested_format if isinstance(requested_format, str) else None,
upstream_content_type=(
upstream_content_type
if upstream_content_type is not None and upstream_content_type.lower().startswith("audio/")
else extra_media_type or upstream_content_type
),
response_format=requested_format_str,
)
return StreamingResponse(
@ -12745,13 +12763,16 @@ async def audio_speech(
detail=e.detail,
headers=headers_with_litellm_call_id(e.headers, litellm_call_id),
)
raw_status_code: Final = getattr(e, "status_code", None)
if not isinstance(raw_status_code, int):
raise
raise ProxyException(
message=getattr(e, "message", f"{e}"),
type=getattr(e, "type", "None"),
param=getattr(e, "param", "None"),
headers=litellm_call_id_headers(litellm_call_id),
openai_code=getattr(e, "code", None),
code=getattr(e, "status_code", 500),
code=raw_status_code,
)

View file

@ -1,7 +1,7 @@
from collections.abc import Iterable, Mapping
from enum import Enum
from os import PathLike
from typing import IO, Any, Final, Literal, Optional, TypeAlias, Union
from typing import IO, TYPE_CHECKING, Any, Final, Literal, Optional, TypeAlias, Union
import httpx
from openai import Omit
@ -92,6 +92,9 @@ from litellm.types.responses.main import (
from .base import CachedTokensDetails
if TYPE_CHECKING:
from litellm.types.utils import Usage
FileContent = IO[bytes] | bytes | PathLike
FileTypes = (
@ -119,10 +122,12 @@ class BinaryResponseSummary(TypedDict):
class HttpxBinaryResponseContent(_HttpxBinaryResponseContent):
_hidden_params: dict
usage: "Usage | None"
def __init__(self, response: httpx.Response) -> None:
super().__init__(response)
self._hidden_params = {}
self.usage = None
def logging_summary(self) -> BinaryResponseSummary:
return {

View file

@ -1,7 +1,9 @@
from collections.abc import Sequence
from enum import Enum
from typing import Any, Final, Literal, Protocol
from typing_extensions import (
ReadOnly,
Required,
TypedDict,
)
@ -226,8 +228,18 @@ class VoiceConfig(TypedDict):
prebuiltVoiceConfig: PrebuiltVoiceConfig
class SpeakerVoiceConfig(TypedDict):
speaker: ReadOnly[str]
voiceConfig: ReadOnly[VoiceConfig]
class MultiSpeakerVoiceConfig(TypedDict):
speakerVoiceConfigs: ReadOnly[Sequence[SpeakerVoiceConfig]]
class SpeechConfig(TypedDict, total=False):
voiceConfig: VoiceConfig
multiSpeakerVoiceConfig: ReadOnly[MultiSpeakerVoiceConfig]
languageCode: str

View file

@ -4,7 +4,9 @@ Type definitions for Vertex AI Text-to-Speech API
Reference: https://cloud.google.com/text-to-speech/docs/reference/rest/v1/text/synthesize
"""
from typing_extensions import TypedDict
from collections.abc import Sequence
from typing_extensions import ReadOnly, TypedDict
class VertexTextToSpeechInput(TypedDict, total=False):
@ -16,6 +18,16 @@ class VertexTextToSpeechInput(TypedDict, total=False):
text: str | None
ssml: str | None
prompt: ReadOnly[str | None]
class VertexTextToSpeechSpeakerVoiceConfig(TypedDict):
speakerAlias: ReadOnly[str]
speakerId: ReadOnly[str]
class VertexTextToSpeechMultiSpeakerVoiceConfig(TypedDict):
speakerVoiceConfigs: ReadOnly[Sequence[VertexTextToSpeechSpeakerVoiceConfig]]
class VertexTextToSpeechVoice(TypedDict, total=False):
@ -29,6 +41,8 @@ class VertexTextToSpeechVoice(TypedDict, total=False):
languageCode: str
name: str
modelName: ReadOnly[str]
multiSpeakerVoiceConfig: ReadOnly[VertexTextToSpeechMultiSpeakerVoiceConfig]
class VertexTextToSpeechAudioConfig(TypedDict, total=False):
@ -42,6 +56,7 @@ class VertexTextToSpeechAudioConfig(TypedDict, total=False):
audioEncoding: str
speakingRate: str
sampleRateHertz: ReadOnly[int]
class VertexTextToSpeechRequest(TypedDict, total=False):

View file

@ -741,6 +741,63 @@ def _is_gemini_model(model: str | None, custom_llm_provider: str | None) -> bool
return model is not None and "gemini" in model.lower()
def is_gemini_tts_model(model: str, custom_llm_provider: str | None = None) -> bool:
try:
runtime_model_info: Final = get_model_info(
model=model,
custom_llm_provider=custom_llm_provider,
)
except Exception: # noqa: BLE001 # model lookup failures intentionally fall back to the bundled registry
provider_model: Final = (
f"{custom_llm_provider}/{model}"
if custom_llm_provider is not None and not model.startswith(f"{custom_llm_provider}/")
else model
)
bundled_model_cost: Final = _get_bundled_model_cost_map()
bundled_model_info: Final = (
bundled_model_cost.get(provider_model)
or bundled_model_cost.get(model)
or (bundled_model_cost.get(f"vertex_ai/{model}") if custom_llm_provider is None else None)
)
bundled_entry: Final = (
bundled_model_info.get("provider_specific_entry") if bundled_model_info is not None else None
)
return (
bundled_model_info is not None
and bundled_model_info.get("litellm_provider")
in (
"gemini",
"vertex_ai-language-models",
)
and (
bundled_model_info.get("mode") == "audio_speech"
or (isinstance(bundled_entry, dict) and bundled_entry.get("gemini_tts") == 1)
)
)
provider_entry: Final = runtime_model_info.get("provider_specific_entry")
if runtime_model_info.get("litellm_provider") in ("gemini", "vertex_ai-language-models") and (
runtime_model_info.get("mode") == "audio_speech"
or (isinstance(provider_entry, dict) and provider_entry.get("gemini_tts") == 1)
):
return True
if runtime_model_info.get("litellm_provider") != "vertex_ai" or runtime_model_info.get("mode") != "audio_speech":
return False
bundled_model_name: Final = model if model.startswith("vertex_ai/") else f"vertex_ai/{model}"
vertex_model_info: Final = _get_bundled_model_cost_map().get(bundled_model_name)
return (
vertex_model_info is not None
and vertex_model_info.get("litellm_provider") == "vertex_ai-language-models"
and (
vertex_model_info.get("mode") == "audio_speech"
or (
isinstance(vertex_model_info.get("provider_specific_entry"), dict)
and vertex_model_info["provider_specific_entry"].get("gemini_tts") == 1
)
)
)
def _remove_thought_signature_from_id(tool_call_id: str, separator: str) -> str:
"""
Remove thought signature from a tool call ID.

View file

@ -28630,6 +28630,9 @@
"mode": "chat",
"output_cost_per_audio_token": 2e-05,
"output_cost_per_token": 2e-05,
"provider_specific_entry": {
"gemini_tts": 1
},
"source": "https://cloud.google.com/gemini-enterprise-agent-platform/generative-ai/pricing",
"supported_modalities": [
"text"
@ -30749,6 +30752,9 @@
"output_cost_per_audio_token": 2e-05,
"output_cost_per_token": 2e-05,
"output_cost_per_token_batches": 1e-05,
"provider_specific_entry": {
"gemini_tts": 1
},
"rpm": 10000,
"source": "https://ai.google.dev/gemini-api/docs/pricing",
"supported_modalities": [
@ -50855,6 +50861,33 @@
"web_search_billing_unit": "per_query",
"google_maps_grounding_cost_per_query": 0.014
},
"vertex_ai/gemini-3.1-flash-tts-preview": {
"input_cost_per_token": 1e-06,
"input_cost_per_token_batches": 5e-07,
"litellm_provider": "vertex_ai-language-models",
"mode": "audio_speech",
"output_cost_per_audio_token": 2e-05,
"output_cost_per_token": 2e-05,
"source": "https://cloud.google.com/text-to-speech/pricing",
"max_input_tokens": 8192,
"max_output_tokens": 16384,
"max_tokens": 16384,
"supported_endpoints": [
"/v1/audio/speech"
],
"supported_modalities": [
"text"
],
"supported_output_modalities": [
"audio"
],
"supports_audio_input": false,
"supports_audio_output": true,
"supports_function_calling": false,
"supports_prompt_caching": false,
"tpm": 4000000,
"rpm": 10
},
"vertex_ai/gemini-3.1-flash-lite": {
"cache_read_input_audio_token_cost": 5e-08,
"deprecation_date": "2027-05-07",
@ -57784,6 +57817,32 @@
"supports_response_schema": false,
"supports_web_search": false
},
"gemini-3.1-flash-tts-preview": {
"input_cost_per_token": 1e-06,
"litellm_provider": "gemini",
"max_input_tokens": 8192,
"max_output_tokens": 16384,
"max_tokens": 16384,
"mode": "audio_speech",
"output_cost_per_audio_token": 2e-05,
"output_cost_per_token": 2e-05,
"source": "https://cloud.google.com/text-to-speech/pricing",
"supported_endpoints": [
"/v1/audio/speech"
],
"supported_modalities": [
"text"
],
"supported_output_modalities": [
"audio"
],
"supports_audio_input": false,
"supports_audio_output": true,
"supports_function_calling": false,
"supports_prompt_caching": false,
"tpm": 4000000,
"rpm": 10
},
"gemini-flash-latest": {
"cache_read_input_token_cost": 7.5e-08,
"input_cost_per_token": 7.5e-07,
@ -70542,22 +70601,61 @@
"output_cost_per_token": 2e-06,
"source": "https://cloud.google.com/gemini-enterprise-agent-platform/generative-ai/pricing"
},
"vertex_ai/gemini-2.5-flash-tts": {
"input_cost_per_token": 5e-07,
"input_cost_per_token_batches": 2.5e-07,
"litellm_provider": "vertex_ai-language-models",
"mode": "audio_speech",
"output_cost_per_audio_token": 1e-05,
"output_cost_per_token": 1e-05,
"source": "https://cloud.google.com/text-to-speech/pricing",
"supported_endpoints": [
"/v1/audio/speech"
],
"supported_modalities": [
"text"
],
"supported_output_modalities": [
"audio"
],
"supports_audio_output": true
},
"vertex_ai/gemini-2.5-pro-tts": {
"input_cost_per_token": 1e-06,
"input_cost_per_token_batches": 5e-07,
"litellm_provider": "vertex_ai-language-models",
"mode": "audio_speech",
"output_cost_per_audio_token": 2e-05,
"output_cost_per_token": 2e-05,
"source": "https://cloud.google.com/text-to-speech/pricing",
"supported_endpoints": [
"/v1/audio/speech"
],
"supported_modalities": [
"text"
],
"supported_output_modalities": [
"audio"
],
"supports_audio_output": true
},
"vertex_ai/gemini-2.5-flash-preview-tts": {
"input_cost_per_token": 5e-07,
"input_cost_per_token_batches": 2.5e-07,
"litellm_provider": "vertex_ai",
"litellm_provider": "vertex_ai-language-models",
"mode": "audio_speech",
"output_cost_per_audio_token": 1e-05,
"output_cost_per_token": 1e-05,
"source": "https://cloud.google.com/gemini-enterprise-agent-platform/generative-ai/pricing"
},
"vertex_ai/gemini-3.1-flash-tts-preview": {
"input_cost_per_token": 1e-06,
"input_cost_per_token_batches": 5e-07,
"vertex_ai/gemini-3.1-flash-live-preview": {
"input_cost_per_audio_token": 3e-06,
"input_cost_per_second": 8.33333333333e-05,
"input_cost_per_token": 7.5e-07,
"litellm_provider": "vertex_ai",
"mode": "audio_speech",
"output_cost_per_audio_token": 2e-05,
"output_cost_per_token": 2e-05,
"mode": "realtime",
"output_cost_per_audio_token": 1.2e-05,
"output_cost_per_token": 4.5e-06,
"source": "https://cloud.google.com/gemini-enterprise-agent-platform/generative-ai/pricing"
},
"vertex_ai/gemini-3.5-transcribe": {
@ -78729,24 +78827,6 @@
"image"
]
},
"vertex_ai/gemini-2.5-flash-tts": {
"input_cost_per_token": 5e-07,
"input_cost_per_token_batches": 2.5e-07,
"litellm_provider": "vertex_ai",
"mode": "audio_speech",
"output_cost_per_audio_token": 1e-05,
"output_cost_per_token": 1e-05,
"source": "https://cloud.google.com/gemini-enterprise-agent-platform/generative-ai/pricing"
},
"vertex_ai/gemini-2.5-pro-tts": {
"input_cost_per_token": 1e-06,
"input_cost_per_token_batches": 5e-07,
"litellm_provider": "vertex_ai",
"mode": "audio_speech",
"output_cost_per_audio_token": 2e-05,
"output_cost_per_token": 2e-05,
"source": "https://cloud.google.com/gemini-enterprise-agent-platform/generative-ai/pricing"
},
"anthropic.claude-mythos-5-1": {
"litellm_provider": "bedrock_converse",
"max_input_tokens": 1000000,

Binary file not shown.

Binary file not shown.

View file

@ -11,14 +11,14 @@ from litellm.endpoints.speech.speech_to_completion_bridge.transformation import
)
from litellm.types.utils import ChatCompletionAudioResponse, Choices, Message, ModelResponse
pytestmark = pytest.mark.usefixtures("local_model_cost_map")
GEMINI_TTS_MODEL: Final = "gemini-3.1-flash-tts-preview"
PCM_BYTES: Final = b"\x01\x02\x03\x04" * 6
def _model_response(model: str, pcm: bytes) -> ModelResponse:
audio: Final = ChatCompletionAudioResponse(
data=base64.b64encode(pcm).decode(), expires_at=0, transcript="hello"
)
audio: Final = ChatCompletionAudioResponse(data=base64.b64encode(pcm).decode(), expires_at=0, transcript="hello")
return ModelResponse(model=model, choices=[Choices(message=Message(content=None, audio=audio))])
@ -72,6 +72,44 @@ def test_non_gemini_request_forwards_speech_response_format_as_audio_format() ->
assert request["audio"] == {"voice": "alloy", "format": "wav"}
def test_gemini_request_keeps_voice_beside_nested_speech_config() -> None:
request: Final = SpeechToCompletionBridgeTransformationHandler().transform_request(
model=GEMINI_TTS_MODEL,
input="Bonjour",
voice={"speech_config": {"language_code": "fr-FR"}, "voice": "Kore"},
optional_params={},
litellm_params={},
headers={},
litellm_logging_obj=MagicMock(),
custom_llm_provider="gemini",
)
assert request["audio"]["speech_config"] == {
"languageCode": "fr-FR",
"voiceConfig": {"prebuiltVoiceConfig": {"voiceName": "Kore"}},
}
@pytest.mark.parametrize("wrapper", (None, "speech_config", "speechConfig"))
def test_gemini_request_keeps_voice_and_language_without_audio_format(wrapper: str | None) -> None:
voice: Final = {"language_code": "fr-FR", "voice": "Kore", "format": "pcm16"}
request: Final = SpeechToCompletionBridgeTransformationHandler().transform_request(
model=GEMINI_TTS_MODEL,
input="Bonjour",
voice=voice if wrapper is None else {wrapper: voice},
optional_params={},
litellm_params={},
headers={},
litellm_logging_obj=MagicMock(),
custom_llm_provider="gemini",
)
assert request["audio"]["speech_config"] == {
"languageCode": "fr-FR",
"voiceConfig": {"prebuiltVoiceConfig": {"voiceName": "Kore"}},
}
@pytest.mark.parametrize("response_format", ["mp3", "flac", "opus", "aac"])
def test_gemini_tts_request_rejects_formats_gemini_cannot_produce(response_format: str) -> None:
with pytest.raises(litellm.BadRequestError) as excinfo:
@ -86,6 +124,7 @@ def test_gemini_tts_request_rejects_formats_gemini_cannot_produce(response_forma
def test_gemini_tts_pcm_response_returns_raw_pcm_bytes() -> None:
response: Final = SpeechToCompletionBridgeTransformationHandler().transform_response(
model_response=_model_response(GEMINI_TTS_MODEL, PCM_BYTES),
model=GEMINI_TTS_MODEL,
response_format="pcm",
)
@ -97,6 +136,7 @@ def test_gemini_tts_pcm_response_returns_raw_pcm_bytes() -> None:
def test_gemini_tts_wav_and_default_responses_wrap_pcm_in_wav(response_format: str | None) -> None:
response: Final = SpeechToCompletionBridgeTransformationHandler().transform_response(
model_response=_model_response(GEMINI_TTS_MODEL, PCM_BYTES),
model=GEMINI_TTS_MODEL,
response_format=response_format,
)
@ -110,8 +150,20 @@ def test_gemini_tts_wav_and_default_responses_wrap_pcm_in_wav(response_format: s
def test_non_gemini_response_keeps_original_bytes_and_mpeg_content_type() -> None:
response: Final = SpeechToCompletionBridgeTransformationHandler().transform_response(
model_response=_model_response("gpt-4o-audio-preview", PCM_BYTES),
model="gpt-4o-audio-preview",
response_format="mp3",
)
assert response.response.content == PCM_BYTES
assert response.response.headers["content-type"] == "audio/mpeg"
def test_gemini_response_uses_requested_model_when_served_name_differs() -> None:
response: Final = SpeechToCompletionBridgeTransformationHandler().transform_response(
model_response=_model_response("served-speech-alias", PCM_BYTES),
model=GEMINI_TTS_MODEL,
response_format=None,
)
assert response.response.content[:4] == b"RIFF"
assert response.response.headers["content-type"] == "audio/wav"

View file

@ -3,10 +3,10 @@
Test to verify the Google GenAI transformation logic for generateContent parameters
"""
import pytest
from litellm.llms.gemini.google_genai.transformation import GoogleGenAIConfig
from litellm.llms.vertex_ai.google_genai.transformation import VertexAIGoogleGenAIConfig
from litellm.responses.litellm_completion_transformation.transformation import (
LiteLLMCompletionResponsesConfig,
)
@ -31,10 +31,7 @@ def test_map_generate_content_optional_params_response_json_schema_camelcase():
# responseJsonSchema should be in the result (camelCase format for Google GenAI API)
assert "responseJsonSchema" in result
assert (
result["responseJsonSchema"]
== generate_content_config_dict["responseJsonSchema"]
)
assert result["responseJsonSchema"] == generate_content_config_dict["responseJsonSchema"]
assert "temperature" in result
assert result["temperature"] == 1.0
@ -58,10 +55,7 @@ def test_map_generate_content_optional_params_response_schema_snakecase():
# response_schema should be converted to responseJsonSchema (camelCase)
assert "responseJsonSchema" in result
assert (
result["responseJsonSchema"]
== generate_content_config_dict["response_json_schema"]
)
assert result["responseJsonSchema"] == generate_content_config_dict["response_json_schema"]
assert "temperature" in result
@ -157,6 +151,68 @@ def test_map_generate_content_optional_params_response_mime_type():
assert "responseJsonSchema" in result
@pytest.mark.parametrize("config_class", [GoogleGenAIConfig, VertexAIGoogleGenAIConfig])
def test_map_generate_content_optional_params_speech_config(config_class):
config = config_class()
result = config.map_generate_content_optional_params(
generate_content_config_dict={
"response_modalities": ["AUDIO"],
"speech_config": {
"multi_speaker_voice_config": {
"speaker_voice_configs": [
{
"speaker": "Ryan",
"voice_config": {
"prebuilt_voice_config": {
"voice_name": "Umbriel",
},
},
},
{
"speaker": "Katie",
"voice_config": {
"prebuilt_voice_config": {
"voice_name": "Leda",
},
},
},
],
},
"language_code": "en-US",
},
},
model="gemini-3.1-flash-tts-preview",
)
assert result["responseModalities"] == ["AUDIO"]
assert "response_modalities" not in result
assert "speech_config" not in result
assert result["speechConfig"] == {
"multiSpeakerVoiceConfig": {
"speakerVoiceConfigs": [
{
"speaker": "Ryan",
"voiceConfig": {
"prebuiltVoiceConfig": {
"voiceName": "Umbriel",
},
},
},
{
"speaker": "Katie",
"voiceConfig": {
"prebuiltVoiceConfig": {
"voiceName": "Leda",
},
},
},
],
},
"languageCode": "en-US",
}
def test_responses_api_reasoning_dict_format():
"""Test that reasoning parameter with dict format is mapped to reasoning_effort"""
from litellm.types.llms.openai import ResponsesAPIOptionalRequestParams
@ -254,9 +310,7 @@ def test_transform_generate_content_request_with_system_instruction():
# Verify that systemInstruction is in the request
assert "systemInstruction" in result, "systemInstruction should be in request body"
assert (
result["systemInstruction"] == system_instruction
), "systemInstruction should match input"
assert result["systemInstruction"] == system_instruction, "systemInstruction should match input"
assert result["model"] == "gemini-3-flash-preview"
assert result["contents"] == contents
@ -279,9 +333,7 @@ def test_transform_generate_content_request_without_system_instruction():
)
# Verify that systemInstruction is NOT in the request when not provided
assert (
"systemInstruction" not in result
), "systemInstruction should not be in request when None"
assert "systemInstruction" not in result, "systemInstruction should not be in request when None"
assert result["model"] == "gemini-3-flash-preview"
assert result["contents"] == contents
@ -290,9 +342,7 @@ def test_transform_generate_content_request_system_instruction_with_tools():
"""Test that systemInstruction works correctly alongside tools"""
config = GoogleGenAIConfig()
system_instruction = {
"parts": [{"text": "You are a helpful assistant that uses tools"}]
}
system_instruction = {"parts": [{"text": "You are a helpful assistant that uses tools"}]}
contents = [{"role": "user", "parts": [{"text": "What's the weather?"}]}]
@ -374,9 +424,7 @@ def test_transform_generate_content_request_normalizes_response_schema_2_5():
assert "responseJsonSchema" in gen_config
normalized = gen_config["responseJsonSchema"]
assert "$defs" in normalized
assert normalized["properties"]["highlights"]["items"] == {
"$ref": "#/$defs/Highlight"
}
assert normalized["properties"]["highlights"]["items"] == {"$ref": "#/$defs/Highlight"}
def test_transform_generate_content_request_flattens_response_schema_1_5():
@ -581,12 +629,8 @@ def test_validate_environment_with_dict_api_key():
# The dict should be merged into headers, not set as a value
assert "x-goog-api-key" in result, "x-goog-api-key should be in headers"
assert (
result["x-goog-api-key"] == "sk-test-key-123"
), "API key should be the string value, not a dict"
assert isinstance(
result["x-goog-api-key"], str
), "Header value should be a string, not a dict"
assert result["x-goog-api-key"] == "sk-test-key-123", "API key should be the string value, not a dict"
assert isinstance(result["x-goog-api-key"], str), "Header value should be a string, not a dict"
assert "Content-Type" in result, "Content-Type should be in headers"
assert result["Content-Type"] == "application/json"
@ -632,9 +676,7 @@ def test_validate_environment_with_extra_headers():
# Both the auth dict and extra headers should be merged
assert "x-goog-api-key" in result, "x-goog-api-key should be in headers"
assert (
result["x-goog-api-key"] == "sk-test-key-789"
), "API key should be correctly set"
assert result["x-goog-api-key"] == "sk-test-key-789", "API key should be correctly set"
assert isinstance(result["x-goog-api-key"], str), "Header value should be a string"
assert "X-Custom-Header" in result, "Extra headers should be merged"
assert result["X-Custom-Header"] == "custom-value"

View file

@ -2,14 +2,70 @@
Test Gemini TTS (Text-to-Speech) functionality
"""
import pytest
from typing import Final
from unittest.mock import patch, MagicMock
import pytest
import litellm
from litellm.endpoints.speech.speech_to_completion_bridge.transformation import (
SpeechToCompletionBridgeTransformationHandler,
)
from litellm.llms.gemini.chat.transformation import GoogleAIStudioGeminiConfig
from litellm.utils import get_supported_openai_params
pytestmark = pytest.mark.usefixtures("local_model_cost_map")
GEMINI_3_1_FLASH_TTS_MODEL = "gemini-3.1-flash-tts-preview"
MULTI_SPEAKER_SPEECH_CONFIG = {
"multi_speaker_voice_config": {
"speaker_voice_configs": [
{
"speaker": "Ryan",
"voice_config": {
"prebuilt_voice_config": {
"voice_name": "Umbriel",
},
},
},
{
"speaker": "Katie",
"voice_config": {
"prebuilt_voice_config": {
"voice_name": "Leda",
},
},
},
],
},
"language_code": "en-US",
}
NORMALIZED_MULTI_SPEAKER_SPEECH_CONFIG = {
"multiSpeakerVoiceConfig": {
"speakerVoiceConfigs": [
{
"speaker": "Ryan",
"voiceConfig": {
"prebuiltVoiceConfig": {
"voiceName": "Umbriel",
},
},
},
{
"speaker": "Katie",
"voiceConfig": {
"prebuiltVoiceConfig": {
"voiceName": "Leda",
},
},
},
],
},
"languageCode": "en-US",
}
class TestGeminiTTSTransformation:
"""Test Gemini TTS transformation functionality"""
@ -19,17 +75,16 @@ class TestGeminiTTSTransformation:
config = GoogleAIStudioGeminiConfig()
# Test TTS models (both preview and non-preview versions)
assert (
config.is_model_gemini_audio_model("gemini-2.5-flash-preview-tts") == True
)
assert config.is_model_gemini_audio_model("gemini-2.5-pro-preview-tts") == True
assert config.is_model_gemini_audio_model("gemini-2.5-flash-tts") == True
assert config.is_model_gemini_audio_model("gemini-2.5-pro-tts") == True
assert config.is_model_gemini_audio_model("gemini-2.5-flash-preview-tts")
assert config.is_model_gemini_audio_model("gemini-2.5-pro-preview-tts")
assert config.is_model_gemini_audio_model("gemini-2.5-flash-tts")
assert config.is_model_gemini_audio_model("gemini-2.5-pro-tts")
assert config.is_model_gemini_audio_model(GEMINI_3_1_FLASH_TTS_MODEL)
# Test non-TTS models
assert config.is_model_gemini_audio_model("gemini-2.5-flash") == False
assert config.is_model_gemini_audio_model("gemini-2.5-pro") == False
assert config.is_model_gemini_audio_model("gpt-4o-audio-preview") == False
assert not config.is_model_gemini_audio_model("gemini-2.5-flash")
assert not config.is_model_gemini_audio_model("gemini-2.5-pro")
assert not config.is_model_gemini_audio_model("gpt-4o-audio-preview")
def test_gemini_tts_supported_params(self):
"""Test that audio parameter is included for TTS models"""
@ -66,10 +121,7 @@ class TestGeminiTTSTransformation:
assert "speechConfig" in result
assert "voiceConfig" in result["speechConfig"]
assert "prebuiltVoiceConfig" in result["speechConfig"]["voiceConfig"]
assert (
result["speechConfig"]["voiceConfig"]["prebuiltVoiceConfig"]["voiceName"]
== "Kore"
)
assert result["speechConfig"]["voiceConfig"]["prebuiltVoiceConfig"]["voiceName"] == "Kore"
# Check response modalities
assert "responseModalities" in result
@ -78,9 +130,7 @@ class TestGeminiTTSTransformation:
def test_gemini_tts_audio_parameter_mapping_with_language_code(self):
config = GoogleAIStudioGeminiConfig()
non_default_params = {
"audio": {"voice": "Kore", "format": "pcm16", "language_code": "en-US"}
}
non_default_params = {"audio": {"voice": "Kore", "format": "pcm16", "language_code": "en-US"}}
optional_params = {}
result = config.map_openai_params(
@ -92,21 +142,26 @@ class TestGeminiTTSTransformation:
assert "speechConfig" in result
assert result["speechConfig"]["languageCode"] == "en-US"
assert (
result["speechConfig"]["voiceConfig"]["prebuiltVoiceConfig"]["voiceName"]
== "Kore"
)
assert result["speechConfig"]["voiceConfig"]["prebuiltVoiceConfig"]["voiceName"] == "Kore"
def test_map_audio_params_language_code(self):
config = GoogleAIStudioGeminiConfig()
result = config._map_audio_params(
{"voice": "Kore", "format": "pcm16", "language_code": "de-DE"}
)
result = config._map_audio_params({"voice": "Kore", "format": "pcm16", "language_code": "de-DE"})
assert result["languageCode"] == "de-DE"
assert result["voiceConfig"]["prebuiltVoiceConfig"]["voiceName"] == "Kore"
def test_map_audio_params_keeps_voice_beside_nested_speech_config(self):
config = GoogleAIStudioGeminiConfig()
result = config._map_audio_params(
{"speech_config": {"language_code": "fr-FR"}, "voice": "Kore", "format": "pcm16"}
)
assert result["languageCode"] == "fr-FR"
assert result["voiceConfig"]["prebuiltVoiceConfig"]["voiceName"] == "Kore"
def test_map_audio_params_no_language_code(self):
config = GoogleAIStudioGeminiConfig()
@ -115,6 +170,56 @@ class TestGeminiTTSTransformation:
assert "languageCode" not in result
assert result["voiceConfig"]["prebuiltVoiceConfig"]["voiceName"] == "Kore"
def test_map_audio_params_preserves_nested_list_and_values_without_voice(self):
config: Final = GoogleAIStudioGeminiConfig()
result: Final = config._map_audio_params(
{
"speech_config": {
"language_code": "fr-FR",
"multi_speaker_voice_config": {
"speaker_voice_configs": [
{"speaker": "Ryan", "voice_config": {"prebuilt_voice_config": {"voice_name": "Umbriel"}}}
]
},
"metadata": ["studio", 2],
},
"format": "pcm16",
}
)
assert result == {
"languageCode": "fr-FR",
"multiSpeakerVoiceConfig": {
"speakerVoiceConfigs": [
{"speaker": "Ryan", "voiceConfig": {"prebuiltVoiceConfig": {"voiceName": "Umbriel"}}}
]
},
"metadata": ["studio", 2],
}
def test_gemini_tts_multi_speaker_audio_parameter_mapping(self):
"""Test multi-speaker audio parameter mapping for Gemini 3.1 TTS models"""
config = GoogleAIStudioGeminiConfig()
non_default_params = {
"audio": {
"speech_config": MULTI_SPEAKER_SPEECH_CONFIG,
"format": "pcm16",
}
}
optional_params = {}
result = config.map_openai_params(
non_default_params=non_default_params,
optional_params=optional_params,
model=GEMINI_3_1_FLASH_TTS_MODEL,
drop_params=False,
)
assert result["speechConfig"] == NORMALIZED_MULTI_SPEAKER_SPEECH_CONFIG
assert result["responseModalities"] == ["AUDIO"]
def test_gemini_tts_audio_parameter_with_existing_modalities(self):
"""Test audio parameter mapping when modalities already exist"""
config = GoogleAIStudioGeminiConfig()
@ -198,9 +303,7 @@ class TestGeminiTTSTransformation:
}
optional_params = {}
with pytest.raises(
ValueError, match="Unsupported audio format for Gemini TTS models"
):
with pytest.raises(ValueError, match="Unsupported audio format for Gemini TTS models"):
config.map_openai_params(
non_default_params=non_default_params,
optional_params=optional_params,
@ -239,6 +342,28 @@ def test_gemini_tts_completion_mock():
assert response.choices[0].message.content is not None
def test_gemini_tts_speech_bridge_accepts_multi_speaker_voice_dict():
handler = SpeechToCompletionBridgeTransformationHandler()
result = handler.transform_request(
model=f"vertex_ai/{GEMINI_3_1_FLASH_TTS_MODEL}",
input="Ryan: How are you doing today Katie?\nKatie: Not too bad.",
voice=MULTI_SPEAKER_SPEECH_CONFIG,
optional_params={"response_format": "pcm"},
litellm_params={},
headers={},
litellm_logging_obj=MagicMock(),
custom_llm_provider="vertex_ai",
)
assert result["modalities"] == ["audio"]
assert result["audio"] == {
"speech_config": NORMALIZED_MULTI_SPEAKER_SPEECH_CONFIG,
"format": "pcm16",
}
assert "response_format" not in result
class TestGeminiTTSSpeechConfigInRequestBody:
"""Test that speechConfig is properly included in the final request body.
@ -255,11 +380,11 @@ class TestGeminiTTSSpeechConfigInRequestBody:
("gemini-2.5-flash-preview-tts", "vertex_ai"),
("gemini-2.5-flash-preview-tts", "gemini"),
("gemini-2.5-pro-tts", "vertex_ai"),
(GEMINI_3_1_FLASH_TTS_MODEL, "vertex_ai"),
(GEMINI_3_1_FLASH_TTS_MODEL, "gemini"),
],
)
def test_speechconfig_in_generation_config_transform_request_body(
self, model, custom_llm_provider
):
def test_speechconfig_in_generation_config_transform_request_body(self, model, custom_llm_provider):
"""Test that speechConfig is included in generationConfig after _transform_request_body()"""
from litellm.llms.vertex_ai.gemini.transformation import (
_transform_request_body,
@ -267,9 +392,7 @@ class TestGeminiTTSSpeechConfigInRequestBody:
# Simulate optional_params after map_openai_params() has run
optional_params = {
"speechConfig": {
"voiceConfig": {"prebuiltVoiceConfig": {"voiceName": "Kore"}}
},
"speechConfig": {"voiceConfig": {"prebuiltVoiceConfig": {"voiceName": "Kore"}}},
"responseModalities": ["AUDIO"],
}
@ -292,12 +415,7 @@ class TestGeminiTTSSpeechConfigInRequestBody:
f"speechConfig was filtered out of generationConfig for model={model}, provider={custom_llm_provider}. "
"Ensure speechConfig is in the GenerationConfig TypedDict."
)
assert (
generation_config["speechConfig"]["voiceConfig"]["prebuiltVoiceConfig"][
"voiceName"
]
== "Kore"
)
assert generation_config["speechConfig"]["voiceConfig"]["prebuiltVoiceConfig"]["voiceName"] == "Kore"
@pytest.mark.parametrize(
"model,custom_llm_provider",
@ -305,16 +423,18 @@ class TestGeminiTTSSpeechConfigInRequestBody:
("gemini-2.5-flash-tts", "vertex_ai"),
("gemini-2.5-flash-tts", "gemini"),
("gemini-2.5-flash-preview-tts", "vertex_ai"),
(GEMINI_3_1_FLASH_TTS_MODEL, "vertex_ai"),
(GEMINI_3_1_FLASH_TTS_MODEL, "gemini"),
],
)
def test_speechconfig_end_to_end_mapping(self, model, custom_llm_provider):
"""Test full pipeline: audio param -> map_openai_params -> _transform_request_body"""
from litellm.llms.vertex_ai.gemini.vertex_and_google_ai_studio_gemini import (
VertexGeminiConfig,
)
from litellm.llms.vertex_ai.gemini.transformation import (
_transform_request_body,
)
from litellm.llms.vertex_ai.gemini.vertex_and_google_ai_studio_gemini import (
VertexGeminiConfig,
)
config = VertexGeminiConfig()
@ -351,17 +471,54 @@ class TestGeminiTTSSpeechConfigInRequestBody:
f"speechConfig was filtered out during _transform_request_body() for model={model}, provider={custom_llm_provider}. "
"This breaks Gemini TTS - speechConfig must be in GenerationConfig TypedDict."
)
assert (
generation_config["speechConfig"]["voiceConfig"]["prebuiltVoiceConfig"][
"voiceName"
]
== "Puck"
)
assert generation_config["speechConfig"]["voiceConfig"]["prebuiltVoiceConfig"]["voiceName"] == "Puck"
# Also verify responseModalities is present
assert "responseModalities" in generation_config
assert "AUDIO" in generation_config["responseModalities"]
@pytest.mark.parametrize(
"custom_llm_provider",
[
"vertex_ai",
"gemini",
],
)
def test_multi_speaker_speechconfig_end_to_end_mapping(self, custom_llm_provider):
"""Test full pipeline for Gemini 3.1 multi-speaker TTS speechConfig"""
from litellm.llms.vertex_ai.gemini.transformation import (
_transform_request_body,
)
from litellm.llms.vertex_ai.gemini.vertex_and_google_ai_studio_gemini import (
VertexGeminiConfig,
)
config = VertexGeminiConfig()
mapped_params = config.map_openai_params(
non_default_params={
"audio": {
"speech_config": MULTI_SPEAKER_SPEECH_CONFIG,
"format": "pcm16",
}
},
optional_params={},
model=GEMINI_3_1_FLASH_TTS_MODEL,
drop_params=False,
)
request_body = _transform_request_body(
messages=[{"role": "user", "content": "Ryan: Hi.\nKatie: Hello."}],
model=GEMINI_3_1_FLASH_TTS_MODEL,
optional_params=mapped_params,
custom_llm_provider=custom_llm_provider,
litellm_params={},
cached_content=None,
)
generation_config = request_body["generationConfig"]
assert generation_config["speechConfig"] == NORMALIZED_MULTI_SPEAKER_SPEECH_CONFIG
assert generation_config["responseModalities"] == ["AUDIO"]
@pytest.mark.parametrize(
"model,custom_llm_provider",
@ -372,18 +529,16 @@ class TestGeminiTTSSpeechConfigInRequestBody:
],
)
def test_language_code_end_to_end_mapping(self, model, custom_llm_provider):
from litellm.llms.vertex_ai.gemini.vertex_and_google_ai_studio_gemini import (
VertexGeminiConfig,
)
from litellm.llms.vertex_ai.gemini.transformation import (
_transform_request_body,
)
from litellm.llms.vertex_ai.gemini.vertex_and_google_ai_studio_gemini import (
VertexGeminiConfig,
)
config = VertexGeminiConfig()
non_default_params = {
"audio": {"voice": "Puck", "format": "pcm16", "language_code": "pt-BR"}
}
non_default_params = {"audio": {"voice": "Puck", "format": "pcm16", "language_code": "pt-BR"}}
optional_params = {}
mapped_params = config.map_openai_params(
@ -406,14 +561,35 @@ class TestGeminiTTSSpeechConfigInRequestBody:
generation_config = request_body["generationConfig"]
assert generation_config["speechConfig"]["languageCode"] == "pt-BR"
assert (
generation_config["speechConfig"]["voiceConfig"]["prebuiltVoiceConfig"][
"voiceName"
]
== "Puck"
)
assert generation_config["speechConfig"]["voiceConfig"]["prebuiltVoiceConfig"]["voiceName"] == "Puck"
assert "AUDIO" in generation_config["responseModalities"]
if __name__ == "__main__":
pytest.main([__file__])
@pytest.mark.parametrize("voice_key", ["name", "voiceName", "voice_name", "voice"])
@pytest.mark.parametrize("wrapper", [None, "speechConfig", "speech_config"])
@pytest.mark.parametrize("model", ["gemini-2.5-flash-tts", "gemini-2.5-pro-tts", GEMINI_3_1_FLASH_TTS_MODEL])
def test_speech_bridge_preserves_single_speaker_voice_mapping(voice_key: str, wrapper: str | None, model: str):
handler = SpeechToCompletionBridgeTransformationHandler()
config = {voice_key: "Umbriel", "language_code": "en-US"}
voice = {wrapper: config} if wrapper else config
result = handler.transform_request(
model=model,
input="Hello",
voice=voice,
optional_params={},
litellm_params={},
headers={},
litellm_logging_obj=MagicMock(),
custom_llm_provider="vertex_ai",
)
assert result["audio"] == {
"speech_config": {
"voiceConfig": {"prebuiltVoiceConfig": {"voiceName": "Umbriel"}},
"languageCode": "en-US",
},
"format": "pcm16",
}

View file

@ -17,12 +17,28 @@ from litellm.llms.gemini.chat.transformation import GoogleAIStudioGeminiConfig
from litellm.llms.vertex_ai.common_utils import VertexAIError
from litellm.llms.vertex_ai.gemini.vertex_and_google_ai_studio_gemini import (
VertexGeminiConfig,
normalize_gemini_speech_config,
)
from litellm.types.llms.vertex_ai import GeminiFinishReason, UsageMetadata
from litellm.types.utils import ChoiceLogprobs, Usage
from litellm.utils import CustomStreamWrapper
def test_gemini_speech_config_drops_non_string_keys_without_adding_voice():
speech_config: Final = {
"speech_config": {
"language_code": "fr-FR",
"metadata": {1: "discard", "source": ["studio", 2]},
},
"format": "pcm16",
}
assert normalize_gemini_speech_config(speech_config) == {
"languageCode": "fr-FR",
"metadata": {"source": ["studio", 2]},
}
def test_top_logprobs():
non_default_params = {
"top_logprobs": 2,

View file

@ -1,19 +1,31 @@
import base64
import io
import math
import struct
import sys
import wave
from pathlib import Path
from typing import Final
from unittest.mock import MagicMock, Mock, patch
import httpx
import pytest
import soundfile
from pydantic import ValidationError
import litellm
from litellm.llms.vertex_ai.text_to_speech.transformation import (
VertexAILyriaTextToSpeechConfig,
VertexAITextToSpeechConfig,
_fallback_gemini_tts_audio_duration,
)
from litellm.cost_calculator import response_cost_calculator
from litellm.litellm_core_utils.audio_utils.utils import calculate_request_duration
from litellm.types.utils import LlmProviders
from litellm.utils import ProviderConfigManager
pytestmark = pytest.mark.usefixtures("local_model_cost_map")
class TestVertexAITextToSpeechConfig:
"""Tests for VertexAITextToSpeechConfig transformation"""
@ -43,8 +55,12 @@ class TestVertexAITextToSpeechConfig:
assert url == custom_url
@patch.object(VertexAITextToSpeechConfig, "_ensure_access_token")
@patch.object(VertexAITextToSpeechConfig, "_get_token_and_url")
@patch.object( # test-quality-ok: isolates provider credentials while testing request serialization
VertexAITextToSpeechConfig, "_ensure_access_token"
)
@patch.object( # test-quality-ok: fixes the provider URL at the authentication boundary
VertexAITextToSpeechConfig, "_get_token_and_url"
)
def test_transform_text_to_speech_request_body(self, mock_get_token, mock_ensure_token):
"""Test that transform_text_to_speech_request generates correct request body"""
# Mock authentication
@ -89,6 +105,20 @@ class TestVertexAITextToSpeechConfig:
assert "headers" in result
assert "Authorization" in result["headers"]
raw_voice_request: Final = config.transform_text_to_speech_request(
model="vertex_ai/chirp",
input="Hello",
voice="en-US-Chirp3-HD-Charon",
optional_params={},
litellm_params={
"vertex_credentials": None,
"vertex_project": "test-project",
"vertex_location": "us-central1",
},
headers={},
)
assert raw_voice_request["dict_body"]["voice"]["name"] == "en-US-Chirp3-HD-Charon"
def test_voice_mapping_openai_to_vertex(self):
"""Test that OpenAI voice names are correctly mapped to Vertex AI voices"""
config = VertexAITextToSpeechConfig()
@ -126,6 +156,318 @@ class TestVertexAITextToSpeechConfig:
assert voice_str is None
assert voice_dict == voice_input
def test_gemini_tts_multi_speaker_voice_mapping(self):
config = VertexAITextToSpeechConfig()
voice = {
"multi_speaker_voice_config": {
"speaker_voice_configs": [
{
"speaker": "Ryan",
"voice_config": {
"prebuilt_voice_config": {
"voice_name": "Umbriel",
},
},
},
{
"speaker": "Katie",
"voice_config": {
"prebuilt_voice_config": {
"voice_name": "Leda",
},
},
},
],
},
}
expected_voice = {
"languageCode": "en-US",
"modelName": "gemini-3.1-flash-tts-preview",
"multiSpeakerVoiceConfig": {
"speakerVoiceConfigs": [
{
"speakerAlias": "Ryan",
"speakerId": "Umbriel",
},
{
"speakerAlias": "Katie",
"speakerId": "Leda",
},
],
},
}
voice_str, optional_params = config.map_openai_params(
model="gemini-3.1-flash-tts-preview",
optional_params={"response_format": "mp3"},
voice=voice,
)
assert voice_str is None
assert optional_params["audioEncoding"] == "MP3"
assert optional_params["vertex_voice_dict"] == expected_voice
voice_str, optional_params = config.map_openai_params(
model="gemini-3.1-flash-tts-preview",
optional_params={"response_format": "pcm16"},
voice=voice,
)
assert voice_str is None
assert optional_params["audioEncoding"] == "LINEAR16"
assert optional_params["vertex_voice_dict"] == expected_voice
def test_gemini_tts_nested_language_and_voice_alias_reach_cloud_tts(self):
config: Final = VertexAITextToSpeechConfig()
voice: Final = {"speech_config": {"language_code": "fr-FR"}, "voice": "Kore"}
voice_name, optional_params = config.map_openai_params(
model="gemini-3.1-flash-tts-preview",
optional_params={"response_format": "mp3"},
voice=voice,
)
assert voice_name is None
assert optional_params["vertex_voice_dict"] == {
"languageCode": "fr-FR",
"modelName": "gemini-3.1-flash-tts-preview",
"name": "Kore",
}
def test_gemini_tts_string_voice_reaches_cloud_tts(self):
config: Final = VertexAITextToSpeechConfig()
voice_name, optional_params = config.map_openai_params(
model="gemini-3.1-flash-tts-preview",
optional_params={"response_format": "mp3"},
voice="Kore",
)
assert voice_name == "Kore"
assert optional_params["vertex_voice_dict"] == {
"languageCode": "en-US",
"modelName": "gemini-3.1-flash-tts-preview",
"name": "Kore",
}
def test_gemini_tts_ignores_incomplete_speaker_entries(self):
config: Final = VertexAITextToSpeechConfig()
voice: Final = {
"multiSpeakerVoiceConfig": {
"speakerVoiceConfigs": [
None,
{"speakerAlias": "Ryan"},
{"speakerAlias": "Katie", "speakerId": "Leda"},
]
}
}
_, optional_params = config.map_openai_params(
model="gemini-3.1-flash-tts-preview",
optional_params={"response_format": "mp3"},
voice=voice,
)
assert optional_params["vertex_voice_dict"]["multiSpeakerVoiceConfig"] == {
"speakerVoiceConfigs": [{"speakerAlias": "Katie", "speakerId": "Leda"}]
}
def test_gemini_tts_ignores_non_list_speaker_entries(self):
config: Final = VertexAITextToSpeechConfig()
assert config._extract_gemini_tts_speaker_configs(
{"multiSpeakerVoiceConfig": {"speakerVoiceConfigs": "invalid"}}
) == []
@pytest.mark.parametrize(
("voice", "expected_name"),
[
({"speechConfig": {"voice": "Kore"}}, "Kore"),
({"speechConfig": {"voiceConfig": {"prebuiltVoiceConfig": {"voiceName": "Kore"}}}}, "Kore"),
({"speechConfig": {"voiceConfig": {}}}, None),
],
)
def test_gemini_tts_nested_voice_mapping(self, voice: dict[str, object], expected_name: str | None):
config: Final = VertexAITextToSpeechConfig()
_, optional_params = config.map_openai_params(
model="gemini-3.1-flash-tts-preview",
optional_params={"response_format": "mp3"},
voice=voice,
)
mapped_voice: Final = optional_params["vertex_voice_dict"]
if expected_name is None:
assert mapped_voice["speechConfig"]["voiceConfig"] == {}
assert "name" not in mapped_voice
else:
assert mapped_voice["name"] == expected_name
@pytest.mark.parametrize(
"voice",
[
{"name": "Umbriel", "modelName": "gemini-2.5-flash-tts"},
{"name": "Umbriel", "model_name": "gemini-2.5-flash-tts"},
{"modelName": "gemini-2.5-flash-tts", "model_name": "chirp-3"},
{
"modelName": "gemini-2.5-flash-tts",
"model_name": "chirp-3",
"multi_speaker_voice_config": {
"speaker_voice_configs": [
{
"speaker": "Ryan",
"voice_config": {
"prebuilt_voice_config": {
"voice_name": "Umbriel",
},
},
},
],
},
},
],
)
def test_gemini_tts_ignores_voice_model_name_override(self, voice):
config = VertexAITextToSpeechConfig()
routed_model = "gemini-3.1-flash-tts-preview"
_, optional_params = config.map_openai_params(
model=routed_model,
optional_params={"response_format": "mp3"},
voice=voice,
)
assert optional_params["vertex_voice_dict"]["modelName"] == routed_model
def test_dispatch_maps_gemini_cloud_tts_params_when_provider_config_skipped(self):
config = VertexAITextToSpeechConfig()
handler = MagicMock()
handler.text_to_speech_handler.return_value = "ok"
result = config.dispatch_text_to_speech(
model="gemini-3.1-flash-tts-preview",
input="Hi",
voice={
"name": "Umbriel",
"modelName": "gemini-2.5-flash-tts",
},
optional_params={"response_format": "mp3"},
litellm_params_dict={},
logging_obj=MagicMock(),
timeout=10,
extra_headers=None,
base_llm_http_handler=handler,
aspeech=False,
api_base=None,
api_key=None,
)
assert result == "ok"
mapped_params = handler.text_to_speech_handler.call_args.kwargs["text_to_speech_optional_params"]
assert mapped_params["audioEncoding"] == "MP3"
assert mapped_params["vertex_voice_dict"]["name"] == "Umbriel"
assert mapped_params["vertex_voice_dict"]["modelName"] == "gemini-3.1-flash-tts-preview"
@pytest.mark.parametrize(
("voice", "expected_name"),
[
("en-US-Chirp3-HD-Charon", "en-US-Chirp3-HD-Charon"),
({"name": "en-US-Chirp3-HD-Charon"}, "en-US-Chirp3-HD-Charon"),
(None, None),
],
)
def test_dispatch_keeps_pre_mapped_cloud_tts_params(
self, voice: str | dict[str, str] | None, expected_name: str | None
):
config = VertexAITextToSpeechConfig()
handler = MagicMock()
handler.text_to_speech_handler.return_value = "ok"
optional_params = {
"audioEncoding": "OGG_OPUS",
"vertex_voice_dict": {"languageCode": "en-US", "name": "en-US-Chirp3-HD-Charon"},
}
config.dispatch_text_to_speech(
model="chirp",
input="Hi",
voice=voice,
optional_params=optional_params,
litellm_params_dict={},
logging_obj=MagicMock(),
timeout=10,
extra_headers=None,
base_llm_http_handler=handler,
aspeech=False,
api_base=None,
api_key=None,
)
call_kwargs = handler.text_to_speech_handler.call_args.kwargs
assert call_kwargs["voice"] == expected_name
assert call_kwargs["text_to_speech_optional_params"] is optional_params
assert call_kwargs["text_to_speech_optional_params"]["audioEncoding"] == "OGG_OPUS"
@patch.object( # test-quality-ok: isolates provider credentials while testing Gemini request serialization
VertexAITextToSpeechConfig, "_ensure_access_token"
)
@patch.object( # test-quality-ok: fixes the Gemini provider URL at the authentication boundary
VertexAITextToSpeechConfig, "_get_token_and_url"
)
def test_gemini_tts_mp3_request_body(self, mock_get_token, mock_ensure_token):
mock_ensure_token.return_value = ("mock-token", "test-project")
mock_get_token.return_value = ("mock-token", "mock-url")
config = VertexAITextToSpeechConfig()
result = config.transform_text_to_speech_request(
model="gemini-3.1-flash-tts-preview",
input="Ryan: Hi.\nKatie: Hello.",
voice=None,
optional_params={
"audioEncoding": "MP3",
"vertex_voice_dict": {
"languageCode": "en-US",
"modelName": "gemini-3.1-flash-tts-preview",
"multiSpeakerVoiceConfig": {
"speakerVoiceConfigs": [
{
"speakerAlias": "Ryan",
"speakerId": "Umbriel",
},
{
"speakerAlias": "Katie",
"speakerId": "Leda",
},
],
},
},
},
litellm_params={
"vertex_credentials": None,
"vertex_project": "test-project",
"vertex_location": "global",
},
headers={},
)
request_body = result["dict_body"]
assert request_body["input"] == {"text": "Ryan: Hi.\nKatie: Hello."}
assert request_body["voice"] == {
"languageCode": "en-US",
"modelName": "gemini-3.1-flash-tts-preview",
"multiSpeakerVoiceConfig": {
"speakerVoiceConfigs": [
{
"speakerAlias": "Ryan",
"speakerId": "Umbriel",
},
{
"speakerAlias": "Katie",
"speakerId": "Leda",
},
],
},
}
assert request_body["audioConfig"]["audioEncoding"] == "MP3"
@pytest.mark.parametrize(
("audio", "expected_content_type"),
@ -169,6 +511,187 @@ def test_transform_text_to_speech_response_leaves_unknown_bytes_unlabeled():
assert result.response.content == raw_pcm
@pytest.mark.parametrize("encoding", ["LINEAR16", "PCM", "MP3", "OGG_OPUS", "ALAW", "MULAW", "FLAC"])
def test_gemini_cloud_tts_response_bills_text_and_audio(encoding: str, monkeypatch: pytest.MonkeyPatch) -> None:
model: Final = "gemini-3.1-flash-tts-preview"
input_text: Final = "Hello from Gemini text to speech"
pcm_audio: Final = b"\x00\x00" * 24000
wav_buffer: Final = io.BytesIO()
with wave.open(wav_buffer, "wb") as wav_file:
wav_file.setnchannels(1)
wav_file.setsampwidth(2)
wav_file.setframerate(24000)
wav_file.writeframes(pcm_audio)
flac_buffer: Final = io.BytesIO()
soundfile.write(flac_buffer, (0.0,) * 24000, 48000, format="FLAC", subtype="PCM_16")
g711_audio: Final = (
struct.pack(
"<4sI4s4sIHHIIHH4sI",
b"RIFF",
36 + 24000,
b"WAVE",
b"fmt ",
16,
6 if encoding == "ALAW" else 7,
1,
24000,
24000,
1,
8,
b"data",
24000,
)
+ b"\x00" * 24000
)
fixture_dir: Final = Path(__file__).resolve().parents[4] / "audio_tests"
audio_by_encoding: Final = {
"PCM": pcm_audio,
"LINEAR16": wav_buffer.getvalue(),
"MP3": (fixture_dir / "gemini_tts_speech.mp3").read_bytes(),
"OGG_OPUS": (fixture_dir / "gemini_tts_speech.ogg").read_bytes(),
"ALAW": g711_audio,
"MULAW": g711_audio,
"FLAC": flac_buffer.getvalue(),
}
audio_bytes: Final = audio_by_encoding[encoding]
logger: Final = MagicMock()
logger.model_call_details = {
"additional_args": {
"complete_input_dict": {
"dict_body": {
"input": {"text": input_text},
"audioConfig": {"audioEncoding": encoding, "sampleRateHertz": 24000},
}
}
}
}
raw_response: Final = httpx.Response(200, json={"audioContent": base64.b64encode(audio_bytes).decode()})
result: Final = VertexAITextToSpeechConfig().transform_text_to_speech_response(model, raw_response, logger)
usage: Final = result.usage
assert usage is not None
duration: Final = len(audio_bytes) / 48000 if encoding == "PCM" else calculate_request_duration(audio_bytes)
assert duration is not None
assert _fallback_gemini_tts_audio_duration(audio_bytes, encoding, 24000) == pytest.approx(duration), (
"Google Cloud AudioEncoding and RFC 7845, checked 2026-09-23: "
"https://cloud.google.com/text-to-speech/docs/reference/rest/v1/AudioEncoding "
"https://www.rfc-editor.org/rfc/rfc7845.html"
" https://www.rfc-editor.org/rfc/rfc9639.html#section-8.2, checked 2026-10-06"
)
assert usage.completion_tokens == math.ceil(duration * 25), (
"Google Cloud TTS pricing, 2026-09-23: https://cloud.google.com/text-to-speech/pricing"
)
assert usage.prompt_tokens == litellm.token_counter(model=model, text=input_text)
assert usage.total_tokens == usage.prompt_tokens + usage.completion_tokens
assert usage.completion_tokens_details is not None
assert usage.completion_tokens_details.audio_tokens == usage.completion_tokens
with monkeypatch.context() as dependencies:
dependencies.setitem(sys.modules, "soundfile", None)
sdk_result: Final = VertexAITextToSpeechConfig().transform_text_to_speech_response(model, raw_response, logger)
assert sdk_result.usage == usage
model_info: Final = litellm.get_model_info(model, custom_llm_provider="vertex_ai")
cost: Final = response_cost_calculator(
response_object=result,
model=model,
custom_llm_provider="vertex_ai",
call_type="speech",
optional_params={},
prompt=input_text,
)
expected_cost: Final = (
usage.prompt_tokens * model_info["input_cost_per_token"]
+ usage.completion_tokens * model_info["output_cost_per_audio_token"]
)
assert cost == pytest.approx(expected_cost)
assert cost > usage.prompt_tokens * model_info["input_cost_per_token"]
@pytest.mark.parametrize("decoder_available", (False, True))
@pytest.mark.parametrize("invalid_streaminfo", (False, True))
def test_gemini_cloud_tts_rejects_unbillable_flac(
monkeypatch: pytest.MonkeyPatch, decoder_available: bool, invalid_streaminfo: bool
) -> None:
output: Final = io.BytesIO()
soundfile.write(output, (0.0,) * 24000, 48000, format="FLAC", subtype="PCM_16")
audio: Final = output.getvalue()
stream_info: Final = int.from_bytes(audio[18:26], "big") & ~((1 << 36) - 1)
metadata_header: Final = b"\x01\x00\x00\x22" if invalid_streaminfo else audio[4:8]
unknown_duration_audio: Final = audio[:4] + metadata_header + audio[8:18] + stream_info.to_bytes(8, "big") + audio[26:]
raw_response: Final = httpx.Response(
200, json={"audioContent": base64.b64encode(unknown_duration_audio).decode()}
)
logger: Final = MagicMock(
model_call_details={
"additional_args": {
"complete_input_dict": {
"dict_body": {
"input": {"text": "Hello"},
"audioConfig": {"audioEncoding": "FLAC", "sampleRateHertz": 48000},
}
}
}
}
)
if not decoder_available:
monkeypatch.setitem(sys.modules, "soundfile", None)
with pytest.raises(ValueError, match="Cannot determine Gemini TTS output duration"):
VertexAITextToSpeechConfig().transform_text_to_speech_response(
"gemini-3.1-flash-tts-preview", raw_response, logger
)
@pytest.mark.parametrize("encoding", ["ALAW", "MULAW"])
@pytest.mark.parametrize("audio", [b"\x12" * 24000, b"RIFF" + b"\x12" * 23996])
def test_gemini_cloud_tts_returns_raw_g711_audio_when_duration_decoder_is_unavailable(encoding: str, audio: bytes):
raw_response: Final = httpx.Response(200, json={"audioContent": base64.b64encode(audio).decode()})
logger: Final = MagicMock()
logger.model_call_details = {
"additional_args": {
"complete_input_dict": {
"dict_body": {
"input": {"text": "Hello"},
"audioConfig": {"audioEncoding": encoding, "sampleRateHertz": 24000},
}
}
}
}
with patch("litellm.llms.vertex_ai.text_to_speech.transformation.calculate_request_duration", return_value=None):
result: Final = VertexAITextToSpeechConfig().transform_text_to_speech_response(
"gemini-3.1-flash-tts-preview", raw_response, logger
)
assert result.response.content == audio
assert result.usage is not None
assert result.usage.prompt_tokens > 0
assert result.usage.completion_tokens == 25, (
"Google Cloud TTS pricing, 2026-09-23: https://cloud.google.com/text-to-speech/pricing"
)
def test_gemini_cloud_tts_rejects_audio_without_measurable_duration():
raw_response: Final = httpx.Response(200, json={"audioContent": base64.b64encode(b"invalid").decode()})
logger: Final = MagicMock()
logger.model_call_details = {
"additional_args": {
"complete_input_dict": {
"dict_body": {
"input": {"text": "Hello"},
"audioConfig": {"audioEncoding": "MP3", "sampleRateHertz": 24000},
}
}
}
}
with patch("litellm.llms.vertex_ai.text_to_speech.transformation.calculate_request_duration", return_value=None):
with pytest.raises(ValueError, match="Cannot determine Gemini TTS output duration"):
VertexAITextToSpeechConfig().transform_text_to_speech_response(
"gemini-3.1-flash-tts-preview", raw_response, logger
)
class TestVertexAILyriaTextToSpeechConfig:
@pytest.mark.parametrize(
"model",
@ -580,9 +1103,15 @@ def test_litellm_speech_vertex_ai_sends_request_to_the_configured_endpoint(endpo
assert response.content == b"Hello World"
@patch("litellm.llms.custom_httpx.llm_http_handler.HTTPHandler.post")
@patch.object(VertexAITextToSpeechConfig, "_ensure_access_token")
@patch.object(VertexAITextToSpeechConfig, "_get_token_and_url")
@patch( # test-quality-ok: exercises public speech dispatch up to the outbound HTTP boundary
"litellm.llms.custom_httpx.llm_http_handler.HTTPHandler.post"
)
@patch.object( # test-quality-ok: isolates provider credentials in the public API test
VertexAITextToSpeechConfig, "_ensure_access_token"
)
@patch.object( # test-quality-ok: fixes the provider URL for deterministic dispatch assertions
VertexAITextToSpeechConfig, "_get_token_and_url"
)
def test_litellm_speech_vertex_ai_chirp(mock_get_token, mock_ensure_token, mock_post):
"""
Test that litellm.speech(model="vertex_ai/chirp") sends the correct URL and request body
@ -636,6 +1165,83 @@ def test_litellm_speech_vertex_ai_chirp(mock_get_token, mock_ensure_token, mock_
assert call_kwargs["headers"]["Authorization"] == "Bearer mock-token"
@patch( # test-quality-ok: exercises public Gemini speech dispatch up to the outbound HTTP boundary
"litellm.llms.custom_httpx.llm_http_handler.HTTPHandler.post"
)
@patch.object( # test-quality-ok: isolates provider credentials in the public Gemini API test
VertexAITextToSpeechConfig, "_ensure_access_token"
)
@patch.object( # test-quality-ok: fixes the Gemini provider URL for deterministic dispatch assertions
VertexAITextToSpeechConfig, "_get_token_and_url"
)
def test_litellm_speech_vertex_ai_gemini_tts_mp3_uses_cloud_tts(mock_get_token, mock_ensure_token, mock_post):
mock_ensure_token.return_value = ("mock-token", "test-project")
mock_get_token.return_value = ("mock-token", "mock-url")
mock_response = Mock(spec=httpx.Response)
mock_response.status_code = 200
mock_response.headers = {"content-type": "application/json"}
audio_bytes: Final = (Path(__file__).resolve().parents[4] / "audio_tests/gemini_tts_speech.mp3").read_bytes()
mock_response.json.return_value = {"audioContent": base64.b64encode(audio_bytes).decode()}
mock_post.return_value = mock_response
result: Final = litellm.speech(
model="vertex_ai/gemini-3.1-flash-tts-preview",
input="Ryan: Hi.\nKatie: Hello.",
voice={
"multi_speaker_voice_config": {
"speaker_voice_configs": [
{
"speaker": "Ryan",
"voice_config": {
"prebuilt_voice_config": {
"voice_name": "Umbriel",
},
},
},
{
"speaker": "Katie",
"voice_config": {
"prebuilt_voice_config": {
"voice_name": "Leda",
},
},
},
],
},
},
response_format="mp3",
vertex_project="test-project",
vertex_location="global",
)
assert result.response.content == audio_bytes
assert result.usage is not None
assert result.usage.prompt_tokens > 0
assert result.usage.completion_tokens > 0
mock_post.assert_called_once()
call_kwargs = mock_post.call_args.kwargs
assert call_kwargs["url"] == "https://texttospeech.googleapis.com/v1/text:synthesize"
request_body = call_kwargs["json"]
assert request_body["audioConfig"]["audioEncoding"] == "MP3"
assert request_body["voice"] == {
"languageCode": "en-US",
"modelName": "gemini-3.1-flash-tts-preview",
"multiSpeakerVoiceConfig": {
"speakerVoiceConfigs": [
{
"speakerAlias": "Ryan",
"speakerId": "Umbriel",
},
{
"speakerAlias": "Katie",
"speakerId": "Leda",
},
],
},
}
@pytest.mark.parametrize("payload", [{}, {"audioContent": ""}, {"audioContent": None}, {"audioContent": []}])
def test_transform_text_to_speech_response_without_audio_content_reports_it_missing(payload: dict[str, object]):
with pytest.raises(ValueError, match="No audioContent in Vertex AI TTS response"):

View file

@ -14,6 +14,7 @@ from unittest.mock import AsyncMock, MagicMock
import httpx
import pytest
from fastapi import HTTPException
from litellm.proxy import proxy_server
from litellm.types.llms.openai import HttpxBinaryResponseContent
@ -85,7 +86,7 @@ def patched_speech_error(monkeypatch):
def patched_speech_provider_rejection(monkeypatch, patched_speech_error):
import litellm
async def _raise(*args, **kwargs):
async def _raise_rejection(*args, **kwargs):
raise litellm.BadRequestError(
message=(
"Gemini TTS only produces raw PCM16 audio, so response_format='mp3' is not supported."
@ -95,6 +96,29 @@ def patched_speech_provider_rejection(monkeypatch, patched_speech_error):
llm_provider="gemini",
)
monkeypatch.setattr(proxy_server, "route_request", _raise_rejection)
yield
@pytest.fixture
def patched_speech_proxy_error(monkeypatch, patched_speech):
async def _raise(*args, **kwargs):
raise proxy_server.ProxyException(
message="Unsupported audio encoding.",
type="invalid_request_error",
param=None,
code=400,
)
monkeypatch.setattr(proxy_server, "route_request", _raise)
yield
@pytest.fixture
def patched_speech_http_error(monkeypatch, patched_speech):
async def _raise(*args, **kwargs):
raise HTTPException(status_code=422, detail="Provider rejected the speech request.")
monkeypatch.setattr(proxy_server, "route_request", _raise)
yield
@ -119,9 +143,7 @@ def patched_transcription(monkeypatch):
return data
monkeypatch.setattr(proxy_server, "add_litellm_data_to_request", _add_data)
monkeypatch.setattr(
proxy_server, "check_file_size_under_limit", lambda **kwargs: True
)
monkeypatch.setattr(proxy_server, "check_file_size_under_limit", lambda **kwargs: True)
async def _form_data(request):
from starlette.datastructures import FormData, UploadFile
@ -179,6 +201,9 @@ def test_audio_speech_happy_path(client, auth_as, patched_speech, path):
("audio/flac", "flac", "audio/flac"),
("audio/pcm", "pcm", "audio/pcm"),
("audio/wav", "mp3", "audio/wav"),
("audio/wav", "alaw", "audio/wav"),
("audio/wav", "mulaw", "audio/wav"),
("audio/wav", "pcm16", "audio/wav"),
("application/json", "flac", "audio/flac"),
(None, "wav", "audio/wav"),
(None, None, "audio/mpeg"),
@ -201,6 +226,36 @@ def test_audio_speech_content_type_matches_audio_format(
assert response.headers.get("content-type", "").split(";")[0] == expected_content_type
@pytest.mark.parametrize("path", ["/v1/audio/speech", "/audio/speech"])
@pytest.mark.parametrize("patched_speech", (None, "application/json"), indirect=True)
@pytest.mark.parametrize(
"response_format,expected_media_type",
[
("pcm16", "audio/wav"),
("alaw", "audio/x-alaw-basic"),
("mulaw", "audio/basic"),
],
)
def test_audio_speech_uses_requested_media_type(
client,
auth_as,
patched_speech,
path,
response_format,
expected_media_type,
):
payload = {
"model": "gemini-3.1-flash-tts-preview",
"input": "Hi",
"voice": "Umbriel",
"response_format": response_format,
}
with auth_as():
response = client.post(path, json=payload)
assert response.status_code == 200
assert response.headers.get("content-type", "") == expected_media_type
@pytest.mark.parametrize("path", ["/v1/audio/speech", "/audio/speech"])
def test_audio_speech_error(client, auth_as, patched_speech_error, path):
"""Pins ``POST /v1/audio/speech`` and ``POST /audio/speech`` (error)."""
@ -208,7 +263,13 @@ def test_audio_speech_error(client, auth_as, patched_speech_error, path):
with auth_as():
response = client.post(path, json=payload)
assert response.status_code == 500
assert len(response.content) > 0
assert response.json() == {
"error": {
"message": "Internal server error",
"type": "internal_server_error",
}
}
assert "speech boom" not in response.text
def test_audio_speech_bad_request_maps_to_400(client, auth_as, patched_speech_provider_rejection):
@ -223,6 +284,29 @@ def test_audio_speech_bad_request_maps_to_400(client, auth_as, patched_speech_pr
assert "wav" in error["message"]
@pytest.mark.parametrize("path", ["/v1/audio/speech", "/audio/speech"])
def test_audio_speech_proxy_error_preserves_status_and_message(
client,
auth_as,
patched_speech_proxy_error,
path,
):
payload = {"model": "tts-1", "input": "Hi", "voice": "alloy"}
with auth_as():
response = client.post(path, json=payload)
assert response.status_code == 400
assert response.json()["error"]["message"] == "Unsupported audio encoding."
@pytest.mark.parametrize("path", ["/v1/audio/speech", "/audio/speech"])
def test_audio_speech_http_error_preserves_detail(client, auth_as, patched_speech_http_error, path):
payload = {"model": "tts-1", "input": "Hi", "voice": "alloy"}
with auth_as():
response = client.post(path, json=payload)
assert response.status_code == 422
assert response.json()["detail"] == "Provider rejected the speech request."
@pytest.mark.parametrize("path", ["/v1/audio/transcriptions", "/audio/transcriptions"])
def test_audio_transcription_happy_path(client, auth_as, patched_transcription, path):
"""Pins ``POST /v1/audio/transcriptions`` / ``POST /audio/transcriptions`` (happy)."""

View file

@ -0,0 +1,96 @@
import json
from pathlib import Path
from typing import Final
import pytest
import litellm
from litellm.utils import _invalidate_model_cost_lowercase_map, is_gemini_tts_model
pytestmark = pytest.mark.usefixtures("local_model_cost_map")
EXPECTED_MODELS = {
"gemini-3.1-flash-tts-preview": "gemini",
"gemini/gemini-3.1-flash-tts-preview": "gemini",
"vertex_ai/gemini-3.1-flash-tts-preview": "vertex_ai-language-models",
}
def _load_model_cost_map(path: Path) -> dict:
with open(path, encoding="utf-8") as model_cost_file:
return json.load(model_cost_file)
@pytest.mark.parametrize("model,provider", EXPECTED_MODELS.items())
def test_gemini_3_1_flash_tts_model_metadata(model, provider):
model_cost = _load_model_cost_map(Path(__file__).parents[2] / "model_prices_and_context_window.json")
info = model_cost.get(model)
assert info is not None, f"{model} not found in model cost map"
assert info["litellm_provider"] == provider
assert info["mode"] == "audio_speech"
assert "/v1/audio/speech" in info["supported_endpoints"]
def test_gemini_3_1_flash_tts_backup_matches_main():
repo_root = Path(__file__).parents[2]
main_cost = _load_model_cost_map(repo_root / "model_prices_and_context_window.json")
backup_cost = _load_model_cost_map(repo_root / "litellm" / "model_prices_and_context_window_backup.json")
for model in EXPECTED_MODELS:
assert backup_cost.get(model) == main_cost.get(model), (
f"{model} differs between main and backup model cost maps"
)
def test_gemini_tts_detection_uses_model_metadata(monkeypatch):
model: str = "gemini/future-generative-speech"
monkeypatch.setitem(
litellm.model_cost,
model,
{
"litellm_provider": "gemini",
"mode": "audio_speech",
},
)
litellm.get_model_info.cache_clear()
_invalidate_model_cost_lowercase_map()
assert is_gemini_tts_model(model)
def test_gemini_tts_detection_uses_bundled_metadata_when_runtime_provider_is_generic(monkeypatch):
model: Final = "gemini-3.1-flash-tts-preview"
monkeypatch.setitem(
litellm.model_cost,
f"vertex_ai/{model}",
{"litellm_provider": "vertex_ai", "mode": "audio_speech"},
)
litellm.get_model_info.cache_clear()
_invalidate_model_cost_lowercase_map()
assert is_gemini_tts_model(model, custom_llm_provider="vertex_ai")
assert not is_gemini_tts_model("lyria-3-clip-preview", custom_llm_provider="vertex_ai")
@pytest.mark.parametrize(
"model",
["gemini-2.5-flash-preview-tts", "gemini-2.5-flash-tts", "gemini-2.5-pro-tts", "gemini-2.5-pro-preview-tts"],
)
@pytest.mark.parametrize("prefixed", [False, True])
def test_existing_vertex_tts_models_keep_gemini_dispatch(model: str, prefixed: bool, monkeypatch):
model_cost = _load_model_cost_map(Path(__file__).parents[2] / "model_prices_and_context_window.json")
monkeypatch.setattr(litellm, "model_cost", model_cost)
litellm.get_model_info.cache_clear()
_invalidate_model_cost_lowercase_map()
requested_model = f"vertex_ai/{model}" if prefixed else model
assert is_gemini_tts_model(requested_model, custom_llm_provider="vertex_ai")
def test_existing_gemini_pro_preview_tts_keeps_speech_dispatch() -> None:
assert is_gemini_tts_model("gemini/gemini-2.5-pro-preview-tts", custom_llm_provider="gemini")
def test_gemini_music_model_does_not_use_speech_dispatch() -> None:
assert not is_gemini_tts_model("gemini/lyria-3-clip-preview", custom_llm_provider="gemini")

View file

@ -1499,9 +1499,9 @@
}
},
"node_modules/@img/sharp-darwin-arm64": {
"version": "0.35.4",
"resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.35.4.tgz",
"integrity": "sha512-Uhfl4V4lhP2nbUVF9+hyH1+luj86f1gUFeo8ALYxFoULoU+G87D43BfeMP8XHsk9boxAnCY/bf2EHwhA7MuGsA==",
"version": "0.35.5",
"resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.35.5.tgz",
"integrity": "sha512-QRUlFQ0WxvdWyqqG/WtI3iupfD5rBzmCHXSdPsY91sAtVtTo7Q4cb6zOccZ3gqEqkr0f1As1ehLqmEpDsRf+lg==",
"cpu": [
"arm64"
],
@ -1517,13 +1517,13 @@
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-darwin-arm64": "1.3.3"
"@img/sharp-libvips-darwin-arm64": "1.3.4"
}
},
"node_modules/@img/sharp-darwin-x64": {
"version": "0.35.4",
"resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.35.4.tgz",
"integrity": "sha512-hWniXY3bG5qKpkKrAwPe4y+VTPmf086YQAnkxWh7uA1YrlRouWGa0M0Mxj3ZjnXFkv7/TD1bTy9lGUK26vRvWw==",
"version": "0.35.5",
"resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.35.5.tgz",
"integrity": "sha512-+BR255RhDlpygUpOc/Jdt1nT6DQ3XG/ERo5wbcdOf5Q320dKtPCKPLR1LJs9VGXRaMa8l1uUa0tkCNOXiAxZUw==",
"cpu": [
"x64"
],
@ -1539,20 +1539,20 @@
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-darwin-x64": "1.3.3"
"@img/sharp-libvips-darwin-x64": "1.3.4"
}
},
"node_modules/@img/sharp-freebsd-wasm32": {
"version": "0.35.4",
"resolved": "https://registry.npmjs.org/@img/sharp-freebsd-wasm32/-/sharp-freebsd-wasm32-0.35.4.tgz",
"integrity": "sha512-lIsKw/BU+kjB4eZjxrYrZmwOJYi3Ajrv66iAlBmUPyKc3HpnloevB1g3wxGD9P/5BbQ1brBGl65VRRrCvQDEqA==",
"version": "0.35.5",
"resolved": "https://registry.npmjs.org/@img/sharp-freebsd-wasm32/-/sharp-freebsd-wasm32-0.35.5.tgz",
"integrity": "sha512-Y/z91nEZ4uIBX5X3nfTovjU9lHNKFYbL2lpHCLVNmXQK03VIZvXBBt0KxbPGp2SdGSF+2mQU4e+hQaWOt86iAw==",
"license": "Apache-2.0",
"optional": true,
"os": [
"freebsd"
],
"dependencies": {
"@img/sharp-wasm32": "0.35.4"
"@img/sharp-wasm32": "0.35.5"
},
"engines": {
"node": ">=20.9.0"
@ -1562,9 +1562,9 @@
}
},
"node_modules/@img/sharp-libvips-darwin-arm64": {
"version": "1.3.3",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.3.3.tgz",
"integrity": "sha512-suTBPTDGrI9WodccaDdwZItTSaBYASlBk1NSfElSHrUfzu3szG6lvIF58+WiFvnfzuK8ZBFS5zE00PxqxnRiPg==",
"version": "1.3.4",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.3.4.tgz",
"integrity": "sha512-5R89nBYiRdUlSWJxPhO+GVtaXzXSxKnRu/xqMn3KTA3L9EB9Oy/P+Nn2f2vlhPuUdy/Zusb2DarbyTpGCfEDuw==",
"cpu": [
"arm64"
],
@ -1578,9 +1578,9 @@
}
},
"node_modules/@img/sharp-libvips-darwin-x64": {
"version": "1.3.3",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.3.3.tgz",
"integrity": "sha512-FVJZ5mITMobmXIz/hPDTw0EintTW5H3WfrxwLqEqjiIihlu+hVRyGrFQ60xl0Lxn7Bt3zdpevPaQi0HEzqz9fw==",
"version": "1.3.4",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.3.4.tgz",
"integrity": "sha512-iR2OKH80yi0U+dUplyh3/xdpFvps6YkCwsXenIJxqxR1v9o+xtKTGbS9H7cps+2Vxjc8B1j96p75NmTGjIhtpQ==",
"cpu": [
"x64"
],
@ -1594,9 +1594,9 @@
}
},
"node_modules/@img/sharp-libvips-linux-arm": {
"version": "1.3.3",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.3.3.tgz",
"integrity": "sha512-3rbU4vqXXc3hY/OiXdl52xZvT0F1yEngWfvqudtPJg/KkyiaQw2DRsFrNzpmLvfavbwOq3qXn36GP8obHRULQA==",
"version": "1.3.4",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.3.4.tgz",
"integrity": "sha512-LmRtTsOHuvM2+wlO2Db37dx5MiZhB0FvSunciw48YjdOkZz9KAiRbm8ujeMOA1INqmei5NapFxYEK1D1ZSidmw==",
"cpu": [
"arm"
],
@ -1613,9 +1613,9 @@
}
},
"node_modules/@img/sharp-libvips-linux-arm64": {
"version": "1.3.3",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.3.3.tgz",
"integrity": "sha512-0DaL0A6Xu6sQSQFwe4iVCrKWU2cCTItnRsYsCdxAMm9NF6twAA9BKnoqy4hqz4+azQ0JHuA26qiUKsf1XJ/v5A==",
"version": "1.3.4",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.3.4.tgz",
"integrity": "sha512-Y3dgX/6lE2QhQb+Gxy0WZxfg9MEm/JBjamZpS2IklP7xIQoKN4hzAm7KcMVGtaVDt3neE9OKBC7vAfonA/Lr1A==",
"cpu": [
"arm64"
],
@ -1632,9 +1632,9 @@
}
},
"node_modules/@img/sharp-libvips-linux-ppc64": {
"version": "1.3.3",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.3.3.tgz",
"integrity": "sha512-cdn1OvUBwsXhbC0zSzJnNzf5MZ/mTrobawDvNXBTxe8VtqKAm0sRuEY2Evzovb/w9JMk4TvRxqt1mekSuJz64w==",
"version": "1.3.4",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.3.4.tgz",
"integrity": "sha512-Le6boB8Tai0Nis+gIxIpKx68UDVVIqdR8Tin5Yf1z2LJJQLDJvCDRqRu+jC2qCoD+eIomonmOwB4smBRxfVpYQ==",
"cpu": [
"ppc64"
],
@ -1651,9 +1651,9 @@
}
},
"node_modules/@img/sharp-libvips-linux-riscv64": {
"version": "1.3.3",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.3.3.tgz",
"integrity": "sha512-HjPVx7yKz+0lqdhDlTw1tt90wamBoxhiXpvl1XZpJLiHH4RCJ5yDTqH+VlYPv2fwFs89JFw4c1IexYOcQUi4IQ==",
"version": "1.3.4",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.3.4.tgz",
"integrity": "sha512-aHkkIEHPRdQEegJN20MLmGtxYD9R2wQr3Cwpddnu5+YKMt6Uzax7S9h5gpZTo8wyrGuZSlfQ63OevL5mTyOC7Q==",
"cpu": [
"riscv64"
],
@ -1670,9 +1670,9 @@
}
},
"node_modules/@img/sharp-libvips-linux-s390x": {
"version": "1.3.3",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.3.3.tgz",
"integrity": "sha512-neWLh+3yCNThxnfy3c4BbVBeGgt9aftno+XbT56iK28RgeDs3UOFWviLWlUu0bArYVYJaFDK+RRohbicUNCm8Q==",
"version": "1.3.4",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.3.4.tgz",
"integrity": "sha512-ra/mB6MikESDUO7Yg+Mi95bFBb9GsObURuhnOv3OqknjGe9sZrG8tCe9q0xSIGrtLgvgw0gKnFWcK4blSgQOuQ==",
"cpu": [
"s390x"
],
@ -1689,9 +1689,9 @@
}
},
"node_modules/@img/sharp-libvips-linux-x64": {
"version": "1.3.3",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.3.3.tgz",
"integrity": "sha512-4vKmvAst9nrowcqquKFAyZJUDolUaIp8uRiN0mWFguJ1IplC9/pitXtlnnlU4aa/eJw3J7i67V+pwUL+wZGdsA==",
"version": "1.3.4",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.3.4.tgz",
"integrity": "sha512-GJ//SSXbnwSDes02umB3nDJLFcQzw8a18V8fyhqr6tV515tOEMdImjjxj1AoafMRz56F3PHgftnj1QEKSU1zkw==",
"cpu": [
"x64"
],
@ -1708,9 +1708,9 @@
}
},
"node_modules/@img/sharp-libvips-linuxmusl-arm64": {
"version": "1.3.3",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.3.3.tgz",
"integrity": "sha512-Y9kQaLMuNoB0bPYOOdcZMaseNrFpPodIWWMrx+CZyydf2xn68j9WYc6sWWRrDwNkzCQjKYfc68L7jKjGlHMibw==",
"version": "1.3.4",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.3.4.tgz",
"integrity": "sha512-hvulFwtjUcagsis6BBxHwGFwWoNZjgYmULGVrZcyfNbjA8hKILbRxGg15/7w5HDyXHXUos/j6baAWqnCyQ2DWA==",
"cpu": [
"arm64"
],
@ -1727,9 +1727,9 @@
}
},
"node_modules/@img/sharp-libvips-linuxmusl-x64": {
"version": "1.3.3",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.3.3.tgz",
"integrity": "sha512-fj8Mv0HHfD1Rr+4I68+3agJynxDWtBFgicTbSOb9Bke6pIwzGcJ+RX/yHjmiEGFMCavY/dxvem7MyNaJF+wDiw==",
"version": "1.3.4",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.3.4.tgz",
"integrity": "sha512-6zXKeE/p39I1AmA3cJG35eyBGNqNddLnUXjhwBnsGjFPWqf5VKkDBEqaEkPDoTEtkxwi2vv8Tcr2mDyP4So7Fg==",
"cpu": [
"x64"
],
@ -1746,9 +1746,9 @@
}
},
"node_modules/@img/sharp-linux-arm": {
"version": "0.35.4",
"resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.35.4.tgz",
"integrity": "sha512-7OAS8gI0EReKGVN2HssHlM6umJgxF5VI3xN0p9FA91p/YO+ou5hiNghLdZ5BEHztwaaK5+bLKRf8x/o2L2nk9A==",
"version": "0.35.5",
"resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.35.5.tgz",
"integrity": "sha512-LEaXK2WdXVK5ykcw0buWyPMsmLLL2vpHLD6yrNSW+JGEL3BZPA4tpKN6iaMc4AxTTAoaX/sU1rOL51lcIz48ZQ==",
"cpu": [
"arm"
],
@ -1767,13 +1767,13 @@
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linux-arm": "1.3.3"
"@img/sharp-libvips-linux-arm": "1.3.4"
}
},
"node_modules/@img/sharp-linux-arm64": {
"version": "0.35.4",
"resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.35.4.tgz",
"integrity": "sha512-De4jpEnAU8Hd5oT0j1G3uL4ZvTuipVMn7YC6vPaJhy6/7EwEae0SVAoBrUMYQbkLGDm85taVWwuPc1a44LTzCQ==",
"version": "0.35.5",
"resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.35.5.tgz",
"integrity": "sha512-LYVx5JTsOM2CBzmxreh+nl64/3H6Xb09iSLknqH47z2T2DFFxDeFLP5y4dJwe6H7uGQlHPyEEtIqyo3DYsRwdQ==",
"cpu": [
"arm64"
],
@ -1792,13 +1792,13 @@
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linux-arm64": "1.3.3"
"@img/sharp-libvips-linux-arm64": "1.3.4"
}
},
"node_modules/@img/sharp-linux-ppc64": {
"version": "0.35.4",
"resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.35.4.tgz",
"integrity": "sha512-2oYZJeIl4kCcMGk4ouZVjnkCtFrpQFlNEtJ6GbxzhHQchwH0NH/qEb9ykmOl29dqwMq+JhFdZn+1ak2FKhI9fQ==",
"version": "0.35.5",
"resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.35.5.tgz",
"integrity": "sha512-QVxAAq8evVRI9ia2vqgwrmWucn5Dfv+JdWzj75pD8omHLPSP7f8p20O8jxzjCcuCEQEOtYOZUmX1hkiZ0kdevA==",
"cpu": [
"ppc64"
],
@ -1817,13 +1817,13 @@
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linux-ppc64": "1.3.3"
"@img/sharp-libvips-linux-ppc64": "1.3.4"
}
},
"node_modules/@img/sharp-linux-riscv64": {
"version": "0.35.4",
"resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.35.4.tgz",
"integrity": "sha512-cPbNChoRURAWdebDIHSenxRpgEdy7JkPydSnUxRm9VvKD7m0/xVaR/8Fzlu81pk5nHEvHH87UZUA7cTtwnbJSA==",
"version": "0.35.5",
"resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.35.5.tgz",
"integrity": "sha512-LtdreXguaavKODPIfzJ4kffx7UNt1omwtK0rch4EBbbSTXPnxWmYSayXdLJw0fJzQ97kHt1gL/yh4tvU+nCyRQ==",
"cpu": [
"riscv64"
],
@ -1842,13 +1842,13 @@
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linux-riscv64": "1.3.3"
"@img/sharp-libvips-linux-riscv64": "1.3.4"
}
},
"node_modules/@img/sharp-linux-s390x": {
"version": "0.35.4",
"resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.35.4.tgz",
"integrity": "sha512-RY0JFY8Fd6RonCBtHz+DvadaPkXDSI1AUn6yWL9TipqkZ1vY8w8evqdgyDFnkm4/K1ve1TvZiaePP5oSd4+WVQ==",
"version": "0.35.5",
"resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.35.5.tgz",
"integrity": "sha512-UZasTOFiYzotTsGOCu42BfUzP6Tu6Do/947iRm1RsLKvlllxwGcn4RN27LibGWceix4Y+Pmw3jsnTcCQIgWjqA==",
"cpu": [
"s390x"
],
@ -1867,13 +1867,13 @@
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linux-s390x": "1.3.3"
"@img/sharp-libvips-linux-s390x": "1.3.4"
}
},
"node_modules/@img/sharp-linux-x64": {
"version": "0.35.4",
"resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.35.4.tgz",
"integrity": "sha512-9qvvEAuk8k89TfWUoX2htWjbAMX8p+NxCppjpcg5k6xMsjhBQPTsoIh36h9Qde4WRuGpJeYnOjdosDn/cnv+OA==",
"version": "0.35.5",
"resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.35.5.tgz",
"integrity": "sha512-SxFtLTeJInhAA9Q836kux2vZNeOBQEx658qvbboZScr0wIARym3IcGmW7KpVD5sbVg0Ojy+udFQdayYIZyoNog==",
"cpu": [
"x64"
],
@ -1892,13 +1892,13 @@
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linux-x64": "1.3.3"
"@img/sharp-libvips-linux-x64": "1.3.4"
}
},
"node_modules/@img/sharp-linuxmusl-arm64": {
"version": "0.35.4",
"resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.35.4.tgz",
"integrity": "sha512-KB5jxpfWQTr0nc3xdHtWChdbifHrBGsd2SM62Eyxrl8afikm+f5qGBU75SJIZBT/S1MC8XyacdlXBMSWq6OURA==",
"version": "0.35.5",
"resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.35.5.tgz",
"integrity": "sha512-9HbMclmI1zlNkFRs3z9/eBtDjfD0sGlrX1z6b1qwmiFY5ElDLh4BC0LPBdVp7z1DXFiKlIcznf+ZlsuZzLxQqg==",
"cpu": [
"arm64"
],
@ -1917,13 +1917,13 @@
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linuxmusl-arm64": "1.3.3"
"@img/sharp-libvips-linuxmusl-arm64": "1.3.4"
}
},
"node_modules/@img/sharp-linuxmusl-x64": {
"version": "0.35.4",
"resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.35.4.tgz",
"integrity": "sha512-f+eZJZIQNEEd26RPSW+76chwOf1XtA2Y/O+5ocVyLliHkeih3e+jhLVBdNTd2rS3IbNXK8+ug93Vf5ZXtF5Lxg==",
"version": "0.35.5",
"resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.35.5.tgz",
"integrity": "sha512-4KOphqB035HrVdqLZfCgMzzERrQkkzOwRhl4OAkRO1YCldbaFjySXMaK534Mo0V+LndnlJk+sbUyLeU0ULyD1A==",
"cpu": [
"x64"
],
@ -1942,13 +1942,13 @@
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linuxmusl-x64": "1.3.3"
"@img/sharp-libvips-linuxmusl-x64": "1.3.4"
}
},
"node_modules/@img/sharp-wasm32": {
"version": "0.35.4",
"resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.35.4.tgz",
"integrity": "sha512-zQnl4Kwp7Q6NHsENtU2T/00Zi+w3AQNwz3+UaTyVBy2FpXrzXzGjndpK61onhZjRtRpQXxCTeqw19bVyXOh7jA==",
"version": "0.35.5",
"resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.35.5.tgz",
"integrity": "sha512-Ptsga1su4tQx+LLF1ECS9U6nz5kmrXKo6XVbtR48Ke3ZRxxgaWBu7IDtEe1quo8hiupwm6WFqxVlXaSf7IINGQ==",
"license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT",
"optional": true,
"dependencies": {
@ -1962,16 +1962,16 @@
}
},
"node_modules/@img/sharp-webcontainers-wasm32": {
"version": "0.35.4",
"resolved": "https://registry.npmjs.org/@img/sharp-webcontainers-wasm32/-/sharp-webcontainers-wasm32-0.35.4.tgz",
"integrity": "sha512-ESfNkywmCfPNyaZjxooddJQiQ+l/nTpGEOGthxiLnIHXC/CmcBixnfwUleX9mCz9ovrUUvKMap/pm8RYbzfwaA==",
"version": "0.35.5",
"resolved": "https://registry.npmjs.org/@img/sharp-webcontainers-wasm32/-/sharp-webcontainers-wasm32-0.35.5.tgz",
"integrity": "sha512-hfhF/FmoQyTUkA0bIKFOtw536BQSeBMe6BF6QyWlrPxT754+TFLaZ7sKKTfvvM0yJgKgaYTwnFCIZ/GuDw5SUA==",
"cpu": [
"wasm32"
],
"license": "Apache-2.0",
"optional": true,
"dependencies": {
"@img/sharp-wasm32": "0.35.4"
"@img/sharp-wasm32": "0.35.5"
},
"engines": {
"node": ">=20.9.0"
@ -1981,9 +1981,9 @@
}
},
"node_modules/@img/sharp-win32-arm64": {
"version": "0.35.4",
"resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.35.4.tgz",
"integrity": "sha512-iNdlBX9gLVvqe2I3uIJSIKTq6wckP/DYxZtcqxm09x5Gi24DnFBmPAWZmr60ZyYMG0xlzo6goG3670ar+RXvRw==",
"version": "0.35.5",
"resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.35.5.tgz",
"integrity": "sha512-X4t7g+7ZA5DKblCBEXGjUqqemj4vczING/5viFwAL8h4N3qYeyjwdCvRLHi4EdOUI+2Z7UFlp1VM+p/AuEtm6Q==",
"cpu": [
"arm64"
],
@ -2000,9 +2000,9 @@
}
},
"node_modules/@img/sharp-win32-ia32": {
"version": "0.35.4",
"resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.35.4.tgz",
"integrity": "sha512-kqRsbaa5CS6KHlpxnN7WhE6vAAugXyZButpRdvDWetlv6Qv4N9WTcrWzF7tXfB9T7MsoadqdI8hmwLq6UlLvtw==",
"version": "0.35.5",
"resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.35.5.tgz",
"integrity": "sha512-5Zm82LoBc43nhwNybZlG7Y1KO//Zhsn306fQl29ZOuStHLGTo3BWL83q3cznX0poxSAMuYL1On/BHBxkBeKr6A==",
"cpu": [
"ia32"
],
@ -2019,9 +2019,9 @@
}
},
"node_modules/@img/sharp-win32-x64": {
"version": "0.35.4",
"resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.35.4.tgz",
"integrity": "sha512-XtmnYhBcrORsJ4XJngyzr/EWP0hRZLAZRFaApdKuviyqF78+ylxh2y06ZmtULAMOnObJ3ucpN0AcwSWnMowTRg==",
"version": "0.35.5",
"resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.35.5.tgz",
"integrity": "sha512-x76eH0vEiHlcMQu8Y8IenntaACtddpT6W0wmXtWrnKcnKI7ME5DdgqhAD6SEWOEl1v2zDvkZDhFA9KnURwpfqg==",
"cpu": [
"x64"
],
@ -11241,9 +11241,9 @@
}
},
"node_modules/sharp": {
"version": "0.35.4",
"resolved": "https://registry.npmjs.org/sharp/-/sharp-0.35.4.tgz",
"integrity": "sha512-n++8XWcj+jCOr2IOl7h8LbKnGBDY4aPbmprMONBNFdn0ImXqpGVv5zliDs0V9HbmbCQLpbuo2ej9rAoOQTvMDA==",
"version": "0.35.5",
"resolved": "https://registry.npmjs.org/sharp/-/sharp-0.35.5.tgz",
"integrity": "sha512-Ywn4OnzGukp7CDMrp08RQ50YKmuwG47brZgIVPTvBaaAfQlRlygrRqSrxdCiL9M+LlzLBiJ68IR1QqvzHyjC7g==",
"license": "Apache-2.0",
"optional": true,
"dependencies": {
@ -11258,31 +11258,31 @@
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-darwin-arm64": "0.35.4",
"@img/sharp-darwin-x64": "0.35.4",
"@img/sharp-freebsd-wasm32": "0.35.4",
"@img/sharp-libvips-darwin-arm64": "1.3.3",
"@img/sharp-libvips-darwin-x64": "1.3.3",
"@img/sharp-libvips-linux-arm": "1.3.3",
"@img/sharp-libvips-linux-arm64": "1.3.3",
"@img/sharp-libvips-linux-ppc64": "1.3.3",
"@img/sharp-libvips-linux-riscv64": "1.3.3",
"@img/sharp-libvips-linux-s390x": "1.3.3",
"@img/sharp-libvips-linux-x64": "1.3.3",
"@img/sharp-libvips-linuxmusl-arm64": "1.3.3",
"@img/sharp-libvips-linuxmusl-x64": "1.3.3",
"@img/sharp-linux-arm": "0.35.4",
"@img/sharp-linux-arm64": "0.35.4",
"@img/sharp-linux-ppc64": "0.35.4",
"@img/sharp-linux-riscv64": "0.35.4",
"@img/sharp-linux-s390x": "0.35.4",
"@img/sharp-linux-x64": "0.35.4",
"@img/sharp-linuxmusl-arm64": "0.35.4",
"@img/sharp-linuxmusl-x64": "0.35.4",
"@img/sharp-webcontainers-wasm32": "0.35.4",
"@img/sharp-win32-arm64": "0.35.4",
"@img/sharp-win32-ia32": "0.35.4",
"@img/sharp-win32-x64": "0.35.4"
"@img/sharp-darwin-arm64": "0.35.5",
"@img/sharp-darwin-x64": "0.35.5",
"@img/sharp-freebsd-wasm32": "0.35.5",
"@img/sharp-libvips-darwin-arm64": "1.3.4",
"@img/sharp-libvips-darwin-x64": "1.3.4",
"@img/sharp-libvips-linux-arm": "1.3.4",
"@img/sharp-libvips-linux-arm64": "1.3.4",
"@img/sharp-libvips-linux-ppc64": "1.3.4",
"@img/sharp-libvips-linux-riscv64": "1.3.4",
"@img/sharp-libvips-linux-s390x": "1.3.4",
"@img/sharp-libvips-linux-x64": "1.3.4",
"@img/sharp-libvips-linuxmusl-arm64": "1.3.4",
"@img/sharp-libvips-linuxmusl-x64": "1.3.4",
"@img/sharp-linux-arm": "0.35.5",
"@img/sharp-linux-arm64": "0.35.5",
"@img/sharp-linux-ppc64": "0.35.5",
"@img/sharp-linux-riscv64": "0.35.5",
"@img/sharp-linux-s390x": "0.35.5",
"@img/sharp-linux-x64": "0.35.5",
"@img/sharp-linuxmusl-arm64": "0.35.5",
"@img/sharp-linuxmusl-x64": "0.35.5",
"@img/sharp-webcontainers-wasm32": "0.35.5",
"@img/sharp-win32-arm64": "0.35.5",
"@img/sharp-win32-ia32": "0.35.5",
"@img/sharp-win32-x64": "0.35.5"
},
"peerDependenciesMeta": {
"@types/node": {

View file

@ -119,7 +119,7 @@
"axios": "1.13.6",
"postcss": "8.5.23",
"esbuild": "0.28.1",
"sharp": "^0.35.4"
"sharp": "^0.35.5"
},
"engines": {
"node": ">=24.14.1",