This commit is contained in:
Gabriel Creti 2026-10-21 03:37:01 +00:00 • committed by GitHub
commit 66fe76baa7
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
16 changed files with 704 additions and 0 deletions

View file

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

View file

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

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

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

View file

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