diff --git a/litellm/__init__.py b/litellm/__init__.py index 504993cb469..b457410a6f5 100644 --- a/litellm/__init__.py +++ b/litellm/__init__.py @@ -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, diff --git a/litellm/llms/gradium/__init__.py b/litellm/llms/gradium/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/litellm/llms/gradium/common_utils.py b/litellm/llms/gradium/common_utils.py new file mode 100644 index 00000000000..6047d2c164c --- /dev/null +++ b/litellm/llms/gradium/common_utils.py @@ -0,0 +1,5 @@ +from litellm.llms.base_llm.chat.transformation import BaseLLMException + + +class GradiumException(BaseLLMException): + pass diff --git a/litellm/llms/gradium/text_to_speech/__init__.py b/litellm/llms/gradium/text_to_speech/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/litellm/llms/gradium/text_to_speech/transformation.py b/litellm/llms/gradium/text_to_speech/transformation.py new file mode 100644 index 00000000000..44c4a63cfdb --- /dev/null +++ b/litellm/llms/gradium/text_to_speech/transformation.py @@ -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 : "), 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) diff --git a/litellm/main.py b/litellm/main.py index 474d82e8ef1..d7f569253ef 100644 --- a/litellm/main.py +++ b/litellm/main.py @@ -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, diff --git a/litellm/model_prices_and_context_window_backup.json b/litellm/model_prices_and_context_window_backup.json index c973375d543..dd981dc6ddf 100644 --- a/litellm/model_prices_and_context_window_backup.json +++ b/litellm/model_prices_and_context_window_backup.json @@ -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", diff --git a/litellm/types/utils.py b/litellm/types/utils.py index 93037d17062..2ad95debae8 100644 --- a/litellm/types/utils.py +++ b/litellm/types/utils.py @@ -4273,6 +4273,7 @@ class LlmProviders(str, Enum): INFINITY = "infinity" DEEPGRAM = "deepgram" ELEVENLABS = "elevenlabs" + GRADIUM = "gradium" NOVITA = "novita" AIOHTTP_OPENAI = "aiohttp_openai" LANGFUSE = "langfuse" diff --git a/litellm/utils.py b/litellm/utils.py index 4733051f4f6..a56d2e74eb5 100644 --- a/litellm/utils.py +++ b/litellm/utils.py @@ -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, diff --git a/model_prices_and_context_window.json b/model_prices_and_context_window.json index c973375d543..dd981dc6ddf 100644 --- a/model_prices_and_context_window.json +++ b/model_prices_and_context_window.json @@ -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", diff --git a/tests/unit/llms/gradium/__init__.py b/tests/unit/llms/gradium/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/tests/unit/llms/gradium/text_to_speech/__init__.py b/tests/unit/llms/gradium/text_to_speech/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/tests/unit/llms/gradium/text_to_speech/test_transformation.py b/tests/unit/llms/gradium/text_to_speech/test_transformation.py new file mode 100644 index 00000000000..63cdf46755b --- /dev/null +++ b/tests/unit/llms/gradium/text_to_speech/test_transformation.py @@ -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)