mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-14 23:21:35 +00:00
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:
parent
e5da59336d
commit
d9313dfa4e
11 changed files with 764 additions and 3 deletions
5
litellm/llms/dashscope/realtime/__init__.py
Normal file
5
litellm/llms/dashscope/realtime/__init__.py
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
"""DashScope (Alibaba Cloud Model Studio) Realtime API handler."""
|
||||
|
||||
from .handler import DashScopeRealtime
|
||||
|
||||
__all__ = ["DashScopeRealtime"]
|
||||
85
litellm/llms/dashscope/realtime/handler.py
Normal file
85
litellm/llms/dashscope/realtime/handler.py
Normal 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
|
||||
|
|
@ -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()
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -669,7 +669,8 @@
|
|||
"batches": false,
|
||||
"rerank": false,
|
||||
"a2a": true,
|
||||
"interactions": true
|
||||
"interactions": true,
|
||||
"realtime": true
|
||||
}
|
||||
},
|
||||
"qwencloud": {
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -722,7 +722,8 @@
|
|||
"batches": false,
|
||||
"rerank": false,
|
||||
"a2a": true,
|
||||
"interactions": true
|
||||
"interactions": true,
|
||||
"realtime": true
|
||||
}
|
||||
},
|
||||
"qwencloud": {
|
||||
|
|
|
|||
31
tests/llm_translation/realtime/test_dashscope_realtime.py
Normal file
31
tests/llm_translation/realtime/test_dashscope_realtime.py
Normal 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",)
|
||||
|
|
@ -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 "")
|
||||
Loading…
Add table
Reference in a new issue