From 3ceaf793ac2a8547911bf40dd6181df31e1d6311 Mon Sep 17 00:00:00 2001 From: mateo-berri <277851410+mateo-berri@users.noreply.github.com> Date: Thu, 3 Sep 2026 01:33:18 -0700 Subject: [PATCH 1/9] fix(speech): use the provider api key get_llm_provider resolves speech() called get_llm_provider and then dropped its dynamic_api_key everywhere except the logging params, so a TTS deployment whose credential lives in a provider-specific env var fell through to OPENAI_API_KEY on the openai-compatible branch and to AZURE_OPENAI_API_KEY / AZURE_API_KEY on the azure branch, failing with a missing-credentials error naming variables the admin never configured. Fold dynamic_api_key into both branch key chains, right after an explicitly passed api_key, so a provider-scoped key is used while an explicit one stays authoritative. --- litellm/main.py | 4 +- tests/test_litellm/test_main.py | 73 +++++++++++++++++++++++++++++++++ 2 files changed, 76 insertions(+), 1 deletion(-) diff --git a/litellm/main.py b/litellm/main.py index 0bca4a7350e..229520bebf1 100644 --- a/litellm/main.py +++ b/litellm/main.py @@ -8073,7 +8073,8 @@ def speech( # set API KEY api_key = ( api_key - or litellm.api_key # for deepinfra/perplexity/anyscale we check in get_llm_provider and pass in the api key from there + 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") ) @@ -8157,6 +8158,7 @@ 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") diff --git a/tests/test_litellm/test_main.py b/tests/test_litellm/test_main.py index 3c8bf142835..7e2bc3dbe30 100644 --- a/tests/test_litellm/test_main.py +++ b/tests/test_litellm/test_main.py @@ -1,6 +1,8 @@ import asyncio import base64 +import threading from datetime import datetime +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer import contextlib import copy import json @@ -3259,3 +3261,74 @@ def test_stream_chunk_builder_leaves_xai_reported_cost_to_the_calculator(monkeyp assert getattr(response.usage, "cost", None) == pytest.approx(0.42) assert response._hidden_params.get("response_cost") is None assert logging_obj._response_cost_calculator(result=response) == pytest.approx(0.63) + + +@contextlib.contextmanager +def _recording_tts_server(): + received: Final[list[Mapping[str, str]]] = [] + + class _Handler(BaseHTTPRequestHandler): + def do_POST(self): + received.append({key.lower(): value for key, value in self.headers.items()}) + self.rfile.read(int(self.headers.get("Content-Length") or 0)) + body = b"ID3\x04\x00\x00\x00fake-mp3-payload" + self.send_response(200) + self.send_header("Content-Type", "audio/mpeg") + self.send_header("Content-Length", str(len(body))) + self.end_headers() + self.wfile.write(body) + + def log_message(self, format, *args): + return + + server: Final = ThreadingHTTPServer(("127.0.0.1", 0), _Handler) + thread: Final = threading.Thread(target=server.serve_forever, daemon=True) + thread.start() + try: + yield f"http://127.0.0.1:{server.server_port}", received + finally: + server.shutdown() + server.server_close() + thread.join(timeout=5) + + +def test_speech_openai_compatible_sends_the_provider_scoped_key(monkeypatch: pytest.MonkeyPatch): + monkeypatch.setattr(litellm, "api_key", None) + monkeypatch.setattr(litellm, "openai_key", None) + monkeypatch.setattr(litellm, "api_base", None) + monkeypatch.setenv("HOSTED_VLLM_API_KEY", "hosted-vllm-scoped-key") + monkeypatch.setenv("OPENAI_API_KEY", "generic-openai-key") + + with _recording_tts_server() as (api_base, received): + response: Final = litellm.speech( + model="hosted_vllm/tts-1", + input="which key goes out", + voice="alloy", + api_base=api_base, + ) + + assert response.content == b"ID3\x04\x00\x00\x00fake-mp3-payload" + assert len(received) == 1 + assert received[0]["authorization"] == "Bearer hosted-vllm-scoped-key" + + +def test_speech_azure_sends_the_provider_scoped_key(monkeypatch: pytest.MonkeyPatch): + monkeypatch.setattr(litellm, "api_key", None) + monkeypatch.setattr(litellm, "azure_key", None) + monkeypatch.setattr(litellm, "api_base", None) + 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") + + with _recording_tts_server() as (api_base, received): + response: Final = litellm.speech( + model="azure_ai/tts-1", + input="which key goes out", + voice="alloy", + api_base=api_base, + api_version="2024-05-01-preview", + ) + + assert response.content == b"ID3\x04\x00\x00\x00fake-mp3-payload" + assert len(received) == 1 + assert received[0]["api-key"] == "azure-ai-scoped-key" From bc0c5b5ba73401f1a00708449ac46bb0c3fe7830 Mon Sep 17 00:00:00 2001 From: mateo-berri <277851410+mateo-berri@users.noreply.github.com> Date: Thu, 3 Sep 2026 01:40:53 -0700 Subject: [PATCH 2/9] test(main): assert an explicit api_key still outranks the provider-scoped one in speech() --- tests/test_litellm/test_main.py | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/tests/test_litellm/test_main.py b/tests/test_litellm/test_main.py index 7e2bc3dbe30..4e40903a3bb 100644 --- a/tests/test_litellm/test_main.py +++ b/tests/test_litellm/test_main.py @@ -3332,3 +3332,22 @@ def test_speech_azure_sends_the_provider_scoped_key(monkeypatch: pytest.MonkeyPa assert response.content == b"ID3\x04\x00\x00\x00fake-mp3-payload" assert len(received) == 1 assert received[0]["api-key"] == "azure-ai-scoped-key" + + +def test_speech_keeps_an_explicitly_passed_api_key_over_the_provider_scoped_one(monkeypatch: pytest.MonkeyPatch): + monkeypatch.setattr(litellm, "api_key", None) + monkeypatch.setattr(litellm, "openai_key", None) + monkeypatch.setattr(litellm, "api_base", None) + monkeypatch.setenv("HOSTED_VLLM_API_KEY", "hosted-vllm-scoped-key") + + with _recording_tts_server() as (api_base, received): + litellm.speech( + model="hosted_vllm/tts-1", + input="which key goes out", + voice="alloy", + api_base=api_base, + api_key="caller-supplied-key", + ) + + assert len(received) == 1 + assert received[0]["authorization"] == "Bearer caller-supplied-key" From 33f71357d22493ccc85f0c521e74ea79bd6c4e84 Mon Sep 17 00:00:00 2001 From: mateo-berri <277851410+mateo-berri@users.noreply.github.com> Date: Thu, 3 Sep 2026 01:49:24 -0700 Subject: [PATCH 3/9] test(main): hand recorded TTS headers across threads through a queue --- tests/test_litellm/test_main.py | 17 +++++++++-------- 1 file changed, 9 insertions(+), 8 deletions(-) diff --git a/tests/test_litellm/test_main.py b/tests/test_litellm/test_main.py index 4e40903a3bb..7fd1e37bce1 100644 --- a/tests/test_litellm/test_main.py +++ b/tests/test_litellm/test_main.py @@ -1,5 +1,6 @@ import asyncio import base64 +import queue import threading from datetime import datetime from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer @@ -3265,11 +3266,11 @@ def test_stream_chunk_builder_leaves_xai_reported_cost_to_the_calculator(monkeyp @contextlib.contextmanager def _recording_tts_server(): - received: Final[list[Mapping[str, str]]] = [] + received: Final[queue.Queue[Mapping[str, str]]] = queue.Queue() class _Handler(BaseHTTPRequestHandler): def do_POST(self): - received.append({key.lower(): value for key, value in self.headers.items()}) + received.put({key.lower(): value for key, value in self.headers.items()}) self.rfile.read(int(self.headers.get("Content-Length") or 0)) body = b"ID3\x04\x00\x00\x00fake-mp3-payload" self.send_response(200) @@ -3308,8 +3309,8 @@ def test_speech_openai_compatible_sends_the_provider_scoped_key(monkeypatch: pyt ) assert response.content == b"ID3\x04\x00\x00\x00fake-mp3-payload" - assert len(received) == 1 - assert received[0]["authorization"] == "Bearer hosted-vllm-scoped-key" + assert received.get(timeout=5)["authorization"] == "Bearer hosted-vllm-scoped-key" + assert received.empty() def test_speech_azure_sends_the_provider_scoped_key(monkeypatch: pytest.MonkeyPatch): @@ -3330,8 +3331,8 @@ def test_speech_azure_sends_the_provider_scoped_key(monkeypatch: pytest.MonkeyPa ) assert response.content == b"ID3\x04\x00\x00\x00fake-mp3-payload" - assert len(received) == 1 - assert received[0]["api-key"] == "azure-ai-scoped-key" + assert received.get(timeout=5)["api-key"] == "azure-ai-scoped-key" + assert received.empty() def test_speech_keeps_an_explicitly_passed_api_key_over_the_provider_scoped_one(monkeypatch: pytest.MonkeyPatch): @@ -3349,5 +3350,5 @@ def test_speech_keeps_an_explicitly_passed_api_key_over_the_provider_scoped_one( api_key="caller-supplied-key", ) - assert len(received) == 1 - assert received[0]["authorization"] == "Bearer caller-supplied-key" + assert received.get(timeout=5)["authorization"] == "Bearer caller-supplied-key" + assert received.empty() 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 4/9] 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( 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 5/9] 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 ): From 19123dd9f4b5d345d7980ca479f79e6b43c3e0c1 Mon Sep 17 00:00:00 2001 From: mateo-berri <277851410+mateo-berri@users.noreply.github.com> Date: Wed, 28 Oct 2026 18:34:43 -0700 Subject: [PATCH 6/9] fix(speech): keep a deployment's Entra ID credentials ahead of the provider-scoped key --- litellm/main.py | 22 +++++++++----- tests/unit/test_main.py | 66 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 81 insertions(+), 7 deletions(-) diff --git a/litellm/main.py b/litellm/main.py index d4cf5dc8383..63fdf0f5421 100644 --- a/litellm/main.py +++ b/litellm/main.py @@ -97,6 +97,7 @@ from litellm.litellm_core_utils.get_provider_specific_headers import ( ProviderSpecificHeaderUtils, ) from litellm.litellm_core_utils.health_check_utils import ( + OPTIONAL_STR, _filter_model_params, create_health_check_response, ) @@ -241,6 +242,7 @@ from .llms.azure.chat.o_series_handler import AzureOpenAIO1ChatCompletion from .llms.azure.completion.handler import AzureTextCompletion from .llms.azure_ai.anthropic.handler import AzureAnthropicChatCompletion from .llms.azure_ai.anthropic.transformation import azure_anthropic_messages_url +from .llms.azure_ai.common_utils import has_azure_entra_params from .llms.azure_ai.embed import AzureAIEmbedding from .llms.bedrock.chat import BedrockConverseLLM from .llms.bedrock.chat.converse_transformation import AmazonConverseConfig @@ -8837,19 +8839,26 @@ def speech( api_version = api_version or litellm.api_version or get_secret("AZURE_API_VERSION") + request_azure_ad_token: Final = OPTIONAL_STR.validate_python( + optional_params.get("extra_body", {}).pop("azure_ad_token", None) + ) + request_kwargs: Final = _REQUEST_KWARGS_ADAPTER.validate_python(kwargs) + deployment_uses_entra: Final = ( + request_azure_ad_token is not None + or request_kwargs.get("azure_ad_token_provider") is not None + or has_azure_entra_params(request_kwargs) + ) + api_key = ( api_key - or provider_api_key + or (None if deployment_uses_entra else provider_api_key) or litellm.api_key or litellm.azure_key or get_secret("AZURE_OPENAI_API_KEY") or get_secret("AZURE_API_KEY") ) - azure_ad_token: Final[str | None] = optional_params.get("extra_body", {}).pop( - "azure_ad_token", None - ) or get_secret("AZURE_AD_TOKEN") - azure_ad_token_provider: Final = kwargs.get("azure_ad_token_provider", None) + azure_ad_token: Final[str | None] = request_azure_ad_token or get_secret("AZURE_AD_TOKEN") if extra_headers: optional_params["extra_headers"] = extra_headers @@ -8863,7 +8872,7 @@ def speech( api_base=api_base, api_version=api_version, azure_ad_token=azure_ad_token, - azure_ad_token_provider=azure_ad_token_provider, + azure_ad_token_provider=kwargs.get("azure_ad_token_provider", None), organization=organization, max_retries=max_retries, timeout=http_timeout, @@ -9147,7 +9156,6 @@ async def ahealth_check( """ from litellm.litellm_core_utils.cached_imports import get_litellm_logging_class from litellm.litellm_core_utils.health_check_helpers import HealthCheckHelpers, default_health_check_mode - from litellm.litellm_core_utils.health_check_utils import OPTIONAL_STR # Use cached import helper to lazy-load Logging class (only loads when function is called) Logging: Final = get_litellm_logging_class() diff --git a/tests/unit/test_main.py b/tests/unit/test_main.py index 37c64990330..c15b26a6cb2 100644 --- a/tests/unit/test_main.py +++ b/tests/unit/test_main.py @@ -5533,6 +5533,72 @@ def test_speech_azure_sends_the_provider_scoped_key( assert route.calls.last.request.headers["api-key"] == sent_api_key +@pytest.mark.parametrize( + ("model", "provider_env", "module_key", "header", "sent_key"), + [ + ("hosted_vllm/tts-1", "HOSTED_VLLM_API_KEY", "api_key", "authorization", "Bearer provider-scoped-key"), + ("hosted_vllm/tts-1", "HOSTED_VLLM_API_KEY", "openai_key", "authorization", "Bearer provider-scoped-key"), + ("azure_ai/tts-1", "AZURE_AI_API_KEY", "api_key", "api-key", "provider-scoped-key"), + ("azure_ai/tts-1", "AZURE_AI_API_KEY", "azure_key", "api-key", "provider-scoped-key"), + ], + ids=["openai-compatible-over-api_key", "openai-compatible-over-openai_key", "azure-over-api_key", "azure-over-azure_key"], +) +def test_speech_provider_scoped_key_beats_module_level_keys( + respx_mock: respx.MockRouter, + monkeypatch: pytest.MonkeyPatch, + model: str, + provider_env: str, + module_key: str, + header: str, + sent_key: str, +): + _clear_global_tts_keys(monkeypatch) + _set_or_clear_env( + monkeypatch, + {provider_env: "provider-scoped-key", "OPENAI_API_KEY": None, "AZURE_OPENAI_API_KEY": None, "AZURE_API_KEY": None}, + ) + monkeypatch.setattr(litellm, module_key, "module-level-key") + route: Final = respx_mock.post(url__regex=r"https://tts\.internal/.*audio/speech.*").mock( + return_value=httpx.Response(200, content=TTS_AUDIO, headers={"content-type": "audio/mpeg"}) + ) + + litellm.speech( + model=model, + input="which key goes out", + voice="alloy", + api_base="https://tts.internal/v1" if model.startswith("hosted_vllm/") else "https://tts.internal", + api_version="2024-05-01-preview", + ) + + assert route.calls.last.request.headers[header] == sent_key + + +def test_speech_azure_ai_keeps_the_deployments_entra_token_over_the_provider_scoped_key( + respx_mock: respx.MockRouter, monkeypatch: pytest.MonkeyPatch +): + _clear_global_tts_keys(monkeypatch) + _set_or_clear_env( + monkeypatch, + {"AZURE_AI_API_KEY": "key-for-another-resource", "AZURE_OPENAI_API_KEY": None, "AZURE_API_KEY": None}, + ) + route: Final = respx_mock.post(url__regex=r"https://foundry\.internal/openai/v1/audio/speech.*").mock( + return_value=httpx.Response(200, content=TTS_AUDIO, headers={"content-type": "audio/mpeg"}) + ) + + litellm.speech( + model="azure_ai/tts-1", + input="which key goes out", + voice="alloy", + api_base="https://foundry.internal", + api_version="preview", + azure_ad_token="deployment-entra-token", + ) + + sent_headers: Final = route.calls.last.request.headers + assert sent_headers["authorization"] == "Bearer deployment-entra-token" + assert "key-for-another-resource" not in sent_headers.get("api-key", "") + + def test_speech_docker_model_runner_placeholder_does_not_beat_a_real_key( respx_mock: respx.MockRouter, monkeypatch: pytest.MonkeyPatch ): From 33d3544ea6e0a7e57696cd450f65a740524ee558 Mon Sep 17 00:00:00 2001 From: mateo-berri <277851410+mateo-berri@users.noreply.github.com> Date: Wed, 28 Oct 2026 22:38:35 -0700 Subject: [PATCH 7/9] fix(speech): keep Entra ID from env vars and token refresh ahead of the provider-scoped key --- litellm/llms/azure/common_utils.py | 28 +++++++++++ litellm/main.py | 10 ++-- .../llms/azure/test_azure_common_utils.py | 40 ++++++++++++++++ tests/unit/test_main.py | 46 +++++++++++++++++++ 4 files changed, 118 insertions(+), 6 deletions(-) diff --git a/litellm/llms/azure/common_utils.py b/litellm/llms/azure/common_utils.py index 4bd19889bcc..3aa4cf6d86f 100644 --- a/litellm/llms/azure/common_utils.py +++ b/litellm/llms/azure/common_utils.py @@ -145,6 +145,34 @@ def cached_azure_ad_token_refresh_provider(scope: str) -> Callable[[], str]: return _cached_azure_ad_token_refresh_provider(scope) +def _param_or_env_set( + litellm_params: Mapping[str, object], environ: Mapping[str, str], param_key: str, env_var_key: str +) -> bool: + param_value: Final = litellm_params.get(param_key) + return bool(param_value if param_value is not None else environ.get(env_var_key)) + + +def uses_azure_entra_id_auth( + litellm_params: Mapping[str, object], environ: Mapping[str, str], token_refresh_enabled: bool +) -> bool: + client_id_set: Final = _param_or_env_set(litellm_params, environ, "client_id", "AZURE_CLIENT_ID") + return ( + litellm_params.get("azure_ad_token_provider") is not None + or bool(litellm_params.get("azure_ad_token")) + or ( + client_id_set + and _param_or_env_set(litellm_params, environ, "tenant_id", "AZURE_TENANT_ID") + and _param_or_env_set(litellm_params, environ, "client_secret", "AZURE_CLIENT_SECRET") + ) + or ( + client_id_set + and _param_or_env_set(litellm_params, environ, "azure_username", "AZURE_USERNAME") + and _param_or_env_set(litellm_params, environ, "azure_password", "AZURE_PASSWORD") + ) + or token_refresh_enabled + ) + + def _async_azure_ad_token_provider( provider: Callable[[], str | Awaitable[str]], ) -> Callable[[], Awaitable[str]]: diff --git a/litellm/main.py b/litellm/main.py index 63fdf0f5421..3a768477c42 100644 --- a/litellm/main.py +++ b/litellm/main.py @@ -239,10 +239,10 @@ from .llms.anthropic.chat import AnthropicChatCompletion from .llms.azure.audio_transcriptions import AzureAudioTranscription from .llms.azure.azure import AzureChatCompletion, check_dynamic_azure_params from .llms.azure.chat.o_series_handler import AzureOpenAIO1ChatCompletion +from .llms.azure.common_utils import uses_azure_entra_id_auth from .llms.azure.completion.handler import AzureTextCompletion from .llms.azure_ai.anthropic.handler import AzureAnthropicChatCompletion from .llms.azure_ai.anthropic.transformation import azure_anthropic_messages_url -from .llms.azure_ai.common_utils import has_azure_entra_params from .llms.azure_ai.embed import AzureAIEmbedding from .llms.bedrock.chat import BedrockConverseLLM from .llms.bedrock.chat.converse_transformation import AmazonConverseConfig @@ -8843,15 +8843,13 @@ def speech( optional_params.get("extra_body", {}).pop("azure_ad_token", None) ) request_kwargs: Final = _REQUEST_KWARGS_ADAPTER.validate_python(kwargs) - deployment_uses_entra: Final = ( - request_azure_ad_token is not None - or request_kwargs.get("azure_ad_token_provider") is not None - or has_azure_entra_params(request_kwargs) + uses_entra_id: Final = request_azure_ad_token is not None or uses_azure_entra_id_auth( + request_kwargs, os.environ, litellm.enable_azure_ad_token_refresh is True ) api_key = ( api_key - or (None if deployment_uses_entra else provider_api_key) + or (None if uses_entra_id else provider_api_key) or litellm.api_key or litellm.azure_key or get_secret("AZURE_OPENAI_API_KEY") diff --git a/tests/unit/llms/azure/test_azure_common_utils.py b/tests/unit/llms/azure/test_azure_common_utils.py index f7e2e73d426..038e4d79f15 100644 --- a/tests/unit/llms/azure/test_azure_common_utils.py +++ b/tests/unit/llms/azure/test_azure_common_utils.py @@ -22,6 +22,7 @@ from litellm.llms.azure.common_utils import ( get_azure_ad_token_from_entra_id, get_azure_ad_token_from_oidc, get_azure_ad_token_from_username_password, + uses_azure_entra_id_auth, ) from litellm.secret_managers.get_azure_ad_token_provider import ( get_azure_ad_token_provider, @@ -2492,3 +2493,42 @@ def test_an_azure_client_litellm_built_its_own_http_client_for_is_still_closed(m closer.reap() assert wrapper.is_closed() is True + + +@pytest.mark.parametrize( + ("litellm_params", "environ", "token_refresh_enabled", "expected"), + [ + ({"azure_ad_token": "deployment-token"}, {}, False, True), + ({"azure_ad_token_provider": lambda: "token"}, {}, False, True), + ({"tenant_id": "t", "client_id": "c", "client_secret": "s"}, {}, False, True), + ({}, {"AZURE_TENANT_ID": "t", "AZURE_CLIENT_ID": "c", "AZURE_CLIENT_SECRET": "s"}, False, True), + ({"tenant_id": "t", "client_id": "c"}, {"AZURE_CLIENT_SECRET": "s"}, False, True), + ({"client_id": "c", "azure_username": "u"}, {"AZURE_PASSWORD": "p"}, False, True), + ({}, {}, True, True), + ({"tenant_id": "t", "client_id": "c"}, {}, False, False), + ({"client_secret": "s"}, {}, False, False), + ({"azure_username": "u", "azure_password": "p"}, {}, False, False), + ({"tenant_id": None, "client_id": "c", "client_secret": "s"}, {"AZURE_TENANT_ID": "t"}, False, True), + ({"tenant_id": "", "client_id": "c", "client_secret": "s"}, {"AZURE_TENANT_ID": "t"}, False, False), + ({}, {}, False, False), + ], + ids=[ + "ad-token", + "ad-token-provider", + "service-principal-in-params", + "service-principal-in-env", + "ids-in-params-secret-in-env", + "username-password-split", + "token-refresh", + "service-principal-missing-secret", + "secret-alone", + "username-password-missing-client-id", + "none-param-falls-back-to-env", + "empty-param-does-not-fall-back-to-env", + "nothing-set", + ], +) +def test_uses_azure_entra_id_auth_matches_the_sources_the_azure_client_builds_entra_auth_from( + litellm_params, environ, token_refresh_enabled, expected +): + assert uses_azure_entra_id_auth(litellm_params, environ, token_refresh_enabled) is expected diff --git a/tests/unit/test_main.py b/tests/unit/test_main.py index c15b26a6cb2..ddedf7a8b9d 100644 --- a/tests/unit/test_main.py +++ b/tests/unit/test_main.py @@ -5599,6 +5599,52 @@ def test_speech_azure_ai_keeps_the_deployments_entra_token_over_the_provider_sco assert "key-for-another-resource" not in sent_headers.get("api-key", "") +@pytest.mark.parametrize( + ("entra_env", "deployment_params", "token_refresh"), + [ + ({"AZURE_TENANT_ID": "t", "AZURE_CLIENT_ID": "c", "AZURE_CLIENT_SECRET": "s"}, {}, False), + ({"AZURE_CLIENT_SECRET": "s"}, {"tenant_id": "t", "client_id": "c"}, False), + ({}, {}, True), + ], + ids=["service-principal-in-env", "ids-in-config-secret-in-env", "token-refresh"], +) +def test_speech_azure_ai_entra_id_setups_keep_the_key_chain_they_had_before_the_provider_key( + respx_mock: respx.MockRouter, + monkeypatch: pytest.MonkeyPatch, + entra_env: dict[str, str], + deployment_params: dict[str, str], + token_refresh: bool, +): + _clear_global_tts_keys(monkeypatch) + monkeypatch.setattr(litellm, "enable_azure_ad_token_refresh", token_refresh) + _set_or_clear_env( + monkeypatch, + { + "AZURE_TENANT_ID": None, + "AZURE_CLIENT_ID": None, + "AZURE_CLIENT_SECRET": None, + "AZURE_OPENAI_API_KEY": None, + "AZURE_AI_API_KEY": "key-for-another-resource", + "AZURE_API_KEY": "generic-azure-key", + **entra_env, + }, + ) + route: Final = respx_mock.post(url__regex=r"https://foundry\.internal/openai/v1/audio/speech.*").mock( + return_value=httpx.Response(200, content=TTS_AUDIO, headers={"content-type": "audio/mpeg"}) + ) + + litellm.speech( + model="azure_ai/tts-1", + input="which key goes out", + voice="alloy", + api_base="https://foundry.internal", + api_version="preview", + **deployment_params, + ) + + assert route.calls.last.request.headers["authorization"] == "Bearer generic-azure-key" + + def test_speech_docker_model_runner_placeholder_does_not_beat_a_real_key( respx_mock: respx.MockRouter, monkeypatch: pytest.MonkeyPatch ): From 4324ad53f570c18dbb722c52face33b06a7a3f08 Mon Sep 17 00:00:00 2001 From: mateo-berri <277851410+mateo-berri@users.noreply.github.com> Date: Thu, 29 Oct 2026 02:04:22 -0700 Subject: [PATCH 8/9] fix(speech): count a request's Entra ID token ahead of the provider-scoped key --- litellm/main.py | 10 ++++++---- tests/unit/test_main.py | 7 +++++-- 2 files changed, 11 insertions(+), 6 deletions(-) diff --git a/litellm/main.py b/litellm/main.py index 3a768477c42..19bfd356d57 100644 --- a/litellm/main.py +++ b/litellm/main.py @@ -8842,9 +8842,13 @@ def speech( request_azure_ad_token: Final = OPTIONAL_STR.validate_python( optional_params.get("extra_body", {}).pop("azure_ad_token", None) ) + azure_ad_token: Final[str | None] = request_azure_ad_token or get_secret("AZURE_AD_TOKEN") request_kwargs: Final = _REQUEST_KWARGS_ADAPTER.validate_python(kwargs) - uses_entra_id: Final = request_azure_ad_token is not None or uses_azure_entra_id_auth( - request_kwargs, os.environ, litellm.enable_azure_ad_token_refresh is True + request_extra_body: Final = _REQUEST_KWARGS_ADAPTER.validate_python(request_kwargs.get("extra_body") or {}) + uses_entra_id: Final = ( + bool(azure_ad_token) + or bool(request_extra_body.get("azure_ad_token")) + or uses_azure_entra_id_auth(request_kwargs, os.environ, litellm.enable_azure_ad_token_refresh is True) ) api_key = ( @@ -8856,8 +8860,6 @@ def speech( or get_secret("AZURE_API_KEY") ) - azure_ad_token: Final[str | None] = request_azure_ad_token or get_secret("AZURE_AD_TOKEN") - if extra_headers: optional_params["extra_headers"] = extra_headers diff --git a/tests/unit/test_main.py b/tests/unit/test_main.py index ddedf7a8b9d..578be4f6b6d 100644 --- a/tests/unit/test_main.py +++ b/tests/unit/test_main.py @@ -5605,14 +5605,16 @@ def test_speech_azure_ai_keeps_the_deployments_entra_token_over_the_provider_sco ({"AZURE_TENANT_ID": "t", "AZURE_CLIENT_ID": "c", "AZURE_CLIENT_SECRET": "s"}, {}, False), ({"AZURE_CLIENT_SECRET": "s"}, {"tenant_id": "t", "client_id": "c"}, False), ({}, {}, True), + ({"AZURE_AD_TOKEN": "env-entra-token"}, {}, False), + ({}, {"extra_body": {"azure_ad_token": "request-entra-token"}}, False), ], - ids=["service-principal-in-env", "ids-in-config-secret-in-env", "token-refresh"], + ids=["service-principal-in-env", "ids-in-config-secret-in-env", "token-refresh", "ad-token-in-env", "ad-token-in-extra-body"], ) def test_speech_azure_ai_entra_id_setups_keep_the_key_chain_they_had_before_the_provider_key( respx_mock: respx.MockRouter, monkeypatch: pytest.MonkeyPatch, entra_env: dict[str, str], - deployment_params: dict[str, str], + deployment_params: Mapping[str, object], token_refresh: bool, ): _clear_global_tts_keys(monkeypatch) @@ -5623,6 +5625,7 @@ def test_speech_azure_ai_entra_id_setups_keep_the_key_chain_they_had_before_the_ "AZURE_TENANT_ID": None, "AZURE_CLIENT_ID": None, "AZURE_CLIENT_SECRET": None, + "AZURE_AD_TOKEN": None, "AZURE_OPENAI_API_KEY": None, "AZURE_AI_API_KEY": "key-for-another-resource", "AZURE_API_KEY": "generic-azure-key", From a5b9ad2be40501808a7197d4153e081c835ee22b Mon Sep 17 00:00:00 2001 From: mateo-berri <277851410+mateo-berri@users.noreply.github.com> Date: Thu, 29 Oct 2026 03:44:10 -0700 Subject: [PATCH 9/9] fix(speech): keep the provider key next to an Azure AD token TTS never sends --- litellm/main.py | 10 ++++----- tests/unit/test_main.py | 50 ++++++++++++++++++++++++++++++++++++----- 2 files changed, 49 insertions(+), 11 deletions(-) diff --git a/litellm/main.py b/litellm/main.py index 19bfd356d57..3a768477c42 100644 --- a/litellm/main.py +++ b/litellm/main.py @@ -8842,13 +8842,9 @@ def speech( request_azure_ad_token: Final = OPTIONAL_STR.validate_python( optional_params.get("extra_body", {}).pop("azure_ad_token", None) ) - azure_ad_token: Final[str | None] = request_azure_ad_token or get_secret("AZURE_AD_TOKEN") request_kwargs: Final = _REQUEST_KWARGS_ADAPTER.validate_python(kwargs) - request_extra_body: Final = _REQUEST_KWARGS_ADAPTER.validate_python(request_kwargs.get("extra_body") or {}) - uses_entra_id: Final = ( - bool(azure_ad_token) - or bool(request_extra_body.get("azure_ad_token")) - or uses_azure_entra_id_auth(request_kwargs, os.environ, litellm.enable_azure_ad_token_refresh is True) + uses_entra_id: Final = request_azure_ad_token is not None or uses_azure_entra_id_auth( + request_kwargs, os.environ, litellm.enable_azure_ad_token_refresh is True ) api_key = ( @@ -8860,6 +8856,8 @@ def speech( or get_secret("AZURE_API_KEY") ) + azure_ad_token: Final[str | None] = request_azure_ad_token or get_secret("AZURE_AD_TOKEN") + if extra_headers: optional_params["extra_headers"] = extra_headers diff --git a/tests/unit/test_main.py b/tests/unit/test_main.py index 578be4f6b6d..bc7e4888313 100644 --- a/tests/unit/test_main.py +++ b/tests/unit/test_main.py @@ -5605,16 +5605,14 @@ def test_speech_azure_ai_keeps_the_deployments_entra_token_over_the_provider_sco ({"AZURE_TENANT_ID": "t", "AZURE_CLIENT_ID": "c", "AZURE_CLIENT_SECRET": "s"}, {}, False), ({"AZURE_CLIENT_SECRET": "s"}, {"tenant_id": "t", "client_id": "c"}, False), ({}, {}, True), - ({"AZURE_AD_TOKEN": "env-entra-token"}, {}, False), - ({}, {"extra_body": {"azure_ad_token": "request-entra-token"}}, False), ], - ids=["service-principal-in-env", "ids-in-config-secret-in-env", "token-refresh", "ad-token-in-env", "ad-token-in-extra-body"], + ids=["service-principal-in-env", "ids-in-config-secret-in-env", "token-refresh"], ) def test_speech_azure_ai_entra_id_setups_keep_the_key_chain_they_had_before_the_provider_key( respx_mock: respx.MockRouter, monkeypatch: pytest.MonkeyPatch, entra_env: dict[str, str], - deployment_params: Mapping[str, object], + deployment_params: dict[str, str], token_refresh: bool, ): _clear_global_tts_keys(monkeypatch) @@ -5625,7 +5623,6 @@ def test_speech_azure_ai_entra_id_setups_keep_the_key_chain_they_had_before_the_ "AZURE_TENANT_ID": None, "AZURE_CLIENT_ID": None, "AZURE_CLIENT_SECRET": None, - "AZURE_AD_TOKEN": None, "AZURE_OPENAI_API_KEY": None, "AZURE_AI_API_KEY": "key-for-another-resource", "AZURE_API_KEY": "generic-azure-key", @@ -5648,6 +5645,49 @@ def test_speech_azure_ai_entra_id_setups_keep_the_key_chain_they_had_before_the_ assert route.calls.last.request.headers["authorization"] == "Bearer generic-azure-key" +@pytest.mark.parametrize( + ("unsent_env", "request_params"), + [ + ({"AZURE_AD_TOKEN": "token-tts-never-sends"}, {}), + ({}, {"extra_body": {"azure_ad_token": "token-tts-never-sends"}}), + ({}, {"extra_body": ["not", "an", "object"]}), + ], + ids=["ad-token-in-env", "ad-token-in-extra-body", "non-object-extra-body"], +) +def test_speech_azure_ai_sends_the_provider_scoped_key_next_to_an_ad_token_tts_never_sends( + respx_mock: respx.MockRouter, + monkeypatch: pytest.MonkeyPatch, + unsent_env: dict[str, str], + request_params: Mapping[str, object], +): + _clear_global_tts_keys(monkeypatch) + _set_or_clear_env( + monkeypatch, + { + "AZURE_AD_TOKEN": None, + "AZURE_OPENAI_API_KEY": None, + "AZURE_API_KEY": None, + "AZURE_AI_API_KEY": "azure-ai-scoped-key", + **unsent_env, + }, + ) + 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"})) + + response: Final = litellm.speech( + model="azure_ai/tts-1", + input="which key goes out", + voice="alloy", + api_base="https://foundry.internal", + api_version="2024-05-01-preview", + **request_params, + ) + + assert response.content == TTS_AUDIO + assert route.calls.last.request.headers["api-key"] == "azure-ai-scoped-key" + + def test_speech_docker_model_runner_placeholder_does_not_beat_a_real_key( respx_mock: respx.MockRouter, monkeypatch: pytest.MonkeyPatch ):