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.
- flow_engine/__init__.py +4 -0
- flow_engine/bridge/__init__.py +6 -0
- flow_engine/bridge/flow_client.py +444 -0
- flow_engine/bridge/flow_sdk.py +1743 -0
- flow_engine/bridge/ws_server.py +84 -0
- flow_engine/catalog.py +332 -0
- flow_engine/cli/__init__.py +1 -0
- flow_engine/cli/app.py +149 -0
- flow_engine/cli/config.py +81 -0
- flow_engine/cli/dashboard.py +125 -0
- flow_engine/cli/health.py +40 -0
- flow_engine/cli/processes.py +109 -0
- flow_engine/cli/supervisor.py +184 -0
- flow_engine/config.py +43 -0
- flow_engine/ingest.py +172 -0
- flow_engine/job_store.py +28 -0
- flow_engine/jobs.py +245 -0
- flow_engine/main.py +187 -0
- flow_engine/media.py +227 -0
- flow_engine/media_store.py +202 -0
- flow_engine/openai/__init__.py +1 -0
- flow_engine/openai/_util.py +59 -0
- flow_engine/openai/images.py +134 -0
- flow_engine/openai/models.py +38 -0
- flow_engine/openai/uploads.py +70 -0
- flow_engine/openai/videos.py +289 -0
- flow_engine/pool.py +138 -0
- flow_engine/posthog_client.py +117 -0
- flow_engine/session.py +89 -0
- flow_engine/video_context.py +77 -0
- flow_engine/video_context_store.py +49 -0
- vrex_flow_engine-0.2.0.dist-info/METADATA +62 -0
- vrex_flow_engine-0.2.0.dist-info/RECORD +36 -0
- vrex_flow_engine-0.2.0.dist-info/WHEEL +5 -0
- vrex_flow_engine-0.2.0.dist-info/entry_points.txt +3 -0
- vrex_flow_engine-0.2.0.dist-info/top_level.txt +1 -0
|
@@ -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}")
|