mirror of
https://github.com/BerriAI/litellm.git
synced 2026-10-12 03:48:56 +00:00
Merge d10acc96ec into 126e79c967
This commit is contained in:
commit
268ecfe89c
27 changed files with 2061 additions and 357 deletions
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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 {},
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
BIN
tests/audio_tests/gemini_tts_speech.mp3
Normal file
BIN
tests/audio_tests/gemini_tts_speech.mp3
Normal file
Binary file not shown.
BIN
tests/audio_tests/gemini_tts_speech.ogg
Normal file
BIN
tests/audio_tests/gemini_tts_speech.ogg
Normal file
Binary file not shown.
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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"):
|
||||
|
|
|
|||
|
|
@ -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)."""
|
||||
|
|
|
|||
96
tests/unit/test_gemini_3_1_flash_tts_metadata.py
Normal file
96
tests/unit/test_gemini_3_1_flash_tts_metadata.py
Normal 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")
|
||||
236
ui/litellm-dashboard/package-lock.json
generated
236
ui/litellm-dashboard/package-lock.json
generated
|
|
@ -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": {
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue