reflex-sdk 0.3.2__tar.gz → 0.3.4__tar.gz

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.
Files changed (55) hide show
  1. {reflex_sdk-0.3.2 → reflex_sdk-0.3.4}/PKG-INFO +1 -1
  2. {reflex_sdk-0.3.2 → reflex_sdk-0.3.4}/pyproject.toml +1 -1
  3. {reflex_sdk-0.3.2 → reflex_sdk-0.3.4}/reflex/auth_runner.py +84 -1
  4. {reflex_sdk-0.3.2 → reflex_sdk-0.3.4}/reflex/connect_runner.py +25 -1
  5. {reflex_sdk-0.3.2 → reflex_sdk-0.3.4}/reflex/transports/_webrtc_client.py +71 -24
  6. {reflex_sdk-0.3.2 → reflex_sdk-0.3.4}/reflex/transports/webrtc.py +6 -1
  7. {reflex_sdk-0.3.2 → reflex_sdk-0.3.4}/reflex_sdk.egg-info/PKG-INFO +1 -1
  8. {reflex_sdk-0.3.2 → reflex_sdk-0.3.4}/README.md +0 -0
  9. {reflex_sdk-0.3.2 → reflex_sdk-0.3.4}/reflex/__init__.py +0 -0
  10. {reflex_sdk-0.3.2 → reflex_sdk-0.3.4}/reflex/__main__.py +0 -0
  11. {reflex_sdk-0.3.2 → reflex_sdk-0.3.4}/reflex/_convex.py +0 -0
  12. {reflex_sdk-0.3.2 → reflex_sdk-0.3.4}/reflex/_region_probe.py +0 -0
  13. {reflex_sdk-0.3.2 → reflex_sdk-0.3.4}/reflex/_transport.py +0 -0
  14. {reflex_sdk-0.3.2 → reflex_sdk-0.3.4}/reflex/_version.py +0 -0
  15. {reflex_sdk-0.3.2 → reflex_sdk-0.3.4}/reflex/actions.py +0 -0
  16. {reflex_sdk-0.3.2 → reflex_sdk-0.3.4}/reflex/cameras/__init__.py +0 -0
  17. {reflex_sdk-0.3.2 → reflex_sdk-0.3.4}/reflex/cameras/base.py +0 -0
  18. {reflex_sdk-0.3.2 → reflex_sdk-0.3.4}/reflex/cameras/realsense.py +0 -0
  19. {reflex_sdk-0.3.2 → reflex_sdk-0.3.4}/reflex/cameras/shm.py +0 -0
  20. {reflex_sdk-0.3.2 → reflex_sdk-0.3.4}/reflex/cameras/v4l2.py +0 -0
  21. {reflex_sdk-0.3.2 → reflex_sdk-0.3.4}/reflex/cli.py +0 -0
  22. {reflex_sdk-0.3.2 → reflex_sdk-0.3.4}/reflex/client.py +0 -0
  23. {reflex_sdk-0.3.2 → reflex_sdk-0.3.4}/reflex/connectors/__init__.py +0 -0
  24. {reflex_sdk-0.3.2 → reflex_sdk-0.3.4}/reflex/connectors/base.py +0 -0
  25. {reflex_sdk-0.3.2 → reflex_sdk-0.3.4}/reflex/connectors/shell.py +0 -0
  26. {reflex_sdk-0.3.2 → reflex_sdk-0.3.4}/reflex/connectors/yam_bimanual.py +0 -0
  27. {reflex_sdk-0.3.2 → reflex_sdk-0.3.4}/reflex/datasets.py +0 -0
  28. {reflex_sdk-0.3.2 → reflex_sdk-0.3.4}/reflex/deployments.py +0 -0
  29. {reflex_sdk-0.3.2 → reflex_sdk-0.3.4}/reflex/instances.py +0 -0
  30. {reflex_sdk-0.3.2 → reflex_sdk-0.3.4}/reflex/keys.py +0 -0
  31. {reflex_sdk-0.3.2 → reflex_sdk-0.3.4}/reflex/models.py +0 -0
  32. {reflex_sdk-0.3.2 → reflex_sdk-0.3.4}/reflex/product.py +0 -0
  33. {reflex_sdk-0.3.2 → reflex_sdk-0.3.4}/reflex/receipts.py +0 -0
  34. {reflex_sdk-0.3.2 → reflex_sdk-0.3.4}/reflex/robot_runtime.py +0 -0
  35. {reflex_sdk-0.3.2 → reflex_sdk-0.3.4}/reflex/robots.py +0 -0
  36. {reflex_sdk-0.3.2 → reflex_sdk-0.3.4}/reflex/sessions.py +0 -0
  37. {reflex_sdk-0.3.2 → reflex_sdk-0.3.4}/reflex/so101.py +0 -0
  38. {reflex_sdk-0.3.2 → reflex_sdk-0.3.4}/reflex/training.py +0 -0
  39. {reflex_sdk-0.3.2 → reflex_sdk-0.3.4}/reflex/transports/__init__.py +0 -0
  40. {reflex_sdk-0.3.2 → reflex_sdk-0.3.4}/reflex/transports/_webrtc_streaming_client.py +0 -0
  41. {reflex_sdk-0.3.2 → reflex_sdk-0.3.4}/reflex/transports/base.py +0 -0
  42. {reflex_sdk-0.3.2 → reflex_sdk-0.3.4}/reflex/transports/edge_http.py +0 -0
  43. {reflex_sdk-0.3.2 → reflex_sdk-0.3.4}/reflex/transports/platform.py +0 -0
  44. {reflex_sdk-0.3.2 → reflex_sdk-0.3.4}/reflex_sdk.egg-info/SOURCES.txt +0 -0
  45. {reflex_sdk-0.3.2 → reflex_sdk-0.3.4}/reflex_sdk.egg-info/dependency_links.txt +0 -0
  46. {reflex_sdk-0.3.2 → reflex_sdk-0.3.4}/reflex_sdk.egg-info/entry_points.txt +0 -0
  47. {reflex_sdk-0.3.2 → reflex_sdk-0.3.4}/reflex_sdk.egg-info/requires.txt +0 -0
  48. {reflex_sdk-0.3.2 → reflex_sdk-0.3.4}/reflex_sdk.egg-info/top_level.txt +0 -0
  49. {reflex_sdk-0.3.2 → reflex_sdk-0.3.4}/setup.cfg +0 -0
  50. {reflex_sdk-0.3.2 → reflex_sdk-0.3.4}/tests/test_connect_runner.py +0 -0
  51. {reflex_sdk-0.3.2 → reflex_sdk-0.3.4}/tests/test_prod1_smoke.py +0 -0
  52. {reflex_sdk-0.3.2 → reflex_sdk-0.3.4}/tests/test_product_cli.py +0 -0
  53. {reflex_sdk-0.3.2 → reflex_sdk-0.3.4}/tests/test_public_sdk.py +0 -0
  54. {reflex_sdk-0.3.2 → reflex_sdk-0.3.4}/tests/test_region_probe.py +0 -0
  55. {reflex_sdk-0.3.2 → reflex_sdk-0.3.4}/tests/test_so101_actions.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: reflex-sdk
