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.
This commit is contained in:
waani 2026-09-16 18:47:27 +08:00
parent f2118fc767
commit d5307f6a76
10 changed files with 796 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,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 <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_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

View file

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

View file

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

View file

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

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

View file

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

View file

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

View file

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