diff --git a/litellm/constants.py b/litellm/constants.py index 97631b9997a..778e618493b 100644 --- a/litellm/constants.py +++ b/litellm/constants.py @@ -350,6 +350,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/llms/azure/common_utils.py b/litellm/llms/azure/common_utils.py index 6bb6d584385..2b36ee1f377 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 45177f4223e..52b57317cdd 100644 --- a/litellm/main.py +++ b/litellm/main.py @@ -69,6 +69,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 @@ -102,6 +103,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, ) @@ -251,6 +253,7 @@ from .llms.anthropic.common_utils import is_claude_tool_target 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 @@ -9006,6 +9009,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=caller_api_base ) + provider_api_key: Final = None if dynamic_api_key in PLACEHOLDER_API_KEYS else dynamic_api_key kwargs.pop("tags", []) optional_params = {} @@ -9095,9 +9099,11 @@ 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 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 ) organization = ( @@ -9178,18 +9184,24 @@ 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) + 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 uses_entra_id 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 @@ -9203,7 +9215,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, @@ -9487,7 +9499,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/llms/azure/test_azure_common_utils.py b/tests/unit/llms/azure/test_azure_common_utils.py index 0a41861e088..274765b5057 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, @@ -2500,3 +2501,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 47629aab326..eb097acd032 100644 --- a/tests/unit/test_main.py +++ b/tests/unit/test_main.py @@ -6107,6 +6107,276 @@ def test_speech_mistral_routes_to_configured_api_base(respx_mock: respx.MockRout assert response.content == audio_bytes + +TTS_AUDIO: Final = b"ID3\x04\x00\x00\x00fake-mp3-payload" + + +def _clear_global_tts_keys(monkeypatch: pytest.MonkeyPatch) -> None: + for attribute in ("api_key", "openai_key", "azure_key", "api_base"): + monkeypatch.setattr(litellm, attribute, None) + + +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 hosted-vllm-scoped-key"), + (None, "generic-openai-key", "Bearer generic-openai-key"), + (None, None, "Bearer fake-api-key"), + ], + ids=["provider-key-alone", "provider-key-beats-generic", "generic-key-beats-placeholder", "placeholder-when-unset"], +) +def test_speech_openai_compatible_sends_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) + _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"}) + ) + + response: Final = litellm.speech( + model="hosted_vllm/tts-1", input="which key goes out", voice="alloy", api_base="https://vllm.internal/v1" + ) + + assert response.content == TTS_AUDIO + assert route.call_count == 1 + assert route.calls.last.request.headers["authorization"] == sent_authorization + + +@pytest.mark.parametrize( + ("generic_azure_key", "sent_api_key"), + [(None, "azure-ai-scoped-key"), ("generic-azure-key", "azure-ai-scoped-key")], + ids=["provider-key-alone", "provider-key-beats-generic"], +) +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) + _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"})) + + 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", + ) + + assert response.content == TTS_AUDIO + assert route.call_count == 1 + 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", "") + + +@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" + + +@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 +): + _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 +): + _clear_global_tts_keys(monkeypatch) + monkeypatch.setenv("HOSTED_VLLM_API_KEY", "hosted-vllm-scoped-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"}) + ) + + litellm.speech( + model="hosted_vllm/tts-1", + input="which key goes out", + voice="alloy", + api_base="https://vllm.internal/v1", + api_key="caller-supplied-key", + ) + + assert route.calls.last.request.headers["authorization"] == "Bearer caller-supplied-key" + + FOUNDRY_HOST: Final = "https://my-project.services.ai.azure.com"