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 <noreply@anthropic.com>
This commit is contained in:
Gabriel Creti 2026-10-14 16:43:29 +02:00
parent e89422693c
commit 264651bc36
No known key found for this signature in database
GPG key ID: E3015A42D3CA9D22
2 changed files with 41 additions and 19 deletions

View file

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

View file

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