diff --git a/getstream/config.py b/getstream/config.py index 486205ec..18a3742b 100644 --- a/getstream/config.py +++ b/getstream/config.py @@ -1,5 +1,7 @@ from getstream.version import VERSION +BASE_URL = "https://chat.stream-io-api.com/" + class BaseConfig: def __init__( diff --git a/getstream/stream.py b/getstream/stream.py index ec946b01..1ac22c62 100644 --- a/getstream/stream.py +++ b/getstream/stream.py @@ -18,15 +18,14 @@ from getstream.models import FullUserResponse, UserRequest from getstream.moderation.client import ModerationClient from getstream.moderation.async_client import ModerationClient as AsyncModerationClient +from getstream.config import BASE_URL from getstream.utils import validate_and_clean_url from getstream.video.client import VideoClient from getstream.video.async_client import VideoClient as AsyncVideoClient +from getstream.ws import StreamWS from typing_extensions import deprecated -BASE_URL = "https://chat.stream-io-api.com/" - - class Settings(BaseSettings): # Env names: STREAM_API_KEY, STREAM_API_SECRET, STREAM_BASE_URL, STREAM_TIMEOUT api_key: str @@ -208,6 +207,36 @@ def moderation(self) -> AsyncModerationClient: user_agent=self.user_agent, ) + def connect_ws( + self, + user_id: str, + user_details: Optional[dict] = None, + **kwargs, + ): + """Create a StreamWS instance. Use as an async context manager: + + async with client.connect_ws(user_id="alice") as ws: + ws.on("custom", handler) + """ + defaults = { + "base_url": self.base_url, + "user_agent": self.user_agent, + } + defaults.update(kwargs) + ws = StreamWS( + api_key=self.api_key, + api_secret=self.api_secret, + user_id=user_id, + user_details=user_details or {"id": user_id}, + **defaults, + ) + self._ws_connections.append(ws) + return ws + + @cached_property + def _ws_connections(self) -> list: + return [] + async def aclose(self): """Close all child clients and the main HTTPX client.""" # AsyncExitStack ensures all clients are closed even if one fails. @@ -220,6 +249,10 @@ async def aclose(self): stack.push_async_callback(self.chat.aclose) if "moderation" in cached: stack.push_async_callback(self.moderation.aclose) + if "_ws_connections" in cached: + for ws in self._ws_connections: + stack.push_async_callback(ws.disconnect) + self._ws_connections.clear() stack.push_async_callback(super().aclose) @cached_property diff --git a/getstream/ws/__init__.py b/getstream/ws/__init__.py new file mode 100644 index 00000000..983cdc9e --- /dev/null +++ b/getstream/ws/__init__.py @@ -0,0 +1,3 @@ +from .client import StreamWS, StreamWSAuthError + +__all__ = ["StreamWS", "StreamWSAuthError"] diff --git a/getstream/ws/client.py b/getstream/ws/client.py new file mode 100644 index 00000000..f3aebfed --- /dev/null +++ b/getstream/ws/client.py @@ -0,0 +1,365 @@ +import asyncio +import json +import logging +import random +import time +from typing import List, Optional, Tuple +from urllib.parse import urlencode, urlparse, urlunparse + +import httpx +import jwt +import websockets +from websockets import ClientConnection + +from getstream.config import BASE_URL +from getstream.utils.event_emitter import StreamAsyncIOEventEmitter +from getstream.version import VERSION + +logger = logging.getLogger(__name__) + + +TOKEN_EXPIRED_CODE = 40 + + +class StreamWSAuthError(Exception): + def __init__(self, message: str, response: dict | None = None): + super().__init__(message) + self.response = response or {} + + +class StreamWS(StreamAsyncIOEventEmitter): + def __init__( + self, + api_key: str, + api_secret: str, + user_id: str, + *, + base_url: str = BASE_URL, + user_agent: str | None = None, + user_details: Optional[dict] = None, + token: Optional[str] = None, + healthcheck_interval: float = 25.0, + healthcheck_timeout: float = 35.0, + max_retries: int = 5, + backoff_base: float = 0.25, + backoff_max: float = 5.0, + watch_call: Optional[Tuple[str, str]] = None, + watch_channels: Optional[List[Tuple[str, str]]] = None, + ): + super().__init__() + self.api_key = api_key + self.api_secret = api_secret + self.user_id = user_id + self._base_url = base_url.rstrip("/") + self._user_agent = user_agent or f"stream-python-client-{VERSION}" + self._user_details = user_details or {"id": user_id} + self._token = token + self._static_token = token is not None + self._healthcheck_interval = healthcheck_interval + self._healthcheck_timeout = healthcheck_timeout + self._max_retries = max_retries + self._backoff_base = backoff_base + self._backoff_max = backoff_max + self._watch_call = watch_call + self._watch_channels = watch_channels + + self._websocket: Optional[ClientConnection] = None + self._connected = False + self._connection_id: Optional[str] = None + self._ws_id: int = 0 + self._reader_task: Optional[asyncio.Task] = None + self._heartbeat_task: Optional[asyncio.Task] = None + self._last_received: float = 0.0 + self._reconnecting = False + self._reconnect_task: Optional[asyncio.Task] = None + + @property + def _stream_headers(self) -> dict: + return { + "stream-auth-type": "jwt", + "X-Stream-Client": self._user_agent, + } + + @property + def ws_url(self) -> str: + parsed = urlparse(self._base_url) + ws_scheme = "wss" if parsed.scheme == "https" else "ws" + query = urlencode({"api_key": self.api_key, **self._stream_headers}) + ws_parsed = parsed._replace( + scheme=ws_scheme, + path="/api/v2/connect", + query=query, + ) + return urlunparse(ws_parsed) + + @property + def connected(self) -> bool: + return self._connected and self._websocket is not None + + @property + def connection_id(self) -> Optional[str]: + return self._connection_id + + @property + def ws_id(self) -> int: + return self._ws_id + + async def _close_websocket(self, code: int = 1000, reason: str = "") -> None: + if self._websocket: + try: + await self._websocket.close(code=code, reason=reason) + except Exception: + logger.debug("Error closing WebSocket", exc_info=True) + self._websocket = None + + def _ensure_token(self) -> str: + if self._token: + return self._token + now = int(time.time()) + self._token = jwt.encode( + {"user_id": self.user_id, "iat": now - 5}, + self.api_secret, + algorithm="HS256", + ) + return self._token + + async def _open_connection(self) -> dict: + self._ws_id += 1 + self._websocket = await websockets.connect( + self.ws_url, + ping_interval=None, + ping_timeout=None, + close_timeout=1.0, + ) + + try: + auth_payload = { + "token": self._ensure_token(), + "user_details": self._user_details, + } + await self._websocket.send(json.dumps(auth_payload)) + + raw = await self._websocket.recv() + message = json.loads(raw) + + msg_type = message.get("type") + if msg_type != "connection.ok": + raise StreamWSAuthError( + f"Expected connection.ok, got {msg_type}: {message}", + response=message, + ) + except Exception: + await self._close_websocket() + raise + + self._connection_id = message.get("connection_id") + self._last_received = time.monotonic() + return message + + async def __aenter__(self): + await self.connect() + if self._watch_call: + call_type, call_id = self._watch_call + await self._subscribe_to_call(call_type, call_id) + if self._watch_channels: + await self._subscribe_to_channels(self._watch_channels) + return self + + async def _subscribe_to_call(self, call_type: str, call_id: str) -> None: + """Subscribe to call events by 'watching' the call with our connection_id. + + Uses the user token (not admin token) because the coordinator only + registers subscriptions for client-side requests. + """ + token = self._ensure_token() + parsed = urlparse(self._base_url) + url = urlunparse( + parsed._replace(path=f"/api/v2/video/call/{call_type}/{call_id}") + ) + params = {"api_key": self.api_key, "connection_id": self._connection_id} + headers = {"authorization": token, **self._stream_headers} + async with httpx.AsyncClient() as http: + response = await http.get(url, params=params, headers=headers) + response.raise_for_status() + logger.info( + "Watching call %s/%s (connection_id=%s)", + call_type, + call_id, + self._connection_id, + ) + + async def _subscribe_to_channels(self, channels: List[Tuple[str, str]]) -> None: + """Subscribe to chat channel events by querying channels with watch=true. + + Uses the user token (not admin token) because the coordinator only + registers subscriptions for client-side requests. + """ + token = self._ensure_token() + parsed = urlparse(self._base_url) + url = urlunparse(parsed._replace(path="/api/v2/chat/channels")) + cids = [f"{ch_type}:{ch_id}" for ch_type, ch_id in channels] + payload = { + "filter_conditions": {"cid": {"$in": cids}}, + "watch": True, + "connection_id": self._connection_id, + } + headers = {"authorization": token, **self._stream_headers} + async with httpx.AsyncClient() as http: + response = await http.post( + url, + params={"api_key": self.api_key}, + headers=headers, + json=payload, + ) + response.raise_for_status() + logger.info( + "Watching %d channel(s) (connection_id=%s)", + len(channels), + self._connection_id, + ) + + async def __aexit__(self, exc_type, exc_val, exc_tb): + await self.disconnect() + return False + + async def connect(self) -> dict: + if self._connected: + raise RuntimeError("Already connected. Call disconnect() first.") + + message = await self._open_connection() + self._connected = True + self._start_tasks() + return message + + def _start_tasks(self) -> None: + self._reader_task = asyncio.create_task(self._reader_loop()) + self._heartbeat_task = asyncio.create_task(self._heartbeat_loop()) + + def _trigger_reconnect(self, reason: str) -> None: + if self._connected and not self._reconnecting: + self._reconnecting = True + self._reconnect_task = asyncio.create_task(self._reconnect(reason)) + + async def _reader_loop(self) -> None: + my_ws_id = self._ws_id + while self._connected and self._websocket and self._ws_id == my_ws_id: + try: + raw = await self._websocket.recv() + if self._ws_id != my_ws_id: + break + self._last_received = time.monotonic() + message = json.loads(raw) + event_type = message.get("type", "unknown") + try: + self.emit(event_type, message) + except Exception: + logger.exception("Error in event listener for %s", event_type) + except websockets.exceptions.ConnectionClosed as e: + close_code = getattr(e.rcvd, "code", None) if e.rcvd else None + logger.debug("WebSocket closed (code=%s) in reader", close_code) + if close_code == 1000: + logger.info("Server closed connection normally, not reconnecting") + self._connected = False + else: + self._trigger_reconnect("connection closed") + break + except json.JSONDecodeError: + logger.warning("Failed to parse WebSocket message as JSON") + + async def _heartbeat_loop(self) -> None: + while self._connected: + await asyncio.sleep(self._healthcheck_interval) + if not self._connected or not self._websocket: + break + + elapsed = time.monotonic() - self._last_received + if elapsed > self._healthcheck_timeout: + logger.warning("Healthcheck timeout (%.1fs)", elapsed) + self._trigger_reconnect("healthcheck timeout") + break + + try: + msg = [{"type": "health.check", "client_id": self._connection_id}] + await self._websocket.send(json.dumps(msg)) + logger.debug("Sent heartbeat") + except websockets.exceptions.ConnectionClosed: + logger.debug("WebSocket closed while sending heartbeat") + self._trigger_reconnect("connection closed") + break + + async def _reconnect(self, reason: str) -> None: + logger.info("Reconnecting: %s", reason) + + try: + await self._cancel_background_tasks() + await self._close_websocket() + + for attempt in range(1, self._max_retries + 1): + if not self._connected: + return + delay = min( + self._backoff_base * (2 ** (attempt - 1)), + self._backoff_max, + ) + delay *= 1 + random.random() + logger.debug( + "Reconnect attempt %d/%d in %.2fs", + attempt, + self._max_retries, + delay, + ) + await asyncio.sleep(delay) + + if not self._connected: + return + + try: + await self._open_connection() + self._start_tasks() + logger.info("Reconnected after %d attempt(s)", attempt) + return + except StreamWSAuthError as e: + error_code = e.response.get("error", {}).get("code") + if error_code == TOKEN_EXPIRED_CODE and not self._static_token: + logger.info("Token expired (code 40), refreshing") + self._token = None + continue + logger.error("Auth failed during reconnect (code %s)", error_code) + self._connected = False + return + except Exception as e: + logger.warning("Reconnect attempt %d failed: %s", attempt, e) + + logger.error("All %d reconnect attempts failed", self._max_retries) + self._connected = False + finally: + self._reconnecting = False + + async def _cancel_background_tasks(self) -> None: + """Cancel reader and heartbeat tasks (safe to call from _reconnect).""" + for task in (self._reader_task, self._heartbeat_task): + if task: + task.cancel() + try: + await task + except (asyncio.CancelledError, Exception): + pass + self._reader_task = None + self._heartbeat_task = None + + async def _cancel_all_tasks(self) -> None: + """Cancel all tasks including reconnect (used by disconnect).""" + await self._cancel_background_tasks() + if self._reconnect_task: + self._reconnect_task.cancel() + try: + await self._reconnect_task + except (asyncio.CancelledError, Exception): + pass + self._reconnect_task = None + + async def disconnect(self) -> None: + self._connected = False + self._connection_id = None + await self._cancel_all_tasks() + await self._close_websocket(code=1000, reason="client disconnect") diff --git a/pyproject.toml b/pyproject.toml index 258d9bdc..6c98f710 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -31,6 +31,9 @@ dependencies = [ ] [project.optional-dependencies] +ws = [ + "websockets>=15.0.1,<16", +] openai-realtime = [ "openai[realtime]>=1.93.0", ] diff --git a/tests/ws/__init__.py b/tests/ws/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/ws/test_async_stream_ws.py b/tests/ws/test_async_stream_ws.py new file mode 100644 index 00000000..410e4cc5 --- /dev/null +++ b/tests/ws/test_async_stream_ws.py @@ -0,0 +1,82 @@ +import json + +import pytest +import pytest_asyncio +import websockets + +from getstream import AsyncStream + + +@pytest_asyncio.fixture() +async def mock_server(): + """WS server that handles auth and keeps connections open.""" + connections = [] + + async def handler(ws): + connections.append(ws) + raw = await ws.recv() + msg = json.loads(raw) + await ws.send( + json.dumps( + { + "type": "connection.ok", + "connection_id": "conn-int", + "me": {"id": msg["user_details"]["id"]}, + } + ) + ) + try: + async for _ in ws: + pass + except websockets.exceptions.ConnectionClosed: + pass + + async with websockets.serve(handler, "127.0.0.1", 0) as server: + port = server.sockets[0].getsockname()[1] + yield {"port": port, "connections": connections} + + +@pytest.mark.asyncio +async def test_connect_ws_context_manager(mock_server): + client = AsyncStream( + api_key="k" * 32, + api_secret="s" * 32, + base_url=f"http://127.0.0.1:{mock_server['port']}", + ) + async with client.connect_ws(user_id="alice") as ws: + assert ws.connected + assert ws.connection_id == "conn-int" + assert ws.user_id == "alice" + + assert not ws.connected + await client.aclose() + + +@pytest.mark.asyncio +async def test_aclose_disconnects_ws(mock_server): + client = AsyncStream( + api_key="k" * 32, + api_secret="s" * 32, + base_url=f"http://127.0.0.1:{mock_server['port']}", + ) + async with client.connect_ws(user_id="bob") as ws: + assert ws.connected + + await client.aclose() + assert not ws.connected + + +@pytest.mark.asyncio +async def test_standalone_context_manager(mock_server): + from getstream.ws import StreamWS + + async with StreamWS( + api_key="k" * 32, + api_secret="s" * 32, + user_id="alice", + base_url=f"http://127.0.0.1:{mock_server['port']}", + ) as ws: + assert ws.connected + assert ws.connection_id == "conn-int" + + assert not ws.connected diff --git a/tests/ws/test_ws_client.py b/tests/ws/test_ws_client.py new file mode 100644 index 00000000..a48fa0dd --- /dev/null +++ b/tests/ws/test_ws_client.py @@ -0,0 +1,331 @@ +import asyncio +import json + +import pytest +import pytest_asyncio +import websockets + +from getstream.ws import StreamWS, StreamWSAuthError + + +def test_stream_ws_instantiation(): + ws = StreamWS( + api_key="test-key", + api_secret="test-secret", + user_id="alice", + ) + assert ws.api_key == "test-key" + assert ws.user_id == "alice" + assert not ws.connected + + +def test_ws_url_construction(): + ws = StreamWS( + api_key="my-key", + api_secret="my-secret", + user_id="alice", + base_url="https://chat.stream-io-api.com", + ) + url = ws.ws_url + assert url.startswith("wss://chat.stream-io-api.com/api/v2/connect?") + assert "api_key=my-key" in url + assert "stream-auth-type=jwt" in url + + +def test_ws_url_construction_http(): + ws = StreamWS( + api_key="k", + api_secret="s", + user_id="bob", + base_url="http://localhost:3030", + ) + url = ws.ws_url + assert url.startswith("ws://localhost:3030/api/v2/connect?") + + +@pytest_asyncio.fixture() +async def mock_server(): + """WS server that handles auth and keeps connections open.""" + auth_payloads = [] + connections = [] + + client_messages = [] + + async def handler(ws): + connections.append(ws) + raw = await ws.recv() + msg = json.loads(raw) + auth_payloads.append(msg) + await ws.send( + json.dumps( + { + "type": "connection.ok", + "connection_id": "conn-123", + "me": {"id": msg["user_details"]["id"]}, + } + ) + ) + try: + async for raw_msg in ws: + client_messages.append(json.loads(raw_msg)) + except websockets.exceptions.ConnectionClosed: + pass + + async with websockets.serve(handler, "127.0.0.1", 0) as server: + port = server.sockets[0].getsockname()[1] + yield { + "port": port, + "auth_payloads": auth_payloads, + "connections": connections, + "client_messages": client_messages, + } + + +@pytest.mark.asyncio +async def test_connect_and_authenticate(mock_server): + ws = StreamWS( + api_key="test-key", + api_secret="test-secret", + user_id="alice", + base_url=f"http://127.0.0.1:{mock_server['port']}", + ) + result = await ws.connect() + + assert ws.connected + assert ws.connection_id == "conn-123" + assert result["type"] == "connection.ok" + + auth = mock_server["auth_payloads"][0] + assert "token" in auth + assert auth["user_details"]["id"] == "alice" + + await ws.disconnect() + + +@pytest.mark.asyncio +async def test_event_dispatched_to_listener(mock_server): + ws = StreamWS( + api_key="k", + api_secret="s" * 32, + user_id="alice", + base_url=f"http://127.0.0.1:{mock_server['port']}", + ) + await ws.connect() + + received_events = [] + + @ws.on("message.new") + def on_message(event): + received_events.append(event) + + server_ws = mock_server["connections"][0] + await server_ws.send(json.dumps({"type": "message.new", "text": "hello"})) + await asyncio.sleep(0.1) + + assert len(received_events) == 1 + assert received_events[0]["text"] == "hello" + + await ws.disconnect() + + +@pytest.mark.asyncio +async def test_heartbeat_sent(mock_server): + ws = StreamWS( + api_key="k", + api_secret="s" * 32, + user_id="alice", + base_url=f"http://127.0.0.1:{mock_server['port']}", + healthcheck_interval=0.1, + ) + await ws.connect() + + # Wait long enough for at least 2 heartbeats + await asyncio.sleep(0.35) + + # Health checks are sent as arrays per the Stream protocol: [{type, client_id}] + heartbeats = [ + m + for m in mock_server["client_messages"] + if isinstance(m, list) and m and m[0].get("type") == "health.check" + ] + assert len(heartbeats) >= 2 + assert heartbeats[0][0]["client_id"] == "conn-123" + + await ws.disconnect() + + +@pytest_asyncio.fixture() +async def unexpected_response_server(): + """Server that sends an unexpected response type instead of connection.ok.""" + + async def handler(ws): + await ws.recv() + await ws.send(json.dumps({"type": "something.unexpected", "data": "foo"})) + try: + async for _ in ws: + pass + except websockets.exceptions.ConnectionClosed: + pass + + async with websockets.serve(handler, "127.0.0.1", 0) as server: + port = server.sockets[0].getsockname()[1] + yield {"port": port} + + +@pytest.mark.asyncio +async def test_reject_unexpected_auth_response(unexpected_response_server): + ws = StreamWS( + api_key="k", + api_secret="s" * 32, + user_id="alice", + base_url=f"http://127.0.0.1:{unexpected_response_server['port']}", + ) + with pytest.raises(StreamWSAuthError, match="Expected connection.ok"): + await ws.connect() + assert not ws.connected + + +@pytest.mark.asyncio +async def test_reconnect_on_server_close(mock_server): + ws = StreamWS( + api_key="k", + api_secret="s" * 32, + user_id="alice", + base_url=f"http://127.0.0.1:{mock_server['port']}", + healthcheck_interval=100, + max_retries=3, + ) + await ws.connect() + assert ws.connected + assert len(mock_server["connections"]) == 1 + ws_id_before = ws.ws_id + + # Server closes with non-1000 code (abnormal) -- should trigger reconnect + await mock_server["connections"][0].close(code=1001, reason="going away") + + # Wait for reconnect + await asyncio.sleep(0.5) + + assert ws.connected + # A second connection was made (the reconnect) + assert len(mock_server["connections"]) == 2 + assert len(mock_server["auth_payloads"]) == 2 + # ws_id should have incremented to guard against stale messages + assert ws.ws_id > ws_id_before + + await ws.disconnect() + + +@pytest.mark.asyncio +async def test_no_reconnect_on_intentional_close(mock_server): + ws = StreamWS( + api_key="k", + api_secret="s" * 32, + user_id="alice", + base_url=f"http://127.0.0.1:{mock_server['port']}", + healthcheck_interval=100, + max_retries=3, + ) + await ws.connect() + assert ws.connected + + # Server closes with code 1000 (intentional) -- should NOT reconnect + await mock_server["connections"][0].close(code=1000, reason="normal closure") + await asyncio.sleep(0.5) + + assert not ws.connected + assert len(mock_server["connections"]) == 1 + + +@pytest_asyncio.fixture() +async def token_expiry_server(): + """Server that rejects the 2nd connection with code 40, then accepts the 3rd.""" + auth_payloads = [] + connect_count = 0 + + async def handler(ws): + nonlocal connect_count + connect_count += 1 + raw = await ws.recv() + msg = json.loads(raw) + auth_payloads.append(msg) + + if connect_count == 2: + # Reject with token expired + await ws.send( + json.dumps( + { + "type": "connection.error", + "error": {"code": 40, "message": "token expired"}, + } + ) + ) + return + + await ws.send( + json.dumps( + { + "type": "connection.ok", + "connection_id": f"conn-{connect_count}", + "me": {"id": msg["user_details"]["id"]}, + } + ) + ) + try: + async for _ in ws: + pass + except websockets.exceptions.ConnectionClosed: + pass + + async with websockets.serve(handler, "127.0.0.1", 0) as server: + port = server.sockets[0].getsockname()[1] + yield {"port": port, "auth_payloads": auth_payloads} + + +@pytest.mark.asyncio +async def test_token_refresh_on_expired(token_expiry_server): + ws = StreamWS( + api_key="k", + api_secret="s" * 32, + user_id="alice", + base_url=f"http://127.0.0.1:{token_expiry_server['port']}", + healthcheck_interval=100, + max_retries=5, + backoff_base=0.05, + backoff_max=0.1, + ) + await ws.connect() + + # Simulate abnormal close -> reconnect hits code 40 -> should refresh token and retry + await ws._websocket.close(code=1001, reason="going away") + await asyncio.sleep(0.5) + + assert ws.connected + # 3 auth attempts: initial, rejected (code 40), successful retry + assert len(token_expiry_server["auth_payloads"]) == 3 + + await ws.disconnect() + + +@pytest.mark.asyncio +async def test_static_token_not_refreshed(token_expiry_server): + """When a user provides a static token, code 40 should NOT refresh it.""" + ws = StreamWS( + api_key="k", + api_secret="s" * 32, + user_id="alice", + token="my-static-token", + base_url=f"http://127.0.0.1:{token_expiry_server['port']}", + healthcheck_interval=100, + max_retries=5, + backoff_base=0.05, + backoff_max=0.1, + ) + await ws.connect() + + await ws._websocket.close(code=1001, reason="going away") + await asyncio.sleep(0.5) + + # With a static token, code 40 should be treated as fatal auth error + # (can't refresh), so client should give up + assert not ws.connected diff --git a/uv.lock b/uv.lock index 3bc04b64..3d5982be 100644 --- a/uv.lock +++ b/uv.lock @@ -930,6 +930,9 @@ webrtc = [ { name = "websocket-client" }, { name = "websockets" }, ] +ws = [ + { name = "websockets" }, +] [package.dev-dependencies] dev = [ @@ -986,8 +989,9 @@ requires-dist = [ { name = "websocket-client", marker = "extra == 'webrtc'", specifier = ">=1.8.0" }, { name = "websockets", marker = "extra == 'webrtc'", specifier = ">=15.0.1" }, { name = "websockets", marker = "extra == 'webrtc'", specifier = ">=15.0.1,<16" }, + { name = "websockets", marker = "extra == 'ws'", specifier = ">=15.0.1,<16" }, ] -provides-extras = ["openai-realtime", "telemetry", "webrtc"] +provides-extras = ["openai-realtime", "telemetry", "webrtc", "ws"] [package.metadata.requires-dev] dev = [