Skip to content

Commit f72eeff

Browse files
Merge pull request #231 from songguocola/feat/qwen-live
feat: add qwen live api
2 parents 20ee363 + 80dabae commit f72eeff

12 files changed

Lines changed: 4605 additions & 193 deletions

‎dashscope/resources/audio/__init__.py‎

Lines changed: 69 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,12 @@
66
here is two levels deep at most: ``client.audio.asr.transcribe``,
77
``client.audio.asr.recognize``, ``client.audio.tts.synthesize``,
88
``client.audio.tts.http.synthesize``, ``client.audio.realtime.connect``,
9-
and the management plane ``client.audio.vocabularies`` / ``phrases`` /
10-
``voices`` / ``voice_design``.
9+
``client.audio.live.connect``, and the management plane
10+
``client.audio.vocabularies`` / ``phrases`` / ``voices`` /
11+
``voice_design``.
12+
13+
The Qwen-Live wire event names (``EVENT_*``) are re-exported here too, so
14+
callers can import them from the same package as ``LiveSession``.
1115
"""
1216

1317
from dashscope.resources._blocking import run_blocking
@@ -34,6 +38,38 @@
3438
HttpSynthesisStream,
3539
HttpTts,
3640
)
41+
from dashscope.resources.audio.live import (
42+
EVENT_ERROR,
43+
EVENT_RESPONSE_CREATE,
44+
EVENT_RESPONSE_EVENT,
45+
EVENT_RESPONSE_ITEM_CREATE,
46+
EVENT_SESSION_CLOSE,
47+
EVENT_SESSION_CLOSED,
48+
EVENT_SESSION_COMMENTARY_APPEND,
49+
EVENT_SESSION_COMMENTARY_APPENDED,
50+
EVENT_SESSION_CREATED,
51+
EVENT_SESSION_DELEGATION_CREATED,
52+
EVENT_SESSION_EVENT,
53+
EVENT_SESSION_INPUT_AUDIO_APPEND,
54+
EVENT_SESSION_INPUT_AUDIO_MUTE,
55+
EVENT_SESSION_INPUT_AUDIO_MUTED,
56+
EVENT_SESSION_INPUT_AUDIO_UNMUTE,
57+
EVENT_SESSION_INPUT_AUDIO_UNMUTED,
58+
EVENT_SESSION_INPUT_TRANSCRIPT_DELTA,
59+
EVENT_SESSION_INSTRUCTION_APPEND,
60+
EVENT_SESSION_INSTRUCTIONS_APPENDED,
61+
EVENT_SESSION_ITEM_CREATE,
62+
EVENT_SESSION_OUTPUT_AUDIO_DELTA,
63+
EVENT_SESSION_OUTPUT_AUDIO_INTERRUPTED,
64+
EVENT_SESSION_OUTPUT_TRANSCRIPT_DELTA,
65+
EVENT_SESSION_THINKING_APPEND,
66+
EVENT_SESSION_THINKING_APPENDED,
67+
EVENT_SESSION_UPDATE,
68+
EVENT_SESSION_UPDATED,
69+
EVENT_SESSION_USAGE_UPDATED,
70+
Live,
71+
LiveSession,
72+
)
3773
from dashscope.resources.audio.phrases import (
3874
Phrase,
3975
PhraseList,
@@ -50,10 +86,40 @@
5086
"Audio",
5187
"DesignedVoice",
5288
"DesignedVoiceList",
89+
"EVENT_ERROR",
90+
"EVENT_RESPONSE_CREATE",
91+
"EVENT_RESPONSE_EVENT",
92+
"EVENT_RESPONSE_ITEM_CREATE",
93+
"EVENT_SESSION_CLOSE",
94+
"EVENT_SESSION_CLOSED",
95+
"EVENT_SESSION_COMMENTARY_APPEND",
96+
"EVENT_SESSION_COMMENTARY_APPENDED",
97+
"EVENT_SESSION_CREATED",
98+
"EVENT_SESSION_DELEGATION_CREATED",
99+
"EVENT_SESSION_EVENT",
100+
"EVENT_SESSION_INPUT_AUDIO_APPEND",
101+
"EVENT_SESSION_INPUT_AUDIO_MUTE",
102+
"EVENT_SESSION_INPUT_AUDIO_MUTED",
103+
"EVENT_SESSION_INPUT_AUDIO_UNMUTE",
104+
"EVENT_SESSION_INPUT_AUDIO_UNMUTED",
105+
"EVENT_SESSION_INPUT_TRANSCRIPT_DELTA",
106+
"EVENT_SESSION_INSTRUCTION_APPEND",
107+
"EVENT_SESSION_INSTRUCTIONS_APPENDED",
108+
"EVENT_SESSION_ITEM_CREATE",
109+
"EVENT_SESSION_OUTPUT_AUDIO_DELTA",
110+
"EVENT_SESSION_OUTPUT_AUDIO_INTERRUPTED",
111+
"EVENT_SESSION_OUTPUT_TRANSCRIPT_DELTA",
112+
"EVENT_SESSION_THINKING_APPEND",
113+
"EVENT_SESSION_THINKING_APPENDED",
114+
"EVENT_SESSION_UPDATE",
115+
"EVENT_SESSION_UPDATED",
116+
"EVENT_SESSION_USAGE_UPDATED",
53117
"HttpSynthesis",
54118
"HttpSynthesisChunk",
55119
"HttpSynthesisStream",
56120
"HttpTts",
121+
"Live",
122+
"LiveSession",
57123
"Phrase",
58124
"PhraseList",
59125
"Phrases",
@@ -110,6 +176,7 @@ def __init__(self, client) -> None:
110176
self.asr = Asr(client)
111177
self.tts = Tts(client)
112178
self.realtime = Realtime(client)
179+
self.live = Live(client)
113180
self.vocabularies = Vocabularies(client)
114181
self.phrases = Phrases(client)
115182
self.voices = Voices(client)
Lines changed: 292 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,292 @@
1+
# -*- coding: utf-8 -*-
2+
# Copyright (c) Alibaba, Inc. and its affiliates.
3+
"""Raw WebSocket transport shared by Live and Omni realtime sessions.
4+
5+
Session payloads, terminal events and error policy belong to the callers.
6+
This layer only assembles connections, exchanges raw frames and releases I/O.
7+
"""
8+
9+
from __future__ import annotations
10+
11+
import base64
12+
import socket
13+
import threading
14+
from typing import Any, Mapping, Optional, TypeVar, Union
15+
from urllib.parse import parse_qsl, urlencode, urlsplit, urlunsplit
16+
17+
from dashscope._config import WS_REALTIME_PATH, workspace_ws_url
18+
from dashscope._logging import request_id_from_headers
19+
from dashscope.transport import default_user_agent
20+
from dashscope.transport._ident import sdk_headers
21+
from dashscope.transport.ws import (
22+
Connector,
23+
_close_websocket,
24+
_default_connector,
25+
)
26+
27+
__all__ = ["WsSessionBase"]
28+
29+
MODULE = "audio"
30+
31+
_UNSET = object()
32+
_CLOSE_TIMEOUT = 1.0
33+
34+
_SelfT = TypeVar("_SelfT", bound="WsSessionBase")
35+
36+
37+
def _as_base64(data: Union[str, bytes]) -> str:
38+
if isinstance(data, str):
39+
return data
40+
if isinstance(data, bytes):
41+
return base64.b64encode(data).decode("ascii")
42+
raise TypeError("expected a base64 string or bytes")
43+
44+
45+
class WsSessionBase:
46+
"""One socket per session; subclasses own all protocol state."""
47+
48+
_LABEL = "realtime"
49+
50+
def __init__(
51+
self,
52+
client,
53+
*,
54+
model: str,
55+
ws_url: Optional[str] = None,
56+
connector: Optional[Connector] = None,
57+
timeout: Optional[float] = None,
58+
headers: Optional[Mapping[str, str]] = None,
59+
api_key: Optional[str] = None,
60+
workspace: Optional[str] = None,
61+
) -> None:
62+
config = client.config
63+
if timeout is not None and timeout <= 0:
64+
raise ValueError("timeout must be positive")
65+
# Keep the derived gateway and workspace header consistent.
66+
space = workspace or config.workspace
67+
url = ws_url or workspace_ws_url(
68+
space,
69+
WS_REALTIME_PATH,
70+
config.region,
71+
)
72+
parts = urlsplit(url)
73+
query = parse_qsl(parts.query, keep_blank_values=True)
74+
if not any(key == "model" for key, _ in query):
75+
query.append(("model", model))
76+
url = urlunsplit(parts._replace(query=urlencode(query)))
77+
# Inherit the client's agent (and any surface suffix on it) so the
78+
# realtime socket reports the same identity as its HTTP calls; the
79+
# dedup below still lets an explicit per-call header win. The
80+
# lookup is defensive for the same reason as
81+
# ``transport.ws.with_client_user_agent``.
82+
all_headers = {
83+
"user-agent": (
84+
getattr(
85+
getattr(client, "transport", None),
86+
"user_agent",
87+
None,
88+
)
89+
or default_user_agent()
90+
),
91+
**sdk_headers(MODULE),
92+
}
93+
key = api_key or config.api_key
94+
if key:
95+
all_headers["Authorization"] = f"Bearer {key}"
96+
if space:
97+
all_headers["X-DashScope-WorkSpace"] = space
98+
user_headers = dict(headers or {})
99+
user_keys = {key.lower() for key in user_headers}
100+
all_headers = {
101+
key: value
102+
for key, value in all_headers.items()
103+
if key.lower() not in user_keys
104+
}
105+
all_headers.update(user_headers)
106+
self._model = model
107+
self._wire_model = next(
108+
value for key, value in query if key == "model"
109+
)
110+
self._url = url
111+
self._headers = all_headers
112+
self._connector = connector
113+
self._timeout = config.timeout if timeout is None else timeout
114+
self._lock = threading.RLock()
115+
self._send_lock = threading.RLock()
116+
self._recv_lock = threading.Lock()
117+
self._generation = 0
118+
self._ws = None
119+
self._closing = False
120+
self._reset_state()
121+
122+
def _reset_state(self) -> None:
123+
self._closed = False
124+
self._header_request_id: Optional[str] = None
125+
126+
@property
127+
def model(self) -> str:
128+
return self._model
129+
130+
@property
131+
def url(self) -> str:
132+
return self._url
133+
134+
def connect(self: _SelfT) -> _SelfT:
135+
"""Open the connection, either explicitly or on context entry."""
136+
closing_ws = None
137+
try:
138+
with self._lock:
139+
if self._closing:
140+
raise RuntimeError(f"{self._LABEL} session is closing")
141+
if self._ws is not None:
142+
raise RuntimeError(
143+
f"{self._LABEL} session is already open",
144+
)
145+
self._generation += 1
146+
# Reset first so handshake context survives into failure logs.
147+
self._reset_state()
148+
try:
149+
if self._connector is None:
150+
self._ws = _default_connector(
151+
self._url,
152+
self._headers,
153+
timeout=self._timeout,
154+
)
155+
else:
156+
self._ws = self._connector(self._url, self._headers)
157+
self._header_request_id = request_id_from_headers(
158+
getattr(self._ws, "headers", None),
159+
)
160+
if self._timeout is not None:
161+
self._ws.settimeout(self._timeout)
162+
# pylint: disable=import-outside-toplevel
163+
from websocket import WebSocket
164+
165+
if isinstance(self._ws, WebSocket):
166+
# Raw writes share the native lock with automatic Close
167+
# replies; send_frame() acquires this same lock again.
168+
self._ws.lock = threading.RLock()
169+
except BaseException as exc:
170+
self._on_connect_error(exc)
171+
closing_ws = self._detach_socket()
172+
self._reset_state()
173+
raise
174+
finally:
175+
self._close_socket(closing_ws)
176+
return self
177+
178+
def _on_connect_error(self, exc: BaseException) -> None:
179+
"""Hook for subclass diagnostics before the failed socket is released."""
180+
181+
def __enter__(self: _SelfT) -> _SelfT:
182+
return self.connect()
183+
184+
def __exit__(self, *exc: Any) -> None:
185+
self.close()
186+
187+
def _send_raw(self, ws: Any, raw: Union[str, bytes]) -> None:
188+
"""Write without retries or holding the session state lock."""
189+
from websocket import ( # pylint: disable=import-outside-toplevel
190+
WebSocket,
191+
WebSocketConnectionClosedException,
192+
)
193+
194+
with self._lock:
195+
if ws is not self._ws:
196+
raise WebSocketConnectionClosedException("socket is closed")
197+
if isinstance(ws, WebSocket):
198+
with ws.lock:
199+
if not ws.connected:
200+
raise WebSocketConnectionClosedException(
201+
"socket is closed",
202+
)
203+
ws.send(raw)
204+
else:
205+
ws.send(raw)
206+
207+
def _recv_raw(self, ws: Any, generation: Optional[int] = None) -> Any:
208+
"""Skip detached sockets; read errors are left to session policy."""
209+
with self._lock:
210+
if ws is not self._ws or (
211+
generation is not None and generation != self._generation
212+
):
213+
return None
214+
return ws.recv()
215+
216+
def close(self) -> None:
217+
"""Wait up to one second for sends, then one for the Close handshake."""
218+
self._close(abort=False)
219+
220+
def _close(self, *, abort: bool) -> None:
221+
with self._lock:
222+
ws = self._detach_socket()
223+
self._close_socket(ws, abort=abort)
224+
225+
def _detach_socket(self) -> Any:
226+
"""Detach under _lock; the caller must close the socket outside it."""
227+
ws, self._ws = self._ws, None
228+
if ws is not None:
229+
self._closed = self._closing = True
230+
return ws
231+
232+
def _close_socket(self, ws: Any, *, abort: bool = False) -> None:
233+
"""Release only the detached socket, keeping reconnect blocked until done."""
234+
from websocket import (
235+
WebSocket,
236+
) # pylint: disable=import-outside-toplevel
237+
238+
if ws is None:
239+
return
240+
241+
deadline = None
242+
sending = False
243+
try:
244+
if isinstance(ws, WebSocket) and ws.sock is not None:
245+
sock = ws.sock
246+
247+
def interrupt() -> None:
248+
# close() sets connected=False before sending, so abort()
249+
# cannot wake its blocked writer or reader at the deadline.
250+
try:
251+
sock.shutdown(socket.SHUT_RDWR)
252+
except OSError:
253+
pass
254+
255+
def release() -> None:
256+
interrupt()
257+
# Retain the descriptor until the native writer wakes;
258+
# closing it too early can strand socket.send on macOS.
259+
writing = ws.lock.acquire(timeout=0.1)
260+
try:
261+
ws.shutdown()
262+
finally:
263+
if writing:
264+
ws.lock.release()
265+
266+
if abort:
267+
release()
268+
return
269+
# Complete an in-flight data frame before sending Close. The
270+
# reentrant lock also permits cleanup from a failed send.
271+
# A context manager cannot bound lock-acquisition time.
272+
# pylint: disable-next=consider-using-with
273+
sending = self._send_lock.acquire(timeout=_CLOSE_TIMEOUT)
274+
if not sending:
275+
release()
276+
return
277+
# Waiting for the sender must not consume the handshake budget.
278+
deadline = threading.Timer(_CLOSE_TIMEOUT, interrupt)
279+
deadline.daemon = True
280+
try:
281+
deadline.start()
282+
except Exception:
283+
release()
284+
return
285+
_close_websocket(ws)
286+
finally:
287+
if deadline is not None:
288+
deadline.cancel()
289+
if sending:
290+
self._send_lock.release()
291+
with self._lock:
292+
self._closing = False

0 commit comments

Comments
 (0)