From 264651bc3609360b3e5c39cc2945adc04bd5b431 Mon Sep 17 00:00:00 2001 From: Gabriel Creti Date: Wed, 14 Oct 2026 16:43:29 +0200 Subject: [PATCH] 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)