mirror of
https://github.com/BerriAI/litellm.git
synced 2026-10-27 06:13:07 +00:00
Merge 264651bc36 into 8d6b8dab3d
This commit is contained in:
commit
66fe76baa7
16 changed files with 704 additions and 0 deletions
1
.github/workflows/test-unit.yml
vendored
1
.github/workflows/test-unit.yml
vendored
|
|
@ -274,6 +274,7 @@ jobs:
|
|||
tests/unit/llms/google_pse
|
||||
tests/unit/llms/github_copilot
|
||||
tests/unit/llms/gradient_ai
|
||||
tests/unit/llms/gradium
|
||||
tests/unit/llms/groq
|
||||
tests/unit/llms/heroku
|
||||
tests/unit/llms/hosted_vllm
|
||||
|
|
|
|||
|
|
@ -673,6 +673,7 @@ nebius_embedding_models: Set = set()
|
|||
aiml_models: Set = set()
|
||||
deepgram_models: Set = set()
|
||||
elevenlabs_models: Set = set()
|
||||
gradium_models: set[str] = set() # mutable-ok: filled from the cost map at import time
|
||||
dashscope_models: Set = set()
|
||||
qwencloud_models: Set = set()
|
||||
qwen_ai_platform_models: Set = set()
|
||||
|
|
@ -948,6 +949,8 @@ def _populate_provider_model_sets(model_cost_map: Mapping[str, Mapping[str, obje
|
|||
deepgram_models.add(key)
|
||||
elif value.get("litellm_provider") == "elevenlabs":
|
||||
elevenlabs_models.add(key)
|
||||
elif value.get("litellm_provider") == "gradium":
|
||||
gradium_models.add(key)
|
||||
elif value.get("litellm_provider") == "heroku":
|
||||
heroku_models.add(key)
|
||||
elif value.get("litellm_provider") == "dashscope":
|
||||
|
|
@ -1128,6 +1131,7 @@ model_list = list(
|
|||
| nscale_models
|
||||
| deepgram_models
|
||||
| elevenlabs_models
|
||||
| gradium_models
|
||||
| dashscope_models
|
||||
| qwencloud_models
|
||||
| qwen_ai_platform_models
|
||||
|
|
@ -1243,6 +1247,7 @@ def _build_models_by_provider() -> dict:
|
|||
"featherless_ai": featherless_ai_models,
|
||||
"deepgram": deepgram_models,
|
||||
"elevenlabs": elevenlabs_models,
|
||||
"gradium": gradium_models,
|
||||
"heroku": heroku_models,
|
||||
"dashscope": dashscope_models,
|
||||
"qwencloud": qwencloud_models,
|
||||
|
|
|
|||
0
litellm/llms/gradium/__init__.py
Normal file
0
litellm/llms/gradium/__init__.py
Normal file
5
litellm/llms/gradium/common_utils.py
Normal file
5
litellm/llms/gradium/common_utils.py
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
from litellm.llms.base_llm.chat.transformation import BaseLLMException
|
||||
|
||||
|
||||
class GradiumException(BaseLLMException):
|
||||
pass
|
||||
0
litellm/llms/gradium/text_to_speech/__init__.py
Normal file
0
litellm/llms/gradium/text_to_speech/__init__.py
Normal file
220
litellm/llms/gradium/text_to_speech/transformation.py
Normal file
220
litellm/llms/gradium/text_to_speech/transformation.py
Normal file
|
|
@ -0,0 +1,220 @@
|
|||
"""
|
||||
Gradium Text-to-Speech transformation
|
||||
|
||||
Maps OpenAI TTS spec to Gradium's REST TTS API.
|
||||
|
||||
Reference: https://docs.gradium.ai/api-reference/endpoint/tts-post
|
||||
"""
|
||||
|
||||
from collections.abc import Mapping
|
||||
from typing import TYPE_CHECKING, ClassVar, Final, TypeAlias
|
||||
|
||||
import httpx
|
||||
from httpx import Headers
|
||||
|
||||
import litellm
|
||||
from litellm.llms.base_llm.chat.transformation import BaseLLMException
|
||||
from litellm.llms.base_llm.text_to_speech.transformation import (
|
||||
BaseTextToSpeechConfig,
|
||||
TextToSpeechRequestData,
|
||||
)
|
||||
|
||||
from ..common_utils import GradiumException
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from litellm.litellm_core_utils.litellm_logging import Logging as LiteLLMLoggingObj
|
||||
from litellm.types.llms.openai import HttpxBinaryResponseContent
|
||||
else:
|
||||
LiteLLMLoggingObj: TypeAlias = object
|
||||
HttpxBinaryResponseContent: TypeAlias = object
|
||||
|
||||
|
||||
class GradiumTextToSpeechConfig(BaseTextToSpeechConfig):
|
||||
"""
|
||||
Configuration for Gradium Text-to-Speech
|
||||
|
||||
Reference: https://docs.gradium.ai/api-reference/endpoint/tts-post
|
||||
"""
|
||||
|
||||
TTS_ENDPOINT_PATH = "/api/post/speech/tts"
|
||||
DEFAULT_OUTPUT_FORMAT = "wav"
|
||||
|
||||
# OpenAI canonical voice names → Gradium library voice IDs. Anything not
|
||||
# listed is forwarded verbatim, so callers can pass raw Gradium voice IDs.
|
||||
VOICE_MAPPINGS: ClassVar[Mapping[str, str]] = {
|
||||
"alloy": "YTpq7expH9539ERJ",
|
||||
"echo": "s0PhgjzOTRD5wo5L",
|
||||
"fable": "1VAVLmmbQFDw7TMn",
|
||||
"onyx": "QkmUhBH4hIV2_BkY",
|
||||
"nova": "w9V1722uEmTkWqnR",
|
||||
"shimmer": "BbLb4TxdlrldgpHI",
|
||||
}
|
||||
|
||||
# Gradium serves raw PCM and Ogg-wrapped Opus, plus WAV. It has no mp3,
|
||||
# aac or flac encoder, so those OpenAI formats have no mapping.
|
||||
FORMAT_MAPPINGS: ClassVar[Mapping[str, str]] = {
|
||||
"wav": "wav",
|
||||
"pcm": "pcm_24000",
|
||||
"opus": "opus",
|
||||
}
|
||||
|
||||
def get_supported_openai_params(self, model: str) -> list[str]: # mutable-ok: BaseTextToSpeechConfig declares list
|
||||
return ["voice", "response_format"]
|
||||
|
||||
def _resolve_voice_id(
|
||||
self,
|
||||
voice: str | dict[str, object] | None, # mutable-ok: mirrors the dict|str the base class passes through
|
||||
params: Mapping[str, object],
|
||||
) -> str:
|
||||
"""Normalize the caller's `voice` into a Gradium voice_id."""
|
||||
sources: Final = (
|
||||
(voice,)
|
||||
if isinstance(voice, str)
|
||||
else ((voice.get("voice_id"), voice.get("id"), voice.get("name")) if isinstance(voice, dict) else ())
|
||||
)
|
||||
candidate: Final = next(
|
||||
(value.strip() for value in (*sources, params.get("voice_id")) if isinstance(value, str) and value.strip()),
|
||||
None,
|
||||
)
|
||||
|
||||
if candidate is None:
|
||||
raise ValueError("Gradium voice_id is required. Pass `voice` when calling `litellm.speech()`.")
|
||||
|
||||
return self.VOICE_MAPPINGS.get(candidate.lower(), candidate)
|
||||
|
||||
def map_openai_params(
|
||||
self,
|
||||
model: str,
|
||||
optional_params: dict[str, object], # mutable-ok: signature fixed by BaseTextToSpeechConfig
|
||||
voice: str | dict[str, object] | None = None, # mutable-ok: signature fixed by BaseTextToSpeechConfig
|
||||
drop_params: bool = False,
|
||||
kwargs: dict[str, object] | None = None, # mutable-ok: signature fixed by BaseTextToSpeechConfig
|
||||
) -> tuple[str | None, dict[str, object]]: # mutable-ok: callers mutate the returned params downstream
|
||||
params: Final = dict(optional_params) if optional_params else {}
|
||||
mapped_voice: Final = self._resolve_voice_id(voice, params)
|
||||
|
||||
response_format: Final = params.pop("response_format", None)
|
||||
output_format: Final = self.FORMAT_MAPPINGS.get(response_format) if isinstance(response_format, str) else None
|
||||
|
||||
if isinstance(response_format, str) and output_format is None and not (drop_params or litellm.drop_params):
|
||||
raise litellm.UnsupportedParamsError(
|
||||
status_code=400,
|
||||
message=(
|
||||
f"Gradium TTS does not support response_format={response_format!r}. "
|
||||
f"Supported: {sorted(self.FORMAT_MAPPINGS)}. "
|
||||
"Set `litellm.drop_params = True` to fall back to the default format."
|
||||
),
|
||||
)
|
||||
|
||||
# Gradium synthesizes at the voice's native rate; there is no speed
|
||||
# control and no instructions field on the REST endpoint.
|
||||
passthrough: Final = {
|
||||
key: value
|
||||
for key, value in params.items()
|
||||
if value is not None and key not in ("speed", "instructions", "voice_id")
|
||||
}
|
||||
mapped_params: Final[dict[str, object]] = ( # mutable-ok: the declared return type is dict
|
||||
{**passthrough, "output_format": output_format} if output_format is not None else dict(passthrough)
|
||||
)
|
||||
|
||||
return mapped_voice, mapped_params
|
||||
|
||||
def validate_environment(
|
||||
self,
|
||||
headers: dict[str, str], # mutable-ok: signature fixed by BaseTextToSpeechConfig
|
||||
model: str,
|
||||
api_key: str | None = None,
|
||||
api_base: str | None = None,
|
||||
) -> dict[str, str]: # mutable-ok: signature fixed by BaseTextToSpeechConfig
|
||||
if not api_key:
|
||||
raise ValueError("Gradium API key is required. Set `api_key` on the model.")
|
||||
|
||||
return {
|
||||
**headers,
|
||||
"x-api-key": api_key,
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
|
||||
def get_error_class(
|
||||
self,
|
||||
error_message: str,
|
||||
status_code: int,
|
||||
headers: dict[str, str] | Headers, # mutable-ok: signature fixed by BaseTextToSpeechConfig
|
||||
) -> BaseLLMException:
|
||||
return GradiumException(message=error_message, status_code=status_code, headers=headers)
|
||||
|
||||
def get_complete_url(
|
||||
self,
|
||||
model: str,
|
||||
api_base: str | None,
|
||||
litellm_params: dict[str, object], # mutable-ok: signature fixed by BaseTextToSpeechConfig
|
||||
) -> str:
|
||||
if not api_base:
|
||||
raise ValueError(
|
||||
"Gradium API base is required. Set `api_base` on the model, e.g. https://eu.api.gradium.ai."
|
||||
)
|
||||
|
||||
base_url: Final = api_base.rstrip("/")
|
||||
# Gradium documents its regional bases with the /api suffix already on
|
||||
# them (https://eu.api.gradium.ai/api), so appending the full endpoint
|
||||
# path to one would request /api/api/post/speech/tts.
|
||||
root: Final = base_url.removesuffix("/api")
|
||||
return f"{root}{self.TTS_ENDPOINT_PATH}"
|
||||
|
||||
def transform_text_to_speech_request(
|
||||
self,
|
||||
model: str,
|
||||
input: str,
|
||||
voice: str | None,
|
||||
optional_params: dict[str, object], # mutable-ok: signature fixed by BaseTextToSpeechConfig
|
||||
litellm_params: dict[str, object], # mutable-ok: signature fixed by BaseTextToSpeechConfig
|
||||
headers: dict[str, str], # mutable-ok: signature fixed by BaseTextToSpeechConfig
|
||||
) -> TextToSpeechRequestData:
|
||||
params: Final = dict(optional_params) if optional_params else {}
|
||||
extra_body: Final = params.pop("extra_body", None)
|
||||
passthrough: Final = {key: value for key, value in params.items() if value is not None}
|
||||
model_name: Final = {"model_name": model} if model and model != "default" else {}
|
||||
|
||||
request_body: Final[dict[str, object]] = { # mutable-ok: TextToSpeechRequestData.dict_body is dict
|
||||
"text": input,
|
||||
"voice_id": voice,
|
||||
"output_format": params.get("output_format", self.DEFAULT_OUTPUT_FORMAT),
|
||||
# Without this Gradium answers a line-delimited JSON message
|
||||
# stream instead of audio bytes, which `/v1/audio/speech` callers
|
||||
# cannot read.
|
||||
"only_audio": True,
|
||||
**model_name,
|
||||
**passthrough,
|
||||
}
|
||||
|
||||
# Written in a loop rather than a comprehension: `extra_body` is typed
|
||||
# `object` here, and isinstance only narrows it to dict[Unknown, Unknown].
|
||||
if isinstance(extra_body, dict):
|
||||
for key, value in extra_body.items(): # pyright: ignore[reportUnknownVariableType] # isinstance narrows a bare dict only to dict[Unknown, Unknown]
|
||||
if value is not None:
|
||||
request_body[str(key)] = value # pyright: ignore[reportUnknownArgumentType] # same bare-dict narrowing
|
||||
|
||||
return TextToSpeechRequestData(
|
||||
dict_body=request_body,
|
||||
headers={"Content-Type": "application/json"},
|
||||
)
|
||||
|
||||
def transform_text_to_speech_response(
|
||||
self,
|
||||
model: str,
|
||||
raw_response: httpx.Response,
|
||||
logging_obj: LiteLLMLoggingObj,
|
||||
) -> "HttpxBinaryResponseContent":
|
||||
from litellm.types.llms.openai import HttpxBinaryResponseContent
|
||||
|
||||
# Gradium reports pre-stream failures as HTTP 500 with a plain-text
|
||||
# body ("error from server <code>: <reason>"), so the status alone
|
||||
# would surface as an opaque binary payload.
|
||||
if raw_response.status_code >= 400:
|
||||
raise GradiumException(
|
||||
message=raw_response.text,
|
||||
status_code=raw_response.status_code,
|
||||
headers=raw_response.headers,
|
||||
)
|
||||
|
||||
return HttpxBinaryResponseContent(raw_response)
|
||||
|
|
@ -8838,6 +8838,45 @@ def speech(
|
|||
client=client,
|
||||
_is_async=aspeech or False,
|
||||
)
|
||||
elif custom_llm_provider == "gradium":
|
||||
from litellm.llms.gradium.text_to_speech.transformation import (
|
||||
GradiumTextToSpeechConfig,
|
||||
)
|
||||
|
||||
if text_to_speech_provider_config is None:
|
||||
text_to_speech_provider_config = GradiumTextToSpeechConfig() # rebind-ok: set per provider branch
|
||||
|
||||
gradium_config: Final = cast( # cast-ok: narrowed to the config the branch above just selected
|
||||
GradiumTextToSpeechConfig, text_to_speech_provider_config
|
||||
)
|
||||
|
||||
gradium_voice_id: Final = voice.strip() if isinstance(voice, str) else ""
|
||||
if not gradium_voice_id:
|
||||
raise litellm.BadRequestError(
|
||||
message="'voice' must resolve to a Gradium voice id for Gradium TTS",
|
||||
model=model,
|
||||
llm_provider=custom_llm_provider,
|
||||
)
|
||||
|
||||
if api_base is not None:
|
||||
litellm_params_dict["api_base"] = api_base
|
||||
if api_key is not None:
|
||||
litellm_params_dict["api_key"] = api_key
|
||||
|
||||
response = base_llm_http_handler.text_to_speech_handler( # rebind-ok: set per provider branch # pyright: ignore[reportUnknownMemberType] # untyped shared TTS handler, as for every provider branch
|
||||
model=model,
|
||||
input=input,
|
||||
voice=gradium_voice_id,
|
||||
text_to_speech_provider_config=gradium_config,
|
||||
text_to_speech_optional_params=optional_params,
|
||||
custom_llm_provider=custom_llm_provider,
|
||||
litellm_params=litellm_params_dict,
|
||||
logging_obj=logging_obj,
|
||||
timeout=http_timeout,
|
||||
extra_headers=extra_headers, # pyright: ignore[reportUnknownArgumentType] # speech() takes these untyped
|
||||
client=client, # pyright: ignore[reportUnknownArgumentType] # speech() takes these untyped
|
||||
_is_async=aspeech or False,
|
||||
)
|
||||
elif custom_llm_provider == "vertex_ai" or custom_llm_provider == "vertex_ai_beta":
|
||||
from litellm.llms.vertex_ai.text_to_speech.transformation import (
|
||||
VertexAILyriaTextToSpeechConfig,
|
||||
|
|
|
|||
|
|
@ -39113,6 +39113,34 @@
|
|||
"max_input_tokens": 200000,
|
||||
"max_output_tokens": 100000
|
||||
},
|
||||
"gradium/default": {
|
||||
"input_cost_per_character": 6.9e-05,
|
||||
"litellm_provider": "gradium",
|
||||
"metadata": {
|
||||
"calculation": "1 credit per character synthesized; $6.9 per 100k add-on credits (XS plan) = $0.000069/char. Larger plans are cheaper (down to $3.8/100k on L) -- override input_cost_per_character for your plan.",
|
||||
"notes": "Gradium TTS bills per character of input text, not per second of audio."
|
||||
},
|
||||
"mode": "audio_speech",
|
||||
"output_cost_per_character": 0.0,
|
||||
"source": "https://gradium.ai/pricing",
|
||||
"supported_endpoints": [
|
||||
"/v1/audio/speech"
|
||||
]
|
||||
},
|
||||
"gradium/pro-voice-clone": {
|
||||
"input_cost_per_character": 8.28e-05,
|
||||
"litellm_provider": "gradium",
|
||||
"metadata": {
|
||||
"calculation": "Pro Voice Clones bill 1.2 credits per character: 1.2 x $0.000069 = $0.0000828/char. Excludes the one-time 1M-credit training fee.",
|
||||
"notes": "Gradium TTS using a Pro Voice Clone voice."
|
||||
},
|
||||
"mode": "audio_speech",
|
||||
"output_cost_per_character": 0.0,
|
||||
"source": "https://gradium.ai/pricing",
|
||||
"supported_endpoints": [
|
||||
"/v1/audio/speech"
|
||||
]
|
||||
},
|
||||
"lemonade/Qwen3-Coder-30B-A3B-Instruct-GGUF": {
|
||||
"input_cost_per_token": 0,
|
||||
"litellm_provider": "lemonade",
|
||||
|
|
|
|||
|
|
@ -1801,6 +1801,24 @@
|
|||
],
|
||||
"default_model_placeholder": "gpt-3.5-turbo"
|
||||
},
|
||||
{
|
||||
"provider": "Gradium",
|
||||
"provider_display_name": "Gradium",
|
||||
"litellm_provider": "gradium",
|
||||
"credential_fields": [
|
||||
{
|
||||
"key": "api_key",
|
||||
"label": "API Key",
|
||||
"placeholder": null,
|
||||
"tooltip": null,
|
||||
"required": true,
|
||||
"field_type": "password",
|
||||
"options": null,
|
||||
"default_value": null
|
||||
}
|
||||
],
|
||||
"default_model_placeholder": "gradium/default"
|
||||
},
|
||||
{
|
||||
"provider": "Groq",
|
||||
"provider_display_name": "Groq",
|
||||
|
|
|
|||
|
|
@ -4489,6 +4489,7 @@ class LlmProviders(str, Enum):
|
|||
INFINITY = "infinity"
|
||||
DEEPGRAM = "deepgram"
|
||||
ELEVENLABS = "elevenlabs"
|
||||
GRADIUM = "gradium"
|
||||
NOVITA = "novita"
|
||||
AIOHTTP_OPENAI = "aiohttp_openai"
|
||||
LANGFUSE = "langfuse"
|
||||
|
|
|
|||
|
|
@ -10749,6 +10749,12 @@ class ProviderConfigManager:
|
|||
)
|
||||
|
||||
return ElevenLabsTextToSpeechConfig()
|
||||
elif litellm.LlmProviders.GRADIUM == provider:
|
||||
from litellm.llms.gradium.text_to_speech.transformation import (
|
||||
GradiumTextToSpeechConfig,
|
||||
)
|
||||
|
||||
return GradiumTextToSpeechConfig()
|
||||
elif litellm.LlmProviders.RUNWAYML == provider:
|
||||
from litellm.llms.runwayml.text_to_speech.transformation import (
|
||||
RunwayMLTextToSpeechConfig,
|
||||
|
|
|
|||
|
|
@ -39113,6 +39113,34 @@
|
|||
"max_input_tokens": 200000,
|
||||
"max_output_tokens": 100000
|
||||
},
|
||||
"gradium/default": {
|
||||
"input_cost_per_character": 6.9e-05,
|
||||
"litellm_provider": "gradium",
|
||||
"metadata": {
|
||||
"calculation": "1 credit per character synthesized; $6.9 per 100k add-on credits (XS plan) = $0.000069/char. Larger plans are cheaper (down to $3.8/100k on L) -- override input_cost_per_character for your plan.",
|
||||
"notes": "Gradium TTS bills per character of input text, not per second of audio."
|
||||
},
|
||||
"mode": "audio_speech",
|
||||
"output_cost_per_character": 0.0,
|
||||
"source": "https://gradium.ai/pricing",
|
||||
"supported_endpoints": [
|
||||
"/v1/audio/speech"
|
||||
]
|
||||
},
|
||||
"gradium/pro-voice-clone": {
|
||||
"input_cost_per_character": 8.28e-05,
|
||||
"litellm_provider": "gradium",
|
||||
"metadata": {
|
||||
"calculation": "Pro Voice Clones bill 1.2 credits per character: 1.2 x $0.000069 = $0.0000828/char. Excludes the one-time 1M-credit training fee.",
|
||||
"notes": "Gradium TTS using a Pro Voice Clone voice."
|
||||
},
|
||||
"mode": "audio_speech",
|
||||
"output_cost_per_character": 0.0,
|
||||
"source": "https://gradium.ai/pricing",
|
||||
"supported_endpoints": [
|
||||
"/v1/audio/speech"
|
||||
]
|
||||
},
|
||||
"lemonade/Qwen3-Coder-30B-A3B-Instruct-GGUF": {
|
||||
"input_cost_per_token": 0,
|
||||
"litellm_provider": "lemonade",
|
||||
|
|
|
|||
|
|
@ -1340,6 +1340,22 @@
|
|||
"interactions": true
|
||||
}
|
||||
},
|
||||
"gradium": {
|
||||
"display_name": "Gradium (`gradium`)",
|
||||
"url": "https://docs.litellm.ai/docs/providers/gradium",
|
||||
"endpoints": {
|
||||
"chat_completions": false,
|
||||
"messages": false,
|
||||
"responses": false,
|
||||
"embeddings": false,
|
||||
"image_generations": false,
|
||||
"audio_transcriptions": false,
|
||||
"audio_speech": true,
|
||||
"moderations": false,
|
||||
"batches": false,
|
||||
"rerank": false
|
||||
}
|
||||
},
|
||||
"groq": {
|
||||
"display_name": "Groq AI (`groq`)",
|
||||
"url": "https://docs.litellm.ai/docs/providers/groq",
|
||||
|
|
|
|||
0
tests/unit/llms/gradium/__init__.py
Normal file
0
tests/unit/llms/gradium/__init__.py
Normal file
0
tests/unit/llms/gradium/text_to_speech/__init__.py
Normal file
0
tests/unit/llms/gradium/text_to_speech/__init__.py
Normal file
337
tests/unit/llms/gradium/text_to_speech/test_transformation.py
Normal file
337
tests/unit/llms/gradium/text_to_speech/test_transformation.py
Normal file
|
|
@ -0,0 +1,337 @@
|
|||
import json
|
||||
from typing import Final
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
import respx
|
||||
|
||||
import litellm
|
||||
from litellm.llms.gradium.common_utils import GradiumException
|
||||
from litellm.llms.gradium.text_to_speech.transformation import (
|
||||
GradiumTextToSpeechConfig,
|
||||
)
|
||||
|
||||
_AUDIO: Final = b"RIFFgradium-audio"
|
||||
_API_BASE: Final = "https://eu.api.gradium.ai"
|
||||
_API_KEY: Final = "gd_test_key"
|
||||
_URL: Final = f"{_API_BASE}/api/post/speech/tts"
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def gradium_env(monkeypatch):
|
||||
monkeypatch.setattr(litellm, "disable_aiohttp_transport", True)
|
||||
|
||||
|
||||
def _sent_body(route) -> dict:
|
||||
return json.loads(route.calls[-1].request.content)
|
||||
|
||||
|
||||
def test_maps_openai_voice_names_to_gradium_voice_ids():
|
||||
config = GradiumTextToSpeechConfig()
|
||||
|
||||
voice, _ = config.map_openai_params(model="default", optional_params={}, voice="alloy")
|
||||
|
||||
assert voice == "YTpq7expH9539ERJ"
|
||||
|
||||
|
||||
def test_passes_unknown_voices_through_as_raw_gradium_ids():
|
||||
config = GradiumTextToSpeechConfig()
|
||||
|
||||
voice, _ = config.map_openai_params(model="default", optional_params={}, voice="w9V1722uEmTkWqnR")
|
||||
|
||||
assert voice == "w9V1722uEmTkWqnR"
|
||||
|
||||
|
||||
def test_requires_a_voice():
|
||||
config = GradiumTextToSpeechConfig()
|
||||
|
||||
with pytest.raises(ValueError, match="voice_id is required"):
|
||||
config.map_openai_params(model="default", optional_params={}, voice=None)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("response_format", "expected"),
|
||||
[("wav", "wav"), ("pcm", "pcm_24000"), ("opus", "opus")],
|
||||
)
|
||||
def test_maps_supported_openai_response_formats(response_format: str, expected: str):
|
||||
config = GradiumTextToSpeechConfig()
|
||||
|
||||
_, params = config.map_openai_params(
|
||||
model="default",
|
||||
optional_params={"response_format": response_format},
|
||||
voice="alloy",
|
||||
)
|
||||
|
||||
assert params["output_format"] == expected
|
||||
|
||||
|
||||
@pytest.mark.parametrize("response_format", ["mp3", "aac", "flac"])
|
||||
def test_rejects_formats_gradium_cannot_encode(response_format: str):
|
||||
config = GradiumTextToSpeechConfig()
|
||||
|
||||
with pytest.raises(litellm.UnsupportedParamsError, match="does not support response_format"):
|
||||
config.map_openai_params(
|
||||
model="default",
|
||||
optional_params={"response_format": response_format},
|
||||
voice="alloy",
|
||||
)
|
||||
|
||||
|
||||
def test_drops_unencodable_format_when_drop_params_is_set():
|
||||
config = GradiumTextToSpeechConfig()
|
||||
|
||||
_, params = config.map_openai_params(
|
||||
model="default",
|
||||
optional_params={"response_format": "mp3"},
|
||||
voice="alloy",
|
||||
drop_params=True,
|
||||
)
|
||||
|
||||
assert "output_format" not in params
|
||||
|
||||
|
||||
def test_drops_openai_only_speed_and_instructions():
|
||||
config = GradiumTextToSpeechConfig()
|
||||
|
||||
_, params = config.map_openai_params(
|
||||
model="default",
|
||||
optional_params={"speed": 2.0, "instructions": "whisper it"},
|
||||
voice="alloy",
|
||||
)
|
||||
|
||||
assert "speed" not in params
|
||||
assert "instructions" not in params
|
||||
|
||||
|
||||
def test_advertises_the_openai_params_it_honors():
|
||||
config = GradiumTextToSpeechConfig()
|
||||
|
||||
assert config.get_supported_openai_params(model="default") == ["voice", "response_format"]
|
||||
|
||||
|
||||
def test_maps_upstream_failures_to_a_gradium_exception():
|
||||
config = GradiumTextToSpeechConfig()
|
||||
|
||||
error = config.get_error_class(error_message="unknown voice", status_code=422, headers={})
|
||||
|
||||
assert isinstance(error, GradiumException)
|
||||
assert error.status_code == 422
|
||||
|
||||
|
||||
def test_requires_an_api_key():
|
||||
config = GradiumTextToSpeechConfig()
|
||||
|
||||
with pytest.raises(ValueError, match="Gradium API key is required"):
|
||||
config.validate_environment(headers={}, model="default", api_key=None)
|
||||
|
||||
|
||||
def test_sends_the_api_key_as_x_api_key():
|
||||
config = GradiumTextToSpeechConfig()
|
||||
|
||||
headers = config.validate_environment(headers={}, model="default", api_key="gd_abc")
|
||||
|
||||
assert headers["x-api-key"] == "gd_abc"
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"api_base",
|
||||
[
|
||||
"https://eu.api.gradium.ai",
|
||||
"https://eu.api.gradium.ai/",
|
||||
# Gradium documents its regional bases with /api already appended.
|
||||
"https://eu.api.gradium.ai/api",
|
||||
"https://eu.api.gradium.ai/api/",
|
||||
],
|
||||
)
|
||||
def test_builds_the_rest_tts_url_without_doubling_the_api_segment(api_base: str):
|
||||
config = GradiumTextToSpeechConfig()
|
||||
|
||||
url = config.get_complete_url(model="default", api_base=api_base, litellm_params={})
|
||||
|
||||
assert url == "https://eu.api.gradium.ai/api/post/speech/tts"
|
||||
|
||||
|
||||
def test_requires_an_api_base():
|
||||
config = GradiumTextToSpeechConfig()
|
||||
|
||||
with pytest.raises(ValueError, match="Gradium API base is required"):
|
||||
config.get_complete_url(model="default", api_base=None, litellm_params={})
|
||||
|
||||
|
||||
def test_ignores_gradium_environment_variables(monkeypatch):
|
||||
monkeypatch.setenv("GRADIUM_API_KEY", "gd_from_env")
|
||||
monkeypatch.setenv("GRADIUM_API_BASE", "https://eu.api.gradium.ai")
|
||||
config = GradiumTextToSpeechConfig()
|
||||
|
||||
with pytest.raises(ValueError, match="Gradium API key is required"):
|
||||
config.validate_environment(headers={}, model="default", api_key=None)
|
||||
with pytest.raises(ValueError, match="Gradium API base is required"):
|
||||
config.get_complete_url(model="default", api_base=None, litellm_params={})
|
||||
|
||||
|
||||
def test_requests_raw_audio_rather_than_a_json_message_stream():
|
||||
config = GradiumTextToSpeechConfig()
|
||||
|
||||
data = config.transform_text_to_speech_request(
|
||||
model="default",
|
||||
input="bonjour",
|
||||
voice="YTpq7expH9539ERJ",
|
||||
optional_params={},
|
||||
litellm_params={},
|
||||
headers={},
|
||||
)
|
||||
|
||||
assert data["dict_body"]["only_audio"] is True
|
||||
|
||||
|
||||
def test_omits_model_name_for_the_default_model():
|
||||
config = GradiumTextToSpeechConfig()
|
||||
|
||||
data = config.transform_text_to_speech_request(
|
||||
model="default",
|
||||
input="bonjour",
|
||||
voice="YTpq7expH9539ERJ",
|
||||
optional_params={},
|
||||
litellm_params={},
|
||||
headers={},
|
||||
)
|
||||
|
||||
assert "model_name" not in data["dict_body"]
|
||||
|
||||
|
||||
def test_merges_extra_body_into_the_request_and_skips_none_values():
|
||||
config = GradiumTextToSpeechConfig()
|
||||
|
||||
data = config.transform_text_to_speech_request(
|
||||
model="default",
|
||||
input="bonjour",
|
||||
voice="YTpq7expH9539ERJ",
|
||||
optional_params={"extra_body": {"json_config": '{"padding_bonus": -1.2}', "unset": None}},
|
||||
litellm_params={},
|
||||
headers={},
|
||||
)
|
||||
|
||||
assert data["dict_body"]["json_config"] == '{"padding_bonus": -1.2}'
|
||||
assert "unset" not in data["dict_body"]
|
||||
assert "extra_body" not in data["dict_body"]
|
||||
|
||||
|
||||
def test_raises_on_gradium_plain_text_errors():
|
||||
config = GradiumTextToSpeechConfig()
|
||||
raw_response = httpx.Response(
|
||||
500,
|
||||
text="error from server 42: unknown voice",
|
||||
request=httpx.Request("POST", _URL),
|
||||
)
|
||||
|
||||
with pytest.raises(GradiumException, match="unknown voice"):
|
||||
config.transform_text_to_speech_response(model="default", raw_response=raw_response, logging_obj=None)
|
||||
|
||||
|
||||
def test_speech_calls_the_gradium_rest_endpoint(gradium_env):
|
||||
with respx.mock:
|
||||
route = respx.post(_URL).mock(return_value=httpx.Response(200, content=_AUDIO))
|
||||
|
||||
response = litellm.speech(
|
||||
model="gradium/default",
|
||||
input="Bonjour",
|
||||
voice="alloy",
|
||||
response_format="wav",
|
||||
api_base=_API_BASE,
|
||||
api_key=_API_KEY,
|
||||
)
|
||||
|
||||
assert response.content == _AUDIO
|
||||
assert route.calls[-1].request.headers["x-api-key"] == "gd_test_key"
|
||||
assert _sent_body(route) == {
|
||||
"text": "Bonjour",
|
||||
"voice_id": "YTpq7expH9539ERJ",
|
||||
"output_format": "wav",
|
||||
"only_audio": True,
|
||||
}
|
||||
|
||||
|
||||
def test_speech_forwards_a_non_default_model_name(gradium_env):
|
||||
with respx.mock:
|
||||
route = respx.post(_URL).mock(return_value=httpx.Response(200, content=_AUDIO))
|
||||
|
||||
litellm.speech(
|
||||
model="gradium/pro-voice-clone", input="Bonjour", voice="alloy", api_base=_API_BASE, api_key=_API_KEY
|
||||
)
|
||||
|
||||
assert _sent_body(route)["model_name"] == "pro-voice-clone"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_aspeech_calls_the_gradium_rest_endpoint(gradium_env):
|
||||
with respx.mock:
|
||||
respx.post(_URL).mock(return_value=httpx.Response(200, content=_AUDIO))
|
||||
|
||||
response = await litellm.aspeech(
|
||||
model="gradium/default", input="Bonjour", voice="alloy", api_base=_API_BASE, api_key=_API_KEY
|
||||
)
|
||||
|
||||
assert response.content == _AUDIO
|
||||
|
||||
|
||||
def test_speech_rejects_a_missing_voice(gradium_env):
|
||||
with pytest.raises(ValueError, match="voice_id is required"):
|
||||
litellm.speech(model="gradium/default", input="Bonjour", voice=None, api_base=_API_BASE, api_key=_API_KEY)
|
||||
|
||||
|
||||
def test_speech_rejects_a_missing_voice_without_a_provider_config(gradium_env, monkeypatch):
|
||||
"""`speech()` guards the voice itself on the path where no config was resolved."""
|
||||
monkeypatch.setattr(
|
||||
litellm.utils.ProviderConfigManager,
|
||||
"get_provider_text_to_speech_config",
|
||||
staticmethod(lambda **_: None),
|
||||
)
|
||||
|
||||
with pytest.raises(litellm.BadRequestError, match="must resolve to a Gradium voice id"):
|
||||
litellm.speech(model="gradium/default", input="Bonjour", voice=None, api_base=_API_BASE, api_key=_API_KEY)
|
||||
|
||||
|
||||
def test_speech_honors_an_explicit_api_base_and_api_key(gradium_env):
|
||||
with respx.mock:
|
||||
route = respx.post("https://eu.api.gradium.ai/api/post/speech/tts").mock(
|
||||
return_value=httpx.Response(200, content=_AUDIO)
|
||||
)
|
||||
|
||||
litellm.speech(
|
||||
model="gradium/default",
|
||||
input="Bonjour",
|
||||
voice="alloy",
|
||||
api_base="https://eu.api.gradium.ai",
|
||||
api_key="gd_explicit",
|
||||
)
|
||||
|
||||
assert route.calls[-1].request.headers["x-api-key"] == "gd_explicit"
|
||||
|
||||
|
||||
def test_speech_falls_back_to_its_own_config(gradium_env, monkeypatch):
|
||||
"""`speech()` instantiates the config itself when the manager returns none."""
|
||||
monkeypatch.setattr(
|
||||
litellm.utils.ProviderConfigManager,
|
||||
"get_provider_text_to_speech_config",
|
||||
staticmethod(lambda **_: None),
|
||||
)
|
||||
|
||||
with respx.mock:
|
||||
respx.post(_URL).mock(return_value=httpx.Response(200, content=_AUDIO))
|
||||
|
||||
response = litellm.speech(
|
||||
model="gradium/default", input="Bonjour", voice="alloy", api_base=_API_BASE, api_key=_API_KEY
|
||||
)
|
||||
|
||||
assert response.content == _AUDIO
|
||||
|
||||
|
||||
def test_speech_tracks_cost_per_character(gradium_env, local_model_cost_map):
|
||||
with respx.mock:
|
||||
respx.post(_URL).mock(return_value=httpx.Response(200, content=_AUDIO))
|
||||
|
||||
response = litellm.speech(
|
||||
model="gradium/default", input="a" * 100, voice="alloy", api_base=_API_BASE, api_key=_API_KEY
|
||||
)
|
||||
|
||||
assert response._hidden_params["response_cost"] == pytest.approx(100 * 6.9e-05)
|
||||
Loading…
Add table
Reference in a new issue