mirror of
https://github.com/BerriAI/litellm.git
synced 2026-10-31 06:56:59 +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.
This commit is contained in:
parent
f2118fc767
commit
d5307f6a76
10 changed files with 796 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",)
|
||||
75
litellm/llms/dashscope/realtime/handler.py
Normal file
75
litellm/llms/dashscope/realtime/handler.py
Normal 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
|
||||
|
|
@ -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()
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -705,7 +705,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.
|
||||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -778,7 +778,8 @@
|
|||
"rerank": false,
|
||||
"a2a": true,
|
||||
"interactions": true,
|
||||
"video_generations": true
|
||||
"video_generations": true,
|
||||
"realtime": true
|
||||
}
|
||||
},
|
||||
"qwencloud": {
|
||||
|
|
|
|||
266
tests/unit/llms/dashscope/test_dashscope_realtime_handler.py
Normal file
266
tests/unit/llms/dashscope/test_dashscope_realtime_handler.py
Normal 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 "")
|
||||
Loading…
Add table
Reference in a new issue