diff --git a/litellm/llms/dashscope/realtime/__init__.py b/litellm/llms/dashscope/realtime/__init__.py new file mode 100644 index 00000000000..74cf56aa693 --- /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..41e9116c3b6 --- /dev/null +++ b/litellm/llms/dashscope/realtime/handler.py @@ -0,0 +1,75 @@ +""" +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_additional_headers( + self, api_key: str, *, openai_beta_realtime: bool = False + ) -> dict[str, str]: # mutable-ok: matches the base hook's signature + """DashScope authenticates with the bearer token alone, so a client's ``OpenAI-Beta`` header stops here.""" + return {"Authorization": f"Bearer {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 _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 080fc3d24c9..390adb4e336 100644 --- a/litellm/llms/openai/realtime/handler.py +++ b/litellm/llms/openai/realtime/handler.py @@ -128,6 +128,17 @@ class OpenAIRealtime(OpenAIChatCompletion): """Return a per-session usage meter, or None when billing needs no metering.""" 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, @@ -196,6 +207,7 @@ class OpenAIRealtime(OpenAIChatCompletion): ), event_normalizer=self._make_event_normalizer(), usage_meter=self._make_usage_meter(), + 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 cb9bfa6ffe4..865858f1e3a 100644 --- a/litellm/model_prices_and_context_window_backup.json +++ b/litellm/model_prices_and_context_window_backup.json @@ -17498,6 +17498,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", @@ -18211,6 +18255,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 f95b553c2b4..c85303a26b1 100644 --- a/litellm/provider_endpoints_support_backup.json +++ b/litellm/provider_endpoints_support_backup.json @@ -705,7 +705,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 088dfb54155..229748b112b 100644 --- a/litellm/realtime_api/main.py +++ b/litellm/realtime_api/main.py @@ -42,6 +42,10 @@ from ..llms.azure.common_utils import get_azure_ad_token from ..llms.azure.realtime.handler import AzureOpenAIRealtime, azure_realtime_protocol_for_client from ..llms.bedrock.realtime.handler import BedrockRealtime, raise_if_session_committed from ..llms.custom_httpx.http_handler import realtime_ssl_for_url +from ..llms.dashscope.realtime.handler import ( + DashScopeRealtime, + resolve_dashscope_realtime_api_base, +) from ..llms.openai.live.handler import OpenAILiveSessions from ..llms.openai.realtime.handler import OpenAIRealtime from ..llms.openai.workload_identity import openai_static_api_key, resolve_openai_bearer_token_async @@ -61,6 +65,7 @@ openai_realtime: Final = OpenAIRealtime() openai_live_sessions: Final = OpenAILiveSessions() 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, object]] = MappingProxyType({}) @@ -718,6 +723,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( # pyright: ignore[reportUnknownMemberType] # the shared realtime handler's signature is partially unknown for every provider branch + 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"), # pyright: ignore[reportUnknownMemberType, reportUnknownArgumentType] # proxy kwargs are untyped, as in the sibling provider branches + litellm_metadata=_build_litellm_metadata(kwargs), + ) else: raise ValueError(f"Unsupported model: {model}") @@ -850,6 +874,11 @@ async def realtime_health_check( url = xai_realtime.construct_url( api_base=resolved_api_base or "https://api.x.ai/v1", query_params={"model": model} ) + elif custom_llm_provider == "dashscope": + url = dashscope_realtime.construct_url( # rebind-ok: shares the health-check url slot with the provider branches above + api_base=resolve_dashscope_realtime_api_base(api_base=resolved_api_base), + query_params={"model": model}, + ) elif custom_llm_provider == "vertex_ai": if is_vertex_speech_to_text_model(model): raise ValueError( diff --git a/model_prices_and_context_window.json b/model_prices_and_context_window.json index cb9bfa6ffe4..865858f1e3a 100644 --- a/model_prices_and_context_window.json +++ b/model_prices_and_context_window.json @@ -17498,6 +17498,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", @@ -18211,6 +18255,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 d65da3f41fd..b920a8ecedf 100644 --- a/provider_endpoints_support.json +++ b/provider_endpoints_support.json @@ -778,7 +778,8 @@ "rerank": false, "a2a": true, "interactions": true, - "video_generations": true + "video_generations": true, + "realtime": true } }, "qwencloud": { diff --git a/tests/unit/llms/dashscope/test_dashscope_realtime_handler.py b/tests/unit/llms/dashscope/test_dashscope_realtime_handler.py new file mode 100644 index 00000000000..c8afdfa9fc5 --- /dev/null +++ b/tests/unit/llms/dashscope/test_dashscope_realtime_handler.py @@ -0,0 +1,266 @@ +"""Unit tests for the DashScope realtime handler: URL construction, api base resolution, event forwarding.""" + +import asyncio +import json +from typing import Final +from unittest.mock import patch + +import pytest +import websockets +from websockets.exceptions import ConnectionClosedOK +from websockets.frames import Close + +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, +) + +dashscope_realtime = DashScopeRealtime() + +WORKSPACE_API_BASE = "wss://ws-abc123.cn-beijing.maas.aliyuncs.com" + +MODEL: Final = "dashscope/qwen3.5-omni-plus-realtime" + + +class _ScriptedClientWebSocket: + """Client side of the proxy socket: replays scripted frames, records what the proxy sent.""" + + def __init__(self, frames: list[str], headers: list[tuple[bytes, bytes]] | None = None) -> None: + self.scope = {"headers": headers or []} + self._frames = list(frames) + self._closed = asyncio.Event() + self.received: list[dict] = [] + self.close_code: int | None = None + self.close_reason: str | None = None + + async def receive_text(self) -> str: + if self._frames: + return self._frames.pop(0) + await self._closed.wait() + raise RuntimeError("client disconnected") + + async def send_text(self, data: str) -> None: + self.received.append(json.loads(data)) + + async def close(self, code: int = 1000, reason: str | None = None) -> None: + self.close_code = code + self.close_reason = reason + self._closed.set() + + +class _FakeBackendSocket: + """DashScope's end of the socket: emits its opening frames, then records what the proxy forwards.""" + + def __init__(self, opening_frames: list[str]) -> None: + self._opening_frames = list(opening_frames) + self._forwarded = asyncio.Event() + self.forwarded: list[dict] = [] + + async def recv(self, decode: bool = True) -> str: + if self._opening_frames: + return self._opening_frames.pop(0) + await self._forwarded.wait() + raise ConnectionClosedOK(Close(1000, ""), None) + + async def send(self, data: str) -> None: + self.forwarded.append(json.loads(data)) + self._forwarded.set() + + +class _FakeBackendConnection: + def __init__(self, socket: _FakeBackendSocket) -> None: + self._socket = socket + + async def __aenter__(self) -> _FakeBackendSocket: + return self._socket + + async def __aexit__(self, *exc_info: object) -> None: + return None + + +def _connect_recorder(socket: _FakeBackendSocket, calls: list[dict]): + def connect(url: str, **kwargs: object) -> _FakeBackendConnection: + calls.append({"url": url, **kwargs}) + return _FakeBackendConnection(socket) + + return connect + + +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_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 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. + """ + session_update: Final = { + "type": "session.update", + "session": {"modalities": ["text", "audio"], "input_audio_format": "pcm", "voice": "Ethan"}, + } + client_ws: Final = _ScriptedClientWebSocket([json.dumps(session_update)]) + backend_ws: Final = _FakeBackendSocket([json.dumps({"type": "session.created", "session": {"id": "sess_1"}})]) + connect_calls: Final[list[dict]] = [] + + with patch("websockets.connect", _connect_recorder(backend_ws, connect_calls)): + await litellm._arealtime( + model=MODEL, + websocket=client_ws, + api_key="sk-dashscope-test", + api_base="https://dashscope.aliyuncs.com", + timeout=10, + ) + + assert ( + connect_calls[0]["url"] + == f"wss://dashscope.aliyuncs.com{REALTIME_WEBSOCKET_PATH}?model=qwen3.5-omni-plus-realtime" + ) + assert connect_calls[0]["additional_headers"] == {"Authorization": "Bearer sk-dashscope-test"} + assert backend_ws.forwarded == [session_update] + assert [message["type"] for message in client_ws.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) + + +@pytest.mark.asyncio +async def test_client_beta_header_is_not_forwarded_upstream(): + """DashScope authenticates with the bearer token alone, so the client's ``OpenAI-Beta`` header stops at the proxy. + + The clients that speak the flat beta dialect are the ones that send this header, so without + the override the header would reach DashScope on every working session. + """ + session_update: Final = {"type": "session.update", "session": {"modalities": ["text"]}} + client_ws: Final = _ScriptedClientWebSocket( + [json.dumps(session_update)], + headers=[(b"openai-beta", b"realtime=v1")], + ) + backend_ws: Final = _FakeBackendSocket([json.dumps({"type": "session.created", "session": {"id": "sess_1"}})]) + connect_calls: Final[list[dict]] = [] + + with patch("websockets.connect", _connect_recorder(backend_ws, connect_calls)): + await litellm._arealtime( + model=MODEL, + websocket=client_ws, + api_key="sk-dashscope-test", + api_base="https://dashscope.aliyuncs.com", + timeout=10, + ) + + assert connect_calls[0]["additional_headers"] == {"Authorization": "Bearer sk-dashscope-test"} + assert backend_ws.forwarded == [session_update] + + +_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. + """ + + client_ws: Final = _ScriptedClientWebSocket([]) + refused: Final = websockets.exceptions.InvalidStatus( + websockets.http11.Response(401, "Unauthorized", websockets.datastructures.Headers()) + ) + + with patch("websockets.connect", side_effect=refused): + await litellm._arealtime( + model=MODEL, + websocket=client_ws, + api_key="sk-rejected", + api_base="https://dashscope.aliyuncs.com", + timeout=10, + ) + + assert client_ws.close_code in _VALID_WS_CLOSE_CODES + assert "401" in (client_ws.close_reason or "")