3
- Version: 0.3.2
3
+ Version: 0.3.4
4
4
  Summary: Python SDK for Reflex hosted robot inference and training
5
5
  Author: Reflex
6
6
  Project-URL: Homepage, https://tryreflex.ai
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "reflex-sdk"
3
- version = "0.3.2"
3
+ version = "0.3.4"
4
4
  description = "Python SDK for Reflex hosted robot inference and training"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.9"
@@ -75,6 +75,76 @@ def _resolve_api_key() -> str | None:
75
75
  return None
76
76
 
77
77
 
78
+ # NET.3: known Modal regional endpoints we can probe to find the nearest.
79
+ # Each entry: (region_key, probe_url). Probe must be cheap (HEAD or short GET).
80
+ # Added regions appear here once their primeNode is registered with Convex.
81
+ KNOWN_REGIONS = (
82
+ (
83
+ "us-west",
84
+ "https://reflex-inc--reflex-inference-molmoact-webrtc-baseline-mo-8e694d.us-west.modal.direct/health",
85
+ ),
86
+ (
87
+ "us-east",
88
+ "https://reflex-inc--reflex-inference-molmoact-webrtc-baseline-us-e787db.us-east.modal.direct/health",
89
+ ),
90
+ )
91
+ REGION_PROBE_TIMEOUT_S = 2.5
92
+ REGION_PROBE_SAMPLES = 2
93
+
94
+
95
+ def _probe_nearest_region() -> str | None:
96
+ """Probe known regions; return the lowest-latency region key.
97
+
98
+ Costs one HEAD-equivalent per region per sample. Cached for the lifetime
99
+ of the process via REFLEX_PINNED_REGION env var; subsequent calls skip
100
+ the probe. Returns None on total failure (no region pickable, authorize
101
+ proceeds with no region hint).
102
+ """
103
+ pinned = (os.environ.get("REFLEX_PINNED_REGION") or "").strip().lower()
104
+ if pinned:
105
+ return pinned
106
+
107
+ import concurrent.futures as _cf
108
+
109
+ def _probe_one(url: str) -> float:
110
+ latencies = []
111
+ for _ in range(REGION_PROBE_SAMPLES):
112
+ t0 = time.perf_counter()
113
+ try:
114
+ req = urllib.request.Request(
115
+ url, method="GET",
116
+ headers={"user-agent": USER_AGENT},
117
+ )
118
+ with urllib.request.urlopen(req, timeout=REGION_PROBE_TIMEOUT_S) as r:
119
+ r.read(64)
120
+ latencies.append(time.perf_counter() - t0)
121
+ except Exception:
122
+ return float("inf")
123
+ return min(latencies)
124
+
125
+ results: dict[str, float] = {}
126
+ with _cf.ThreadPoolExecutor(max_workers=len(KNOWN_REGIONS)) as ex:
127
+ future_to_region = {
128
+ ex.submit(_probe_one, url): region for region, url in KNOWN_REGIONS
129
+ }
130
+ for fut in _cf.as_completed(future_to_region):
131
+ region = future_to_region[fut]
132
+ results[region] = fut.result()
133
+
134
+ if not results or all(v == float("inf") for v in results.values()):
135
+ return None
136
+ best = min(results, key=lambda k: results[k])
137
+ best_ms = results[best] * 1000
138
+ print(
139
+ f"[reflex] region probe: chose {best!r} ({best_ms:.0f}ms) — "
140
+ f"{ {k: f'{v*1000:.0f}ms' for k, v in results.items()} }",
141
+ file=sys.stderr, flush=True,
142
+ )
143
+ # Cache the pick for the rest of the process (avoids re-probing per call).
144
+ os.environ["REFLEX_PINNED_REGION"] = best
145
+ return best
146
+
147
+
78
148
  def _convex_mutation(convex_url: str, path: str, args: dict, *, timeout: float = 30.0) -> dict:
