feat(gradium): add text-to-speech support for /v1/audio/speech

Gradium (https://gradium.ai) is a voice AI provider whose TTS had no
LiteLLM route. Adds it as a first-class TTS provider behind the existing
BaseTextToSpeechConfig seam, so `litellm.speech(model="gradium/default")`
and the proxy's /v1/audio/speech both work.

The provider targets Gradium's REST TTS endpoint
(POST /api/post/speech/tts) rather than its Python SDK, so the call
flows through base_llm_http_handler and keeps retries, logging and cost
tracking that an SDK-internal HTTP client would bypass.

Notes on the mapping:
- `only_audio: true` is always sent. Without it Gradium answers a
  line-delimited JSON message stream instead of audio bytes, which
  /v1/audio/speech callers cannot read.
- The six canonical OpenAI voice names map to Gradium library voice IDs;
  anything else is forwarded verbatim, so raw Gradium voice IDs work.
- Gradium has no mp3/aac/flac encoder, so those response_formats raise
  UnsupportedParamsError instead of silently returning mislabeled audio.
  `litellm.drop_params` falls back to the default format.
- Pre-stream failures come back as HTTP 500 with a plain-text body, so
  the response transform raises rather than handing the caller an opaque
  binary payload.
- Cost is per character of input text (1 credit/char), priced from the
  published add-on credit rate.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Gabriel Creti 2026-10-14 15:02:35 +02:00
parent 2f3ccbe7c7
commit dfb84299b7
No known key found for this signature in database
GPG key ID: E3015A42D3CA9D22
13 changed files with 547 additions and 0 deletions

View file

@ -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,

View file

View file

@ -0,0 +1,5 @@
from litellm.llms.base_llm.chat.transformation import BaseLLMException
class GradiumException(BaseLLMException):
pass

View file

@ -0,0 +1,218 @@
"""
Gradium Text-to-Speech transformation
Maps OpenAI TTS spec to Gradium's REST TTS API.
Reference: https://docs.gradium.ai/api-reference/endpoint/tts-post
"""
from typing import TYPE_CHECKING, ClassVar, Final
import httpx
from httpx import Headers
import litellm
from litellm.llms.base_llm.chat.transformation import BaseLLMException
from litellm.llms.base_llm.text_to_speech.transformation import (
BaseTextToSpeechConfig,
TextToSpeechRequestData,
)
from litellm.secret_managers.main import get_secret_str
from ..common_utils import GradiumException
if TYPE_CHECKING:
from litellm.litellm_core_utils.litellm_logging import Logging as LiteLLMLoggingObj
from litellm.types.llms.openai import HttpxBinaryResponseContent
else:
LiteLLMLoggingObj = object
HttpxBinaryResponseContent = object
class GradiumTextToSpeechConfig(BaseTextToSpeechConfig):
"""
Configuration for Gradium Text-to-Speech
Reference: https://docs.gradium.ai/api-reference/endpoint/tts-post
"""
TTS_BASE_URL = "https://api.gradium.ai"
TTS_ENDPOINT_PATH = "/api/post/speech/tts"
DEFAULT_OUTPUT_FORMAT = "wav"
# OpenAI canonical voice names → Gradium library voice IDs. Anything not
# listed is forwarded verbatim, so callers can pass raw Gradium voice IDs.
VOICE_MAPPINGS: ClassVar[dict[str, str]] = {
"alloy": "YTpq7expH9539ERJ",
"echo": "s0PhgjzOTRD5wo5L",
"fable": "1VAVLmmbQFDw7TMn",
"onyx": "QkmUhBH4hIV2_BkY",
"nova": "w9V1722uEmTkWqnR",
"shimmer": "BbLb4TxdlrldgpHI",
}
# Gradium serves raw PCM and Ogg-wrapped Opus, plus WAV. It has no mp3,
# aac or flac encoder, so those OpenAI formats have no mapping.
FORMAT_MAPPINGS: ClassVar[dict[str, str]] = {
"wav": "wav",
"pcm": "pcm_24000",
"opus": "opus",
}
def get_supported_openai_params(self, model: str) -> list:
return ["voice", "response_format"]
def _resolve_voice_id(self, voice: str | dict | None, params: dict) -> str:
"""Normalize the caller's `voice` into a Gradium voice_id."""
candidate: str | None = None
if isinstance(voice, str) and voice.strip():
candidate = voice.strip()
elif isinstance(voice, dict):
for key in ("voice_id", "id", "name"):
value = voice.get(key)
if isinstance(value, str) and value.strip():
candidate = value.strip()
break
if candidate is None:
override = params.pop("voice_id", None)
if isinstance(override, str) and override.strip():
candidate = override.strip()
if candidate is None:
raise ValueError("Gradium voice_id is required. Pass `voice` when calling `litellm.speech()`.")
return self.VOICE_MAPPINGS.get(candidate.lower(), candidate)
def map_openai_params(
self,
model: str,
optional_params: dict,
voice: str | dict | None = None,
drop_params: bool = False,
kwargs: dict | None = None,
) -> tuple[str | None, dict]:
params: Final = dict(optional_params) if optional_params else {}
mapped_params: Final[dict[str, object]] = {}
mapped_voice: Final = self._resolve_voice_id(voice, params)
response_format: Final = params.pop("response_format", None)
if isinstance(response_format, str):
mapped_format = self.FORMAT_MAPPINGS.get(response_format)
if mapped_format is None:
if not (drop_params or litellm.drop_params):
raise litellm.UnsupportedParamsError(
status_code=400,
message=(
f"Gradium TTS does not support response_format={response_format!r}. "
f"Supported: {sorted(self.FORMAT_MAPPINGS)}. "
"Set `litellm.drop_params = True` to fall back to the default format."
),
)
else:
mapped_params["output_format"] = mapped_format
# Gradium synthesizes at the voice's native rate; there is no speed
# control and no instructions field on the REST endpoint.
params.pop("speed", None)
params.pop("instructions", None)
for key, value in params.items():
if value is not None:
mapped_params[key] = value
return mapped_voice, mapped_params
def validate_environment(
self,
headers: dict,
model: str,
api_key: str | None = None,
api_base: str | None = None,
) -> dict:
api_key = api_key or litellm.api_key or get_secret_str("GRADIUM_API_KEY")
if api_key is None:
raise ValueError("Gradium API key is required. Set GRADIUM_API_KEY environment variable.")
headers.update(
{
"x-api-key": api_key,
"Content-Type": "application/json",
}
)
return headers
def get_error_class(self, error_message: str, status_code: int, headers: dict | Headers) -> BaseLLMException:
return GradiumException(message=error_message, status_code=status_code, headers=headers)
def get_complete_url(
self,
model: str,
api_base: str | None,
litellm_params: dict,
) -> str:
base_url: Final = (api_base or get_secret_str("GRADIUM_API_BASE") or self.TTS_BASE_URL).rstrip("/")
return f"{base_url}{self.TTS_ENDPOINT_PATH}"
def transform_text_to_speech_request(
self,
model: str,
input: str,
voice: str | None,
optional_params: dict,
litellm_params: dict,
headers: dict,
) -> TextToSpeechRequestData:
params: Final = dict(optional_params) if optional_params else {}
extra_body: Final = params.pop("extra_body", None)
request_body: Final[dict[str, object]] = {
"text": input,
"voice_id": voice,
"output_format": params.pop("output_format", self.DEFAULT_OUTPUT_FORMAT),
# Without this Gradium answers a line-delimited JSON message
# stream instead of audio bytes, which `/v1/audio/speech` callers
# cannot read.
"only_audio": True,
}
if model and model != "default":
request_body["model_name"] = model
for key, value in params.items():
if value is not None:
request_body[key] = value
if isinstance(extra_body, dict):
for key, value in extra_body.items():
if value is not None:
request_body[key] = value
return TextToSpeechRequestData(
dict_body=request_body,
headers={"Content-Type": "application/json"},
)
def transform_text_to_speech_response(
self,
model: str,
raw_response: httpx.Response,
logging_obj: LiteLLMLoggingObj,
) -> "HttpxBinaryResponseContent":
from litellm.types.llms.openai import HttpxBinaryResponseContent
# Gradium reports pre-stream failures as HTTP 500 with a plain-text
# body ("error from server <code>: <reason>"), so the status alone
# would surface as an opaque binary payload.
if raw_response.status_code >= 400:
raise GradiumException(
message=raw_response.text,
status_code=raw_response.status_code,
headers=raw_response.headers,
)
return HttpxBinaryResponseContent(raw_response)

View file

@ -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,

View file

@ -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",

View file

@ -4273,6 +4273,7 @@ class LlmProviders(str, Enum):
INFINITY = "infinity"
DEEPGRAM = "deepgram"
ELEVENLABS = "elevenlabs"
GRADIUM = "gradium"
NOVITA = "novita"
AIOHTTP_OPENAI = "aiohttp_openai"
LANGFUSE = "langfuse"

View file

@ -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,

View file

@ -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",

View file

View file

@ -0,0 +1,219 @@
import json
from typing import Final
import httpx
import pytest
import respx
import litellm
from litellm.llms.gradium.common_utils import GradiumException
from litellm.llms.gradium.text_to_speech.transformation import (
GradiumTextToSpeechConfig,
)
_AUDIO: Final = b"RIFFgradium-audio"
_URL: Final = "https://api.gradium.ai/api/post/speech/tts"
@pytest.fixture
def gradium_env(monkeypatch):
monkeypatch.setattr(litellm, "disable_aiohttp_transport", True)
monkeypatch.setenv("GRADIUM_API_KEY", "gd_test_key")
def _sent_body(route) -> dict:
return json.loads(route.calls[-1].request.content)
def test_maps_openai_voice_names_to_gradium_voice_ids():
config = GradiumTextToSpeechConfig()
voice, _ = config.map_openai_params(model="default", optional_params={}, voice="alloy")
assert voice == "YTpq7expH9539ERJ"
def test_passes_unknown_voices_through_as_raw_gradium_ids():
config = GradiumTextToSpeechConfig()
voice, _ = config.map_openai_params(model="default", optional_params={}, voice="w9V1722uEmTkWqnR")
assert voice == "w9V1722uEmTkWqnR"
def test_requires_a_voice():
config = GradiumTextToSpeechConfig()
with pytest.raises(ValueError, match="voice_id is required"):
config.map_openai_params(model="default", optional_params={}, voice=None)
@pytest.mark.parametrize(
("response_format", "expected"),
[("wav", "wav"), ("pcm", "pcm_24000"), ("opus", "opus")],
)
def test_maps_supported_openai_response_formats(response_format: str, expected: str):
config = GradiumTextToSpeechConfig()
_, params = config.map_openai_params(
model="default",
optional_params={"response_format": response_format},
voice="alloy",
)
assert params["output_format"] == expected
@pytest.mark.parametrize("response_format", ["mp3", "aac", "flac"])
def test_rejects_formats_gradium_cannot_encode(response_format: str):
config = GradiumTextToSpeechConfig()
with pytest.raises(litellm.UnsupportedParamsError, match="does not support response_format"):
config.map_openai_params(
model="default",
optional_params={"response_format": response_format},
voice="alloy",
)
def test_drops_unencodable_format_when_drop_params_is_set():
config = GradiumTextToSpeechConfig()
_, params = config.map_openai_params(
model="default",
optional_params={"response_format": "mp3"},
voice="alloy",
drop_params=True,
)
assert "output_format" not in params
def test_drops_openai_only_speed_and_instructions():
config = GradiumTextToSpeechConfig()
_, params = config.map_openai_params(
model="default",
optional_params={"speed": 2.0, "instructions": "whisper it"},
voice="alloy",
)
assert "speed" not in params
assert "instructions" not in params
def test_requires_an_api_key():
config = GradiumTextToSpeechConfig()
with pytest.raises(ValueError, match="Gradium API key is required"):
config.validate_environment(headers={}, model="default", api_key=None)
def test_sends_the_api_key_as_x_api_key():
config = GradiumTextToSpeechConfig()
headers = config.validate_environment(headers={}, model="default", api_key="gd_abc")
assert headers["x-api-key"] == "gd_abc"
def test_builds_the_rest_tts_url_from_api_base():
config = GradiumTextToSpeechConfig()
url = config.get_complete_url(model="default", api_base="https://eu.example.com/", litellm_params={})
assert url == "https://eu.example.com/api/post/speech/tts"
def test_requests_raw_audio_rather_than_a_json_message_stream():
config = GradiumTextToSpeechConfig()
data = config.transform_text_to_speech_request(
model="default",
input="bonjour",
voice="YTpq7expH9539ERJ",
optional_params={},
litellm_params={},
headers={},
)
assert data["dict_body"]["only_audio"] is True
def test_omits_model_name_for_the_default_model():
config = GradiumTextToSpeechConfig()
data = config.transform_text_to_speech_request(
model="default",
input="bonjour",
voice="YTpq7expH9539ERJ",
optional_params={},
litellm_params={},
headers={},
)
assert "model_name" not in data["dict_body"]
def test_raises_on_gradium_plain_text_errors():
config = GradiumTextToSpeechConfig()
raw_response = httpx.Response(
500,
text="error from server 42: unknown voice",
request=httpx.Request("POST", _URL),
)
with pytest.raises(GradiumException, match="unknown voice"):
config.transform_text_to_speech_response(model="default", raw_response=raw_response, logging_obj=None)
def test_speech_calls_the_gradium_rest_endpoint(gradium_env):
with respx.mock:
route = respx.post(_URL).mock(return_value=httpx.Response(200, content=_AUDIO))
response = litellm.speech(
model="gradium/default",
input="Bonjour",
voice="alloy",
response_format="wav",
)
assert response.content == _AUDIO
assert route.calls[-1].request.headers["x-api-key"] == "gd_test_key"
assert _sent_body(route) == {
"text": "Bonjour",
"voice_id": "YTpq7expH9539ERJ",
"output_format": "wav",
"only_audio": True,
}
def test_speech_forwards_a_non_default_model_name(gradium_env):
with respx.mock:
route = respx.post(_URL).mock(return_value=httpx.Response(200, content=_AUDIO))
litellm.speech(model="gradium/pro-voice-clone", input="Bonjour", voice="alloy")
assert _sent_body(route)["model_name"] == "pro-voice-clone"
@pytest.mark.asyncio
async def test_aspeech_calls_the_gradium_rest_endpoint(gradium_env):
with respx.mock:
respx.post(_URL).mock(return_value=httpx.Response(200, content=_AUDIO))
response = await litellm.aspeech(model="gradium/default", input="Bonjour", voice="alloy")
assert response.content == _AUDIO
def test_speech_tracks_cost_per_character(gradium_env, monkeypatch):
monkeypatch.setenv("LITELLM_LOCAL_MODEL_COST_MAP", "True")
litellm.model_cost = litellm.get_model_cost_map(url="")
with respx.mock:
respx.post(_URL).mock(return_value=httpx.Response(200, content=_AUDIO))
response = litellm.speech(model="gradium/default", input="a" * 100, voice="alloy")
assert response._hidden_params["response_cost"] == pytest.approx(100 * 6.9e-05)