mirror of
https://github.com/BerriAI/litellm.git
synced 2026-10-25 05:54:53 +00:00
feat(gradium): add text-to-speech support for /v1/audio/speech
Gradium (https://gradium.ai) is a voice AI provider whose TTS had no LiteLLM route. Adds it as a first-class TTS provider behind the existing BaseTextToSpeechConfig seam, so `litellm.speech(model="gradium/default")` and the proxy's /v1/audio/speech both work. The provider targets Gradium's REST TTS endpoint (POST /api/post/speech/tts) rather than its Python SDK, so the call flows through base_llm_http_handler and keeps retries, logging and cost tracking that an SDK-internal HTTP client would bypass. Notes on the mapping: - `only_audio: true` is always sent. Without it Gradium answers a line-delimited JSON message stream instead of audio bytes, which /v1/audio/speech callers cannot read. - The six canonical OpenAI voice names map to Gradium library voice IDs; anything else is forwarded verbatim, so raw Gradium voice IDs work. - Gradium has no mp3/aac/flac encoder, so those response_formats raise UnsupportedParamsError instead of silently returning mislabeled audio. `litellm.drop_params` falls back to the default format. - Pre-stream failures come back as HTTP 500 with a plain-text body, so the response transform raises rather than handing the caller an opaque binary payload. - Cost is per character of input text (1 credit/char), priced from the published add-on credit rate. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
parent
2f3ccbe7c7
commit
dfb84299b7
13 changed files with 547 additions and 0 deletions
|
|
@ -689,6 +689,7 @@ nebius_embedding_models: Set = set()
|
|||
aiml_models: Set = set()
|
||||
deepgram_models: Set = set()
|
||||
elevenlabs_models: Set = set()
|
||||
gradium_models: Set = set()
|
||||
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":
|
||||
|
|
@ -1126,6 +1129,7 @@ model_list = list(
|
|||
| nscale_models
|
||||
| deepgram_models
|
||||
| elevenlabs_models
|
||||
| gradium_models
|
||||
| dashscope_models
|
||||
| qwencloud_models
|
||||
| qwen_ai_platform_models
|
||||
|
|
@ -1238,6 +1242,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
218
litellm/llms/gradium/text_to_speech/transformation.py
Normal file
218
litellm/llms/gradium/text_to_speech/transformation.py
Normal file
|
|
@ -0,0 +1,218 @@
|
|||
"""
|
||||
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 typing import TYPE_CHECKING, ClassVar, Final
|
||||
|
||||
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 litellm.secret_managers.main import get_secret_str
|
||||
|
||||
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 = object
|
||||
HttpxBinaryResponseContent = object
|
||||
|
||||
|
||||
class GradiumTextToSpeechConfig(BaseTextToSpeechConfig):
|
||||
"""
|
||||
Configuration for Gradium Text-to-Speech
|
||||
|
||||
Reference: https://docs.gradium.ai/api-reference/endpoint/tts-post
|
||||
"""
|
||||
|
||||
TTS_BASE_URL = "https://api.gradium.ai"
|
||||
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[dict[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[dict[str, str]] = {
|
||||
"wav": "wav",
|
||||
"pcm": "pcm_24000",
|
||||
"opus": "opus",
|
||||
}
|
||||
|
||||
def get_supported_openai_params(self, model: str) -> list:
|
||||
return ["voice", "response_format"]
|
||||
|
||||
def _resolve_voice_id(self, voice: str | dict | None, params: dict) -> str:
|
||||
"""Normalize the caller's `voice` into a Gradium voice_id."""
|
||||
candidate: str | None = None
|
||||
|
||||
if isinstance(voice, str) and voice.strip():
|
||||
candidate = voice.strip()
|
||||
elif isinstance(voice, dict):
|
||||
for key in ("voice_id", "id", "name"):
|
||||
value = voice.get(key)
|
||||
if isinstance(value, str) and value.strip():
|
||||
candidate = value.strip()
|
||||
break
|
||||
|
||||
if candidate is None:
|
||||
override = params.pop("voice_id", None)
|
||||
if isinstance(override, str) and override.strip():
|
||||
candidate = override.strip()
|
||||
|
||||
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,
|
||||
voice: str | dict | None = None,
|
||||
drop_params: bool = False,
|
||||
kwargs: dict | None = None,
|
||||
) -> tuple[str | None, dict]:
|
||||
params: Final = dict(optional_params) if optional_params else {}
|
||||
mapped_params: Final[dict[str, object]] = {}
|
||||
|
||||
mapped_voice: Final = self._resolve_voice_id(voice, params)
|
||||
|
||||
response_format: Final = params.pop("response_format", None)
|
||||
if isinstance(response_format, str):
|
||||
mapped_format = self.FORMAT_MAPPINGS.get(response_format)
|
||||
if mapped_format is None:
|
||||
if 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."
|
||||
),
|
||||
)
|
||||
else:
|
||||
mapped_params["output_format"] = mapped_format
|
||||
|
||||
# Gradium synthesizes at the voice's native rate; there is no speed
|
||||
# control and no instructions field on the REST endpoint.
|
||||
params.pop("speed", None)
|
||||
params.pop("instructions", None)
|
||||
|
||||
for key, value in params.items():
|
||||
if value is not None:
|
||||
mapped_params[key] = value
|
||||
|
||||
return mapped_voice, mapped_params
|
||||
|
||||
def validate_environment(
|
||||
self,
|
||||
headers: dict,
|
||||
model: str,
|
||||
api_key: str | None = None,
|
||||
api_base: str | None = None,
|
||||
) -> dict:
|
||||
api_key = api_key or litellm.api_key or get_secret_str("GRADIUM_API_KEY")
|
||||
|
||||
if api_key is None:
|
||||
raise ValueError("Gradium API key is required. Set GRADIUM_API_KEY environment variable.")
|
||||
|
||||
headers.update(
|
||||
{
|
||||
"x-api-key": api_key,
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
)
|
||||
|
||||
return headers
|
||||
|
||||
def get_error_class(self, error_message: str, status_code: int, headers: dict | Headers) -> 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:
|
||||
base_url: Final = (api_base or get_secret_str("GRADIUM_API_BASE") or self.TTS_BASE_URL).rstrip("/")
|
||||
return f"{base_url}{self.TTS_ENDPOINT_PATH}"
|
||||
|
||||
def transform_text_to_speech_request(
|
||||
self,
|
||||
model: str,
|
||||
input: str,
|
||||
voice: str | None,
|
||||
optional_params: dict,
|
||||
litellm_params: dict,
|
||||
headers: dict,
|
||||
) -> TextToSpeechRequestData:
|
||||
params: Final = dict(optional_params) if optional_params else {}
|
||||
extra_body: Final = params.pop("extra_body", None)
|
||||
|
||||
request_body: Final[dict[str, object]] = {
|
||||
"text": input,
|
||||
"voice_id": voice,
|
||||
"output_format": params.pop("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,
|
||||
}
|
||||
|
||||
if model and model != "default":
|
||||
request_body["model_name"] = model
|
||||
|
||||
for key, value in params.items():
|
||||
if value is not None:
|
||||
request_body[key] = value
|
||||
|
||||
if isinstance(extra_body, dict):
|
||||
for key, value in extra_body.items():
|
||||
if value is not None:
|
||||
request_body[key] = value
|
||||
|
||||
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)
|
||||
|
|
@ -8534,6 +8534,43 @@ 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()
|
||||
|
||||
gradium_config: Final = cast(GradiumTextToSpeechConfig, text_to_speech_provider_config)
|
||||
|
||||
gradium_voice_id = voice if isinstance(voice, str) else None
|
||||
if gradium_voice_id is None or not gradium_voice_id.strip():
|
||||
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(
|
||||
model=model,
|
||||
input=input,
|
||||
voice=gradium_voice_id.strip(),
|
||||
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,
|
||||
client=client,
|
||||
_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,
|
||||
|
|
|
|||
|
|
@ -37465,6 +37465,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",
|
||||
|
|
|
|||
|
|
@ -4273,6 +4273,7 @@ class LlmProviders(str, Enum):
|
|||
INFINITY = "infinity"
|
||||
DEEPGRAM = "deepgram"
|
||||
ELEVENLABS = "elevenlabs"
|
||||
GRADIUM = "gradium"
|
||||
NOVITA = "novita"
|
||||
AIOHTTP_OPENAI = "aiohttp_openai"
|
||||
LANGFUSE = "langfuse"
|
||||
|
|
|
|||
|
|
@ -10117,6 +10117,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,
|
||||
|
|
|
|||
|
|
@ -37465,6 +37465,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",
|
||||
|
|
|
|||
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
219
tests/unit/llms/gradium/text_to_speech/test_transformation.py
Normal file
219
tests/unit/llms/gradium/text_to_speech/test_transformation.py
Normal file
|
|
@ -0,0 +1,219 @@
|
|||
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"
|
||||
_URL: Final = "https://api.gradium.ai/api/post/speech/tts"
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def gradium_env(monkeypatch):
|
||||
monkeypatch.setattr(litellm, "disable_aiohttp_transport", True)
|
||||
monkeypatch.setenv("GRADIUM_API_KEY", "gd_test_key")
|
||||
|
||||
|
||||
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_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"
|
||||
|
||||
|
||||
def test_builds_the_rest_tts_url_from_api_base():
|
||||
config = GradiumTextToSpeechConfig()
|
||||
|
||||
url = config.get_complete_url(model="default", api_base="https://eu.example.com/", litellm_params={})
|
||||
|
||||
assert url == "https://eu.example.com/api/post/speech/tts"
|
||||
|
||||
|
||||
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_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",
|
||||
)
|
||||
|
||||
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")
|
||||
|
||||
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")
|
||||
|
||||
assert response.content == _AUDIO
|
||||
|
||||
|
||||
def test_speech_tracks_cost_per_character(gradium_env, monkeypatch):
|
||||
monkeypatch.setenv("LITELLM_LOCAL_MODEL_COST_MAP", "True")
|
||||
litellm.model_cost = litellm.get_model_cost_map(url="")
|
||||
|
||||
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")
|
||||
|
||||
assert response._hidden_params["response_cost"] == pytest.approx(100 * 6.9e-05)
|
||||
Loading…
Add table
Reference in a new issue