reactor-sdk 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,57 @@
1
+ """
2
+ Reactor Python SDK - Real-time AI video streaming.
3
+
4
+ This SDK provides a Python interface for connecting to Reactor models
5
+ and streaming video in real-time using WebRTC.
6
+
7
+ Example:
8
+ from reactor_sdk import Reactor, ReactorStatus
9
+
10
+ reactor = Reactor(model_name="my-model", api_key="your-api-key")
11
+
12
+ @reactor.on_frame
13
+ def handle_frame(frame):
14
+ print(f"Frame: {frame.shape}")
15
+
16
+ @reactor.on_status(ReactorStatus.READY)
17
+ def handle_ready(status):
18
+ print("Connected!")
19
+
20
+ await reactor.connect()
21
+ """
22
+
23
+ # Main class
24
+ from reactor_sdk.interface import Reactor
25
+
26
+ # Types
27
+ from reactor_sdk.types import (
28
+ ConflictError,
29
+ FrameCallback,
30
+ GPUMachineEvent,
31
+ GPUMachineStatus,
32
+ ReactorError,
33
+ ReactorEvent,
34
+ ReactorState,
35
+ ReactorStatus,
36
+ )
37
+
38
+ # Utilities
39
+ from reactor_sdk.utils.tokens import fetch_jwt_token
40
+
41
+ __all__ = [
42
+ # Main class
43
+ "Reactor",
44
+ # Utilities
45
+ "fetch_jwt_token",
46
+ # Types
47
+ "ReactorStatus",
48
+ "ReactorError",
49
+ "ReactorState",
50
+ "ReactorEvent",
51
+ "GPUMachineStatus",
52
+ "GPUMachineEvent",
53
+ "FrameCallback",
54
+ "ConflictError",
55
+ ]
56
+
57
+ __version__ = "0.1.0"
@@ -0,0 +1,13 @@
1
+ """
2
+ Coordinator clients for the Reactor SDK.
3
+
4
+ This module contains clients for communicating with the Reactor coordinator.
5
+ """
6
+
7
+ from reactor_sdk.coordinator.client import CoordinatorClient
8
+ from reactor_sdk.coordinator.local_client import LocalCoordinatorClient
9
+
10
+ __all__ = [
11
+ "CoordinatorClient",
12
+ "LocalCoordinatorClient",
13
+ ]
@@ -0,0 +1,362 @@
1
+ """
2
+ CoordinatorClient for the Reactor SDK.
3
+
4
+ This module handles HTTP communication with the Reactor coordinator
5
+ for session management and WebRTC signaling.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import asyncio
11
+ import logging
12
+ from typing import Optional
13
+
14
+ import aiohttp
15
+ from aiortc import RTCIceServer
16
+
17
+ from reactor_sdk.types import (
18
+ CreateSessionRequest,
19
+ CreateSessionResponse,
20
+ IceServersResponse,
21
+ SDPParamsRequest,
22
+ SDPParamsResponse,
23
+ SessionInfoResponse,
24
+ )
25
+ from reactor_sdk.utils.webrtc import transform_ice_servers
26
+
27
+ logger = logging.getLogger(__name__)
28
+
29
+
30
+ # =============================================================================
31
+ # Configuration
32
+ # =============================================================================
33
+
34
+ # Polling configuration for async SDP answer retrieval
35
+ INITIAL_BACKOFF_MS = 500
36
+ MAX_BACKOFF_MS = 30000
37
+ BACKOFF_MULTIPLIER = 2
38
+
39
+
40
+ # =============================================================================
41
+ # CoordinatorClient
42
+ # =============================================================================
43
+
44
+
45
+ class CoordinatorClient:
46
+ """
47
+ HTTP client for communicating with the Reactor coordinator.
48
+
49
+ Handles session creation, SDP exchange, and ICE server retrieval.
50
+ """
51
+
52
+ def __init__(
53
+ self,
54
+ base_url: str,
55
+ jwt_token: str,
56
+ model: str,
57
+ ) -> None:
58
+ """
59
+ Initialize the CoordinatorClient.
60
+
61
+ Args:
62
+ base_url: Base URL of the coordinator API.
63
+ jwt_token: JWT token for authentication.
64
+ model: Name of the model to connect to.
65
+ """
66
+ self._base_url = base_url.rstrip("/")
67
+ self._jwt_token = jwt_token
68
+ self._model = model
69
+ self._current_session_id: Optional[str] = None
70
+ self._session: Optional[aiohttp.ClientSession] = None
71
+
72
+ async def _get_session(self) -> aiohttp.ClientSession:
73
+ """Get or create the aiohttp session."""
74
+ if self._session is None or self._session.closed:
75
+ self._session = aiohttp.ClientSession()
76
+ return self._session
77
+
78
+ async def close(self) -> None:
79
+ """Close the HTTP session."""
80
+ if self._session is not None and not self._session.closed:
81
+ await self._session.close()
82
+ self._session = None
83
+
84
+ def _get_auth_headers(self) -> dict[str, str]:
85
+ """Get authorization headers with JWT Bearer token."""
86
+ return {
87
+ "Authorization": f"Bearer {self._jwt_token}",
88
+ "Content-Type": "application/json",
89
+ }
90
+
91
+ # =========================================================================
92
+ # ICE Servers
93
+ # =========================================================================
94
+
95
+ async def get_ice_servers(self) -> list[RTCIceServer]:
96
+ """
97
+ Fetch ICE servers from the coordinator.
98
+
99
+ Returns:
100
+ List of RTCIceServer objects for WebRTC peer connection configuration.
101
+
102
+ Raises:
103
+ aiohttp.ClientError: If the request fails.
104
+ """
105
+ logger.debug("Fetching ICE servers...")
106
+
107
+ session = await self._get_session()
108
+ url = f"{self._base_url}/ice_servers?model={self._model}"
109
+
110
+ async with session.get(url, headers=self._get_auth_headers()) as response:
111
+ if not response.ok:
112
+ text = await response.text()
113
+ raise RuntimeError(f"Failed to fetch ICE servers: {response.status} {text}")
114
+
115
+ data: IceServersResponse = await response.json()
116
+ ice_servers = transform_ice_servers(data)
117
+
118
+ logger.debug(f"Received {len(ice_servers)} ICE servers")
119
+ return ice_servers
120
+
121
+ # =========================================================================
122
+ # Session Management
123
+ # =========================================================================
124
+
125
+ async def create_session(self, sdp_offer: str) -> str:
126
+ """
127
+ Create a new session with the coordinator.
128
+
129
+ Args:
130
+ sdp_offer: The SDP offer from the local WebRTC peer connection.
131
+
132
+ Returns:
133
+ The session ID.
134
+
135
+ Raises:
136
+ aiohttp.ClientError: If the request fails.
137
+ """
138
+ logger.debug("Creating session...")
139
+
140
+ session = await self._get_session()
141
+ url = f"{self._base_url}/sessions"
142
+
143
+ request_body: CreateSessionRequest = {
144
+ "model": {"name": self._model},
145
+ "sdp_offer": sdp_offer,
146
+ "extra_args": {},
147
+ }
148
+
149
+ async with session.post(
150
+ url,
151
+ headers=self._get_auth_headers(),
152
+ json=request_body,
153
+ ) as response:
154
+ if not response.ok:
155
+ text = await response.text()
156
+ raise RuntimeError(f"Failed to create session: {response.status} {text}")
157
+
158
+ data: CreateSessionResponse = await response.json()
159
+ self._current_session_id = data["session_id"]
160
+
161
+ logger.debug(f"Session created with ID: {self._current_session_id}")
162
+ return data["session_id"]
163
+
164
+ async def get_session_info(self) -> SessionInfoResponse:
165
+ """
166
+ Get the current session information from the coordinator.
167
+
168
+ Returns:
169
+ The session info response.
170
+
171
+ Raises:
172
+ RuntimeError: If no active session exists.
173
+ aiohttp.ClientError: If the request fails.
174
+ """
175
+ if self._current_session_id is None:
176
+ raise RuntimeError("No active session. Call create_session() first.")
177
+
178
+ logger.debug(f"Getting session info for: {self._current_session_id}")
179
+
180
+ session = await self._get_session()
181
+ url = f"{self._base_url}/sessions/{self._current_session_id}"
182
+
183
+ async with session.get(url, headers=self._get_auth_headers()) as response:
184
+ if not response.ok:
185
+ text = await response.text()
186
+ raise RuntimeError(f"Failed to get session: {response.status} {text}")
187
+
188
+ data: SessionInfoResponse = await response.json()
189
+ return data
190
+
191
+ async def terminate_session(self) -> None:
192
+ """
193
+ Terminate the current session.
194
+
195
+ Raises:
196
+ RuntimeError: If no active session exists or termination fails.
197
+ """
198
+ if self._current_session_id is None:
199
+ raise RuntimeError("No active session. Call create_session() first.")
200
+
201
+ logger.debug(f"Terminating session: {self._current_session_id}")
202
+
203
+ session = await self._get_session()
204
+ url = f"{self._base_url}/sessions/{self._current_session_id}"
205
+
206
+ async with session.delete(url, headers=self._get_auth_headers()) as response:
207
+ if response.ok:
208
+ self._current_session_id = None
209
+ return
210
+
211
+ if response.status == 404:
212
+ # Session doesn't exist on server, clear local state
213
+ logger.debug(
214
+ f"Session not found on server, clearing local state: "
215
+ f"{self._current_session_id}"
216
+ )
217
+ self._current_session_id = None
218
+ return
219
+
220
+ # For other error codes, throw without clearing state
221
+ text = await response.text()
222
+ raise RuntimeError(f"Failed to terminate session: {response.status} {text}")
223
+
224
+ def get_session_id(self) -> Optional[str]:
225
+ """Get the current session ID."""
226
+ return self._current_session_id
227
+
228
+ # =========================================================================
229
+ # SDP Exchange
230
+ # =========================================================================
231
+
232
+ async def _send_sdp_offer(
233
+ self,
234
+ session_id: str,
235
+ sdp_offer: str,
236
+ ) -> Optional[str]:
237
+ """
238
+ Send an SDP offer to the server for reconnection.
239
+
240
+ Args:
241
+ session_id: The session ID to connect to.
242
+ sdp_offer: The SDP offer from the local WebRTC peer connection.
243
+
244
+ Returns:
245
+ The SDP answer if ready (200), or None if pending (202).
246
+
247
+ Raises:
248
+ aiohttp.ClientError: If the request fails.
249
+ """
250
+ logger.debug(f"Sending SDP offer for session: {session_id}")
251
+
252
+ session = await self._get_session()
253
+ url = f"{self._base_url}/sessions/{session_id}/sdp_params"
254
+
255
+ request_body: SDPParamsRequest = {
256
+ "sdp_offer": sdp_offer,
257
+ "extra_args": {},
258
+ }
259
+
260
+ async with session.put(
261
+ url,
262
+ headers=self._get_auth_headers(),
263
+ json=request_body,
264
+ ) as response:
265
+ if response.status == 200:
266
+ data: SDPParamsResponse = await response.json()
267
+ logger.debug("Received SDP answer immediately")
268
+ return data["sdp_answer"]
269
+
270
+ if response.status == 202:
271
+ logger.debug("SDP offer accepted, answer pending (202)")
272
+ return None
273
+
274
+ text = await response.text()
275
+ raise RuntimeError(f"Failed to send SDP offer: {response.status} {text}")
276
+
277
+ async def _poll_sdp_answer(self, session_id: str) -> str:
278
+ """
279
+ Poll for the SDP answer with geometric backoff.
280
+
281
+ Used for async reconnection when the answer is not immediately available.
282
+
283
+ Args:
284
+ session_id: The session ID to poll for.
285
+
286
+ Returns:
287
+ The SDP answer from the server.
288
+
289
+ Raises:
290
+ RuntimeError: If polling fails.
291
+ """
292
+ logger.debug(f"Polling for SDP answer for session: {session_id}")
293
+
294
+ backoff_ms = INITIAL_BACKOFF_MS
295
+ attempt = 0
296
+
297
+ session = await self._get_session()
298
+ url = f"{self._base_url}/sessions/{session_id}/sdp_params"
299
+
300
+ while True:
301
+ attempt += 1
302
+ logger.debug(f"SDP poll attempt {attempt} for session {session_id}")
303
+
304
+ async with session.get(url, headers=self._get_auth_headers()) as response:
305
+ if response.status == 200:
306
+ data: SDPParamsResponse = await response.json()
307
+ logger.debug("Received SDP answer via polling")
308
+ return data["sdp_answer"]
309
+
310
+ if response.status == 202:
311
+ logger.warning(
312
+ f"SDP answer pending (202), retrying in {backoff_ms}ms..."
313
+ )
314
+ await asyncio.sleep(backoff_ms / 1000)
315
+ backoff_ms = min(backoff_ms * BACKOFF_MULTIPLIER, MAX_BACKOFF_MS)
316
+ continue
317
+
318
+ # For other error codes, throw immediately
319
+ text = await response.text()
320
+ raise RuntimeError(f"Failed to poll SDP answer: {response.status} {text}")
321
+
322
+ async def connect(
323
+ self,
324
+ session_id: str,
325
+ sdp_offer: Optional[str] = None,
326
+ ) -> str:
327
+ """
328
+ Connect to the session by sending an SDP offer and receiving an SDP answer.
329
+
330
+ If sdp_offer is provided, sends it first. If the answer is pending (202),
331
+ falls back to polling. If no sdp_offer is provided, goes directly to polling.
332
+
333
+ Args:
334
+ session_id: The session ID to connect to.
335
+ sdp_offer: Optional SDP offer from the local WebRTC peer connection.
336
+
337
+ Returns:
338
+ The SDP answer from the server.
339
+ """
340
+ logger.debug(f"Connecting to session: {session_id}")
341
+
342
+ if sdp_offer:
343
+ # Reconnection: we have a new SDP offer
344
+ answer = await self._send_sdp_offer(session_id, sdp_offer)
345
+ if answer is not None:
346
+ return answer
347
+ # Server accepted but answer not ready yet (202), fall back to polling
348
+
349
+ # No SDP offer = async reconnection, poll until server has the answer
350
+ return await self._poll_sdp_answer(session_id)
351
+
352
+ # =========================================================================
353
+ # Cleanup
354
+ # =========================================================================
355
+
356
+ async def __aenter__(self) -> "CoordinatorClient":
357
+ """Async context manager entry."""
358
+ return self
359
+
360
+ async def __aexit__(self, *args: object) -> None:
361
+ """Async context manager exit."""
362
+ await self.close()
@@ -0,0 +1,163 @@
1
+ """
2
+ LocalCoordinatorClient for the Reactor SDK.
3
+
4
+ This module provides a coordinator client for local development,
5
+ extending CoordinatorClient with simplified local endpoints.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import logging
11
+ from typing import Optional
12
+
13
+ from aiortc import RTCIceServer
14
+
15
+ from reactor_sdk.coordinator.client import CoordinatorClient
16
+ from reactor_sdk.types import ConflictError, IceServersResponse
17
+ from reactor_sdk.utils.webrtc import transform_ice_servers
18
+
19
+ logger = logging.getLogger(__name__)
20
+
21
+
22
+ class LocalCoordinatorClient(CoordinatorClient):
23
+ """
24
+ Coordinator client for local development.
25
+
26
+ Extends CoordinatorClient and overrides methods for local development
27
+ endpoints that don't require authentication.
28
+ """
29
+
30
+ def __init__(self, base_url: str) -> None:
31
+ """
32
+ Initialize the LocalCoordinatorClient.
33
+
34
+ Args:
35
+ base_url: Base URL of the local coordinator (e.g., http://localhost:8080).
36
+ """
37
+ # Pass dummy values to parent - they won't be used for local
38
+ super().__init__(
39
+ base_url=base_url,
40
+ jwt_token="local",
41
+ model="local",
42
+ )
43
+ self._local_base_url = base_url.rstrip("/")
44
+ self._sdp_offer: Optional[str] = None
45
+
46
+ def _get_auth_headers(self) -> dict[str, str]:
47
+ """
48
+ Get headers for local requests (no auth required).
49
+
50
+ Returns:
51
+ Headers dict with Content-Type only.
52
+ """
53
+ return {"Content-Type": "application/json"}
54
+
55
+ async def get_ice_servers(self) -> list[RTCIceServer]:
56
+ """
57
+ Get ICE servers from the local HTTP runtime.
58
+
59
+ Returns:
60
+ List of RTCIceServer objects.
61
+
62
+ Raises:
63
+ RuntimeError: If the request fails.
64
+ """
65
+ logger.debug("Fetching ICE servers from local coordinator...")
66
+
67
+ session = await self._get_session()
68
+ url = f"{self._local_base_url}/ice_servers"
69
+
70
+ async with session.get(url) as response:
71
+ if not response.ok:
72
+ raise RuntimeError("Failed to get ICE servers from local coordinator.")
73
+
74
+ data: IceServersResponse = await response.json()
75
+ ice_servers = transform_ice_servers(data)
76
+
77
+ logger.debug(f"Received {len(ice_servers)} ICE servers from local coordinator")
78
+ return ice_servers
79
+
80
+ async def create_session(self, sdp_offer: str) -> str:
81
+ """
82
+ Create a local session by posting to /start_session.
83
+
84
+ Args:
85
+ sdp_offer: The SDP offer string (stored for later use).
86
+
87
+ Returns:
88
+ Always returns "local" as the session ID.
89
+
90
+ Raises:
91
+ RuntimeError: If the request fails.
92
+ """
93
+ logger.debug("Creating local session...")
94
+
95
+ self._sdp_offer = sdp_offer
96
+
97
+ session = await self._get_session()
98
+ url = f"{self._local_base_url}/start_session"
99
+
100
+ async with session.post(url) as response:
101
+ if not response.ok:
102
+ raise RuntimeError("Failed to send local start session command.")
103
+
104
+ logger.debug("Local session created")
105
+ return "local"
106
+
107
+ async def connect(
108
+ self,
109
+ session_id: str,
110
+ sdp_offer: Optional[str] = None,
111
+ ) -> str:
112
+ """
113
+ Connect to the local session by posting SDP params to /sdp_params.
114
+
115
+ Args:
116
+ session_id: The session ID (ignored for local).
117
+ sdp_offer: The SDP offer from the local WebRTC peer connection.
118
+
119
+ Returns:
120
+ The SDP answer from the server.
121
+
122
+ Raises:
123
+ ConflictError: If the connection is superseded by a newer request.
124
+ RuntimeError: If the request fails.
125
+ """
126
+ # Use provided offer or the stored one from create_session
127
+ self._sdp_offer = sdp_offer or self._sdp_offer
128
+
129
+ logger.debug("Connecting to local session...")
130
+
131
+ session = await self._get_session()
132
+ url = f"{self._local_base_url}/sdp_params"
133
+
134
+ sdp_body = {
135
+ "sdp": self._sdp_offer,
136
+ "type": "offer",
137
+ }
138
+
139
+ async with session.post(
140
+ url,
141
+ headers=self._get_auth_headers(),
142
+ json=sdp_body,
143
+ ) as response:
144
+ if not response.ok:
145
+ if response.status == 409:
146
+ raise ConflictError("Connection superseded by newer request")
147
+ raise RuntimeError("Failed to get SDP answer from local coordinator.")
148
+
149
+ data = await response.json()
150
+ logger.debug("Received SDP answer from local coordinator")
151
+ return data["sdp"]
152
+
153
+ async def terminate_session(self) -> None:
154
+ """
155
+ Stop the local session by posting to /stop_session.
156
+ """
157
+ logger.debug("Stopping local session...")
158
+
159
+ session = await self._get_session()
160
+ url = f"{self._local_base_url}/stop_session"
161
+
162
+ await session.post(url)
163
+ self._current_session_id = None