From d9313dfa4ec7cf73c51e3f732548ab1c2d68ccdb Mon Sep 17 00:00:00 2001 From: waani Date: Thu, 10 Sep 2026 20:22:45 +0800 Subject: [PATCH] feat(dashscope): add realtime websocket support Add a handler for DashScope's /api-ws/v1/realtime WebSocket API, wire it into the realtime proxy path and the realtime health check, and register the Qwen-Omni-Realtime family in the cost map. DashScope deviates from OpenAI's realtime endpoint in three ways that matter: the path is /api-ws/v1/realtime rather than /v1/realtime, auth is a bearer token with no OpenAI-Beta header, and the session shape is the flat beta one. That last point needs OpenAIRealtime to stop remapping the client's session.update into GA's nested output_modalities / audio.input form, which DashScope silently drops and which broke audio sessions. It sits behind an opt-in override so no other provider's behavior changes. The realtime health check resolves the API key from the environment for DashScope, matching what a real call does, so a deployment whose key lives only in DASHSCOPE_API_KEY stops reporting itself unhealthy. --- litellm/llms/dashscope/realtime/__init__.py | 5 + litellm/llms/dashscope/realtime/handler.py | 85 ++++++++ litellm/llms/openai/realtime/handler.py | 12 ++ ...odel_prices_and_context_window_backup.json | 202 ++++++++++++++++++ .../provider_endpoints_support_backup.json | 3 +- litellm/realtime_api/README.md | 2 +- litellm/realtime_api/main.py | 33 +++ model_prices_and_context_window.json | 202 ++++++++++++++++++ provider_endpoints_support.json | 3 +- .../realtime/test_dashscope_realtime.py | 31 +++ .../test_dashscope_realtime_handler.py | 189 ++++++++++++++++ 11 files changed, 764 insertions(+), 3 deletions(-) create mode 100644 litellm/llms/dashscope/realtime/__init__.py create mode 100644 litellm/llms/dashscope/realtime/handler.py create mode 100644 tests/llm_translation/realtime/test_dashscope_realtime.py create mode 100644 tests/test_litellm/llms/dashscope/test_dashscope_realtime_handler.py diff --git a/litellm/llms/dashscope/realtime/__init__.py b/litellm/llms/dashscope/realtime/__init__.py new file mode 100644 index 00000000000..3f0b4b0721a --- /dev/null +++ b/litellm/llms/dashscope/realtime/__init__.py @@ -0,0 +1,5 @@ +"""DashScope (Alibaba Cloud Model Studio) Realtime API handler.""" + +from .handler import DashScopeRealtime + +__all__ = ["DashScopeRealtime"] diff --git a/litellm/llms/dashscope/realtime/handler.py b/litellm/llms/dashscope/realtime/handler.py new file mode 100644 index 00000000000..6139405f9da --- /dev/null +++ b/litellm/llms/dashscope/realtime/handler.py @@ -0,0 +1,85 @@ +""" +Handler for Alibaba Cloud Model Studio (DashScope / Bailian) realtime sessions. + +DashScope's Qwen-Omni-Realtime WebSocket API speaks OpenAI-compatible realtime events +(``session.update``, ``input_audio_buffer.append``, ``response.audio.delta``, ...), so the +WebSocket plumbing is inherited from ``OpenAIRealtime``. Only these differ: + +- the endpoint path is ``/api-ws/v1/realtime``, not ``/v1/realtime`` +- auth is ``Authorization: Bearer ``, with no ``OpenAI-Beta`` header +- the event names are the OpenAI beta dialect, so the flat beta-style ``session.update`` + shape must reach the backend untouched +- sessions are served from a region host, or from a workspace-scoped host such as + ``wss://{workspace_id}.cn-beijing.maas.aliyuncs.com`` + +This requires websockets, and is currently only supported on LiteLLM Proxy. +""" + +from typing import Final + +from httpx import URL + +from litellm.types.realtime import RealtimeQueryParams + +from ...openai.realtime.handler import OpenAIRealtime + +REALTIME_WEBSOCKET_PATH: Final = "/api-ws/v1/realtime" + +DASHSCOPE_REALTIME_API_BASE: Final = "wss://dashscope.aliyuncs.com" + +# get_llm_provider resolves ``dashscope`` to this chat base, which is never where realtime lives. +_DASHSCOPE_CHAT_API_BASE: Final = "https://dashscope.aliyuncs.com/compatible-mode/v1" + + +def resolve_dashscope_realtime_api_base(api_base: str | None, dynamic_api_base: str | None = None) -> str: + """ + Resolve the WebSocket base URL for a DashScope realtime session. + + A configured ``api_base`` wins, which is how a workspace-scoped host such as + ``wss://{workspace_id}.cn-beijing.maas.aliyuncs.com`` is supplied. Otherwise the chat + base that ``get_llm_provider`` returns is swapped for the region realtime host. + """ + for candidate in (api_base, dynamic_api_base): + if candidate and candidate.rstrip("/") != _DASHSCOPE_CHAT_API_BASE: + return candidate + return DASHSCOPE_REALTIME_API_BASE + + +class DashScopeRealtime(OpenAIRealtime): + """Handler for DashScope realtime WebSocket sessions.""" + + def _get_default_api_base(self) -> str: + return DASHSCOPE_REALTIME_API_BASE + + def get_auth_headers(self, api_key: str) -> dict: + """DashScope authenticates with a bearer token only, no OpenAI-Beta header.""" + return {"Authorization": f"Bearer {api_key}"} + + def _get_additional_headers( + self, + api_key: str, + *, + openai_beta_realtime: bool = False, + ) -> dict: + return self.get_auth_headers(api_key) + + def _construct_url(self, api_base: str, query_params: RealtimeQueryParams) -> str: + """Build the backend websocket URL on DashScope's ``/api-ws/v1/realtime`` path.""" + websocket_api_base: Final = api_base.replace("https://", "wss://").replace("http://", "ws://") + url: Final = URL(websocket_api_base).copy_with(path=REALTIME_WEBSOCKET_PATH) + if not query_params: + return str(url) + return str(url.copy_with(params=query_params)) + + def get_websocket_url(self, api_base: str, query_params: RealtimeQueryParams) -> str: + """Realtime WebSocket URL for callers outside this handler, such as the health check.""" + return self._construct_url(api_base, query_params) + + def _backend_uses_beta_protocol(self) -> bool | None: + """DashScope always speaks the flat, beta-style realtime session shape. + + Sending a GA-shaped ``session.update`` (``output_modalities``, ``audio.input.*``) + makes DashScope drop the modality and audio-format fields, so the remap must be + skipped regardless of whether the client sent ``OpenAI-Beta``. + """ + return True diff --git a/litellm/llms/openai/realtime/handler.py b/litellm/llms/openai/realtime/handler.py index e3ecbac1a53..af232ecb734 100644 --- a/litellm/llms/openai/realtime/handler.py +++ b/litellm/llms/openai/realtime/handler.py @@ -105,6 +105,17 @@ class OpenAIRealtime(OpenAIChatCompletion): """ return None + def _backend_uses_beta_protocol(self) -> bool | None: + """Whether the upstream speaks the OpenAI beta realtime protocol. + + ``None`` infers it from the client's ``OpenAI-Beta`` header, which is correct for + OpenAI itself because that header is forwarded upstream. Providers whose backend + always speaks beta override this with ``True``, otherwise ``RealTimeStreaming`` + remaps the client's flat ``session.update`` into GA's nested shape and the + backend silently drops the fields it does not know. + """ + return None + async def async_realtime( self, model: str, @@ -172,6 +183,7 @@ class OpenAIRealtime(OpenAIChatCompletion): model if (query_params or {}).get("intent") == "transcription" else None ), event_normalizer=self._make_event_normalizer(), + backend_uses_beta_protocol=self._backend_uses_beta_protocol(), ) await realtime_streaming.bidirectional_forward() diff --git a/litellm/model_prices_and_context_window_backup.json b/litellm/model_prices_and_context_window_backup.json index 0d2eda93323..a6f30eb9943 100644 --- a/litellm/model_prices_and_context_window_backup.json +++ b/litellm/model_prices_and_context_window_backup.json @@ -14784,6 +14784,50 @@ "supports_tool_choice": true, "supports_vision": true }, + "dashscope/qwen-audio-3.0-realtime-flash": { + "input_cost_per_audio_token": 4.5e-06, + "input_cost_per_token": 4.5e-07, + "litellm_provider": "dashscope", + "mode": "realtime", + "output_cost_per_audio_token": 1.5e-05, + "output_cost_per_token": 4.5e-06, + "source": "https://www.alibabacloud.com/help/en/model-studio/model-pricing", + "supported_endpoints": [ + "/v1/realtime" + ], + "supported_modalities": [ + "text", + "audio" + ], + "supported_output_modalities": [ + "text", + "audio" + ], + "supports_audio_input": true, + "supports_audio_output": true + }, + "dashscope/qwen-audio-3.0-realtime-plus": { + "input_cost_per_audio_token": 6.4e-06, + "input_cost_per_token": 8e-07, + "litellm_provider": "dashscope", + "mode": "realtime", + "output_cost_per_audio_token": 2.4e-05, + "output_cost_per_token": 6.4e-06, + "source": "https://www.alibabacloud.com/help/en/model-studio/model-pricing", + "supported_endpoints": [ + "/v1/realtime" + ], + "supported_modalities": [ + "text", + "audio" + ], + "supported_output_modalities": [ + "text", + "audio" + ], + "supports_audio_input": true, + "supports_audio_output": true + }, "dashscope/qwen-coder": { "input_cost_per_token": 3e-07, "litellm_provider": "dashscope", @@ -15497,6 +15541,164 @@ } ] }, + "dashscope/qwen3.5-livetranslate-flash-realtime": { + "input_cost_per_audio_token": 7.5e-06, + "input_cost_per_image_token": 5.5e-07, + "litellm_provider": "dashscope", + "max_input_tokens": 49152, + "max_output_tokens": 4096, + "max_tokens": 4096, + "mode": "realtime", + "output_cost_per_audio_token": 3e-05, + "output_cost_per_token": 2e-05, + "source": "https://www.alibabacloud.com/help/en/model-studio/model-pricing", + "supported_endpoints": [ + "/v1/realtime" + ], + "supported_modalities": [ + "audio", + "image" + ], + "supported_output_modalities": [ + "text", + "audio" + ], + "supports_audio_input": true, + "supports_audio_output": true + }, + "dashscope/qwen3.5-livetranslate-flash-realtime-2026-05-19": { + "input_cost_per_audio_token": 7.5e-06, + "input_cost_per_image_token": 5.5e-07, + "litellm_provider": "dashscope", + "max_input_tokens": 49152, + "max_output_tokens": 4096, + "max_tokens": 4096, + "mode": "realtime", + "output_cost_per_audio_token": 3e-05, + "output_cost_per_token": 2e-05, + "source": "https://www.alibabacloud.com/help/en/model-studio/model-pricing", + "supported_endpoints": [ + "/v1/realtime" + ], + "supported_modalities": [ + "audio", + "image" + ], + "supported_output_modalities": [ + "text", + "audio" + ], + "supports_audio_input": true, + "supports_audio_output": true + }, + "dashscope/qwen3.5-omni-flash-realtime": { + "input_cost_per_audio_token": 4.5e-06, + "input_cost_per_token": 5.5e-07, + "litellm_provider": "dashscope", + "max_input_tokens": 196608, + "max_output_tokens": 65536, + "max_tokens": 65536, + "mode": "realtime", + "output_cost_per_audio_token": 1.77e-05, + "output_cost_per_token": 3.3e-06, + "source": "https://www.alibabacloud.com/help/en/model-studio/qwen3-5-omni-flash-realtime", + "supported_endpoints": [ + "/v1/realtime" + ], + "supported_modalities": [ + "text", + "image", + "audio" + ], + "supported_output_modalities": [ + "text", + "audio" + ], + "supports_audio_input": true, + "supports_audio_output": true, + "supports_function_calling": true + }, + "dashscope/qwen3.5-omni-flash-realtime-2026-03-15": { + "input_cost_per_audio_token": 4.5e-06, + "input_cost_per_token": 5.5e-07, + "litellm_provider": "dashscope", + "max_input_tokens": 196608, + "max_output_tokens": 65536, + "max_tokens": 65536, + "mode": "realtime", + "output_cost_per_audio_token": 1.77e-05, + "output_cost_per_token": 3.3e-06, + "source": "https://www.alibabacloud.com/help/en/model-studio/qwen3-5-omni-flash-realtime", + "supported_endpoints": [ + "/v1/realtime" + ], + "supported_modalities": [ + "text", + "image", + "audio" + ], + "supported_output_modalities": [ + "text", + "audio" + ], + "supports_audio_input": true, + "supports_audio_output": true, + "supports_function_calling": true + }, + "dashscope/qwen3.5-omni-plus-realtime": { + "input_cost_per_audio_token": 1.65e-05, + "input_cost_per_token": 2.1e-06, + "litellm_provider": "dashscope", + "max_input_tokens": 196608, + "max_output_tokens": 65536, + "max_tokens": 65536, + "mode": "realtime", + "output_cost_per_audio_token": 6.2e-05, + "output_cost_per_token": 1.24e-05, + "source": "https://www.alibabacloud.com/help/en/model-studio/qwen3-5-omni-plus-realtime", + "supported_endpoints": [ + "/v1/realtime" + ], + "supported_modalities": [ + "text", + "image", + "audio" + ], + "supported_output_modalities": [ + "text", + "audio" + ], + "supports_audio_input": true, + "supports_audio_output": true, + "supports_function_calling": true + }, + "dashscope/qwen3.5-omni-plus-realtime-2026-03-15": { + "input_cost_per_audio_token": 1.65e-05, + "input_cost_per_token": 2.1e-06, + "litellm_provider": "dashscope", + "max_input_tokens": 196608, + "max_output_tokens": 65536, + "max_tokens": 65536, + "mode": "realtime", + "output_cost_per_audio_token": 6.2e-05, + "output_cost_per_token": 1.24e-05, + "source": "https://www.alibabacloud.com/help/en/model-studio/qwen3-5-omni-plus-realtime", + "supported_endpoints": [ + "/v1/realtime" + ], + "supported_modalities": [ + "text", + "image", + "audio" + ], + "supported_output_modalities": [ + "text", + "audio" + ], + "supports_audio_input": true, + "supports_audio_output": true, + "supports_function_calling": true + }, "dashscope/qwen3.5-plus": { "litellm_provider": "dashscope", "max_input_tokens": 991808, diff --git a/litellm/provider_endpoints_support_backup.json b/litellm/provider_endpoints_support_backup.json index dbeaccdda2d..bdf2c2db123 100644 --- a/litellm/provider_endpoints_support_backup.json +++ b/litellm/provider_endpoints_support_backup.json @@ -669,7 +669,8 @@ "batches": false, "rerank": false, "a2a": true, - "interactions": true + "interactions": true, + "realtime": true } }, "qwencloud": { diff --git a/litellm/realtime_api/README.md b/litellm/realtime_api/README.md index d810de2f24f..171286ea16c 100644 --- a/litellm/realtime_api/README.md +++ b/litellm/realtime_api/README.md @@ -4,6 +4,6 @@ Supported endpoints: - WebSocket: `/v1/realtime` (with `intent=transcription` for transcription-only sessions) - HTTP: `/v1/realtime/client_secrets`, `/v1/realtime/transcription_sessions` -Supported providers: OpenAI, Azure OpenAI, Bedrock, Vertex AI, xAI. +Supported providers: OpenAI, Azure OpenAI, Bedrock, Vertex AI, xAI, DashScope. For user-facing documentation and usage examples, see the litellm-docs repo. \ No newline at end of file diff --git a/litellm/realtime_api/main.py b/litellm/realtime_api/main.py index b824a5928c6..945b529ed8d 100644 --- a/litellm/realtime_api/main.py +++ b/litellm/realtime_api/main.py @@ -36,6 +36,11 @@ 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 +from ..llms.dashscope.common_utils import resolve_dashscope_family_api_key +from ..llms.dashscope.realtime.handler import ( + DashScopeRealtime, + resolve_dashscope_realtime_api_base, +) from ..llms.openai.realtime.handler import OpenAIRealtime from ..llms.vertex_ai.realtime.transformation import VertexAIRealtimeConfig from ..llms.vertex_ai.vertex_llm_base import VertexBase @@ -51,6 +56,7 @@ azure_realtime: Final = AzureOpenAIRealtime() openai_realtime: Final = OpenAIRealtime() bedrock_realtime: Final = BedrockRealtime() xai_realtime: Final = XAIRealtime() +dashscope_realtime: Final = DashScopeRealtime() vertex_llm_base: Final = VertexBase() base_llm_http_handler = BaseLLMHTTPHandler() _EMPTY_MODEL_PARAMS: Final[Mapping[str, Any]] = MappingProxyType({}) @@ -559,6 +565,25 @@ async def _arealtime( litellm_metadata=_build_litellm_metadata(kwargs), query_params=query_params, ) + elif _custom_llm_provider == "dashscope": + dashscope_api_base: Final = resolve_dashscope_realtime_api_base( + api_base=api_base, + dynamic_api_base=dynamic_api_base, + ) + dashscope_api_key: Final = dynamic_api_key or api_key + + await dashscope_realtime.async_realtime( + model=model, + websocket=websocket, + logging_obj=litellm_logging_obj, + api_base=dashscope_api_base, + api_key=dashscope_api_key, + client=None, + timeout=timeout, + query_params=query_params, + user_api_key_dict=kwargs.get("user_api_key_dict"), + litellm_metadata=_build_litellm_metadata(kwargs), + ) else: raise ValueError(f"Unsupported model: {model}") @@ -594,6 +619,9 @@ def _azure_realtime_health_protocol( 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 == "dashscope": + resolved_key: Final = resolve_dashscope_family_api_key(custom_llm_provider, api_key) + return MappingProxyType(dashscope_realtime.get_auth_headers(resolved_key or "")) if custom_llm_provider != "azure": return MappingProxyType({"api-key": api_key}) return azure_realtime.get_auth_headers( @@ -657,6 +685,11 @@ async def _realtime_health_check( ) elif custom_llm_provider == "xai": url = xai_realtime._construct_url(api_base=api_base or "https://api.x.ai/v1", query_params={"model": model}) + elif custom_llm_provider == "dashscope": + url = dashscope_realtime.get_websocket_url( + api_base=resolve_dashscope_realtime_api_base(api_base=api_base), + query_params={"model": model}, + ) elif custom_llm_provider == "vertex_ai": vertex_model_params: Final = model_params or {} resolved_location: Final = vertex_llm_base.get_vertex_region( diff --git a/model_prices_and_context_window.json b/model_prices_and_context_window.json index 0d2eda93323..a6f30eb9943 100644 --- a/model_prices_and_context_window.json +++ b/model_prices_and_context_window.json @@ -14784,6 +14784,50 @@ "supports_tool_choice": true, "supports_vision": true }, + "dashscope/qwen-audio-3.0-realtime-flash": { + "input_cost_per_audio_token": 4.5e-06, + "input_cost_per_token": 4.5e-07, + "litellm_provider": "dashscope", + "mode": "realtime", + "output_cost_per_audio_token": 1.5e-05, + "output_cost_per_token": 4.5e-06, + "source": "https://www.alibabacloud.com/help/en/model-studio/model-pricing", + "supported_endpoints": [ + "/v1/realtime" + ], + "supported_modalities": [ + "text", + "audio" + ], + "supported_output_modalities": [ + "text", + "audio" + ], + "supports_audio_input": true, + "supports_audio_output": true + }, + "dashscope/qwen-audio-3.0-realtime-plus": { + "input_cost_per_audio_token": 6.4e-06, + "input_cost_per_token": 8e-07, + "litellm_provider": "dashscope", + "mode": "realtime", + "output_cost_per_audio_token": 2.4e-05, + "output_cost_per_token": 6.4e-06, + "source": "https://www.alibabacloud.com/help/en/model-studio/model-pricing", + "supported_endpoints": [ + "/v1/realtime" + ], + "supported_modalities": [ + "text", + "audio" + ], + "supported_output_modalities": [ + "text", + "audio" + ], + "supports_audio_input": true, + "supports_audio_output": true + }, "dashscope/qwen-coder": { "input_cost_per_token": 3e-07, "litellm_provider": "dashscope", @@ -15497,6 +15541,164 @@ } ] }, + "dashscope/qwen3.5-livetranslate-flash-realtime": { + "input_cost_per_audio_token": 7.5e-06, + "input_cost_per_image_token": 5.5e-07, + "litellm_provider": "dashscope", + "max_input_tokens": 49152, + "max_output_tokens": 4096, + "max_tokens": 4096, + "mode": "realtime", + "output_cost_per_audio_token": 3e-05, + "output_cost_per_token": 2e-05, + "source": "https://www.alibabacloud.com/help/en/model-studio/model-pricing", + "supported_endpoints": [ + "/v1/realtime" + ], + "supported_modalities": [ + "audio", + "image" + ], + "supported_output_modalities": [ + "text", + "audio" + ], + "supports_audio_input": true, + "supports_audio_output": true + }, + "dashscope/qwen3.5-livetranslate-flash-realtime-2026-05-19": { + "input_cost_per_audio_token": 7.5e-06, + "input_cost_per_image_token": 5.5e-07, + "litellm_provider": "dashscope", + "max_input_tokens": 49152, + "max_output_tokens": 4096, + "max_tokens": 4096, + "mode": "realtime", + "output_cost_per_audio_token": 3e-05, + "output_cost_per_token": 2e-05, + "source": "https://www.alibabacloud.com/help/en/model-studio/model-pricing", + "supported_endpoints": [ + "/v1/realtime" + ], + "supported_modalities": [ + "audio", + "image" + ], + "supported_output_modalities": [ + "text", + "audio" + ], + "supports_audio_input": true, + "supports_audio_output": true + }, + "dashscope/qwen3.5-omni-flash-realtime": { + "input_cost_per_audio_token": 4.5e-06, + "input_cost_per_token": 5.5e-07, + "litellm_provider": "dashscope", + "max_input_tokens": 196608, + "max_output_tokens": 65536, + "max_tokens": 65536, + "mode": "realtime", + "output_cost_per_audio_token": 1.77e-05, + "output_cost_per_token": 3.3e-06, + "source": "https://www.alibabacloud.com/help/en/model-studio/qwen3-5-omni-flash-realtime", + "supported_endpoints": [ + "/v1/realtime" + ], + "supported_modalities": [ + "text", + "image", + "audio" + ], + "supported_output_modalities": [ + "text", + "audio" + ], + "supports_audio_input": true, + "supports_audio_output": true, + "supports_function_calling": true + }, + "dashscope/qwen3.5-omni-flash-realtime-2026-03-15": { + "input_cost_per_audio_token": 4.5e-06, + "input_cost_per_token": 5.5e-07, + "litellm_provider": "dashscope", + "max_input_tokens": 196608, + "max_output_tokens": 65536, + "max_tokens": 65536, + "mode": "realtime", + "output_cost_per_audio_token": 1.77e-05, + "output_cost_per_token": 3.3e-06, + "source": "https://www.alibabacloud.com/help/en/model-studio/qwen3-5-omni-flash-realtime", + "supported_endpoints": [ + "/v1/realtime" + ], + "supported_modalities": [ + "text", + "image", + "audio" + ], + "supported_output_modalities": [ + "text", + "audio" + ], + "supports_audio_input": true, + "supports_audio_output": true, + "supports_function_calling": true + }, + "dashscope/qwen3.5-omni-plus-realtime": { + "input_cost_per_audio_token": 1.65e-05, + "input_cost_per_token": 2.1e-06, + "litellm_provider": "dashscope", + "max_input_tokens": 196608, + "max_output_tokens": 65536, + "max_tokens": 65536, + "mode": "realtime", + "output_cost_per_audio_token": 6.2e-05, + "output_cost_per_token": 1.24e-05, + "source": "https://www.alibabacloud.com/help/en/model-studio/qwen3-5-omni-plus-realtime", + "supported_endpoints": [ + "/v1/realtime" + ], + "supported_modalities": [ + "text", + "image", + "audio" + ], + "supported_output_modalities": [ + "text", + "audio" + ], + "supports_audio_input": true, + "supports_audio_output": true, + "supports_function_calling": true + }, + "dashscope/qwen3.5-omni-plus-realtime-2026-03-15": { + "input_cost_per_audio_token": 1.65e-05, + "input_cost_per_token": 2.1e-06, + "litellm_provider": "dashscope", + "max_input_tokens": 196608, + "max_output_tokens": 65536, + "max_tokens": 65536, + "mode": "realtime", + "output_cost_per_audio_token": 6.2e-05, + "output_cost_per_token": 1.24e-05, + "source": "https://www.alibabacloud.com/help/en/model-studio/qwen3-5-omni-plus-realtime", + "supported_endpoints": [ + "/v1/realtime" + ], + "supported_modalities": [ + "text", + "image", + "audio" + ], + "supported_output_modalities": [ + "text", + "audio" + ], + "supports_audio_input": true, + "supports_audio_output": true, + "supports_function_calling": true + }, "dashscope/qwen3.5-plus": { "litellm_provider": "dashscope", "max_input_tokens": 991808, diff --git a/provider_endpoints_support.json b/provider_endpoints_support.json index c71f4a82a4a..80d682ff594 100644 --- a/provider_endpoints_support.json +++ b/provider_endpoints_support.json @@ -722,7 +722,8 @@ "batches": false, "rerank": false, "a2a": true, - "interactions": true + "interactions": true, + "realtime": true } }, "qwencloud": { diff --git a/tests/llm_translation/realtime/test_dashscope_realtime.py b/tests/llm_translation/realtime/test_dashscope_realtime.py new file mode 100644 index 00000000000..315ea2cde5d --- /dev/null +++ b/tests/llm_translation/realtime/test_dashscope_realtime.py @@ -0,0 +1,31 @@ +""" +DashScope Realtime API E2E Tests + +Tests Alibaba Cloud Model Studio (DashScope) Qwen-Omni-Realtime through LiteLLM's +realtime interface. Uses the base test class to ensure consistent behavior across +providers. + +Requires DASHSCOPE_API_KEY; skipped otherwise. Pass api_base to exercise a +workspace-scoped host such as wss://{workspace_id}.cn-beijing.maas.aliyuncs.com. +""" + +from tests.llm_translation.realtime.base_realtime_tests import BaseRealtimeTest + + +class TestDashScopeRealtime(BaseRealtimeTest): + """ + E2E tests for DashScope's Qwen-Omni-Realtime WebSocket API. + + The API speaks OpenAI-compatible realtime events: + - Endpoint: wss://dashscope.aliyuncs.com/api-ws/v1/realtime + - Initial event: "session.created" + """ + + def get_model(self) -> str: + return "dashscope/qwen3.5-omni-plus-realtime" + + def get_api_key_env_var(self) -> str: + return "DASHSCOPE_API_KEY" + + def get_initial_event_type(self) -> tuple[str, ...]: + return ("session.created",) diff --git a/tests/test_litellm/llms/dashscope/test_dashscope_realtime_handler.py b/tests/test_litellm/llms/dashscope/test_dashscope_realtime_handler.py new file mode 100644 index 00000000000..6245df3f1a1 --- /dev/null +++ b/tests/test_litellm/llms/dashscope/test_dashscope_realtime_handler.py @@ -0,0 +1,189 @@ +"""Unit tests for the DashScope realtime handler: URL construction, api base resolution, event forwarding.""" + +import json + +import pytest +import websockets + +import litellm +from litellm.litellm_core_utils.llm_cost_calc.utils import generic_cost_per_token +from litellm.llms.dashscope.realtime.handler import ( + DASHSCOPE_REALTIME_API_BASE, + REALTIME_WEBSOCKET_PATH, + DashScopeRealtime, + resolve_dashscope_realtime_api_base, +) +from litellm.types.utils import ( + CompletionTokensDetailsWrapper, + PromptTokensDetailsWrapper, + Usage, +) +from tests.llm_translation.realtime.base_realtime_tests import RealTimeWebSocketClient + +dashscope_realtime = DashScopeRealtime() + +WORKSPACE_API_BASE = "wss://ws-abc123.cn-beijing.maas.aliyuncs.com" + + +def test_construct_url_uses_dashscope_realtime_path(): + url = dashscope_realtime._construct_url(DASHSCOPE_REALTIME_API_BASE, {"model": "qwen3.5-omni-plus-realtime"}) + assert url == "wss://dashscope.aliyuncs.com/api-ws/v1/realtime?model=qwen3.5-omni-plus-realtime" + + +def test_construct_url_upgrades_http_and_keeps_workspace_host(): + url = dashscope_realtime._construct_url("https://ws-abc123.cn-beijing.maas.aliyuncs.com", {"model": "m"}) + assert url == f"{WORKSPACE_API_BASE}/api-ws/v1/realtime?model=m" + + +def test_construct_url_omits_empty_query_params(): + url = dashscope_realtime._construct_url(WORKSPACE_API_BASE, {}) + assert url == f"{WORKSPACE_API_BASE}/api-ws/v1/realtime" + + +def test_construct_url_replaces_openai_style_path(): + url = dashscope_realtime._construct_url("wss://dashscope.aliyuncs.com/v1/realtime", {"model": "m"}) + assert url == "wss://dashscope.aliyuncs.com/api-ws/v1/realtime?model=m" + + +def test_get_auth_headers_sends_bearer_token(): + assert dashscope_realtime.get_auth_headers("sk-test") == {"Authorization": "Bearer sk-test"} + + +def test_additional_headers_never_send_openai_beta(): + """DashScope needs no OpenAI-Beta opt-in header; its session shape is always the flat one.""" + assert dashscope_realtime._get_additional_headers("sk-test", openai_beta_realtime=True) == { + "Authorization": "Bearer sk-test" + } + + +def test_resolve_api_base_defaults_to_region_host(): + assert resolve_dashscope_realtime_api_base(None, None) == DASHSCOPE_REALTIME_API_BASE + + +def test_resolve_api_base_maps_chat_base_to_realtime_host(): + chat_base = "https://dashscope.aliyuncs.com/compatible-mode/v1" + assert resolve_dashscope_realtime_api_base(chat_base) == DASHSCOPE_REALTIME_API_BASE + + +def test_resolve_api_base_prefers_configured_workspace_host(): + chat_base = "https://dashscope.aliyuncs.com/compatible-mode/v1" + assert resolve_dashscope_realtime_api_base(WORKSPACE_API_BASE, chat_base) == WORKSPACE_API_BASE + + +def test_resolve_api_base_falls_back_to_dynamic_api_base(): + workspace = "wss://ws-abc123.ap-southeast-1.maas.aliyuncs.com" + assert resolve_dashscope_realtime_api_base(None, workspace) == workspace + + +@pytest.mark.asyncio +async def test_proxy_forwards_events_to_and_from_a_dashscope_shaped_backend(): + """Drive the whole `_arealtime` path against a local stand-in for DashScope's backend. + + Locks the wire contract a real DashScope session depends on: the endpoint path, the + bearer-only auth, and that the client's flat beta-style ``session.update`` reaches the + backend unchanged. litellm remaps that payload into GA's nested shape unless the + handler declares its backend a beta-protocol one, and DashScope drops the remapped + modality and audio-format fields. + """ + handshake: dict = {} + client_events: list = [] + session_update: dict = { + "type": "session.update", + "session": {"modalities": ["text", "audio"], "input_audio_format": "pcm", "voice": "Ethan"}, + } + + async def backend_handler(backend_ws): + handshake["path"] = backend_ws.request.path + handshake["headers"] = backend_ws.request.headers + await backend_ws.send(json.dumps({"type": "session.created", "session": {"id": "sess_1"}})) + async for raw in backend_ws: + client_events.append(json.loads(raw)) + + async with websockets.serve(backend_handler, "127.0.0.1", 0) as server: + port = server.sockets[0].getsockname()[1] + client_ws = RealTimeWebSocketClient() + client_ws.queue_client_message(json.dumps(session_update)) + await litellm._arealtime( + model="dashscope/qwen3.5-omni-plus-realtime", + websocket=client_ws, + api_key="sk-dashscope-test", + api_base=f"http://127.0.0.1:{port}", + timeout=10, + ) + + assert handshake["path"] == f"{REALTIME_WEBSOCKET_PATH}?model=qwen3.5-omni-plus-realtime" + assert handshake["headers"]["Authorization"] == "Bearer sk-dashscope-test" + assert "OpenAI-Beta" not in handshake["headers"] + assert client_events == [session_update] + assert [message["type"] for message in client_ws.messages_received] == ["session.created"] + + +@pytest.mark.parametrize( + "model", + [ + "dashscope/qwen3.5-omni-plus-realtime", + "dashscope/qwen3.5-omni-flash-realtime", + "dashscope/qwen-audio-3.0-realtime-plus", + "dashscope/qwen-audio-3.0-realtime-flash", + "dashscope/qwen3.5-livetranslate-flash-realtime", + ], +) +def test_cost_map_bills_realtime_audio_tokens_at_the_audio_rate(model, local_model_cost_map): + """The cost map entries must price audio tokens separately, otherwise a realtime + session silently bills audio at the far cheaper text rate.""" + model_info = litellm.get_model_info(model=model) + assert model_info["mode"] == "realtime" + assert "/v1/realtime" in model_info["supported_endpoints"] + + audio_input_rate = model_info["input_cost_per_audio_token"] + audio_output_rate = model_info["output_cost_per_audio_token"] + # A text rate that differs from the audio rate proves the audio rate was really applied. + # The livetranslate family takes no text input, so it has no text rate at all. + assert audio_input_rate != model_info.get("input_cost_per_token") + assert audio_output_rate != model_info.get("output_cost_per_token") + + usage = Usage( + prompt_tokens=1_000, + completion_tokens=500, + total_tokens=1_500, + prompt_tokens_details=PromptTokensDetailsWrapper(audio_tokens=1_000, text_tokens=0), + completion_tokens_details=CompletionTokensDetailsWrapper(audio_tokens=500, text_tokens=0), + ) + input_cost, output_cost = generic_cost_per_token(model=model, usage=usage, custom_llm_provider="dashscope") + + assert input_cost == pytest.approx(1_000 * audio_input_rate) + assert output_cost == pytest.approx(500 * audio_output_rate) + + +_RESERVED_WS_CLOSE_CODES = frozenset({1004, 1005, 1006, 1015}) +_VALID_WS_CLOSE_CODES = (frozenset(range(1000, 1016)) - _RESERVED_WS_CLOSE_CODES) | frozenset(range(3000, 5000)) + + +@pytest.mark.asyncio +async def test_rejected_handshake_closes_the_client_with_a_valid_websocket_code(): + """A rejected DashScope handshake must not reuse the upstream HTTP status as the code. + + WebSocket close codes are limited to RFC 6455's ranges. Passing 401 makes the server + drop the code and the client observes a plain 1000 instead, so the upstream status + belongs in the close reason rather than the code. + """ + + async def backend_handler(backend_ws): + raise AssertionError("the upgrade should have been rejected before the handler runs") + + async def reject_upgrade(connection, request): + return websockets.Response(401, "Unauthorized", websockets.Headers()) + + async with websockets.serve(backend_handler, "127.0.0.1", 0, process_request=reject_upgrade) as server: + port = server.sockets[0].getsockname()[1] + client_ws = RealTimeWebSocketClient() + await litellm._arealtime( + model="dashscope/qwen3.5-omni-plus-realtime", + websocket=client_ws, + api_key="sk-rejected", + api_base=f"http://127.0.0.1:{port}", + timeout=10, + ) + + assert client_ws.close_code in _VALID_WS_CLOSE_CODES + assert "401" in (client_ws.close_reason or "")