diff --git a/.github/workflows/test-unit.yml b/.github/workflows/test-unit.yml index c593acbe8fb..afdcf60ef52 100644 --- a/.github/workflows/test-unit.yml +++ b/.github/workflows/test-unit.yml @@ -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 diff --git a/litellm/__init__.py b/litellm/__init__.py index 1784deeb797..885d8411947 100644 --- a/litellm/__init__.py +++ b/litellm/__init__.py @@ -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, 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..79e6eb10b00 --- /dev/null +++ b/litellm/llms/gradium/text_to_speech/transformation.py @@ -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 : "), 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 d647cab24e3..db09cf5f32c 100644 --- a/litellm/main.py +++ b/litellm/main.py @@ -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, diff --git a/litellm/model_prices_and_context_window_backup.json b/litellm/model_prices_and_context_window_backup.json index 3d89af9d691..b2145217639 100644 --- a/litellm/model_prices_and_context_window_backup.json +++ b/litellm/model_prices_and_context_window_backup.json @@ -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", diff --git a/litellm/proxy/public_endpoints/provider_create_fields.json b/litellm/proxy/public_endpoints/provider_create_fields.json index 4bec42a7868..75b8075bcb0 100644 --- a/litellm/proxy/public_endpoints/provider_create_fields.json +++ b/litellm/proxy/public_endpoints/provider_create_fields.json @@ -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", diff --git a/litellm/types/utils.py b/litellm/types/utils.py index 5939a350970..d3bc1f90f2e 100644 --- a/litellm/types/utils.py +++ b/litellm/types/utils.py @@ -4489,6 +4489,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 1a114ffe9f0..b150fe876d1 100644 --- a/litellm/utils.py +++ b/litellm/utils.py @@ -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, diff --git a/model_prices_and_context_window.json b/model_prices_and_context_window.json index 3d89af9d691..b2145217639 100644 --- a/model_prices_and_context_window.json +++ b/model_prices_and_context_window.json @@ -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", diff --git a/provider_endpoints_support.json b/provider_endpoints_support.json index d65da3f41fd..bbb84ed4cdd 100644 --- a/provider_endpoints_support.json +++ b/provider_endpoints_support.json @@ -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", 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..7461fb1fc4b --- /dev/null +++ b/tests/unit/llms/gradium/text_to_speech/test_transformation.py @@ -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)