vrex-flow-engine 0.2.4__tar.gz → 0.3.0__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 (46) hide show
  1. {vrex_flow_engine-0.2.4 → vrex_flow_engine-0.3.0}/PKG-INFO +1 -1
  2. {vrex_flow_engine-0.2.4 → vrex_flow_engine-0.3.0}/flow_engine/__init__.py +1 -1
  3. vrex_flow_engine-0.3.0/flow_engine/bridge/flow_client.py +315 -0
  4. vrex_flow_engine-0.3.0/flow_engine/bridge/flow_rpc.py +546 -0
  5. vrex_flow_engine-0.3.0/flow_engine/bridge/flow_sdk.py +482 -0
  6. {vrex_flow_engine-0.2.4 → vrex_flow_engine-0.3.0}/flow_engine/catalog.py +92 -117
  7. {vrex_flow_engine-0.2.4 → vrex_flow_engine-0.3.0}/flow_engine/config.py +11 -3
  8. {vrex_flow_engine-0.2.4 → vrex_flow_engine-0.3.0}/flow_engine/ingest.py +22 -0
  9. {vrex_flow_engine-0.2.4 → vrex_flow_engine-0.3.0}/flow_engine/jobs.py +8 -7
  10. {vrex_flow_engine-0.2.4 → vrex_flow_engine-0.3.0}/flow_engine/openai/images.py +8 -3
  11. vrex_flow_engine-0.3.0/flow_engine/openai/models.py +41 -0
  12. {vrex_flow_engine-0.2.4 → vrex_flow_engine-0.3.0}/flow_engine/openai/uploads.py +13 -9
  13. {vrex_flow_engine-0.2.4 → vrex_flow_engine-0.3.0}/flow_engine/openai/videos.py +54 -156
  14. {vrex_flow_engine-0.2.4 → vrex_flow_engine-0.3.0}/flow_engine/pool.py +2 -1
  15. {vrex_flow_engine-0.2.4 → vrex_flow_engine-0.3.0}/flow_engine/session.py +34 -20
  16. {vrex_flow_engine-0.2.4 → vrex_flow_engine-0.3.0}/pyproject.toml +1 -1
  17. {vrex_flow_engine-0.2.4 → vrex_flow_engine-0.3.0}/vrex_flow_engine.egg-info/PKG-INFO +1 -1
  18. {vrex_flow_engine-0.2.4 → vrex_flow_engine-0.3.0}/vrex_flow_engine.egg-info/SOURCES.txt +1 -1
  19. vrex_flow_engine-0.2.4/README.md +0 -268
  20. vrex_flow_engine-0.2.4/flow_engine/bridge/flow_client.py +0 -466
  21. vrex_flow_engine-0.2.4/flow_engine/bridge/flow_sdk.py +0 -1890
  22. vrex_flow_engine-0.2.4/flow_engine/openai/models.py +0 -38
  23. {vrex_flow_engine-0.2.4 → vrex_flow_engine-0.3.0}/PYPI_README.md +0 -0
  24. {vrex_flow_engine-0.2.4 → vrex_flow_engine-0.3.0}/flow_engine/bridge/__init__.py +0 -0
  25. {vrex_flow_engine-0.2.4 → vrex_flow_engine-0.3.0}/flow_engine/bridge/ws_server.py +0 -0
  26. {vrex_flow_engine-0.2.4 → vrex_flow_engine-0.3.0}/flow_engine/cli/__init__.py +0 -0
  27. {vrex_flow_engine-0.2.4 → vrex_flow_engine-0.3.0}/flow_engine/cli/app.py +0 -0
  28. {vrex_flow_engine-0.2.4 → vrex_flow_engine-0.3.0}/flow_engine/cli/config.py +0 -0
  29. {vrex_flow_engine-0.2.4 → vrex_flow_engine-0.3.0}/flow_engine/cli/dashboard.py +0 -0
  30. {vrex_flow_engine-0.2.4 → vrex_flow_engine-0.3.0}/flow_engine/cli/health.py +0 -0
  31. {vrex_flow_engine-0.2.4 → vrex_flow_engine-0.3.0}/flow_engine/cli/processes.py +0 -0
  32. {vrex_flow_engine-0.2.4 → vrex_flow_engine-0.3.0}/flow_engine/cli/supervisor.py +0 -0
  33. {vrex_flow_engine-0.2.4 → vrex_flow_engine-0.3.0}/flow_engine/job_store.py +0 -0
  34. {vrex_flow_engine-0.2.4 → vrex_flow_engine-0.3.0}/flow_engine/main.py +0 -0
  35. {vrex_flow_engine-0.2.4 → vrex_flow_engine-0.3.0}/flow_engine/media.py +0 -0
  36. {vrex_flow_engine-0.2.4 → vrex_flow_engine-0.3.0}/flow_engine/media_store.py +0 -0
  37. {vrex_flow_engine-0.2.4 → vrex_flow_engine-0.3.0}/flow_engine/openai/__init__.py +0 -0
  38. {vrex_flow_engine-0.2.4 → vrex_flow_engine-0.3.0}/flow_engine/openai/_util.py +0 -0
  39. {vrex_flow_engine-0.2.4 → vrex_flow_engine-0.3.0}/flow_engine/posthog_client.py +0 -0
  40. {vrex_flow_engine-0.2.4 → vrex_flow_engine-0.3.0}/flow_engine/video_context.py +0 -0
  41. {vrex_flow_engine-0.2.4 → vrex_flow_engine-0.3.0}/flow_engine/video_context_store.py +0 -0
  42. {vrex_flow_engine-0.2.4 → vrex_flow_engine-0.3.0}/setup.cfg +0 -0
  43. {vrex_flow_engine-0.2.4 → vrex_flow_engine-0.3.0}/vrex_flow_engine.egg-info/dependency_links.txt +0 -0
  44. {vrex_flow_engine-0.2.4 → vrex_flow_engine-0.3.0}/vrex_flow_engine.egg-info/entry_points.txt +0 -0
  45. {vrex_flow_engine-0.2.4 → vrex_flow_engine-0.3.0}/vrex_flow_engine.egg-info/requires.txt +0 -0
  46. {vrex_flow_engine-0.2.4 → vrex_flow_engine-0.3.0}/vrex_flow_engine.egg-info/top_level.txt +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: vrex-flow-engine
