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.
This commit is contained in:
waani 2026-09-10 20:22:45 +08:00
parent e5da59336d
commit d9313dfa4e
11 changed files with 764 additions and 3 deletions

View file

@ -0,0 +1,5 @@
"""DashScope (Alibaba Cloud Model Studio) Realtime API handler."""
from .handler import DashScopeRealtime
__all__ = ["DashScopeRealtime"]

View file

@ -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 <DASHSCOPE_API_KEY>``, 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

View file

@ -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()

View file

@ -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,

View file

@ -669,7 +669,8 @@
"batches": false,
"rerank": false,
"a2a": true,
"interactions": true
"interactions": true,
"realtime": true
}
},
"qwencloud": {

View file

@ -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.

View file

@ -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(

View file

@ -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,

View file

@ -722,7 +722,8 @@
"batches": false,
"rerank": false,
"a2a": true,
"interactions": true
"interactions": true,
"realtime": true
}
},
"qwencloud": {

View file

@ -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",)

View file

@ -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 "")