From dfb84299b7bb3d1fe90d3aa7db4dccb2c5b558b9 Mon Sep 17 00:00:00 2001 From: Gabriel Creti Date: Wed, 14 Oct 2026 15:02:35 +0200 Subject: [PATCH 1/6] 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 --- litellm/__init__.py | 5 + litellm/llms/gradium/__init__.py | 0 litellm/llms/gradium/common_utils.py | 5 + .../llms/gradium/text_to_speech/__init__.py | 0 .../gradium/text_to_speech/transformation.py | 218 +++++++++++++++++ litellm/main.py | 37 +++ ...odel_prices_and_context_window_backup.json | 28 +++ litellm/types/utils.py | 1 + litellm/utils.py | 6 + model_prices_and_context_window.json | 28 +++ tests/unit/llms/gradium/__init__.py | 0 .../llms/gradium/text_to_speech/__init__.py | 0 .../text_to_speech/test_transformation.py | 219 ++++++++++++++++++ 13 files changed, 547 insertions(+) create mode 100644 litellm/llms/gradium/__init__.py create mode 100644 litellm/llms/gradium/common_utils.py create mode 100644 litellm/llms/gradium/text_to_speech/__init__.py create mode 100644 litellm/llms/gradium/text_to_speech/transformation.py create mode 100644 tests/unit/llms/gradium/__init__.py create mode 100644 tests/unit/llms/gradium/text_to_speech/__init__.py create mode 100644 tests/unit/llms/gradium/text_to_speech/test_transformation.py 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) From 21d6fe53c5c2bce5e5abb1e5b6d5f168968800c7 Mon Sep 17 00:00:00 2001 From: Gabriel Creti Date: Wed, 14 Oct 2026 15:15:08 +0200 Subject: [PATCH 2/6] fix(gradium): satisfy CI gates and handle /api-suffixed api_base - register the provider in provider_endpoints_support.json and claim tests/unit/llms/gradium in the llms-providers shard, so the provider documentation and CI/shard coverage gates see it - rework the transformation to build its params and request body functionally rather than by mutation, so it clears the type-discipline rules; the suppressions that remain are the signatures BaseTextToSpeechConfig fixes and the dicts its return types require - get_complete_url now tolerates an api_base that already ends in /api. Gradium documents its regional bases that way (https://eu.api.gradium.ai/api), and pasting one produced /api/api/post/speech/tts. Co-Authored-By: Claude Opus 5 --- .github/workflows/test-unit.yml | 1 + .../gradium/text_to_speech/transformation.py | 161 +++++++++--------- litellm/main.py | 14 +- provider_endpoints_support.json | 16 ++ .../text_to_speech/test_transformation.py | 24 ++- 5 files changed, 127 insertions(+), 89 deletions(-) diff --git a/.github/workflows/test-unit.yml b/.github/workflows/test-unit.yml index b020122ee3b..55665752b55 100644 --- a/.github/workflows/test-unit.yml +++ b/.github/workflows/test-unit.yml @@ -272,6 +272,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/llms/gradium/text_to_speech/transformation.py b/litellm/llms/gradium/text_to_speech/transformation.py index 44c4a63cfdb..0664c59b050 100644 --- a/litellm/llms/gradium/text_to_speech/transformation.py +++ b/litellm/llms/gradium/text_to_speech/transformation.py @@ -6,7 +6,8 @@ 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 +from collections.abc import Mapping +from typing import TYPE_CHECKING, ClassVar, Final, TypeAlias import httpx from httpx import Headers @@ -25,8 +26,8 @@ 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 + LiteLLMLoggingObj: TypeAlias = object + HttpxBinaryResponseContent: TypeAlias = object class GradiumTextToSpeechConfig(BaseTextToSpeechConfig): @@ -42,7 +43,7 @@ class GradiumTextToSpeechConfig(BaseTextToSpeechConfig): # 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]] = { + VOICE_MAPPINGS: ClassVar[Mapping[str, str]] = { "alloy": "YTpq7expH9539ERJ", "echo": "s0PhgjzOTRD5wo5L", "fable": "1VAVLmmbQFDw7TMn", @@ -53,32 +54,30 @@ class GradiumTextToSpeechConfig(BaseTextToSpeechConfig): # 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]] = { + FORMAT_MAPPINGS: ClassVar[Mapping[str, str]] = { "wav": "wav", "pcm": "pcm_24000", "opus": "opus", } - def get_supported_openai_params(self, model: str) -> list: + def get_supported_openai_params(self, model: str) -> list: # mutable-ok: BaseTextToSpeechConfig declares list return ["voice", "response_format"] - def _resolve_voice_id(self, voice: str | dict | None, params: dict) -> str: + def _resolve_voice_id( + self, + voice: str | dict | 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.""" - 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() + 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()`.") @@ -88,110 +87,112 @@ class GradiumTextToSpeechConfig(BaseTextToSpeechConfig): def map_openai_params( self, model: str, - optional_params: dict, - voice: str | dict | None = None, + optional_params: dict, # mutable-ok: signature fixed by BaseTextToSpeechConfig + voice: str | dict | None = None, # mutable-ok: signature fixed by BaseTextToSpeechConfig drop_params: bool = False, - kwargs: dict | None = None, - ) -> tuple[str | None, dict]: + kwargs: dict | None = None, # mutable-ok: signature fixed by BaseTextToSpeechConfig + ) -> tuple[str | None, dict]: # mutable-ok: callers mutate the returned params further down the TTS path 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 + 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. - params.pop("speed", None) - params.pop("instructions", None) - - for key, value in params.items(): - if value is not None: - mapped_params[key] = value + 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, + headers: dict, # mutable-ok: signature fixed by BaseTextToSpeechConfig 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") + ) -> dict: # mutable-ok: signature fixed by BaseTextToSpeechConfig + resolved_key: Final = api_key or litellm.api_key or get_secret_str("GRADIUM_API_KEY") - if api_key is None: + if resolved_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, + "x-api-key": resolved_key, + "Content-Type": "application/json", + } - return headers - - def get_error_class(self, error_message: str, status_code: int, headers: dict | Headers) -> BaseLLMException: + def get_error_class( + self, + error_message: str, + status_code: int, + headers: dict | 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, + litellm_params: dict, # mutable-ok: signature fixed by BaseTextToSpeechConfig ) -> 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}" + # 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, - litellm_params: dict, - headers: dict, + optional_params: dict, # mutable-ok: signature fixed by BaseTextToSpeechConfig + litellm_params: dict, # mutable-ok: signature fixed by BaseTextToSpeechConfig + headers: dict, # mutable-ok: signature fixed by BaseTextToSpeechConfig ) -> TextToSpeechRequestData: params: Final = dict(optional_params) if optional_params else {} extra_body: Final = params.pop("extra_body", None) - request_body: Final[dict[str, object]] = { + passthrough: Final = {key: value for key, value in params.items() if value is not None} + extras: Final = ( + {key: value for key, value in extra_body.items() if value is not None} + if isinstance(extra_body, dict) + else {} + ) + 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.pop("output_format", self.DEFAULT_OUTPUT_FORMAT), + "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, + **extras, } - 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"}, diff --git a/litellm/main.py b/litellm/main.py index d7f569253ef..c5b85a89b1d 100644 --- a/litellm/main.py +++ b/litellm/main.py @@ -8540,12 +8540,14 @@ def speech( ) if text_to_speech_provider_config is None: - text_to_speech_provider_config = GradiumTextToSpeechConfig() + text_to_speech_provider_config = GradiumTextToSpeechConfig() # rebind-ok: shared across every provider branch of speech() - gradium_config: Final = cast(GradiumTextToSpeechConfig, text_to_speech_provider_config) + gradium_config: Final = cast( # cast-ok: narrowed to the config the branch above just selected + 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(): + 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, @@ -8557,10 +8559,10 @@ def speech( if api_key is not None: litellm_params_dict["api_key"] = api_key - response = base_llm_http_handler.text_to_speech_handler( + response = base_llm_http_handler.text_to_speech_handler( # rebind-ok: shared across every provider branch of speech() model=model, input=input, - voice=gradium_voice_id.strip(), + voice=gradium_voice_id, text_to_speech_provider_config=gradium_config, text_to_speech_optional_params=optional_params, custom_llm_provider=custom_llm_provider, diff --git a/provider_endpoints_support.json b/provider_endpoints_support.json index 8263c05a4d9..656bbf81c41 100644 --- a/provider_endpoints_support.json +++ b/provider_endpoints_support.json @@ -1336,6 +1336,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/text_to_speech/test_transformation.py b/tests/unit/llms/gradium/text_to_speech/test_transformation.py index 63cdf46755b..3ded036afa6 100644 --- a/tests/unit/llms/gradium/text_to_speech/test_transformation.py +++ b/tests/unit/llms/gradium/text_to_speech/test_transformation.py @@ -117,12 +117,30 @@ def test_sends_the_api_key_as_x_api_key(): assert headers["x-api-key"] == "gd_abc" -def test_builds_the_rest_tts_url_from_api_base(): +@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="https://eu.example.com/", litellm_params={}) + url = config.get_complete_url(model="default", api_base=api_base, litellm_params={}) - assert url == "https://eu.example.com/api/post/speech/tts" + assert url == "https://eu.api.gradium.ai/api/post/speech/tts" + + +def test_defaults_to_the_latency_routed_endpoint(): + config = GradiumTextToSpeechConfig() + + url = config.get_complete_url(model="default", api_base=None, litellm_params={}) + + assert url == "https://api.gradium.ai/api/post/speech/tts" def test_requests_raw_audio_rather_than_a_json_message_stream(): From a790ddf9b28bb700e4f45f15b8db1bfff38c4aa1 Mon Sep 17 00:00:00 2001 From: Gabriel Creti Date: Wed, 14 Oct 2026 15:22:41 +0200 Subject: [PATCH 3/6] test(gradium): cover the remaining branches, format under the pinned ruff - ruff format with the pinned 0.15.3 (the suppression comments were long enough to push their lines over the limit and get them wrapped) - cover get_supported_openai_params, get_error_class, the explicit api_base/api_key path, and both missing-voice paths, taking the provider to 100% patch coverage The missing-voice tests record where each guard actually fires: map_openai_params raises ValueError first (same as elevenlabs), so speech()'s own BadRequestError is reachable only when the provider config manager returns None. Co-Authored-By: Claude Opus 5 --- litellm/main.py | 4 +- .../text_to_speech/test_transformation.py | 65 +++++++++++++++++++ 2 files changed, 67 insertions(+), 2 deletions(-) diff --git a/litellm/main.py b/litellm/main.py index c5b85a89b1d..c89fcba92bc 100644 --- a/litellm/main.py +++ b/litellm/main.py @@ -8540,7 +8540,7 @@ def speech( ) if text_to_speech_provider_config is None: - text_to_speech_provider_config = GradiumTextToSpeechConfig() # rebind-ok: shared across every provider branch of speech() + 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 @@ -8559,7 +8559,7 @@ def speech( if api_key is not None: litellm_params_dict["api_key"] = api_key - response = base_llm_http_handler.text_to_speech_handler( # rebind-ok: shared across every provider branch of speech() + response = base_llm_http_handler.text_to_speech_handler( # rebind-ok: set per provider branch model=model, input=input, voice=gradium_voice_id, diff --git a/tests/unit/llms/gradium/text_to_speech/test_transformation.py b/tests/unit/llms/gradium/text_to_speech/test_transformation.py index 3ded036afa6..d9790802f61 100644 --- a/tests/unit/llms/gradium/text_to_speech/test_transformation.py +++ b/tests/unit/llms/gradium/text_to_speech/test_transformation.py @@ -102,6 +102,21 @@ def test_drops_openai_only_speed_and_instructions(): 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() @@ -225,6 +240,56 @@ async def test_aspeech_calls_the_gradium_rest_endpoint(gradium_env): 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) + + +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) + + +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") + + 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="") From db57b9cd4587743441c083fd82fb53b1ec898608 Mon Sep 17 00:00:00 2001 From: Gabriel Creti Date: Wed, 14 Oct 2026 15:28:51 +0200 Subject: [PATCH 4/6] test(gradium): use the local_model_cost_map fixture for the cost test The cost test wrote litellm.model_cost directly, which the test-quality gate flags (TQ005): it is a process-wide global, and the leak is exactly what the save/restore conftest exists to undo. The existing local_model_cost_map fixture does the same setup with teardown, so the suite no longer depends on LITELLM_LOCAL_MODEL_COST_MAP being exported. Co-Authored-By: Claude Opus 5 --- .../unit/llms/gradium/text_to_speech/test_transformation.py | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/tests/unit/llms/gradium/text_to_speech/test_transformation.py b/tests/unit/llms/gradium/text_to_speech/test_transformation.py index d9790802f61..fb7093140fa 100644 --- a/tests/unit/llms/gradium/text_to_speech/test_transformation.py +++ b/tests/unit/llms/gradium/text_to_speech/test_transformation.py @@ -290,10 +290,7 @@ def test_speech_falls_back_to_its_own_config(gradium_env, monkeypatch): 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="") - +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)) From e89422693c4cc2cfab76411bf1c566986115be82 Mon Sep 17 00:00:00 2001 From: Gabriel Creti Date: Wed, 14 Oct 2026 16:03:15 +0200 Subject: [PATCH 5/6] fix(gradium): clear the basedpyright and Add Model dropdown gates - add a provider_create_fields.json entry so gradium appears in the proxy UI's Add Model dropdown; test_public_endpoints asserts every LlmProviders member is listed there - parameterize the dict/list annotations copied from BaseTextToSpeechConfig and type gradium_models, which basedpyright counted as new Unknown-type errors against the merge-base ceiling - suppress the untyped-argument errors on the shared TTS handler call; every provider branch of speech() carries the same ones - keep extra_body support, matching elevenlabs and minimax, and cover it with a test Co-Authored-By: Claude --- litellm/__init__.py | 2 +- .../gradium/text_to_speech/transformation.py | 40 +++++++++---------- litellm/main.py | 6 +-- .../provider_create_fields.json | 18 +++++++++ .../text_to_speech/test_transformation.py | 17 ++++++++ 5 files changed, 59 insertions(+), 24 deletions(-) diff --git a/litellm/__init__.py b/litellm/__init__.py index b457410a6f5..298bb9c743a 100644 --- a/litellm/__init__.py +++ b/litellm/__init__.py @@ -689,7 +689,7 @@ nebius_embedding_models: Set = set() aiml_models: Set = set() deepgram_models: Set = set() elevenlabs_models: Set = set() -gradium_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() diff --git a/litellm/llms/gradium/text_to_speech/transformation.py b/litellm/llms/gradium/text_to_speech/transformation.py index 0664c59b050..bc5d3e0f011 100644 --- a/litellm/llms/gradium/text_to_speech/transformation.py +++ b/litellm/llms/gradium/text_to_speech/transformation.py @@ -60,12 +60,12 @@ class GradiumTextToSpeechConfig(BaseTextToSpeechConfig): "opus": "opus", } - def get_supported_openai_params(self, model: str) -> list: # mutable-ok: BaseTextToSpeechConfig declares list + 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 | None, # mutable-ok: mirrors the dict|str the base class passes through + 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.""" @@ -87,11 +87,11 @@ class GradiumTextToSpeechConfig(BaseTextToSpeechConfig): def map_openai_params( self, model: str, - optional_params: dict, # mutable-ok: signature fixed by BaseTextToSpeechConfig - voice: str | dict | None = None, # mutable-ok: signature fixed by BaseTextToSpeechConfig + 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 | None = None, # mutable-ok: signature fixed by BaseTextToSpeechConfig - ) -> tuple[str | None, dict]: # mutable-ok: callers mutate the returned params further down the TTS path + 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) @@ -123,11 +123,11 @@ class GradiumTextToSpeechConfig(BaseTextToSpeechConfig): def validate_environment( self, - headers: dict, # mutable-ok: signature fixed by BaseTextToSpeechConfig + headers: dict[str, str], # mutable-ok: signature fixed by BaseTextToSpeechConfig model: str, api_key: str | None = None, api_base: str | None = None, - ) -> dict: # mutable-ok: signature fixed by BaseTextToSpeechConfig + ) -> dict[str, str]: # mutable-ok: signature fixed by BaseTextToSpeechConfig resolved_key: Final = api_key or litellm.api_key or get_secret_str("GRADIUM_API_KEY") if resolved_key is None: @@ -143,7 +143,7 @@ class GradiumTextToSpeechConfig(BaseTextToSpeechConfig): self, error_message: str, status_code: int, - headers: dict | Headers, # mutable-ok: signature fixed by BaseTextToSpeechConfig + headers: dict[str, str] | Headers, # mutable-ok: signature fixed by BaseTextToSpeechConfig ) -> BaseLLMException: return GradiumException(message=error_message, status_code=status_code, headers=headers) @@ -151,7 +151,7 @@ class GradiumTextToSpeechConfig(BaseTextToSpeechConfig): self, model: str, api_base: str | None, - litellm_params: dict, # mutable-ok: signature fixed by BaseTextToSpeechConfig + litellm_params: dict[str, object], # mutable-ok: signature fixed by BaseTextToSpeechConfig ) -> str: base_url: Final = (api_base or get_secret_str("GRADIUM_API_BASE") or self.TTS_BASE_URL).rstrip("/") # Gradium documents its regional bases with the /api suffix already on @@ -165,19 +165,13 @@ class GradiumTextToSpeechConfig(BaseTextToSpeechConfig): model: str, input: str, voice: str | None, - optional_params: dict, # mutable-ok: signature fixed by BaseTextToSpeechConfig - litellm_params: dict, # mutable-ok: signature fixed by BaseTextToSpeechConfig - headers: dict, # mutable-ok: signature fixed by BaseTextToSpeechConfig + 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} - extras: Final = ( - {key: value for key, value in extra_body.items() if value is not None} - if isinstance(extra_body, dict) - else {} - ) 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 @@ -190,9 +184,15 @@ class GradiumTextToSpeechConfig(BaseTextToSpeechConfig): "only_audio": True, **model_name, **passthrough, - **extras, } + # 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"}, diff --git a/litellm/main.py b/litellm/main.py index c89fcba92bc..970545a2200 100644 --- a/litellm/main.py +++ b/litellm/main.py @@ -8559,7 +8559,7 @@ def speech( 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 + 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, @@ -8569,8 +8569,8 @@ def speech( litellm_params=litellm_params_dict, logging_obj=logging_obj, timeout=http_timeout, - extra_headers=extra_headers, - client=client, + 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": diff --git a/litellm/proxy/public_endpoints/provider_create_fields.json b/litellm/proxy/public_endpoints/provider_create_fields.json index cb7d099cafb..c7b23108db2 100644 --- a/litellm/proxy/public_endpoints/provider_create_fields.json +++ b/litellm/proxy/public_endpoints/provider_create_fields.json @@ -1763,6 +1763,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/tests/unit/llms/gradium/text_to_speech/test_transformation.py b/tests/unit/llms/gradium/text_to_speech/test_transformation.py index fb7093140fa..7cc915bb05c 100644 --- a/tests/unit/llms/gradium/text_to_speech/test_transformation.py +++ b/tests/unit/llms/gradium/text_to_speech/test_transformation.py @@ -188,6 +188,23 @@ def test_omits_model_name_for_the_default_model(): 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( From 264651bc3609360b3e5c39cc2945adc04bd5b431 Mon Sep 17 00:00:00 2001 From: Gabriel Creti Date: Wed, 14 Oct 2026 16:43:29 +0200 Subject: [PATCH 6/6] refactor(gradium): take api_key and api_base from the model only The API key and base are set by whoever creates the model, like any other deployment credential. Drop the GRADIUM_API_KEY / GRADIUM_API_BASE environment fallbacks, the litellm.api_key fallback (which could send an unrelated provider's global key to Gradium) and the hardcoded default base URL; a missing value now raises a clear error. Co-Authored-By: Claude --- .../gradium/text_to_speech/transformation.py | 17 ++++---- .../text_to_speech/test_transformation.py | 43 ++++++++++++++----- 2 files changed, 41 insertions(+), 19 deletions(-) diff --git a/litellm/llms/gradium/text_to_speech/transformation.py b/litellm/llms/gradium/text_to_speech/transformation.py index bc5d3e0f011..79e6eb10b00 100644 --- a/litellm/llms/gradium/text_to_speech/transformation.py +++ b/litellm/llms/gradium/text_to_speech/transformation.py @@ -18,7 +18,6 @@ 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 @@ -37,7 +36,6 @@ class GradiumTextToSpeechConfig(BaseTextToSpeechConfig): 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" @@ -128,14 +126,12 @@ class GradiumTextToSpeechConfig(BaseTextToSpeechConfig): api_key: str | None = None, api_base: str | None = None, ) -> dict[str, str]: # mutable-ok: signature fixed by BaseTextToSpeechConfig - resolved_key: Final = api_key or litellm.api_key or get_secret_str("GRADIUM_API_KEY") - - if resolved_key is None: - raise ValueError("Gradium API key is required. Set GRADIUM_API_KEY environment variable.") + if not api_key: + raise ValueError("Gradium API key is required. Set `api_key` on the model.") return { **headers, - "x-api-key": resolved_key, + "x-api-key": api_key, "Content-Type": "application/json", } @@ -153,7 +149,12 @@ class GradiumTextToSpeechConfig(BaseTextToSpeechConfig): api_base: str | None, litellm_params: dict[str, object], # mutable-ok: signature fixed by BaseTextToSpeechConfig ) -> str: - base_url: Final = (api_base or get_secret_str("GRADIUM_API_BASE") or self.TTS_BASE_URL).rstrip("/") + 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. diff --git a/tests/unit/llms/gradium/text_to_speech/test_transformation.py b/tests/unit/llms/gradium/text_to_speech/test_transformation.py index 7cc915bb05c..7461fb1fc4b 100644 --- a/tests/unit/llms/gradium/text_to_speech/test_transformation.py +++ b/tests/unit/llms/gradium/text_to_speech/test_transformation.py @@ -12,13 +12,14 @@ from litellm.llms.gradium.text_to_speech.transformation import ( ) _AUDIO: Final = b"RIFFgradium-audio" -_URL: Final = "https://api.gradium.ai/api/post/speech/tts" +_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) - monkeypatch.setenv("GRADIUM_API_KEY", "gd_test_key") def _sent_body(route) -> dict: @@ -150,12 +151,22 @@ def test_builds_the_rest_tts_url_without_doubling_the_api_segment(api_base: str) assert url == "https://eu.api.gradium.ai/api/post/speech/tts" -def test_defaults_to_the_latency_routed_endpoint(): +def test_requires_an_api_base(): config = GradiumTextToSpeechConfig() - url = config.get_complete_url(model="default", api_base=None, litellm_params={}) + with pytest.raises(ValueError, match="Gradium API base is required"): + config.get_complete_url(model="default", api_base=None, litellm_params={}) - assert url == "https://api.gradium.ai/api/post/speech/tts" + +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(): @@ -226,6 +237,8 @@ def test_speech_calls_the_gradium_rest_endpoint(gradium_env): input="Bonjour", voice="alloy", response_format="wav", + api_base=_API_BASE, + api_key=_API_KEY, ) assert response.content == _AUDIO @@ -242,7 +255,9 @@ 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") + 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" @@ -252,14 +267,16 @@ 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") + 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) + 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): @@ -271,7 +288,7 @@ def test_speech_rejects_a_missing_voice_without_a_provider_config(gradium_env, m ) with pytest.raises(litellm.BadRequestError, match="must resolve to a Gradium voice id"): - litellm.speech(model="gradium/default", input="Bonjour", voice=None) + 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): @@ -302,7 +319,9 @@ def test_speech_falls_back_to_its_own_config(gradium_env, monkeypatch): with respx.mock: respx.post(_URL).mock(return_value=httpx.Response(200, content=_AUDIO)) - response = litellm.speech(model="gradium/default", input="Bonjour", voice="alloy") + response = litellm.speech( + model="gradium/default", input="Bonjour", voice="alloy", api_base=_API_BASE, api_key=_API_KEY + ) assert response.content == _AUDIO @@ -311,6 +330,8 @@ 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") + 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)