3
- Version: 0.2.4
3
+ Version: 0.3.0
4
4
  Summary: Launcher + supervisor and OpenAI-compatible image/video generation service (Vrex Flow Engine).
5
5
  Author: Vrex
6
6
  License: Proprietary
@@ -1,4 +1,4 @@
1
1
  """flow_engine — host a Flowboard browser-extension instance and expose
2
2
  OpenAI-compatible image/video generation endpoints over Google Flow."""
3
3
 
4
- __version__ = "0.2.4"
4
+ __version__ = "0.2.5"
@@ -0,0 +1,315 @@
1
+ """Bridge to the Chrome MV3 extension over WebSocket.
2
+
3
+ Control flow:
4
+ 1. Extension opens WS to :9223 and sends ``register_instance``.
5
+ 2. Agent replies ``{type:"callback_secret", secret}``.
6
+ 3. To call Flow, the agent sends ``{id, method:"rpc_request", params:{rpcId,
7
+ payload, captchaAction?, captchaPaths?, sourcePath?}}``. The extension
8
+ solves reCAPTCHA in a flow.google.com tab when asked, POSTs the
9
+ ``batchexecute`` request with the user's cookies + XSRF (``at``) token,
10
+ parses the response envelope, and POSTs ``{id, status, data, rpcError}``
11
+ to ``/api/ext/callback`` with ``X-Callback-Secret``. Video bytes take a
12
+ separate ``upload_request`` command (resumable upload, not batchexecute).
13
+ 4. That HTTP handler resolves the pending future by id.
14
+ 5. Inbound WS messages (``extension_ready``, ``session_captured``,
15
+ ``session_lost``, ``pong``) update per-instance state. A fresh session
16
+ triggers an account refresh (credits + paygate tier) through the bridge.
17
+ """
18
+ from __future__ import annotations
19
+
20
+ import asyncio
21
+ import json
22
+ import logging
23
+ import secrets
24
+ import time
25
+ import uuid
26
+ from typing import Any, Optional
27
+
28
+ from flow_engine.bridge import flow_rpc
29
+
30
+ logger = logging.getLogger(__name__)
31
+
32
+ # Minimum gap between account refreshes for the same session — the extension
33
+ # re-announces its session on every reconnect and on periodic re-bootstraps.
34
+ _ACCOUNT_REFRESH_MIN_INTERVAL_S = 60.0
35
+
36
+
37
+ class FlowClient:
38
+ """Bridge client. One instance per pool entry (one per Chrome profile)."""
39
+
40
+ DEFAULT_TIMEOUT = 180.0 # seconds
41
+
42
+ def __init__(self, semaphore: Optional[asyncio.Semaphore] = None) -> None:
43
+ self._ws: Optional[Any] = None
44
+ self._pending: dict[str, asyncio.Future] = {}
45
+ self._callback_secret: str = secrets.token_urlsafe(32)
46
+ from flow_engine.config import POOL_MAX_CONCURRENCY
47
+ self._semaphore: asyncio.Semaphore = semaphore or asyncio.Semaphore(
48
+ POOL_MAX_CONCURRENCY
49
+ )
50
+ # Set by PoolInstance so _send can update in_flight without importing pool.
51
+ self._on_send_start: Any = lambda: None
52
+ self._on_send_end: Any = lambda: None
53
+
54
+ self._session_present: bool = False
55
+ self._session_captured_at: Optional[float] = None
56
+ self._last_account_refresh_at: Optional[float] = None
57
+ # Identity pushed by the extension from the page bootstrap data.
58
+ self._user_info: Optional[dict] = None
59
+ # Account facts from the credits RPC. paygate_tier is the integer tier
60
+ # id Flow's catalog keys its credit mapping by (1 / 2 / 3).
61
+ self._paygate_tier: Optional[int] = None
62
+ self._credits: Optional[int] = None
63
+ self._plan_code: Optional[int] = None
64
+ self._request_count = 0
65
+ self._success_count = 0
66
+ self._failed_count = 0
67
+ self._last_error: Optional[str] = None
68
+
69
+ # ── connection ─────────────────────────────────────────────────────────
70
+ @property
71
+ def connected(self) -> bool:
72
+ return self._ws is not None
73
+
74
+ @property
75
+ def callback_secret(self) -> str:
76
+ return self._callback_secret
77
+
78
+ def set_extension(self, ws: Any) -> None:
79
+ self._ws = ws
80
+
81
+ def clear_extension(self) -> None:
82
+ self._ws = None
83
+ self._clear_session()
84
+ for fut in self._pending.values():
85
+ if not fut.done():
86
+ fut.set_exception(ConnectionError("extension_disconnected"))
87
+ self._pending.clear()
88
+
89
+ def _clear_session(self) -> None:
90
+ self._session_present = False
91
+ self._user_info = None
92
+ self._paygate_tier = None
93
+ self._credits = None
94
+ self._plan_code = None
95
+
96
+ @property
97
+ def user_info(self) -> Optional[dict]:
98
+ return self._user_info
99
+
100
+ @property
101
+ def paygate_tier(self) -> Optional[int]:
102
+ return self._paygate_tier
103
+
104
+ @property
105
+ def credits(self) -> Optional[int]:
106
+ return self._credits
107
+
108
+ @property
109
+ def session_present(self) -> bool:
110
+ return self._session_present
111
+
112
+ async def refresh_account(self) -> bool:
113
+ """Resolve credits + paygate tier via the credits RPC. Returns True when
114
+ the tier is cached. Runs through the extension, so it needs a live
115
+ signed-in flow.google.com session."""
116
+ resp = await self.rpc(flow_rpc.RPC_ACCOUNT_CREDITS, flow_rpc.credits_payload(), timeout=30.0)
117
+ if resp.get("error"):
118
+ logger.warning("refresh_account failed: %s", resp["error"])
119
+ return False
120
+ account = flow_rpc.parse_credits(resp.get("data"))
121
+ tier = account.get("paygate_tier")
122
+ if tier is None:
123
+ logger.warning("refresh_account: no paygate tier in response %r", resp.get("data"))
124
+ return False
125
+ self._paygate_tier = tier
126
+ self._credits = account.get("credits")
127
+ self._plan_code = account.get("plan_code")
128
+ logger.info("account resolved tier=%s credits=%s", tier, self._credits)
129
+ return True
130
+
131
+ # ── inbound handling ───────────────────────────────────────────────────
132
+ async def handle_message(self, data: dict) -> None:
133
+ t = data.get("type")
134
+ if t == "extension_ready":
135
+ self._session_present = bool(data.get("sessionPresent"))
136
+ logger.info("extension_ready sessionPresent=%s", self._session_present)
137
+ return
138
+ if t == "session_captured":
139
+ self._session_present = True
140
+ self._session_captured_at = time.time()
141
+ email = data.get("email")
142
+ if isinstance(email, str) and email:
143
+ self._user_info = {"email": email}
144
+ now = time.time()
145
+ last = self._last_account_refresh_at or 0.0
146
+ if self._paygate_tier is None or (now - last) > _ACCOUNT_REFRESH_MIN_INTERVAL_S:
147
+ self._last_account_refresh_at = now
148
+ logger.info("session_captured for %s", email or "<no email>")
149
+ # Don't await: the WS handler must stay responsive.
150
+ asyncio.create_task(self.refresh_account())
151
+ return
152
+ if t == "session_lost":
153
+ logger.warning("session_lost: %s", data.get("reason"))
154
+ self._clear_session()
155
+ return
156
+ if t == "pong":
157
+ return
158
+ # Inbound response (legacy path; production flow uses HTTP callback)
159
+ req_id = data.get("id")
160
+ if req_id and req_id in self._pending:
161
+ self._resolve(req_id, data)
162
+
163
+ def resolve_callback(self, data: dict) -> bool:
164
+ """Called by the HTTP callback endpoint after validating the secret.
165
+ Returns True if a pending future matched."""
166
+ req_id = data.get("id")
167
+ if not req_id or req_id not in self._pending:
168
+ return False
169
+ self._resolve(req_id, data)
170
+ return True
171
+
172
+ def _resolve(self, req_id: str, data: dict) -> None:
173
+ fut = self._pending.pop(req_id, None)
174
+ if not fut or fut.done():
175
+ return
176
+ status = data.get("status")
177
+ http_error = isinstance(status, int) and status >= 400
178
+ if http_error or data.get("error") or data.get("rpcError"):
179
+ self._failed_count += 1
180
+ msg = data.get("error") or data.get("rpcError") or f"API_{status}"
181
+ self._last_error = str(msg)[:200]
182
+ else:
183
+ self._success_count += 1
184
+ fut.set_result(data)
185
+
186
+ # ── outbound ──────────────────────────────────────────────────────────
187
+ async def notify(self, message: dict) -> bool:
188
+ """Fire-and-forget WS push to the extension."""
189
+ if not self.connected or self._ws is None:
190
+ return False
191
+ try:
192
+ await self._ws.send(json.dumps(message))
193
+ return True
194
+ except Exception as exc: # noqa: BLE001
195
+ logger.warning("notify failed: %s", exc)
196
+ return False
197
+
198
+ async def _send(self, method: str, params: dict, timeout: Optional[float] = None) -> dict:
199
+ if not self.connected:
200
+ return {"error": "extension_disconnected"}
201
+ # Per-instance concurrency semaphore — excess callers queue, no 503.
202
+ async with self._semaphore:
203
+ self._on_send_start()
204
+ try:
205
+ return await self._send_inner(method, params, timeout)
206
+ finally:
207
+ self._on_send_end()
208
+
209
+ async def _send_inner(
210
+ self, method: str, params: dict, timeout: Optional[float] = None
211
+ ) -> dict:
212
+ req_id = str(uuid.uuid4())
213
+ fut: asyncio.Future = asyncio.get_running_loop().create_future()
214
+ self._pending[req_id] = fut
215
+ self._request_count += 1
216
+ payload = {"id": req_id, "method": method, "params": params}
217
+ try:
218
+ await self._ws.send(json.dumps(payload))
219
+ return await asyncio.wait_for(fut, timeout=timeout or self.DEFAULT_TIMEOUT)
220
+ except asyncio.TimeoutError:
221
+ self._pending.pop(req_id, None)
222
+ self._failed_count += 1
223
+ self._last_error = "timeout"
224
+ return {"error": "timeout"}
225
+ except Exception as exc: # noqa: BLE001
226
+ self._pending.pop(req_id, None)
227
+ self._failed_count += 1
228
+ self._last_error = str(exc)
229
+ return {"error": str(exc)}
230
+
231
+ async def rpc(
232
+ self,
233
+ rpc_id: str,
234
+ payload: Any,
235
+ *,
236
+ captcha_action: Optional[str] = None,
237
+ captcha_path: Optional[list[int]] = None,
238
+ captcha_paths: Optional[list[list[int]]] = None,
239
+ source_path: Optional[str] = None,
240
+ timeout: Optional[float] = None,
241
+ ) -> dict:
242
+ """Run one batchexecute RPC through the extension.
243
+
244
+ Returns ``{"data": <parsed rpc result>, "raw": <envelope>}`` or
245
+ ``{"error": <str>, "raw": <envelope>}``. ``captcha_action`` makes the
246
+ extension solve reCAPTCHA first and write the token at every path in
247
+ ``captcha_paths`` (lists of array indices into ``payload``;
248
+ ``captcha_path`` is the single-path shorthand).
249
+ """
250
+ params: dict[str, Any] = {"rpcId": rpc_id, "payload": payload}
251
+ if captcha_action:
252
+ params["captchaAction"] = captcha_action
253
+ params["captchaPaths"] = captcha_paths or ([captcha_path] if captcha_path else [])
254
+ if source_path:
255
+ params["sourcePath"] = source_path
256
+ resp = await self._send("rpc_request", params, timeout=timeout)
257
+ if not isinstance(resp, dict):
258
+ return {"error": "invalid_bridge_response", "raw": resp}
259
+ if resp.get("error"):
260
+ return {"error": str(resp["error"]), "raw": resp}
261
+ if resp.get("rpcError"):
262
+ return {"error": f"rpc_{rpc_id}_failed: {resp['rpcError']}", "raw": resp}
263
+ status = resp.get("status")
264
+ if isinstance(status, int) and status >= 400:
265
+ return {"error": f"rpc_{rpc_id}_http_{status}", "raw": resp}
266
+ return {"data": resp.get("data"), "raw": resp}
267
+
268
+ async def upload_video(
269
+ self,
270
+ project_id: str,
271
+ file_name: str,
272
+ content_type: str,
273
+ data_base64: str,
274
+ timeout: Optional[float] = None,
275
+ ) -> dict:
276
+ """Run Flow's resumable video upload through the extension (start +
277
+ upload/finalize with the user's cookies). Returns ``{"data": <finalize
278
+ JSON>, "raw"}`` or ``{"error", "raw"}``. Bytes travel as base64 since
279
+ the bridge is JSON."""
280
+ resp = await self._send(
281
+ "upload_request",
282
+ {
283
+ "projectId": project_id,
284
+ "fileName": file_name,
285
+ "contentType": content_type,
286
+ "dataBase64": data_base64,
287
+ },
288
+ timeout=timeout or self.DEFAULT_TIMEOUT,
289
+ )
290
+ if not isinstance(resp, dict):
291
+ return {"error": "invalid_bridge_response", "raw": resp}
292
+ if resp.get("error"):
293
+ return {"error": str(resp["error"]), "raw": resp}
294
+ status = resp.get("status")
295
+ if isinstance(status, int) and status >= 400:
296
+ return {"error": f"upload_http_{status}", "raw": resp}
297
+ return {"data": resp.get("data"), "raw": resp}
298
+
299
+ # ── observability ─────────────────────────────────────────────────────
300
+ @property
301
+ def ws_stats(self) -> dict:
302
+ age = (
303
+ int(time.time() - self._session_captured_at)
304
+ if self._session_captured_at is not None else None
305
+ )
306
+ return {
307
+ "connected": self.connected,
308
+ "session_present": self._session_present,
309
+ "session_age_s": age,
310
+ "pending": len(self._pending),
311
+ "request_count": self._request_count,
312
+ "success_count": self._success_count,
313
+ "failed_count": self._failed_count,
314
+ "last_error": self._last_error,
315
+ }