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] 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 ):