vrex-flow-engine 0.2.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,4 @@
1
+ """flow_engine — host a Flowboard browser-extension instance and expose
2
+ OpenAI-compatible image/video generation endpoints over Google Flow."""
3
+
4
+ __version__ = "0.0.1"
@@ -0,0 +1,6 @@
1
+ """The extension bridge: WS server + Flow client + Flow SDK.
2
+
3
+ These three modules are lifted near-verbatim from flowboard's agent. They are
4
+ intentionally free of any database / board dependency so they drop straight
5
+ into a standalone proxy. The only edits vs. the originals are the import paths.
6
+ """
@@ -0,0 +1,444 @@
1
+ """Bridge to the Chrome MV3 extension over WebSocket.
2
+
3
+ Ported + trimmed from flowkit (https://github.com/crisng95/flowkit).
4
+
5
+ Control flow:
6
+ 1. Extension opens WS to :9223.
7
+ 2. Agent sends ``{type:"callback_secret", secret}`` immediately.
8
+ 3. When the agent wants to make an authenticated call against Google Flow /
9
+ aisandbox-pa, it calls ``flow_client.api_request(url, method, headers, body)``
10
+ which sends ``{id, method:"api_request", params}`` over WS and awaits a future.
11
+ 4. The extension performs ``fetch(url, Authorization: Bearer <token>)`` inside
12
+ the user's browser session and POSTs the response to
13
+ ``/api/ext/callback`` with ``X-Callback-Secret``.
14
+ 5. That HTTP handler resolves the pending future by id.
15
+ 6. WS-side inbound messages from the extension (``token_captured``,
16
+ ``extension_ready``, ``pong``, ``status``) update our stats.
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
+ import httpx
29
+
30
+ logger = logging.getLogger(__name__)
31
+
32
+
33
+ # Google Flow's public API key — appears verbatim in every aisandbox-pa
34
+ # request URL Flow web emits. Not a secret; documented here so we don't
35
+ # need to plumb it through from the extension on every call.
36
+ _FLOW_API_KEY = "AIzaSyBtrm0o5ab1c-Ec8ZuLcGt3oJAA5VWt3pY"
37
+ _FLOW_CREDITS_URL = "https://aisandbox-pa.googleapis.com/v1/credits"
38
+ # Minimum gap between paygate-tier refreshes when the same Bearer token
39
+ # is re-delivered. Tier rarely changes; 60 s is fine for AccountPanel
40
+ # freshness and tames the credits-fetch storm an old extension can
41
+ # induce by re-emitting `token_captured` on every outbound request.
42
+ _TIER_REFRESH_MIN_INTERVAL_S = 60.0
43
+
44
+
45
+ class FlowClient:
46
+ """Bridge client. One instance per pool entry (one per Chrome profile)."""
47
+
48
+ DEFAULT_TIMEOUT = 180.0 # seconds
49
+
50
+ def __init__(self, semaphore: Optional[asyncio.Semaphore] = None) -> None:
51
+ self._ws: Optional[Any] = None
52
+ self._pending: dict[str, asyncio.Future] = {}
53
+ self._callback_secret: str = secrets.token_urlsafe(32)
54
+ # Semaphore limits concurrent in-flight WS calls. Pool passes its own;
55
+ # standalone use (tests, single-tenant) gets a default Semaphore(4).
56
+ # in_flight is tracked on the owning PoolInstance, not here — we
57
+ # accept an optional callback pair to increment/decrement it cleanly.
58
+ from flow_engine.config import POOL_MAX_CONCURRENCY
59
+ self._semaphore: asyncio.Semaphore = semaphore or asyncio.Semaphore(
60
+ POOL_MAX_CONCURRENCY
61
+ )
62
+ # Callbacks set by PoolInstance so _send can update in_flight without
63
+ # importing pool (avoids circular deps). Both are no-ops by default.
64
+ self._on_send_start: Any = lambda: None
65
+ self._on_send_end: Any = lambda: None
66
+
67
+ self._token_captured_at: Optional[float] = None
68
+ self._flow_key_present: bool = False
69
+ # Cached Bearer token for server-side fetches against
70
+ # aisandbox-pa (e.g. /v1/credits for paygate tier resolution).
71
+ # In-memory only; cleared on extension disconnect. NOT logged
72
+ # anywhere — see fetch_paygate_tier() for the only consumer.
73
+ self._flow_key: Optional[str] = None
74
+ # Last time we hit /v1/credits — guards against the extension
75
+ # emitting `token_captured` on every outbound aisandbox-pa
76
+ # request (polls fire dozens per minute during video gen). The
77
+ # extension was patched to only emit on rotation, but we keep
78
+ # this dedupe so older installs don't spam the credits endpoint.
79
+ self._last_tier_fetch_at: Optional[float] = None
80
+ self._last_logged_key: Optional[str] = None
81
+ # Profile pushed by the extension after it resolves the Bearer
82
+ # token via Google's userinfo endpoint. Stays in-memory only —
83
+ # if the agent restarts the extension will replay it on the
84
+ # next WS reconnect.
85
+ self._user_info: Optional[dict] = None
86
+ # Paygate tier authoritative from /v1/credits + sku for display.
87
+ self._paygate_tier: Optional[str] = None
88
+ self._sku: Optional[str] = None # e.g. "WS_ULTRA" / "WS_PRO"
89
+ self._credits: Optional[int] = None
90
+ self._request_count = 0
91
+ self._success_count = 0
92
+ self._failed_count = 0
93
+ self._last_error: Optional[str] = None
94
+
95
+ # ── connection ─────────────────────────────────────────────────────────
96
+ @property
97
+ def connected(self) -> bool:
98
+ return self._ws is not None
99
+
100
+ @property
101
+ def callback_secret(self) -> str:
102
+ return self._callback_secret
103
+
104
+ def set_extension(self, ws: Any) -> None:
105
+ self._ws = ws
106
+
107
+ def clear_extension(self) -> None:
108
+ self._ws = None
109
+ self._flow_key_present = False
110
+ self._flow_key = None
111
+ # Drop the cached identity + tier — next reconnect will replay.
112
+ self._user_info = None
113
+ self._paygate_tier = None
114
+ self._sku = None
115
+ self._credits = None
116
+ for fut in self._pending.values():
117
+ if not fut.done():
118
+ fut.set_exception(ConnectionError("extension_disconnected"))
119
+ self._pending.clear()
120
+
121
+ @property
122
+ def user_info(self) -> Optional[dict]:
123
+ return self._user_info
124
+
125
+ @property
126
+ def paygate_tier(self) -> Optional[str]:
127
+ return self._paygate_tier
128
+
129
+ @property
130
+ def sku(self) -> Optional[str]:
131
+ return self._sku
132
+
133
+ @property
134
+ def credits(self) -> Optional[int]:
135
+ return self._credits
136
+
137
+ async def fetch_paygate_tier(self) -> bool:
138
+ """Authoritative paygate tier resolution via the official Flow
139
+ /v1/credits endpoint. Replaces the passive request-body sniffer
140
+ as the primary path.
141
+
142
+ Triggered automatically when `handle_message` receives a
143
+ `token_captured` message (extension just captured a fresh
144
+ Bearer token), and on demand via /api/auth/scan when the
145
+ cache is cold but the WS is open.
146
+
147
+ Returns True on success (tier cached), False otherwise. Failure
148
+ modes:
149
+ - No Bearer token cached (extension hasn't pushed one yet)
150
+ - HTTP 4xx (token expired / revoked)
151
+ - HTTP 5xx / network (transient — caller can retry)
152
+ - Response missing `userPaygateTier` (Flow API contract change)
153
+
154
+ IMPORTANT: never log the Bearer token. The error path captures
155
+ only the HTTP status / response shape, never headers.
156
+ """
157
+ if not self._flow_key:
158
+ return False
159
+ try:
160
+ async with httpx.AsyncClient(timeout=10.0) as client:
161
+ resp = await client.get(
162
+ _FLOW_CREDITS_URL,
163
+ params={"key": _FLOW_API_KEY},
164
+ headers={
165
+ "authorization": f"Bearer {self._flow_key}",
166
+ "origin": "https://labs.google",
167
+ "referer": "https://labs.google/",
168
+ },
169
+ )
170
+ except httpx.HTTPError as exc:
171
+ logger.warning("fetch_paygate_tier transport error: %s", exc)
172
+ return False
173
+ if resp.status_code != 200:
174
+ logger.warning(
175
+ "fetch_paygate_tier returned HTTP %s (token may be expired)",
176
+ resp.status_code,
177
+ )
178
+ return False
179
+ try:
180
+ data = resp.json()
181
+ except Exception: # noqa: BLE001
182
+ logger.warning("fetch_paygate_tier: response was not JSON")
183
+ return False
184
+ tier = data.get("userPaygateTier")
185
+ if tier not in ("PAYGATE_TIER_ONE", "PAYGATE_TIER_TWO"):
186
+ logger.warning(
187
+ "fetch_paygate_tier: response missing userPaygateTier (got %r)",
188
+ tier,
189
+ )
190
+ return False
191
+ self._paygate_tier = tier
192
+ sku = data.get("sku")
193
+ if isinstance(sku, str):
194
+ self._sku = sku
195
+ credits_val = data.get("credits")
196
+ if isinstance(credits_val, int):
197
+ self._credits = credits_val
198
+ logger.info(
199
+ "fetch_paygate_tier resolved tier=%s sku=%s credits=%s",
200
+ tier, self._sku, self._credits,
201
+ )
202
+ return True
203
+
204
+ # ── inbound handling ───────────────────────────────────────────────────
205
+ async def handle_message(self, data: dict) -> None:
206
+ t = data.get("type")
207
+ if t == "extension_ready":
208
+ self._flow_key_present = bool(data.get("flowKeyPresent"))
209
+ logger.info("extension_ready flowKeyPresent=%s", self._flow_key_present)
210
+ return
211
+ if t == "token_captured":
212
+ self._flow_key_present = True
213
+ self._token_captured_at = time.time()
214
+ flow_key = data.get("flowKey")
215
+ if isinstance(flow_key, str) and flow_key:
216
+ key_changed = flow_key != self._flow_key
217
+ self._flow_key = flow_key
218
+ # Defensive dedupe — see _last_tier_fetch_at field comment.
219
+ # Skip the log + credits refetch when the token hasn't
220
+ # rotated and we already fetched within the rate-limit
221
+ # window. Without this, an older extension re-sending the
222
+ # same token on every poll trips one /v1/credits per poll.
223
+ now = time.time()
224
+ last = self._last_tier_fetch_at or 0.0
225
+ if key_changed or (now - last) > _TIER_REFRESH_MIN_INTERVAL_S:
226
+ if flow_key != self._last_logged_key:
227
+ logger.info("token_captured (len=%d)", len(flow_key))
228
+ self._last_logged_key = flow_key
229
+ self._last_tier_fetch_at = now
230
+ # Authoritative tier resolution — fetch /v1/credits in
231
+ # the background so the AccountPanel sees a real tier
232
+ # within an HTTP RTT instead of waiting for the user's
233
+ # Flow tab to emit a request the passive sniffer can
234
+ # see. Don't await: WS handler must stay responsive.
235
+ asyncio.create_task(self.fetch_paygate_tier())
236
+ return
237
+ if t == "user_info":
238
+ info = data.get("userInfo")
239
+ if isinstance(info, dict):
240
+ # Whitelist on intake — Google's userinfo response can
241
+ # carry id / locale / hd / given_name / family_name etc.
242
+ # The /api/auth/me route filters on output, but caching
243
+ # the full dict here means any future surface that
244
+ # returns flow_client.user_info directly leaks PII.
245
+ # Clamp at the door instead.
246
+ allowed = ("email", "name", "picture", "verified_email")
247
+ self._user_info = {k: info[k] for k in allowed if k in info}
248
+ logger.info(
249
+ "user_info captured for %s",
250
+ self._user_info.get("email") or "<no email>",
251
+ )
252
+ return
253
+ if t == "pong":
254
+ return
255
+ # Inbound response (legacy path; production flow uses HTTP callback)
256
+ req_id = data.get("id")
257
+ if req_id and req_id in self._pending:
258
+ self._resolve(req_id, data)
259
+
260
+ def resolve_callback(self, data: dict) -> bool:
261
+ """Called by the HTTP callback endpoint after validating the secret.
262
+
263
+ Returns True if a pending future matched.
264
+ """
265
+ req_id = data.get("id")
266
+ if not req_id or req_id not in self._pending:
267
+ return False
268
+ self._resolve(req_id, data)
269
+ return True
270
+
271
+ def _resolve(self, req_id: str, data: dict) -> None:
272
+ fut = self._pending.pop(req_id, None)
273
+ if not fut or fut.done():
274
+ return
275
+ # Count as failure if (a) an explicit `error` field is set OR
276
+ # (b) the HTTP status is a 4xx/5xx. Otherwise success.
277
+ status = data.get("status")
278
+ http_error = isinstance(status, int) and status >= 400
279
+ explicit_error = bool(data.get("error"))
280
+ if http_error or explicit_error:
281
+ self._failed_count += 1
282
+ msg = data.get("error") or f"API_{status}"
283
+ self._last_error = str(msg)[:200]
284
+ fut.set_result(data)
285
+ else:
286
+ self._success_count += 1
287
+ fut.set_result(data)
288
+
289
+ # ── outbound ──────────────────────────────────────────────────────────
290
+ async def notify(self, message: dict) -> bool:
291
+ """Fire-and-forget WS push to the extension. Returns False when the
292
+ extension isn't connected so callers can surface a meaningful
293
+ diagnostic instead of silently losing the message.
294
+
295
+ Used by the logout flow (tell extension to clear its in-memory
296
+ token + cached userinfo) and the scan flow (ask extension to
297
+ re-fetch userinfo when the agent has a connection but the cache
298
+ is empty).
299
+ """
300
+ if not self.connected or self._ws is None:
301
+ return False
302
+ try:
303
+ await self._ws.send(json.dumps(message))
304
+ return True
305
+ except Exception as exc: # noqa: BLE001
306
+ logger.warning("notify failed: %s", exc)
307
+ return False
308
+
309
+ async def _send(self, method: str, params: dict, timeout: Optional[float] = None) -> dict:
310
+ if not self.connected:
311
+ return {"error": "extension_disconnected"}
312
+
313
+ # Acquire the per-instance concurrency semaphore before sending.
314
+ # Excess callers queue here — no 503, just back-pressure.
315
+ async with self._semaphore:
316
+ self._on_send_start()
317
+ try:
318
+ return await self._send_inner(method, params, timeout)
319
+ finally:
320
+ self._on_send_end()
321
+
322
+ async def _send_inner(
323
+ self, method: str, params: dict, timeout: Optional[float] = None
324
+ ) -> dict:
325
+ req_id = str(uuid.uuid4())
326
+ fut: asyncio.Future = asyncio.get_running_loop().create_future()
327
+ self._pending[req_id] = fut
328
+ self._request_count += 1
329
+
330
+ payload = {"id": req_id, "method": method, "params": params}
331
+ try:
332
+ await self._ws.send(json.dumps(payload))
333
+ return await asyncio.wait_for(fut, timeout=timeout or self.DEFAULT_TIMEOUT)
334
+ except asyncio.TimeoutError:
335
+ self._pending.pop(req_id, None)
336
+ self._failed_count += 1
337
+ self._last_error = "timeout"
338
+ return {"error": "timeout"}
339
+ except ConnectionError as exc:
340
+ self._pending.pop(req_id, None)
341
+ self._failed_count += 1
342
+ self._last_error = str(exc)
343
+ return {"error": str(exc)}
344
+ except Exception as exc: # noqa: BLE001
345
+ self._pending.pop(req_id, None)
346
+ self._failed_count += 1
347
+ self._last_error = str(exc)
348
+ return {"error": str(exc)}
349
+
350
+ async def api_request(
351
+ self,
352
+ url: str,
353
+ method: str = "POST",
354
+ headers: Optional[dict] = None,
355
+ body: Any = None,
356
+ captcha_action: Optional[str] = None,
357
+ timeout: Optional[float] = None,
358
+ ) -> dict:
359
+ """Proxy an HTTP call against aisandbox-pa.googleapis.com through the
360
+ extension's browser session. If ``captcha_action`` is set, the
361
+ extension solves reCAPTCHA on an active Flow tab before firing the
362
+ fetch and injects the token into the body's recaptchaContext fields.
363
+ """
364
+ params: dict[str, Any] = {
365
+ "url": url,
366
+ "method": method,
367
+ "headers": headers or {},
368
+ "body": body,
369
+ }
370
+ if captcha_action:
371
+ params["captchaAction"] = captcha_action
372
+ return await self._send("api_request", params, timeout=timeout)
373
+
374
+ async def trpc_request(
375
+ self,
376
+ url: str,
377
+ method: str = "POST",
378
+ headers: Optional[dict] = None,
379
+ body: Any = None,
380
+ timeout: Optional[float] = 30.0,
381
+ ) -> dict:
382
+ """Proxy a TRPC call against labs.google through the extension.
383
+
384
+ No captcha; just Bearer auth passthrough on a `credentials: include`
385
+ fetch. Used for metadata calls like ``project.createProject``.
386
+ """
387
+ return await self._send(
388
+ "trpc_request",
389
+ {"url": url, "method": method, "headers": headers or {}, "body": body},
390
+ timeout=timeout,
391
+ )
392
+
393
+ async def upload_video(
394
+ self,
395
+ project_id: str,
396
+ file_name: str,
397
+ content_type: str,
398
+ data_base64: str,
399
+ timeout: Optional[float] = None,
400
+ ) -> dict:
401
+ """Run a resumable video upload through the extension's browser session.
402
+
403
+ The extension performs the two-step handshake (zero-body POST to start
404
+ the session, then a binary PUT of the bytes) against
405
+ labs.google/fx/api/upload-video with the user's cookies, and returns
406
+ Flow's final ``{mediaServerId, ...}`` payload as ``data``. Video bytes
407
+ are passed as base64 over the WS since JSON can't carry binary.
408
+ """
409
+ return await self._send(
410
+ "upload_video",
411
+ {
412
+ "projectId": project_id,
413
+ "fileName": file_name,
414
+ "contentType": content_type,
415
+ "dataBase64": data_base64,
416
+ },
417
+ # Resumable upload of a multi-MB clip can take a while; give it the
418
+ # full default budget rather than the short TRPC timeout.
419
+ timeout=timeout or self.DEFAULT_TIMEOUT,
420
+ )
421
+
422
+ # ── observability ─────────────────────────────────────────────────────
423
+ @property
424
+ def ws_stats(self) -> dict:
425
+ token_age = (
426
+ int(time.time() - self._token_captured_at)
427
+ if self._token_captured_at is not None
428
+ else None
429
+ )
430
+ return {
431
+ "connected": self.connected,
432
+ "flow_key_present": self._flow_key_present,
433
+ "token_age_s": token_age,
434
+ "pending": len(self._pending),
435
+ "request_count": self._request_count,
436
+ "success_count": self._success_count,
437
+ "failed_count": self._failed_count,
438
+ "last_error": self._last_error,
439
+ }
440
+
441
+
442
+ # The module-level singleton has been removed. FlowClient instances are now
443
+ # managed exclusively by pool.PoolRegistry (one per registered account_id).
444
+ # Import `pool` from flow_engine.pool and call pool.get_by_api_key() instead.