79
149
  body = json.dumps({"path": path, "format": "json", "args": args}).encode("utf-8")
80
150
  req = urllib.request.Request(
@@ -116,11 +186,24 @@ def maybe_authorize(
116
186
  or CONVEX_URL_DEFAULT
117
187
  )
118
188
 
189
+ # NET.3: probe nearest region (cached after first call). Skipped via
190
+ # REFLEX_SKIP_REGION_PROBE=1 if the caller already knows their region.
191
+ client_region: str | None = None
192
+ if not os.environ.get("REFLEX_SKIP_REGION_PROBE"):
193
+ try:
194
+ client_region = _probe_nearest_region()
195
+ except Exception as exc:
196
+ print(f"[reflex] region probe failed ({exc}); proceeding without hint", file=sys.stderr, flush=True)
197
+
198
+ auth_args = {"apiKey": api_key, "baseModel": base_model, "robotType": robot_type}
199
+ if client_region:
200
+ auth_args["clientRegion"] = client_region
201
+
119
202
  t0 = time.perf_counter()
120
203
  result = _convex_mutation(
121
204
  convex_url,
122
205
  "publicApi:authorizeSession",
123
- {"apiKey": api_key, "baseModel": base_model, "robotType": robot_type},
206
+ auth_args,
124
207
  )
125
208
  dt_ms = (time.perf_counter() - t0) * 1000
126
209
 
@@ -176,11 +176,35 @@ def run_session(
176
176
  started_cameras: list[str] = []
177
177
  last_heartbeat = 0.0
178
178
  interrupted = False
179
+ sigint_count = 0
179
180
  executor: concurrent.futures.ThreadPoolExecutor | None = None
180
181
 
181
182
  def _on_signal(signum, _frame): # noqa: ANN001 - signal callback signature
182
- nonlocal interrupted
183
+ nonlocal interrupted, sigint_count
183
184
  interrupted = True
185
+ sigint_count += 1
186
+ # Before the transport has connected there's no graceful work pending,
187
+ # so unwind immediately — otherwise the user's ^C only sets a flag while
188
+ # transport.start() keeps blocking in `fut.result(timeout=...)`.
189
+ if not transport_started:
190
+ log.warning(
191
+ "received signal %s before transport connected — aborting", signum
192
+ )
193
+ signal.signal(signal.SIGINT, signal.SIG_DFL)
194
+ signal.signal(signal.SIGTERM, signal.SIG_DFL)
195
+ raise KeyboardInterrupt(f"signal {signum}")
196
+ # During an active session the first signal triggers graceful homing.
197
+ # A second signal escalates to force-exit so a stuck shutdown can't
198
+ # trap the user — restore the default handler and re-raise.
199
+ if sigint_count >= 2:
200
+ log.warning(
201
+ "received signal %s a second time — forcing exit", signum
202
+ )
203
+ signal.signal(signal.SIGINT, signal.SIG_DFL)
204
+ signal.signal(signal.SIGTERM, signal.SIG_DFL)
205
+ raise KeyboardInterrupt(
206
+ f"forced exit (signal {signum} x{sigint_count})"
207
+ )
184
208
  log.warning("received signal %s; ending session and homing arms", signum)
185
209
 
186
210
  prev_sigint = signal.signal(signal.SIGINT, _on_signal)
@@ -415,33 +415,70 @@ class WebRTCClient:
415
415
  method="POST",
416
416
  headers=signaling_headers,
417
417
  )
418
- # Auto-wake cold Modal worker retry up to 60s on 503/connect error.
419
- # Modal scales workers to zero after idle; first request takes 30-90s to
420
- # cold-start. The cli would otherwise crash with HTTPError 503.
418
+ # Auto-wake cold Modal worker. Modal scales workers to zero after idle;
419
+ # first request takes 30-90s to cold-start. Both the signaling POST and
420
+ # the ICE wait below share a single deadline = `timeout` seconds from
421
+ # start, so callers tune one number to govern the whole connect.
422
+ deadline = time.monotonic() + float(timeout)
423
+ loop = asyncio.get_event_loop()
424
+
425
+ def _do_post(per_attempt_timeout: float) -> dict:
426
+ with urllib.request.urlopen(req, timeout=per_attempt_timeout) as r:
427
+ return json.loads(r.read())
428
+
421
429
  t0 = time.perf_counter()
422
- last_err = None
423
- for _attempt in range(7):
430
+ attempt = 0
431
+ last_err: BaseException | None = None
432
+ warned = False
433
+ resp = None
434
+ while True:
435
+ remaining = deadline - time.monotonic()
436
+ if remaining <= 0:
437
+ msg = (
438
+ f"WebRTC signaling timeout after {time.perf_counter()-t0:.1f}s "
439
+ f"(budget {timeout:.0f}s)"
440
+ )
441
+ if last_err is not None:
442
+ raise RuntimeError(f"{msg} — last error: {last_err!r}") from last_err
443
+ raise RuntimeError(f"{msg} — no response from {self.signaling_url}")
444
+ attempt += 1
445
+ # Cap each POST at 30s so a stalled connect can't burn the whole
446
+ # budget on one attempt — we'd rather retry sooner with feedback.
447
+ per_attempt = min(remaining, 30.0)
424
448
  try:
425
- with urllib.request.urlopen(req, timeout=60) as r:
426
- resp = json.loads(r.read())
449
+ resp = await loop.run_in_executor(None, _do_post, per_attempt)
427
450
  last_err = None
428
451
  break
429
452
  except urllib.error.HTTPError as exc:
430
453
  last_err = exc
431
- if exc.code in (502, 503, 504):
432
- if _attempt == 0:
433
- print(f"[webrtc-client] worker cold (HTTP {exc.code}) — waking...", flush=True)
434
- time.sleep(10)
435
- continue
436
- raise
454
+ if exc.code not in (502, 503, 504):
455
+ raise
437
456
  except (urllib.error.URLError, TimeoutError) as exc:
438
457
  last_err = exc
439
- if _attempt == 0:
440
- print(f"[webrtc-client] worker unreachable ({exc!r}) — retrying...", flush=True)
441
- time.sleep(10)
442
- continue
443
- if last_err is not None:
444
- raise last_err
458
+
459
+ if not warned:
460
+ warned = True
461
+ if isinstance(last_err, urllib.error.HTTPError):
462
+ print(
463
+ f"[webrtc-client] worker cold (HTTP {last_err.code}) — "
464
+ f"waking, budget {timeout:.0f}s...",
465
+ flush=True,
466
+ )
467
+ else:
468
+ print(
469
+ f"[webrtc-client] worker unreachable ({last_err!r}) — "
470
+ f"retrying, budget {timeout:.0f}s...",
471
+ flush=True,
472
+ )
473
+ elapsed = time.perf_counter() - t0
474
+ print(
475
+ f"[webrtc-client] cold-wake attempt {attempt} failed at "
476
+ f"{elapsed:.0f}s of {timeout:.0f}s — sleeping before retry",
477
+ flush=True,
478
+ )
479
+ sleep_for = min(10.0, max(0.0, deadline - time.monotonic()))
480
+ if sleep_for > 0:
481
+ await asyncio.sleep(sleep_for)
445
482
  print(f"[webrtc-client] signaling roundtrip: {(time.perf_counter()-t0)*1000:.0f}ms", flush=True)
446
483
 
447
484
  await self.pc.setRemoteDescription(RTCSessionDescription(
@@ -449,13 +486,23 @@ class WebRTCClient:
449
486
  ))
450
487
  self.session_id = resp.get("session_id")
451
488
 
452
- # Wait for ICE + DTLS + DataChannel to come up
453
- t0 = time.perf_counter()
489
+ # Wait for ICE + DTLS + DataChannel using whatever time is left in the
490
+ # shared deadline. Floor at 1s so a near-exhausted budget still gives
491
+ # ICE a meaningful chance instead of insta-timing-out.
492
+ ice_remaining = max(1.0, deadline - time.monotonic())
493
+ ice_t0 = time.perf_counter()
454
494
  try:
455
- await asyncio.wait_for(self._connected.wait(), timeout=timeout)
456
- print(f"[webrtc-client] connected in {(time.perf_counter()-t0)*1000:.0f}ms session={self.session_id}", flush=True)
495
+ await asyncio.wait_for(self._connected.wait(), timeout=ice_remaining)
496
+ print(
497
+ f"[webrtc-client] connected in {(time.perf_counter()-ice_t0)*1000:.0f}ms "
498
+ f"session={self.session_id}",
499
+ flush=True,
500
+ )
457
501
  except asyncio.TimeoutError:
458
- raise RuntimeError("WebRTC connection timeout — likely NAT/firewall blocked direct UDP")
502
+ raise RuntimeError(
503
+ f"WebRTC ICE/DataChannel timeout after {ice_remaining:.1f}s — "
504
+ "likely NAT/firewall blocked direct UDP"
505
+ )
459
506
 
460
507
  # H.264 keyframes can take ~500ms-1s to arrive at the server after
461
508
  # negotiation. Sleep briefly so the first measured infer() finds a
@@ -148,7 +148,12 @@ class WebRTCTransport(Transport):
148
148
  fut = asyncio.run_coroutine_threadsafe(
149
149
  self._client.connect(timeout=self._connect_timeout), self._loop
150
150
  )
151
- fut.result(timeout=self._connect_timeout + 5.0)
151
+ # The client owns the deadline (signaling cold-wake + ICE share one
152
+ # budget = connect_timeout). +30s slack here lets an in-flight urllib
153
+ # POST in an executor thread unwind its TCP timeout naturally after
154
+ # the inner coroutine cancels — avoids a bare TimeoutError leaking up
155
+ # while the inner is in the middle of raising its own diagnostic one.
156
+ fut.result(timeout=self._connect_timeout + 30.0)
152
157
 
153
158
  def stop(self) -> None:
154
159
  if self._client is not None and self._loop is not None:
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: reflex-sdk
3
- Version: 0.3.2
3
+ Version: 0.3.4
4
4
  Summary: Python SDK for Reflex hosted robot inference and training
5
5
  Author: Reflex
6
6
  Project-URL: Homepage, https://tryreflex.ai
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes