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,289 @@
1
+ """Video generation — OpenAI-style job + poll, dynamic model catalog.
2
+
3
+ POST /v1/videos/generations → create job, return {id, status:"in_progress"}
4
+ GET /v1/videos/{id} → {id, status, data:[{url}], ...}
5
+
6
+ Flow video is asynchronous with no webhook, so flow_engine dispatches + polls in
7
+ a background task and the client polls us.
8
+
9
+ Only ``veo_3_1_lite_low_priority`` is accepted; any other ``model`` value
10
+ returns HTTP 400.
11
+
12
+ The generation MODE is inferred from the inputs you provide:
13
+ - ``ref_media_ids`` / ``ref_images`` → reference-to-video (r2v, "Ingredients")
14
+ - ``start_media_ids`` / ``start_images`` → image-to-video (i2v, "Frames")
15
+ - neither → text-to-video (t2v, prompt only)
16
+ Optional ``reference_audio`` (voice ids from GET /v1/audio/voices) and
17
+ ``seconds`` (4/6/8 where the family supports it) refine the request.
18
+ """
19
+ from __future__ import annotations
20
+
21
+ from typing import Any, Optional
22
+
23
+ from fastapi import APIRouter, Depends, HTTPException, Request
24
+ from pydantic import BaseModel
25
+
26
+ from flow_engine import ingest, jobs
27
+ from flow_engine.catalog import catalog
28
+ from flow_engine.openai._util import media_url, require_api_key, video_aspect_from_size
29
+ from flow_engine.posthog_client import capture_engine, capture_exception, elapsed_ms, start_time
30
+ from flow_engine.session import ensure_catalog, get_default_project, get_sdk, require_ready
31
+
32
+ router = APIRouter(prefix="/v1/videos", tags=["videos"])
33
+
34
+ # The only accepted video model family. Any other value → HTTP 400.
35
+ ALLOWED_VIDEO_MODEL = "veo_3_1_lite_low_priority"
36
+
37
+
38
+ class VideoRequest(BaseModel):
39
+ prompt: str
40
+ model: str = ALLOWED_VIDEO_MODEL
41
+ size: Optional[str] = None
42
+ seconds: Optional[int] = 8
43
+ # inputs — presence selects the generation mode
44
+ ref_media_ids: Optional[list[str]] = None # → r2v (Ingredients), pre-uploaded
45
+ start_media_ids: Optional[list[str]] = None # → i2v (Frames), pre-uploaded
46
+ ref_images: Optional[list[str]] = None # → r2v, raw (uploaded for you)
47
+ start_images: Optional[list[str]] = None # → i2v, raw (uploaded for you)
48
+ reference_audio: Optional[list[str]] = None # voice ids (r2v)
49
+ project: Optional[str] = None
50
+ # extend / edit — operate on a previously generated Veo video
51
+ mode: Optional[str] = None # "extend" | "edit" (else inferred)
52
+ source_media_id: Optional[str] = None # the clip to extend/edit
53
+ # explicit context — only needed when the source isn't a flow_engine-generated
54
+ # video tracked this run (the registry fills these in automatically).
55
+ workflow_id: Optional[str] = None # extend+edit: source's workflow
56
+ scene_id: Optional[str] = None # extend: override resolved scene
57
+ position: Optional[int] = None # extend: override timeline slot
58
+ source_seconds: Optional[int] = None # edit: source clip length (→ frames)
59
+
60
+
61
+ # Flow video runs at 30fps; edit selects a frame slice of the source clip.
62
+ _VIDEO_FPS = 30
63
+
64
+
65
+ async def _extend_kwargs(
66
+ sdk: Any,
67
+ req: "VideoRequest",
68
+ project_id: str,
69
+ tier: str,
70
+ aspect: str,
71
+ model_key: str,
72
+ vc_store: Any,
73
+ ) -> dict[str, Any]:
74
+ """Build gen_video_extend kwargs."""
75
+ if not req.source_media_id:
76
+ raise HTTPException(status_code=400, detail="source_media_id is required for extend")
77
+ ctx = vc_store.get(req.source_media_id)
78
+ workflow_id = req.workflow_id or (ctx.workflow_id if ctx else None)
79
+ if not workflow_id:
80
+ raise HTTPException(
81
+ status_code=400,
82
+ detail="workflow_id unknown for this source — pass workflow_id explicitly "
83
+ "(only flow_engine-generated videos from this run are auto-resolved)",
84
+ )
85
+ scene_id = req.scene_id
86
+ position = req.position
87
+ if not scene_id:
88
+ scene = await sdk.resolve_scene(project_id, [workflow_id])
89
+ if scene.get("error"):
90
+ raise HTTPException(
91
+ status_code=502,
92
+ detail=f"could not resolve scene for extend: {scene['error']}",
93
+ )
94
+ scene_id = scene["scene_id"]
95
+ if position is None:
96
+ position = scene["position"]
97
+ source_ref = ctx.operation_id if (ctx and ctx.operation_id) else req.source_media_id
98
+ return dict(
99
+ prompt=req.prompt.strip(),
100
+ project_id=project_id,
101
+ source_media_id=source_ref,
102
+ scene_id=scene_id,
103
+ position=position if position is not None else 1,
104
+ aspect_ratio=aspect,
105
+ paygate_tier=tier,
106
+ model_key=model_key,
107
+ )
108
+
109
+
110
+ def _edit_kwargs(
111
+ req: "VideoRequest",
112
+ project_id: str,
113
+ tier: str,
114
+ aspect: str,
115
+ model_key: str,
116
+ vc_store: Any,
117
+ ) -> dict[str, Any]:
118
+ """Build gen_video_edit kwargs."""
119
+ if not req.source_media_id:
120
+ raise HTTPException(status_code=400, detail="source_media_id is required for edit")
121
+ ctx = vc_store.get(req.source_media_id)
122
+ workflow_id = req.workflow_id or (ctx.workflow_id if ctx else None)
123
+ if not workflow_id:
124
+ raise HTTPException(
125
+ status_code=400,
126
+ detail="workflow_id unknown for this source — pass workflow_id explicitly "
127
+ "(only flow_engine-generated videos from this run are auto-resolved)",
128
+ )
129
+ duration = req.source_seconds or (ctx.duration_sec if ctx else None) or 8
130
+ end_frame = int(round(float(duration) * _VIDEO_FPS))
131
+ return dict(
132
+ prompt=req.prompt.strip(),
133
+ project_id=project_id,
134
+ source_media_id=req.source_media_id,
135
+ workflow_id=workflow_id,
136
+ end_frame_index=end_frame,
137
+ aspect_ratio=aspect,
138
+ paygate_tier=tier,
139
+ model_key=model_key,
140
+ )
141
+
142
+
143
+ def _job_view(job: jobs.Job) -> dict[str, Any]:
144
+ out: dict[str, Any] = {
145
+ "id": job.id,
146
+ "object": "video.generation",
147
+ "status": job.status,
148
+ "created_at": job.created_at,
149
+ }
150
+ if job.status == "completed":
151
+ out["data"] = [
152
+ {"url": media_url(mid)} for mid in job.media_ids if isinstance(mid, str)
153
+ ]
154
+ if job.partial_error:
155
+ out["partial_error"] = job.partial_error
156
+ out["slot_errors"] = job.slot_errors
157
+ elif job.status == "failed":
158
+ out["error"] = {"message": job.error, "slot_errors": job.slot_errors}
159
+ return out
160
+
161
+
162
+ @router.post("/generations")
163
+ async def create_video(
164
+ req: VideoRequest,
165
+ request: Request,
166
+ api_key: Optional[str] = Depends(require_api_key),
167
+ ) -> dict[str, Any]:
168
+ if not req.prompt or not req.prompt.strip():
169
+ raise HTTPException(status_code=400, detail="prompt is required")
170
+
171
+ # Enforce pinned model — reject any other family id.
172
+ if req.model != ALLOWED_VIDEO_MODEL:
173
+ raise HTTPException(
174
+ status_code=400,
175
+ detail=f"only model={ALLOWED_VIDEO_MODEL!r} is supported",
176
+ )
177
+
178
+ t0 = start_time()
179
+ inst, tier = require_ready(api_key)
180
+ await ensure_catalog(api_key)
181
+ project_id = req.project or await get_default_project(api_key)
182
+ sdk = get_sdk(api_key)
183
+ aspect = video_aspect_from_size(req.size)
184
+
185
+ ref_ids = list(req.ref_media_ids or [])
186
+ ref_ids += await ingest.resolve_image_inputs(sdk, project_id, req.ref_images)
187
+ start_ids = list(req.start_media_ids or [])
188
+ start_ids += await ingest.resolve_image_inputs(sdk, project_id, req.start_images)
189
+
190
+ explicit = (req.mode or "").lower().strip()
191
+ if explicit in ("extend", "extension"):
192
+ mode = "extension"
193
+ elif explicit == "edit":
194
+ mode = "edit"
195
+ elif ref_ids:
196
+ mode = "r2v"
197
+ elif start_ids:
198
+ mode = "i2v"
199
+ else:
200
+ mode = "t2v"
201
+
202
+ resolved = catalog.resolve_video(req.model, mode, aspect, req.seconds)
203
+ if resolved.get("error"):
204
+ raise HTTPException(status_code=400, detail=resolved["error"])
205
+ model_key = resolved["key"]
206
+
207
+ if mode == "r2v":
208
+ kwargs: dict[str, Any] = dict(
209
+ prompt=req.prompt.strip(),
210
+ project_id=project_id,
211
+ ref_media_ids=ref_ids,
212
+ aspect_ratio=aspect,
213
+ paygate_tier=tier,
214
+ reference_audio_ids=req.reference_audio,
215
+ model_key=model_key,
216
+ )
217
+ elif mode == "i2v":
218
+ kwargs = dict(
219
+ prompt=req.prompt.strip(),
220
+ project_id=project_id,
221
+ start_media_ids=start_ids,
222
+ aspect_ratio=aspect,
223
+ paygate_tier=tier,
224
+ model_key=model_key,
225
+ )
226
+ elif mode == "extension":
227
+ kwargs = await _extend_kwargs(
228
+ sdk, req, project_id, tier, aspect, model_key, inst.video_ctx
229
+ )
230
+ elif mode == "edit":
231
+ kwargs = _edit_kwargs(
232
+ req, project_id, tier, aspect, model_key, inst.video_ctx
233
+ )
234
+ else: # t2v
235
+ kwargs = dict(
236
+ prompt=req.prompt.strip(),
237
+ project_id=project_id,
238
+ aspect_ratio=aspect,
239
+ paygate_tier=tier,
240
+ model_key=model_key,
241
+ )
242
+
243
+ try:
244
+ job = jobs.start_video_job(
245
+ sdk,
246
+ mode=mode,
247
+ dispatch_kwargs=kwargs,
248
+ job_store=inst.jobs,
249
+ media_store=inst.media,
250
+ vc_store=inst.video_ctx,
251
+ )
252
+ except Exception as exc:
253
+ capture_exception(request, exc, {"latency_ms": elapsed_ms(t0), "mode": mode})
254
+ raise
255
+
256
+ capture_engine(request, "engine_request_started", {
257
+ "model": req.model,
258
+ "mode": mode,
259
+ "seconds": req.seconds,
260
+ "dispatch_ms": elapsed_ms(t0),
261
+ })
262
+ view = _job_view(job)
263
+ view["model"] = req.model
264
+ view["model_key"] = model_key
265
+ view["mode"] = mode
266
+ return view
267
+
268
+
269
+ @router.get("/{job_id}")
270
+ async def get_video(
271
+ job_id: str, api_key: Optional[str] = Depends(require_api_key)
272
+ ) -> dict[str, Any]:
273
+ inst, _ = require_ready(api_key)
274
+ job = jobs.get(job_id, inst.jobs)
275
+ if job is None:
276
+ raise HTTPException(status_code=404, detail="job not found")
277
+ return _job_view(job)
278
+
279
+
280
+ @router.get("/{job_id}/debug")
281
+ async def get_video_debug(
282
+ job_id: str, api_key: Optional[str] = Depends(require_api_key)
283
+ ) -> dict[str, Any]:
284
+ """Full Flow dispatch + poll payload for a job — for diagnosing failures."""
285
+ inst, _ = require_ready(api_key)
286
+ job = jobs.get(job_id, inst.jobs)
287
+ if job is None:
288
+ raise HTTPException(status_code=404, detail="job not found")
289
+ return {"id": job.id, "status": job.status, "error": job.error, "raw": job.raw}
flow_engine/pool.py ADDED
@@ -0,0 +1,138 @@
1
+ """Multi-tenant pool of FlowClient instances, keyed by account_id.
2
+
3
+ Each PoolInstance owns:
4
+ - a FlowClient (one WS connection to a Chrome extension profile)
5
+ - an asyncio.Semaphore(POOL_MAX_CONCURRENCY) — back-pressure, not rejection
6
+ - an in_flight counter (avoids relying on the private Semaphore._value)
7
+ - isolated JobStore / MediaStore / VideoContextStore
8
+
9
+ PoolRegistry routes requests by api_key → account_id. When no explicit
10
+ mapping is present it returns the least-busy instance (lowest in_flight).
11
+ New instances are registered on first WS connect (ws_server._handler calls
12
+ pool.register). The pool is a module-level singleton; import `pool` from here.
13
+ """
14
+ from __future__ import annotations
15
+
16
+ import asyncio
17
+ from dataclasses import dataclass, field
18
+ from typing import Optional
19
+
20
+ from flow_engine.bridge.flow_client import FlowClient
21
+ from flow_engine.config import POOL_MAX_CONCURRENCY
22
+ from flow_engine.job_store import JobStore
23
+ from flow_engine.media_store import MediaStore
24
+ from flow_engine.video_context_store import VideoContextStore
25
+
26
+
27
+ def _inc(inst: "PoolInstance") -> None:
28
+ inst.in_flight += 1
29
+
30
+
31
+ def _dec(inst: "PoolInstance") -> None:
32
+ if inst.in_flight > 0:
33
+ inst.in_flight -= 1
34
+
35
+
36
+ @dataclass
37
+ class PoolInstance:
38
+ account_id: str
39
+ client: FlowClient
40
+ # Semaphore limits concurrent in-flight WS calls; callers queue, not 503.
41
+ semaphore: asyncio.Semaphore = field(
42
+ default_factory=lambda: asyncio.Semaphore(POOL_MAX_CONCURRENCY)
43
+ )
44
+ # Explicit counter — we never read the private Semaphore._value.
45
+ in_flight: int = 0
46
+ jobs: JobStore = field(default_factory=JobStore)
47
+ # MediaStore needs account_id for per-account disk subdir; set post-init.
48
+ media: MediaStore = field(default_factory=MediaStore)
49
+ video_ctx: VideoContextStore = field(default_factory=VideoContextStore)
50
+ # Per-instance default project (replaces the session.py module globals).
51
+ _default_project_id: Optional[str] = None
52
+ _project_lock: asyncio.Lock = field(default_factory=asyncio.Lock)
53
+
54
+ def __post_init__(self) -> None:
55
+ # Re-create the MediaStore with the real account_id subdir now that
56
+ # we have it (the default_factory above can't capture self.account_id).
57
+ self.media = MediaStore(self.account_id)
58
+
59
+
60
+ class PoolRegistry:
61
+ """Thread-safe registry of PoolInstance objects.
62
+
63
+ Supports:
64
+ - register(account_id) — called by ws_server on WS connect
65
+ - get_by_api_key(key) — called by session helpers on every request
66
+ - resolve_callback(data) — routes HTTP callbacks to the right instance
67
+ - status() — admin view of all instances
68
+ """
69
+
70
+ def __init__(self) -> None:
71
+ self._instances: dict[str, PoolInstance] = {} # account_id → instance
72
+ self._api_key_map: dict[str, str] = {} # api_key → account_id
73
+
74
+ def register(self, account_id: str) -> PoolInstance:
75
+ """Return (or create) the PoolInstance for account_id.
76
+
77
+ Idempotent: reconnecting the same account reuses the existing instance
78
+ so its JobStore / MediaStore / VideoContextStore survive reconnects.
79
+ The FlowClient's in_flight callbacks are (re-)attached each time so
80
+ the counter always points at the live PoolInstance.
81
+ """
82
+ if account_id not in self._instances:
83
+ client = FlowClient()
84
+ inst = PoolInstance(
85
+ account_id=account_id,
86
+ client=client,
87
+ semaphore=asyncio.Semaphore(POOL_MAX_CONCURRENCY),
88
+ )
89
+ # Wire in_flight callbacks — increment before send, decrement after.
90
+ inst.client._on_send_start = lambda: _inc(inst)
91
+ inst.client._on_send_end = lambda: _dec(inst)
92
+ self._instances[account_id] = inst
93
+ return self._instances[account_id]
94
+
95
+ def bind_api_key(self, api_key: str, account_id: str) -> None:
96
+ """Associate a caller api_key with an account_id for routing."""
97
+ self._api_key_map[api_key] = account_id
98
+
99
+ def get_by_api_key(self, api_key: Optional[str]) -> Optional[PoolInstance]:
100
+ """Return the instance for the given api_key, or the least-busy
101
+ instance as a fallback when only one account is registered."""
102
+ if api_key and api_key in self._api_key_map:
103
+ acct = self._api_key_map[api_key]
104
+ return self._instances.get(acct)
105
+ # Single-account fallback: pick the instance with the fewest in-flight
106
+ # calls so single-tenant setups work without any key→account mapping.
107
+ if self._instances:
108
+ return min(self._instances.values(), key=lambda i: i.in_flight)
109
+ return None
110
+
111
+ def resolve_callback(self, data: dict) -> bool:
112
+ """Route an HTTP callback to the correct instance's pending dict.
113
+
114
+ Linear scan over all instances — bounded by N accounts (expected 1–5).
115
+ Returns True when a pending future matched.
116
+ """
117
+ for inst in self._instances.values():
118
+ if inst.client.resolve_callback(data):
119
+ return True
120
+ return False
121
+
122
+ def status(self) -> list[dict]:
123
+ return [
124
+ {
125
+ "account_id": inst.account_id,
126
+ "connected": inst.client.connected,
127
+ "paygate_tier": inst.client.paygate_tier,
128
+ "sku": inst.client.sku,
129
+ "credits": inst.client.credits,
130
+ "in_flight": inst.in_flight,
131
+ "capacity": POOL_MAX_CONCURRENCY,
132
+ }
133
+ for inst in self._instances.values()
134
+ ]
135
+
136
+
137
+ # Module-level singleton — import this in ws_server, session, main.
138
+ pool = PoolRegistry()
@@ -0,0 +1,117 @@
1
+ """
2
+ PostHog client for the flow-engine (image + video generation).
3
+
4
+ Initialised from POSTHOG_API_KEY + POSTHOG_HOST env vars.
5
+ When the key is unset (dev / tests), all methods are silent no-ops so engine
6
+ behaviour is never affected.
7
+
8
+ Usage:
9
+ from flow_engine.posthog_client import capture_engine, flush_posthog
10
+
11
+ capture_engine(request, "engine_request_started", {"n": 1})
12
+ capture_engine(request, "engine_request_completed", {"latency_ms": 4500})
13
+ capture_exception(request, exc)
14
+ """
15
+ from __future__ import annotations
16
+
17
+ import logging
18
+ import os
19
+ import time
20
+ from typing import Any, Optional
21
+
22
+ logger = logging.getLogger(__name__)
23
+
24
+ # Header names must match the Node-side constants in posthog-correlation.ts
25
+ _DISTINCT_ID_HEADER = "x-posthog-distinct-id"
26
+ _RUN_ID_HEADER = "x-posthog-run-id"
27
+
28
+ _ENGINE_NAME = "flow"
29
+
30
+ # ---------------------------------------------------------------------------
31
+ # Client init — no-op shim when key is absent
32
+ # ---------------------------------------------------------------------------
33
+
34
+ _api_key = os.environ.get("POSTHOG_API_KEY", "")
35
+ _host = os.environ.get("POSTHOG_HOST", "https://eu.i.posthog.com")
36
+
37
+ _ph: Any = None # real PostHog client or None (no-op path)
38
+
39
+ if _api_key:
40
+ try:
41
+ from posthog import Posthog # type: ignore[import-untyped]
42
+ _ph = Posthog(api_key=_api_key, host=_host, sync_mode=False)
43
+ logger.info("PostHog client initialised (host=%s)", _host)
44
+ except Exception as exc: # noqa: BLE001
45
+ logger.warning("PostHog init failed (no-op): %s", exc)
46
+
47
+
48
+ # ---------------------------------------------------------------------------
49
+ # Public helpers
50
+ # ---------------------------------------------------------------------------
51
+
52
+
53
+ def capture_engine(
54
+ request: Any,
55
+ event: str,
56
+ properties: Optional[dict[str, Any]] = None,
57
+ ) -> None:
58
+ """Capture an engine lifecycle event, tagged with correlation ids from headers.
59
+
60
+ Reads X-PostHog-Distinct-Id / X-PostHog-Run-Id from ``request.headers``.
61
+ Silent no-op when PostHog is disabled.
62
+ """
63
+ if _ph is None:
64
+ return
65
+ try:
66
+ distinct_id: str = request.headers.get(_DISTINCT_ID_HEADER, "anonymous") or "anonymous"
67
+ run_id: str = request.headers.get(_RUN_ID_HEADER, "") or ""
68
+
69
+ props: dict[str, Any] = {"engine": _ENGINE_NAME, **(properties or {})}
70
+ if run_id:
71
+ props["$run_id"] = run_id
72
+
73
+ kwargs: dict[str, Any] = {
74
+ "distinct_id": distinct_id,
75
+ "event": event,
76
+ "properties": props,
77
+ }
78
+ if run_id:
79
+ kwargs["groups"] = {"run": run_id}
80
+
81
+ _ph.capture(**kwargs)
82
+ except Exception as exc: # noqa: BLE001
83
+ logger.debug("PostHog capture error (non-fatal): %s", exc)
84
+
85
+
86
+ def capture_exception(
87
+ request: Any,
88
+ exc: Exception,
89
+ extra: Optional[dict[str, Any]] = None,
90
+ ) -> None:
91
+ """Capture an exception as an engine_request_error event."""
92
+ capture_engine(
93
+ request,
94
+ "engine_request_error",
95
+ {"error": str(exc), "error_type": type(exc).__name__, **(extra or {})},
96
+ )
97
+
98
+
99
+ def flush_posthog() -> None:
100
+ """Flush pending events — call on app shutdown so short container lifetimes
101
+ don't drop queued events."""
102
+ if _ph is None:
103
+ return
104
+ try:
105
+ _ph.flush()
106
+ except Exception as exc: # noqa: BLE001
107
+ logger.debug("PostHog flush error (non-fatal): %s", exc)
108
+
109
+
110
+ def start_time() -> float:
111
+ """Return a monotonic timestamp for latency measurement."""
112
+ return time.monotonic()
113
+
114
+
115
+ def elapsed_ms(t0: float) -> int:
116
+ """Milliseconds elapsed since ``t0`` (from start_time())."""
117
+ return int((time.monotonic() - t0) * 1000)
flow_engine/session.py ADDED
@@ -0,0 +1,89 @@
1
+ """Session resolution + readiness gating — routes via pool.
2
+
3
+ Every request resolves to a PoolInstance via pool.get_by_api_key(). Per-instance
4
+ state (default_project_id, job_store, media_store, video_ctx) lives on the
5
+ instance so there is no shared mutable state between accounts.
6
+ """
7
+ from __future__ import annotations
8
+
9
+ import logging
10
+ from typing import Optional
11
+
12
+ from fastapi import HTTPException
13
+
14
+ from flow_engine.bridge.flow_sdk import FlowSDK
15
+ from flow_engine.catalog import catalog
16
+ from flow_engine.config import DEFAULT_PROJECT_TITLE
17
+ from flow_engine.pool import PoolInstance, pool
18
+
19
+ logger = logging.getLogger(__name__)
20
+
21
+
22
+ def _get_instance(api_key: Optional[str]) -> Optional[PoolInstance]:
23
+ return pool.get_by_api_key(api_key)
24
+
25
+
26
+ def get_bridge(api_key: Optional[str] = None):
27
+ """Resolve the FlowClient for this request via pool lookup."""
28
+ inst = _get_instance(api_key)
29
+ return inst.client if inst else None
30
+
31
+
32
+ def get_sdk(api_key: Optional[str] = None) -> FlowSDK:
33
+ """A FlowSDK bound to the resolved bridge. FlowSDK is stateless, so a
34
+ fresh instance per call is cheap and keeps us pool-ready."""
35
+ return FlowSDK(get_bridge(api_key))
36
+
37
+
38
+ def require_ready(api_key: Optional[str] = None) -> tuple[PoolInstance, str]:
39
+ """Gate a generation request. Raises 503 unless the extension is connected
40
+ AND the paygate tier has been resolved.
41
+
42
+ Returns ``(instance, paygate_tier)``. Callers use instance.jobs /
43
+ instance.media / instance.video_ctx for per-tenant isolation.
44
+ """
45
+ inst = _get_instance(api_key)
46
+ if not inst or not inst.client.connected:
47
+ raise HTTPException(
48
+ status_code=503,
49
+ detail="extension_not_connected — open Chrome with the flow_engine "
50
+ "extension installed and signed in to labs.google",
51
+ )
52
+ tier = inst.client.paygate_tier
53
+ if tier is None:
54
+ raise HTTPException(
55
+ status_code=503,
56
+ detail="paygate_tier_unresolved — extension connected but no Flow "
57
+ "token captured yet; open a labs.google/fx/tools/flow tab",
58
+ )
59
+ return inst, tier
60
+
61
+
62
+ async def get_default_project(api_key: Optional[str] = None) -> str:
63
+ """Return a usable Flow project id for this instance, creating one on
64
+ first use. Per-instance lock prevents duplicate project creation."""
65
+ inst = _get_instance(api_key)
66
+ if not inst:
67
+ raise HTTPException(status_code=503, detail="no_pool_instance")
68
+ async with inst._project_lock:
69
+ if inst._default_project_id:
70
+ return inst._default_project_id
71
+ sdk = FlowSDK(inst.client)
72
+ result = await sdk.create_project(DEFAULT_PROJECT_TITLE)
73
+ pid = result.get("project_id")
74
+ if not pid:
75
+ raise HTTPException(
76
+ status_code=502,
77
+ detail=f"could not create Flow project: {result.get('error') or 'unknown'}",
78
+ )
79
+ inst._default_project_id = pid
80
+ logger.info("flow_engine default project created for %s: %s", inst.account_id, pid)
81
+ return pid
82
+
83
+
84
+ async def ensure_catalog(api_key: Optional[str] = None, *, force: bool = False) -> None:
85
+ """Load the dynamic model catalog (idempotent). Raises 503 on failure."""
86
+ project_id = await get_default_project(api_key)
87
+ err = await catalog.ensure_loaded(get_sdk(api_key), project_id, force=force)
88
+ if err:
89
+ raise HTTPException(status_code=502, detail=f"could not load model catalog: {err}")