From feef600f64d422a447204f5d429e5de19a5c3acc Mon Sep 17 00:00:00 2001 From: mateo-berri <277851410+mateo-berri@users.noreply.github.com> Date: Tue, 20 Oct 2026 21:29:45 -0700 Subject: [PATCH] fix(speech): try the provider-scoped key last so working global keys keep priority Putting get_llm_provider's key ahead of litellm.api_key, OPENAI_API_KEY and AZURE_API_KEY broke TTS setups that already worked through a global key, and let local-inference placeholders like fake-api-key outrank a real OPENAI_API_KEY. The provider-scoped key is now the last fallback in both speech key chains, so only callers that had no key at all change behavior --- litellm/main.py | 4 ++-- tests/unit/test_main.py | 48 +++++++++++++++++++++++++++++++++-------- 2 files changed, 41 insertions(+), 11 deletions(-) diff --git a/litellm/main.py b/litellm/main.py index 22199da259f..864906c1439 100644 --- a/litellm/main.py +++ b/litellm/main.py @@ -8677,10 +8677,10 @@ def speech( # set API KEY api_key = ( 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 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 ) organization = ( @@ -8763,11 +8763,11 @@ def speech( api_key = ( api_key - or dynamic_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 17433f7c5ee..74cb80d348b 100644 --- a/tests/unit/test_main.py +++ b/tests/unit/test_main.py @@ -4999,11 +4999,33 @@ def _clear_global_tts_keys(monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.setattr(litellm, attribute, None) -def test_speech_openai_compatible_sends_the_provider_scoped_key( - respx_mock: respx.MockRouter, monkeypatch: pytest.MonkeyPatch +def _set_or_clear_env(monkeypatch: pytest.MonkeyPatch, values: dict[str, str | None]) -> None: + for name, value in values.items(): + if value is None: + monkeypatch.delenv(name, raising=False) + else: + monkeypatch.setenv(name, value) + + +@pytest.mark.parametrize( + ("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"), + (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"], +) +def test_speech_openai_compatible_falls_back_to_the_provider_scoped_key( + respx_mock: respx.MockRouter, + monkeypatch: pytest.MonkeyPatch, + provider_key: str | None, + openai_key: str | None, + sent_authorization: str, ): _clear_global_tts_keys(monkeypatch) - monkeypatch.setenv("HOSTED_VLLM_API_KEY", "hosted-vllm-scoped-key") + _set_or_clear_env(monkeypatch, {"HOSTED_VLLM_API_KEY": provider_key, "OPENAI_API_KEY": openai_key}) route: Final = respx_mock.post("https://vllm.internal/v1/audio/speech").mock( return_value=httpx.Response(200, content=TTS_AUDIO, headers={"content-type": "audio/mpeg"}) ) @@ -5014,14 +5036,22 @@ def test_speech_openai_compatible_sends_the_provider_scoped_key( assert response.content == TTS_AUDIO assert route.call_count == 1 - assert route.calls.last.request.headers["authorization"] == "Bearer hosted-vllm-scoped-key" + assert route.calls.last.request.headers["authorization"] == sent_authorization -def test_speech_azure_sends_the_provider_scoped_key(respx_mock: respx.MockRouter, monkeypatch: pytest.MonkeyPatch): +@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"], +) +def test_speech_azure_falls_back_to_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) - monkeypatch.setenv("AZURE_AI_API_KEY", "azure-ai-scoped-key") - monkeypatch.setenv("AZURE_OPENAI_API_KEY", "legacy-azure-openai-key") - monkeypatch.setenv("AZURE_API_KEY", "legacy-azure-key") + _set_or_clear_env( + monkeypatch, + {"AZURE_AI_API_KEY": "azure-ai-scoped-key", "AZURE_OPENAI_API_KEY": None, "AZURE_API_KEY": generic_azure_key}, + ) route: Final = respx_mock.post( url__regex=r"https://foundry\.internal/openai/deployments/tts-1/audio/speech\?api-version=.+" ).mock(return_value=httpx.Response(200, content=TTS_AUDIO, headers={"content-type": "audio/mpeg"})) @@ -5036,7 +5066,7 @@ def test_speech_azure_sends_the_provider_scoped_key(respx_mock: respx.MockRouter assert response.content == TTS_AUDIO assert route.call_count == 1 - assert route.calls.last.request.headers["api-key"] == "azure-ai-scoped-key" + assert route.calls.last.request.headers["api-key"] == sent_api_key def test_speech_keeps_an_explicitly_passed_api_key_over_the_provider_scoped_one(