From 03a4e8bfb57ec69cc184b5114b0f66f1480672c6 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Sat, 25 Jul 2026 21:09:23 +0000 Subject: [PATCH 1/3] fix(azure/realtime): authenticate realtime websocket with Azure AD token when no api-key --- litellm/llms/azure/realtime/handler.py | 21 ++- litellm/realtime_api/main.py | 15 +- .../realtime/test_azure_realtime_handler.py | 178 ++++++++++++++++++ 3 files changed, 207 insertions(+), 7 deletions(-) diff --git a/litellm/llms/azure/realtime/handler.py b/litellm/llms/azure/realtime/handler.py index 86c1ed51b68..51f9ef5989c 100644 --- a/litellm/llms/azure/realtime/handler.py +++ b/litellm/llms/azure/realtime/handler.py @@ -30,6 +30,21 @@ async def forward_messages(client_ws: Any, backend_ws: Any): class AzureOpenAIRealtime(AzureChatCompletion): + @staticmethod + def get_auth_headers(api_key: str | None, azure_ad_token: str | None) -> dict[str, str]: + """ + Build the websocket handshake auth headers, preferring a static api-key and falling back to + an Azure AD (Entra ID) bearer token. Never sends both. + """ + if api_key: + return {"api-key": api_key} + if azure_ad_token: + return {"Authorization": f"Bearer {azure_ad_token}"} + raise ValueError( + "Missing Azure credentials for the realtime endpoint. Set an api_key, or configure Azure AD auth " + "(azure_ad_token, tenant_id/client_id/client_secret, or a managed identity)" + ) + def _construct_url( self, api_base: str, @@ -117,13 +132,13 @@ class AzureOpenAIRealtime(AzureChatCompletion): query_params=query_params, ) + auth_headers = self.get_auth_headers(api_key=api_key, azure_ad_token=azure_ad_token) + try: ssl_context = get_shared_realtime_ssl_context() async with websockets.connect( # type: ignore url, - additional_headers={ - "api-key": api_key, # type: ignore - }, + additional_headers=auth_headers, max_size=REALTIME_WEBSOCKET_MAX_MESSAGE_SIZE_BYTES, ssl=ssl_context, ) as backend_ws: diff --git a/litellm/realtime_api/main.py b/litellm/realtime_api/main.py index 5ecf4d91ff6..e9175917f9a 100644 --- a/litellm/realtime_api/main.py +++ b/litellm/realtime_api/main.py @@ -23,6 +23,7 @@ from litellm.utils import ProviderConfigManager from ..litellm_core_utils.get_litellm_params import get_litellm_params from ..litellm_core_utils.litellm_logging import Logging as LiteLLMLogging +from ..llms.azure.common_utils import get_azure_ad_token from ..llms.azure.realtime.handler import AzureOpenAIRealtime from ..llms.bedrock.realtime.handler import BedrockRealtime from ..llms.custom_httpx.http_handler import get_shared_realtime_ssl_context @@ -376,7 +377,7 @@ async def _arealtime( api_base=api_base, api_key=api_key, api_version=api_version, - azure_ad_token=None, + azure_ad_token=(None if api_key else get_azure_ad_token(litellm_params)), client=None, timeout=timeout, logging_obj=litellm_logging_obj, @@ -536,6 +537,7 @@ async def _realtime_health_check( import websockets url: Optional[str] = None + auth_headers: dict[str, str | None] = {"api-key": api_key} if custom_llm_provider == "azure": url = azure_realtime._construct_url( api_base=api_base or "", @@ -543,6 +545,13 @@ async def _realtime_health_check( api_version=api_version or "2024-10-01-preview", realtime_protocol=realtime_protocol, ) + azure_litellm_params = GenericLiteLLMParams(**(model_params or {})) + auth_headers = dict( + azure_realtime.get_auth_headers( + api_key=api_key, + azure_ad_token=(None if api_key else get_azure_ad_token(azure_litellm_params)), + ) + ) elif custom_llm_provider == "openai": url = openai_realtime._construct_url( api_base=api_base or "https://api.openai.com/", @@ -584,9 +593,7 @@ async def _realtime_health_check( ssl_context = get_shared_realtime_ssl_context() async with websockets.connect( # type: ignore url, - additional_headers={ - "api-key": api_key, # type: ignore - }, + additional_headers=auth_headers, max_size=REALTIME_WEBSOCKET_MAX_MESSAGE_SIZE_BYTES, ssl=ssl_context, ): diff --git a/tests/test_litellm/llms/azure/realtime/test_azure_realtime_handler.py b/tests/test_litellm/llms/azure/realtime/test_azure_realtime_handler.py index 4638bc4df0f..d9c49947f19 100644 --- a/tests/test_litellm/llms/azure/realtime/test_azure_realtime_handler.py +++ b/tests/test_litellm/llms/azure/realtime/test_azure_realtime_handler.py @@ -563,3 +563,181 @@ async def test_async_realtime_default_maintains_backwards_compatibility(): mock_realtime_streaming.call_args.kwargs["backend_uses_beta_protocol"] is True ) + + +class _DummyAsyncContextManager: + def __init__(self, value): + self.value = value + + async def __aenter__(self): + return self.value + + async def __aexit__(self, exc_type, exc, tb): + return None + + +@pytest.mark.asyncio +async def test_async_realtime_uses_bearer_token_when_no_api_key(): + """ + Entra ID-only Azure realtime deployments have no static api-key, so the handshake must + authenticate with `Authorization: Bearer ` and must not send `api-key`. + + Regression test for https://github.com/BerriAI/litellm/issues/34654 + """ + from litellm.llms.azure.realtime.handler import AzureOpenAIRealtime + + handler = AzureOpenAIRealtime() + mock_backend_ws = AsyncMock() + + with ( + patch( + "websockets.connect", + return_value=_DummyAsyncContextManager(mock_backend_ws), + ) as mock_ws_connect, + patch("litellm.llms.azure.realtime.handler.RealTimeStreaming") as mock_realtime_streaming, + ): + mock_realtime_streaming.return_value.bidirectional_forward = AsyncMock() + + await handler.async_realtime( + model="gpt-realtime-whisper", + websocket=AsyncMock(), + logging_obj=MagicMock(), + api_base="https://my-endpoint.openai.azure.com", + api_key=None, + api_version="2024-10-01-preview", + azure_ad_token="my-entra-token", + ) + + headers = mock_ws_connect.call_args.kwargs["additional_headers"] + assert headers == {"Authorization": "Bearer my-entra-token"} + + +def test_get_auth_headers_prefers_api_key_and_never_sends_both(): + from litellm.llms.azure.realtime.handler import AzureOpenAIRealtime + + assert AzureOpenAIRealtime.get_auth_headers(api_key="test-key", azure_ad_token="my-entra-token") == { + "api-key": "test-key" + } + + +def test_get_auth_headers_without_credentials_raises(): + from litellm.llms.azure.realtime.handler import AzureOpenAIRealtime + + with pytest.raises(ValueError, match="Missing Azure credentials"): + AzureOpenAIRealtime.get_auth_headers(api_key=None, azure_ad_token=None) + + +@pytest.mark.asyncio +async def test_arealtime_resolves_azure_ad_token_when_no_api_key(monkeypatch): + """ + `_arealtime` must resolve an Azure AD token (managed identity, service principal, etc.) + and forward it to the handler when the deployment has no api_key. + + Regression test for https://github.com/BerriAI/litellm/issues/34654 + """ + from litellm.realtime_api import main as realtime_main + + mock_async_realtime = AsyncMock() + monkeypatch.setattr(realtime_main, "azure_realtime", MagicMock(async_realtime=mock_async_realtime)) + monkeypatch.setattr( + realtime_main, + "get_llm_provider", + lambda model, api_base=None, api_key=None: ( + "gpt-realtime-whisper", + "azure", + None, + "https://my-endpoint.openai.azure.com", + ), + ) + monkeypatch.delenv("AZURE_API_KEY", raising=False) + + captured_params = {} + + def fake_get_azure_ad_token(litellm_params): + captured_params["tenant_id"] = litellm_params.get("tenant_id") + return "my-entra-token" + + monkeypatch.setattr(realtime_main, "get_azure_ad_token", fake_get_azure_ad_token) + + await realtime_main._arealtime( + model="azure/gpt-realtime-whisper", + websocket=MagicMock(), + api_version="2024-10-01-preview", + litellm_logging_obj=MagicMock(), + tenant_id="my-tenant", + client_id="my-client", + client_secret="my-secret", + ) + + assert mock_async_realtime.call_args.kwargs["azure_ad_token"] == "my-entra-token" + assert captured_params["tenant_id"] == "my-tenant" + + +@pytest.mark.asyncio +async def test_arealtime_does_not_resolve_azure_ad_token_when_api_key_present(monkeypatch): + from litellm.realtime_api import main as realtime_main + + mock_async_realtime = AsyncMock() + monkeypatch.setattr(realtime_main, "azure_realtime", MagicMock(async_realtime=mock_async_realtime)) + monkeypatch.setattr( + realtime_main, + "get_llm_provider", + lambda model, api_base=None, api_key=None: ( + "gpt-realtime-whisper", + "azure", + "test-key", + "https://my-endpoint.openai.azure.com", + ), + ) + + def fail_get_azure_ad_token(litellm_params): + raise AssertionError("should not resolve an AD token when an api_key is configured") + + monkeypatch.setattr(realtime_main, "get_azure_ad_token", fail_get_azure_ad_token) + + await realtime_main._arealtime( + model="azure/gpt-realtime-whisper", + websocket=MagicMock(), + api_key="test-key", + api_version="2024-10-01-preview", + litellm_logging_obj=MagicMock(), + ) + + assert mock_async_realtime.call_args.kwargs["azure_ad_token"] is None + + +@pytest.mark.asyncio +async def test_realtime_health_check_uses_bearer_token_when_no_api_key(monkeypatch): + """ + An Entra ID-only realtime deployment must also pass its realtime health check. + + Regression test for https://github.com/BerriAI/litellm/issues/34654 + """ + from litellm.realtime_api import main as realtime_main + + connect_calls = [] + + monkeypatch.setattr( + realtime_main, + "get_azure_ad_token", + lambda litellm_params: "my-entra-token", + ) + + def fake_connect(url, **kwargs): + connect_calls.append(kwargs) + return _DummyAsyncContextManager(MagicMock()) + + monkeypatch.setattr("websockets.connect", fake_connect) + + assert ( + await realtime_main._realtime_health_check( + model="gpt-realtime-whisper", + custom_llm_provider="azure", + api_key=None, + api_base="https://my-endpoint.openai.azure.com", + api_version="2024-10-01-preview", + model_params={"tenant_id": "my-tenant"}, + ) + is True + ) + assert connect_calls[0]["additional_headers"] == {"Authorization": "Bearer my-entra-token"} From b930169f39ff81b34b9088d32596f2fc07241839 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Sat, 25 Jul 2026 21:21:30 +0000 Subject: [PATCH 2/3] fix(azure/realtime): resolve AD token from deployment azure_ad_token param and kwargs --- litellm/realtime_api/main.py | 7 +++- .../realtime/test_azure_realtime_handler.py | 36 +++++++++++++++++++ 2 files changed, 42 insertions(+), 1 deletion(-) diff --git a/litellm/realtime_api/main.py b/litellm/realtime_api/main.py index e9175917f9a..e981db216af 100644 --- a/litellm/realtime_api/main.py +++ b/litellm/realtime_api/main.py @@ -371,13 +371,18 @@ async def _arealtime( if realtime_protocol is None and (query_params or {}).get("intent") == "transcription": realtime_protocol = "GA" realtime_protocol = realtime_protocol or "beta" + resolved_azure_ad_token = ( + None + if api_key + else get_azure_ad_token(GenericLiteLLMParams(**{**kwargs, "azure_ad_token": azure_ad_token})) + ) await azure_realtime.async_realtime( model=model, websocket=websocket, api_base=api_base, api_key=api_key, api_version=api_version, - azure_ad_token=(None if api_key else get_azure_ad_token(litellm_params)), + azure_ad_token=resolved_azure_ad_token, client=None, timeout=timeout, logging_obj=litellm_logging_obj, diff --git a/tests/test_litellm/llms/azure/realtime/test_azure_realtime_handler.py b/tests/test_litellm/llms/azure/realtime/test_azure_realtime_handler.py index d9c49947f19..bf2e89de44c 100644 --- a/tests/test_litellm/llms/azure/realtime/test_azure_realtime_handler.py +++ b/tests/test_litellm/llms/azure/realtime/test_azure_realtime_handler.py @@ -741,3 +741,39 @@ async def test_realtime_health_check_uses_bearer_token_when_no_api_key(monkeypat is True ) assert connect_calls[0]["additional_headers"] == {"Authorization": "Bearer my-entra-token"} + + +@pytest.mark.asyncio +async def test_arealtime_forwards_deployment_azure_ad_token(monkeypatch): + """ + The router binds a deployment's `azure_ad_token` to `_arealtime`'s named parameter rather than + **kwargs, so it must still reach the handler. + + Regression test for https://github.com/BerriAI/litellm/issues/34654 + """ + from litellm.realtime_api import main as realtime_main + + mock_async_realtime = AsyncMock() + monkeypatch.setattr(realtime_main, "azure_realtime", MagicMock(async_realtime=mock_async_realtime)) + monkeypatch.setattr( + realtime_main, + "get_llm_provider", + lambda model, api_base=None, api_key=None: ( + "gpt-realtime-whisper", + "azure", + None, + "https://my-endpoint.openai.azure.com", + ), + ) + monkeypatch.delenv("AZURE_API_KEY", raising=False) + monkeypatch.setattr(realtime_main.litellm, "api_key", None) + + await realtime_main._arealtime( + model="azure/gpt-realtime-whisper", + websocket=MagicMock(), + api_version="2024-10-01-preview", + azure_ad_token="deployment-entra-token", + litellm_logging_obj=MagicMock(), + ) + + assert mock_async_realtime.call_args.kwargs["azure_ad_token"] == "deployment-entra-token" From 5470645f87d1f9a6367121cfa99b8672e57ca350 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Tue, 25 Aug 2026 17:32:21 +0000 Subject: [PATCH 3/3] refactor(azure/realtime): keep auth header build within lint budgets after merge Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- litellm/llms/azure/realtime/handler.py | 10 +++--- litellm/realtime_api/main.py | 33 ++++++++++++------- .../realtime/test_azure_realtime_handler.py | 4 ++- 3 files changed, 30 insertions(+), 17 deletions(-) diff --git a/litellm/llms/azure/realtime/handler.py b/litellm/llms/azure/realtime/handler.py index 70e39f47d63..88492ef996e 100644 --- a/litellm/llms/azure/realtime/handler.py +++ b/litellm/llms/azure/realtime/handler.py @@ -4,6 +4,8 @@ This file contains the calling Azure OpenAI's `/openai/realtime` endpoint. This requires websockets, and is currently only supported on LiteLLM Proxy. """ +from collections.abc import Mapping +from types import MappingProxyType from typing import Any, Final, cast from litellm._logging import _redact_string, verbose_proxy_logger @@ -31,15 +33,15 @@ async def forward_messages(client_ws: Any, backend_ws: Any): class AzureOpenAIRealtime(AzureChatCompletion): @staticmethod - def get_auth_headers(api_key: str | None, azure_ad_token: str | None) -> dict[str, str]: + def get_auth_headers(api_key: str | None, azure_ad_token: str | None) -> Mapping[str, str]: """ Build the websocket handshake auth headers, preferring a static api-key and falling back to an Azure AD (Entra ID) bearer token. Never sends both. """ if api_key: - return {"api-key": api_key} + return MappingProxyType({"api-key": api_key}) if azure_ad_token: - return {"Authorization": f"Bearer {azure_ad_token}"} + return MappingProxyType({"Authorization": f"Bearer {azure_ad_token}"}) raise ValueError( "Missing Azure credentials for the realtime endpoint. Set an api_key, or configure Azure AD auth " "(azure_ad_token, tenant_id/client_id/client_secret, or a managed identity)" @@ -132,7 +134,7 @@ class AzureOpenAIRealtime(AzureChatCompletion): query_params=query_params, ) - auth_headers = self.get_auth_headers(api_key=api_key, azure_ad_token=azure_ad_token) + auth_headers: Final = self.get_auth_headers(api_key=api_key, azure_ad_token=azure_ad_token) try: ssl_context: Final = get_shared_realtime_ssl_context() diff --git a/litellm/realtime_api/main.py b/litellm/realtime_api/main.py index 8933d5e4506..e5f6c8328f4 100644 --- a/litellm/realtime_api/main.py +++ b/litellm/realtime_api/main.py @@ -2,6 +2,8 @@ import asyncio import os +from collections.abc import Mapping +from types import MappingProxyType from typing import Any, Final, Literal, cast import litellm @@ -45,6 +47,7 @@ bedrock_realtime: Final = BedrockRealtime() xai_realtime: Final = XAIRealtime() vertex_llm_base: Final = VertexBase() base_llm_http_handler = BaseLLMHTTPHandler() +_EMPTY_MODEL_PARAMS: Final[Mapping[str, Any]] = MappingProxyType({}) def _with_resolved_session_model(session: dict[str, Any], model_name: str) -> dict[str, Any]: @@ -412,10 +415,8 @@ async def _arealtime( if realtime_protocol is None and (query_params or {}).get("intent") == "transcription": realtime_protocol = "GA" realtime_protocol = realtime_protocol or "beta" - resolved_azure_ad_token = ( - None - if api_key - else get_azure_ad_token(GenericLiteLLMParams(**{**kwargs, "azure_ad_token": azure_ad_token})) + resolved_azure_ad_token: Final = ( + None if api_key else get_azure_ad_token(GenericLiteLLMParams(**kwargs, azure_ad_token=azure_ad_token)) ) await azure_realtime.async_realtime( model=model, @@ -556,6 +557,17 @@ async def _arealtime( raise ValueError(f"Unsupported model: {model}") +def _realtime_health_check_auth_headers( + custom_llm_provider: str, api_key: str | None, model_params: Mapping[str, Any] +) -> Mapping[str, str | None]: + if custom_llm_provider != "azure": + return MappingProxyType({"api-key": api_key}) + return azure_realtime.get_auth_headers( + api_key=api_key, + azure_ad_token=(None if api_key else get_azure_ad_token(GenericLiteLLMParams(**model_params))), + ) + + async def _realtime_health_check( model: str, custom_llm_provider: str, @@ -584,7 +596,11 @@ async def _realtime_health_check( import websockets url: str | None = None - auth_headers: dict[str, str | None] = {"api-key": api_key} + auth_headers: Final = _realtime_health_check_auth_headers( + custom_llm_provider=custom_llm_provider, + api_key=api_key, + model_params=model_params or _EMPTY_MODEL_PARAMS, + ) if custom_llm_provider == "azure": url = azure_realtime._construct_url( api_base=api_base or "", @@ -592,13 +608,6 @@ async def _realtime_health_check( api_version=api_version or "2024-10-01-preview", realtime_protocol=realtime_protocol, ) - azure_litellm_params = GenericLiteLLMParams(**(model_params or {})) - auth_headers = dict( - azure_realtime.get_auth_headers( - api_key=api_key, - azure_ad_token=(None if api_key else get_azure_ad_token(azure_litellm_params)), - ) - ) elif custom_llm_provider == "openai": url = openai_realtime._construct_url( api_base=api_base or "https://api.openai.com/", diff --git a/tests/test_litellm/llms/azure/realtime/test_azure_realtime_handler.py b/tests/test_litellm/llms/azure/realtime/test_azure_realtime_handler.py index 570843da7a8..7d24e604569 100644 --- a/tests/test_litellm/llms/azure/realtime/test_azure_realtime_handler.py +++ b/tests/test_litellm/llms/azure/realtime/test_azure_realtime_handler.py @@ -590,7 +590,9 @@ async def test_async_realtime_uses_bearer_token_when_no_api_key(): "websockets.connect", return_value=_DummyAsyncContextManager(mock_backend_ws), ) as mock_ws_connect, - patch("litellm.llms.azure.realtime.handler.RealTimeStreaming") as mock_realtime_streaming, + patch( # test-quality-ok: handler owns the streaming loop, only the handshake headers are under test + "litellm.llms.azure.realtime.handler.RealTimeStreaming" + ) as mock_realtime_streaming, ): mock_realtime_streaming.return_value.bidirectional_forward = AsyncMock()