From d30be01aca5826954ae8a120f49ded188317d073 Mon Sep 17 00:00:00 2001 From: mateo-berri <277851410+mateo-berri@users.noreply.github.com> Date: Tue, 20 Oct 2026 22:08:26 -0700 Subject: [PATCH] fix(speech): keep the provider key first but skip local-inference placeholders Trying the provider key last let OPENAI_API_KEY or AZURE_API_KEY go to a hosted_vllm, deepinfra or azure_ai endpoint whose own key was configured, which leaks the global credential and still fails the reported setup. The provider key goes first again, matching completion() and transcription(), and the fake-api-key / dummy-key placeholders get_llm_provider returns for local-inference providers are skipped there, so a real OPENAI_API_KEY still wins over them --- litellm/constants.py | 1 + litellm/main.py | 7 +++++-- tests/unit/test_main.py | 28 ++++++++++++++++++++++------ 3 files changed, 28 insertions(+), 8 deletions(-) diff --git a/litellm/constants.py b/litellm/constants.py index 6a503463a8c..238d60e9dde 100644 --- a/litellm/constants.py +++ b/litellm/constants.py @@ -342,6 +342,7 @@ DEFAULT_REASONING_EFFORT_MINIMAL_THINKING_BUDGET: Final = get_env_int( # Provider-specific API base URLs XAI_API_BASE: Final = "https://api.x.ai/v1" PLACEHOLDER_API_KEY: Final = "fake-api-key" +PLACEHOLDER_API_KEYS: Final = frozenset({PLACEHOLDER_API_KEY, "dummy-key"}) OPEN_SANDBOX_API_BASE_ENV_VAR: Final = "OPEN_SANDBOX_API_BASE" OPEN_SANDBOX_API_KEY_ENV_VAR: Final = "OPEN_SANDBOX_API_KEY" OPEN_SANDBOX_DEFAULT_TEMPLATE: Final = "opensandbox/code-interpreter:v1.1.0" diff --git a/litellm/main.py b/litellm/main.py index 864906c1439..f42c2b104b7 100644 --- a/litellm/main.py +++ b/litellm/main.py @@ -66,6 +66,7 @@ from litellm.constants import ( DEFAULT_MOCK_RESPONSE_PROMPT_TOKEN_COUNT, NADIR_DEFAULT_API_BASE, OPENAI_AUDIO_TRANSCRIPTION_PROVIDERS, + PLACEHOLDER_API_KEYS, ) from litellm.exceptions import LiteLLMUnknownProvider from litellm.integrations.custom_logger import CustomLogger @@ -8588,6 +8589,7 @@ def speech( model, custom_llm_provider, dynamic_api_key, api_base = get_llm_provider( model=model, custom_llm_provider=custom_llm_provider, api_base=api_base or base_url ) + provider_api_key: Final = None if dynamic_api_key in PLACEHOLDER_API_KEYS else dynamic_api_key kwargs.pop("tags", []) optional_params = {} @@ -8677,10 +8679,11 @@ def speech( # set API KEY api_key = ( api_key + or provider_api_key # for deepinfra/perplexity/anyscale we check in get_llm_provider and pass in the api key from there or litellm.api_key or litellm.openai_key or get_secret("OPENAI_API_KEY") - or dynamic_api_key # for deepinfra/perplexity/anyscale we check in get_llm_provider and pass in the api key from there + or dynamic_api_key ) organization = ( @@ -8763,11 +8766,11 @@ def speech( api_key = ( api_key + or provider_api_key or litellm.api_key or litellm.azure_key or get_secret("AZURE_OPENAI_API_KEY") or get_secret("AZURE_API_KEY") - or dynamic_api_key ) azure_ad_token: Final[str | None] = optional_params.get("extra_body", {}).pop( diff --git a/tests/unit/test_main.py b/tests/unit/test_main.py index 74cb80d348b..72c2ec0678d 100644 --- a/tests/unit/test_main.py +++ b/tests/unit/test_main.py @@ -5011,13 +5011,13 @@ def _set_or_clear_env(monkeypatch: pytest.MonkeyPatch, values: dict[str, str | N ("provider_key", "openai_key", "sent_authorization"), [ ("hosted-vllm-scoped-key", None, "Bearer hosted-vllm-scoped-key"), - ("hosted-vllm-scoped-key", "generic-openai-key", "Bearer generic-openai-key"), + ("hosted-vllm-scoped-key", "generic-openai-key", "Bearer hosted-vllm-scoped-key"), (None, "generic-openai-key", "Bearer generic-openai-key"), (None, None, "Bearer fake-api-key"), ], - ids=["provider-key-alone", "generic-key-still-wins", "generic-key-beats-placeholder", "placeholder-when-unset"], + ids=["provider-key-alone", "provider-key-beats-generic", "generic-key-beats-placeholder", "placeholder-when-unset"], ) -def test_speech_openai_compatible_falls_back_to_the_provider_scoped_key( +def test_speech_openai_compatible_sends_the_provider_scoped_key( respx_mock: respx.MockRouter, monkeypatch: pytest.MonkeyPatch, provider_key: str | None, @@ -5041,10 +5041,10 @@ def test_speech_openai_compatible_falls_back_to_the_provider_scoped_key( @pytest.mark.parametrize( ("generic_azure_key", "sent_api_key"), - [(None, "azure-ai-scoped-key"), ("generic-azure-key", "generic-azure-key")], - ids=["provider-key-alone", "generic-key-still-wins"], + [(None, "azure-ai-scoped-key"), ("generic-azure-key", "azure-ai-scoped-key")], + ids=["provider-key-alone", "provider-key-beats-generic"], ) -def test_speech_azure_falls_back_to_the_provider_scoped_key( +def test_speech_azure_sends_the_provider_scoped_key( respx_mock: respx.MockRouter, monkeypatch: pytest.MonkeyPatch, generic_azure_key: str | None, sent_api_key: str ): _clear_global_tts_keys(monkeypatch) @@ -5069,6 +5069,22 @@ def test_speech_azure_falls_back_to_the_provider_scoped_key( assert route.calls.last.request.headers["api-key"] == sent_api_key +def test_speech_docker_model_runner_placeholder_does_not_beat_a_real_key( + respx_mock: respx.MockRouter, monkeypatch: pytest.MonkeyPatch +): + _clear_global_tts_keys(monkeypatch) + _set_or_clear_env(monkeypatch, {"DOCKER_MODEL_RUNNER_API_KEY": None, "OPENAI_API_KEY": "generic-openai-key"}) + route: Final = respx_mock.post("https://dmr.internal/v1/audio/speech").mock( + return_value=httpx.Response(200, content=TTS_AUDIO, headers={"content-type": "audio/mpeg"}) + ) + + litellm.speech( + model="docker_model_runner/tts-1", input="which key goes out", voice="alloy", api_base="https://dmr.internal/v1" + ) + + assert route.calls.last.request.headers["authorization"] == "Bearer generic-openai-key" + + def test_speech_keeps_an_explicitly_passed_api_key_over_the_provider_scoped_one( respx_mock: respx.MockRouter, monkeypatch: pytest.MonkeyPatch ):