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,84 @@
|
|
|
1
|
+
"""Standalone WebSocket server on :9223 for the Chrome extension bridge.
|
|
2
|
+
|
|
3
|
+
Kept separate from the FastAPI :8101 app to match flowkit's pattern — the
|
|
4
|
+
extension's `background.js` connects to `ws://127.0.0.1:9223` only.
|
|
5
|
+
|
|
6
|
+
Multi-instance protocol: the extension MUST send a ``register_instance``
|
|
7
|
+
message as its FIRST message before any other traffic. The server registers
|
|
8
|
+
(or retrieves) the PoolInstance for that account_id and binds the WS to it.
|
|
9
|
+
All subsequent messages are routed to that instance's FlowClient.
|
|
10
|
+
"""
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import asyncio
|
|
14
|
+
import json
|
|
15
|
+
import logging
|
|
16
|
+
|
|
17
|
+
import websockets
|
|
18
|
+
|
|
19
|
+
from flow_engine.config import EXTENSION_WS_PORT, WS_HOST
|
|
20
|
+
from flow_engine.pool import pool
|
|
21
|
+
|
|
22
|
+
logger = logging.getLogger(__name__)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
async def _handler(websocket) -> None:
|
|
26
|
+
# First message must be register_instance; close 1008 on protocol violation.
|
|
27
|
+
try:
|
|
28
|
+
raw = await asyncio.wait_for(websocket.recv(), timeout=10.0)
|
|
29
|
+
data = json.loads(raw)
|
|
30
|
+
except Exception:
|
|
31
|
+
await websocket.close(1008, "expected register_instance as first message")
|
|
32
|
+
return
|
|
33
|
+
|
|
34
|
+
if data.get("type") != "register_instance":
|
|
35
|
+
await websocket.close(1008, "first message must be register_instance")
|
|
36
|
+
return
|
|
37
|
+
|
|
38
|
+
account_id = data.get("accountId")
|
|
39
|
+
if not isinstance(account_id, str) or not account_id:
|
|
40
|
+
await websocket.close(1008, "accountId required in register_instance")
|
|
41
|
+
return
|
|
42
|
+
|
|
43
|
+
instance = pool.register(account_id)
|
|
44
|
+
instance.client.set_extension(websocket)
|
|
45
|
+
logger.info(
|
|
46
|
+
"instance %s connected from %s",
|
|
47
|
+
account_id,
|
|
48
|
+
getattr(websocket, "remote_address", "?"),
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
# Hand the extension the per-instance callback secret.
|
|
52
|
+
try:
|
|
53
|
+
await websocket.send(
|
|
54
|
+
json.dumps(
|
|
55
|
+
{"type": "callback_secret", "secret": instance.client.callback_secret}
|
|
56
|
+
)
|
|
57
|
+
)
|
|
58
|
+
except Exception: # noqa: BLE001
|
|
59
|
+
logger.exception("failed to send callback_secret to %s", account_id)
|
|
60
|
+
|
|
61
|
+
try:
|
|
62
|
+
async for raw in websocket:
|
|
63
|
+
try:
|
|
64
|
+
msg = json.loads(raw)
|
|
65
|
+
except json.JSONDecodeError:
|
|
66
|
+
logger.warning("invalid JSON from extension %s", account_id)
|
|
67
|
+
continue
|
|
68
|
+
try:
|
|
69
|
+
await instance.client.handle_message(msg)
|
|
70
|
+
except Exception: # noqa: BLE001
|
|
71
|
+
logger.exception("error handling message from %s", account_id)
|
|
72
|
+
except websockets.ConnectionClosed:
|
|
73
|
+
pass
|
|
74
|
+
finally:
|
|
75
|
+
instance.client.clear_extension()
|
|
76
|
+
logger.info("instance %s disconnected", account_id)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
async def run_ws_server() -> None:
|
|
80
|
+
async with websockets.serve(_handler, WS_HOST, EXTENSION_WS_PORT):
|
|
81
|
+
logger.info(
|
|
82
|
+
"WebSocket server listening on ws://%s:%d", WS_HOST, EXTENSION_WS_PORT
|
|
83
|
+
)
|
|
84
|
+
await asyncio.Future() # run forever
|
flow_engine/catalog.py
ADDED
|
@@ -0,0 +1,332 @@
|
|
|
1
|
+
"""Dynamic model catalog, sourced from Flow's ``flow.projectInitialData``.
|
|
2
|
+
|
|
3
|
+
Instead of hardcoding model keys (which Google rotates), flow_engine fetches the
|
|
4
|
+
live ``modelConfig`` once and resolves the exact ``videoModelKey`` for a given
|
|
5
|
+
(family, mode, aspect, duration) against the user's service tier — picking the
|
|
6
|
+
variant whose ``creditMapping[tier]`` is actually a number (available), not
|
|
7
|
+
``"UNAVAILABLE"``.
|
|
8
|
+
|
|
9
|
+
Mode is read from each usage's ``requirements`` (data-driven), not the key
|
|
10
|
+
string:
|
|
11
|
+
REFERENCES → r2v · END_IMAGE → interpolation · START_IMAGE → i2v ·
|
|
12
|
+
EXTENSION → extension · UPSAMPLE* → upsample · VIDEO_EDIT → edit · else t2v
|
|
13
|
+
|
|
14
|
+
Cached process-wide; call ``refresh()`` (or hit /v1/models?refresh=1) to reload.
|
|
15
|
+
"""
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import asyncio
|
|
19
|
+
import json
|
|
20
|
+
import logging
|
|
21
|
+
import time
|
|
22
|
+
from pathlib import Path
|
|
23
|
+
from typing import Any, Optional
|
|
24
|
+
|
|
25
|
+
from flow_engine.bridge.flow_sdk import (
|
|
26
|
+
VIDEO_EDIT_URL,
|
|
27
|
+
VIDEO_EXTEND_URL,
|
|
28
|
+
VIDEO_I2V_URL,
|
|
29
|
+
VIDEO_R2V_URL,
|
|
30
|
+
VIDEO_T2V_URL,
|
|
31
|
+
FlowSDK,
|
|
32
|
+
)
|
|
33
|
+
from flow_engine.config import CATALOG_TTL_S, STORAGE_DIR
|
|
34
|
+
|
|
35
|
+
logger = logging.getLogger(__name__)
|
|
36
|
+
|
|
37
|
+
_CACHE_FILE = STORAGE_DIR / "catalog.json"
|
|
38
|
+
|
|
39
|
+
# Endpoints we can actually dispatch today (body shapes verified). interpolation
|
|
40
|
+
# / upsample still need a captured request before we wire them — resolve()
|
|
41
|
+
# returns endpoint=None for those so callers 400 clearly.
|
|
42
|
+
_MODE_ENDPOINTS: dict[str, str] = {
|
|
43
|
+
"t2v": VIDEO_T2V_URL,
|
|
44
|
+
"i2v": VIDEO_I2V_URL,
|
|
45
|
+
"r2v": VIDEO_R2V_URL,
|
|
46
|
+
"extension": VIDEO_EXTEND_URL,
|
|
47
|
+
"edit": VIDEO_EDIT_URL,
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class _Catalog:
|
|
52
|
+
def __init__(self) -> None:
|
|
53
|
+
self._loaded_at: Optional[float] = None
|
|
54
|
+
self._raw_model_config: dict[str, Any] = {}
|
|
55
|
+
self._video_families: list[dict[str, Any]] = []
|
|
56
|
+
self._image_families: list[dict[str, Any]] = []
|
|
57
|
+
self._audio_voices: list[dict[str, Any]] = []
|
|
58
|
+
self._service_tier: Optional[str] = None
|
|
59
|
+
self._lock = asyncio.Lock()
|
|
60
|
+
|
|
61
|
+
@property
|
|
62
|
+
def loaded(self) -> bool:
|
|
63
|
+
return self._loaded_at is not None
|
|
64
|
+
|
|
65
|
+
def _fresh(self) -> bool:
|
|
66
|
+
return (
|
|
67
|
+
self._loaded_at is not None
|
|
68
|
+
and (time.time() - self._loaded_at) < CATALOG_TTL_S
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
@property
|
|
72
|
+
def service_tier(self) -> Optional[str]:
|
|
73
|
+
return self._service_tier
|
|
74
|
+
|
|
75
|
+
async def ensure_loaded(
|
|
76
|
+
self, sdk: FlowSDK, project_id: str, *, force: bool = False
|
|
77
|
+
) -> Optional[str]:
|
|
78
|
+
"""Load the catalog if the in-memory copy is missing/stale. Tries the
|
|
79
|
+
on-disk cache before hitting Flow's heavy projectInitialData call;
|
|
80
|
+
refreshes only once per ``CATALOG_TTL_S`` (default 1 day). Returns an
|
|
81
|
+
error string on failure, else None."""
|
|
82
|
+
if self._fresh() and not force:
|
|
83
|
+
return None
|
|
84
|
+
async with self._lock:
|
|
85
|
+
if self._fresh() and not force:
|
|
86
|
+
return None
|
|
87
|
+
# On-disk cache (survives restarts) — use if still within TTL.
|
|
88
|
+
if not force and self._load_from_disk():
|
|
89
|
+
return None
|
|
90
|
+
result = await sdk.get_project_initial_data(project_id)
|
|
91
|
+
if result.get("error"):
|
|
92
|
+
return str(result["error"])[:200]
|
|
93
|
+
self._apply(result.get("data") or {})
|
|
94
|
+
self._loaded_at = time.time()
|
|
95
|
+
self._save_to_disk()
|
|
96
|
+
logger.info(
|
|
97
|
+
"catalog fetched from Flow: %d video families, %d image "
|
|
98
|
+
"families, %d voices, tier=%s",
|
|
99
|
+
len(self._video_families), len(self._image_families),
|
|
100
|
+
len(self._audio_voices), self._service_tier,
|
|
101
|
+
)
|
|
102
|
+
return None
|
|
103
|
+
|
|
104
|
+
# Families exposed via /v1/models and accepted at dispatch time.
|
|
105
|
+
# Requests for any other family are rejected at the handler level (HTTP 400).
|
|
106
|
+
_ALLOWED_VIDEO_FAMILIES: frozenset[str] = frozenset({"veo_3_1_lite_low_priority"})
|
|
107
|
+
_ALLOWED_IMAGE_FAMILIES: frozenset[str] = frozenset({"NANO_BANANA_2"})
|
|
108
|
+
|
|
109
|
+
def _apply(self, data: dict[str, Any]) -> None:
|
|
110
|
+
mc = data.get("modelConfig") or {}
|
|
111
|
+
self._raw_model_config = mc
|
|
112
|
+
# Filter to pinned families immediately so /v1/models only surfaces them.
|
|
113
|
+
all_video = mc.get("videoModelFamilies") or []
|
|
114
|
+
all_image = mc.get("imageModelFamilies") or []
|
|
115
|
+
self._video_families = [
|
|
116
|
+
f for f in all_video if f.get("id") in self._ALLOWED_VIDEO_FAMILIES
|
|
117
|
+
]
|
|
118
|
+
self._image_families = [
|
|
119
|
+
f for f in all_image if f.get("id") in self._ALLOWED_IMAGE_FAMILIES
|
|
120
|
+
]
|
|
121
|
+
self._service_tier = (data.get("userData") or {}).get("serviceTier")
|
|
122
|
+
contents = data.get("projectContents") or {}
|
|
123
|
+
self._audio_voices = [
|
|
124
|
+
{
|
|
125
|
+
"id": v.get("mediaId"),
|
|
126
|
+
"name": (v.get("media") or {}).get("audio", {})
|
|
127
|
+
.get("generatedAudio", {}).get("name")
|
|
128
|
+
or v.get("workflowDisplayName"),
|
|
129
|
+
"description": (v.get("media") or {}).get("audio", {})
|
|
130
|
+
.get("generatedAudio", {}).get("description"),
|
|
131
|
+
}
|
|
132
|
+
for v in (contents.get("externalReferenceMedia") or [])
|
|
133
|
+
if v.get("mediaType") == "AUDIO" and v.get("mediaId")
|
|
134
|
+
]
|
|
135
|
+
|
|
136
|
+
def _load_from_disk(self) -> bool:
|
|
137
|
+
"""Populate from the on-disk cache when present and within TTL."""
|
|
138
|
+
try:
|
|
139
|
+
blob = json.loads(_CACHE_FILE.read_text())
|
|
140
|
+
except (OSError, ValueError):
|
|
141
|
+
return False
|
|
142
|
+
fetched_at = blob.get("fetched_at")
|
|
143
|
+
if not isinstance(fetched_at, (int, float)):
|
|
144
|
+
return False
|
|
145
|
+
if (time.time() - fetched_at) >= CATALOG_TTL_S:
|
|
146
|
+
return False
|
|
147
|
+
all_video = blob.get("video_families") or []
|
|
148
|
+
all_image = blob.get("image_families") or []
|
|
149
|
+
self._video_families = [
|
|
150
|
+
f for f in all_video if f.get("id") in self._ALLOWED_VIDEO_FAMILIES
|
|
151
|
+
]
|
|
152
|
+
self._image_families = [
|
|
153
|
+
f for f in all_image if f.get("id") in self._ALLOWED_IMAGE_FAMILIES
|
|
154
|
+
]
|
|
155
|
+
self._audio_voices = blob.get("audio_voices") or []
|
|
156
|
+
self._service_tier = blob.get("service_tier")
|
|
157
|
+
self._loaded_at = fetched_at
|
|
158
|
+
logger.info(
|
|
159
|
+
"catalog loaded from disk cache (age %ds): %d video families",
|
|
160
|
+
int(time.time() - fetched_at), len(self._video_families),
|
|
161
|
+
)
|
|
162
|
+
return True
|
|
163
|
+
|
|
164
|
+
def _save_to_disk(self) -> None:
|
|
165
|
+
blob = {
|
|
166
|
+
"fetched_at": self._loaded_at,
|
|
167
|
+
"service_tier": self._service_tier,
|
|
168
|
+
"video_families": self._video_families,
|
|
169
|
+
"image_families": self._image_families,
|
|
170
|
+
"audio_voices": self._audio_voices,
|
|
171
|
+
}
|
|
172
|
+
try:
|
|
173
|
+
_CACHE_FILE.write_text(json.dumps(blob))
|
|
174
|
+
except OSError as exc:
|
|
175
|
+
logger.warning("could not persist catalog cache: %s", exc)
|
|
176
|
+
|
|
177
|
+
# ── resolution ─────────────────────────────────────────────────────────
|
|
178
|
+
@staticmethod
|
|
179
|
+
def _usage_mode(usage: dict[str, Any]) -> str:
|
|
180
|
+
reqs: set[str] = set()
|
|
181
|
+
for combo in usage.get("requirements") or []:
|
|
182
|
+
for r in combo:
|
|
183
|
+
reqs.add(r)
|
|
184
|
+
if any("UPSAMPLE" in r for r in reqs):
|
|
185
|
+
return "upsample"
|
|
186
|
+
if "VIDEO_REQUIREMENT_VIDEO_EDIT" in reqs:
|
|
187
|
+
return "edit"
|
|
188
|
+
if "VIDEO_REQUIREMENT_EXTENSION" in reqs:
|
|
189
|
+
return "extension"
|
|
190
|
+
if "VIDEO_REQUIREMENT_REFERENCES" in reqs:
|
|
191
|
+
return "r2v"
|
|
192
|
+
if "VIDEO_REQUIREMENT_END_IMAGE" in reqs:
|
|
193
|
+
return "interpolation"
|
|
194
|
+
if "VIDEO_REQUIREMENT_START_IMAGE" in reqs:
|
|
195
|
+
return "i2v"
|
|
196
|
+
if "VIDEO_REQUIREMENT_TEXT" in reqs:
|
|
197
|
+
return "t2v"
|
|
198
|
+
return "unknown"
|
|
199
|
+
|
|
200
|
+
@staticmethod
|
|
201
|
+
def _cost(usage: dict[str, Any], tier: Optional[str]) -> Optional[int]:
|
|
202
|
+
cm = usage.get("creditMapping") or {}
|
|
203
|
+
entry = cm.get(tier or "") or {}
|
|
204
|
+
cost = entry.get("cost")
|
|
205
|
+
return cost if isinstance(cost, int) else None
|
|
206
|
+
|
|
207
|
+
@staticmethod
|
|
208
|
+
def _aspect_short(aspect: str) -> str:
|
|
209
|
+
return "PORTRAIT" if "PORTRAIT" in (aspect or "") else "LANDSCAPE"
|
|
210
|
+
|
|
211
|
+
def _family(self, family_id: str) -> Optional[dict[str, Any]]:
|
|
212
|
+
for fam in self._video_families:
|
|
213
|
+
if fam.get("id") == family_id:
|
|
214
|
+
return fam
|
|
215
|
+
return None
|
|
216
|
+
|
|
217
|
+
def resolve_video(
|
|
218
|
+
self,
|
|
219
|
+
family_id: str,
|
|
220
|
+
mode: str,
|
|
221
|
+
aspect: str,
|
|
222
|
+
duration: Optional[int],
|
|
223
|
+
service_tier: Optional[str] = None,
|
|
224
|
+
) -> dict[str, Any]:
|
|
225
|
+
"""Resolve a concrete dispatch config for the selection.
|
|
226
|
+
|
|
227
|
+
Returns ``{key, endpoint, mode, video_length, max_image_inputs,
|
|
228
|
+
max_audio_references, outputs_audio, credits}`` or ``{error}``.
|
|
229
|
+
"""
|
|
230
|
+
tier = service_tier or self._service_tier
|
|
231
|
+
fam = self._family(family_id)
|
|
232
|
+
if fam is None:
|
|
233
|
+
return {"error": f"unknown_video_model_family_{family_id}"}
|
|
234
|
+
aspect_short = self._aspect_short(aspect)
|
|
235
|
+
|
|
236
|
+
cands = [
|
|
237
|
+
u for u in (fam.get("usages") or [])
|
|
238
|
+
if self._usage_mode(u) == mode
|
|
239
|
+
and aspect_short in (u.get("supportedAspectRatios") or [])
|
|
240
|
+
]
|
|
241
|
+
if not cands:
|
|
242
|
+
return {
|
|
243
|
+
"error": f"{family_id}_has_no_{mode}_usage_for_{aspect_short}"
|
|
244
|
+
}
|
|
245
|
+
if duration is not None:
|
|
246
|
+
dur = [u for u in cands if u.get("videoLengthSeconds") == duration]
|
|
247
|
+
cands = dur or cands
|
|
248
|
+
# Prefer the variant available to this tier (cost is a number).
|
|
249
|
+
avail = [u for u in cands if self._cost(u, tier) is not None]
|
|
250
|
+
pick = (avail or cands)[0]
|
|
251
|
+
|
|
252
|
+
endpoint = _MODE_ENDPOINTS.get(mode)
|
|
253
|
+
if endpoint is None:
|
|
254
|
+
return {
|
|
255
|
+
"error": (
|
|
256
|
+
f"mode_{mode}_not_dispatchable_yet "
|
|
257
|
+
f"(resolved key {pick.get('key')!r}; endpoint not captured)"
|
|
258
|
+
),
|
|
259
|
+
"key": pick.get("key"),
|
|
260
|
+
"mode": mode,
|
|
261
|
+
}
|
|
262
|
+
spec = pick.get("inputSpec") or {}
|
|
263
|
+
return {
|
|
264
|
+
"key": pick.get("key"),
|
|
265
|
+
"endpoint": endpoint,
|
|
266
|
+
"mode": mode,
|
|
267
|
+
"video_length": pick.get("videoLengthSeconds"),
|
|
268
|
+
"max_image_inputs": pick.get("maxImageInputs"),
|
|
269
|
+
"max_audio_references": spec.get("maxAudioReferences"),
|
|
270
|
+
"outputs_audio": pick.get("outputsAudio"),
|
|
271
|
+
"credits": self._cost(pick, tier),
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
# ── catalog views (for /v1/models) ─────────────────────────────────────
|
|
275
|
+
def video_models_api(self, service_tier: Optional[str] = None) -> list[dict]:
|
|
276
|
+
tier = service_tier or self._service_tier
|
|
277
|
+
out: list[dict[str, Any]] = []
|
|
278
|
+
for fam in self._video_families:
|
|
279
|
+
usages = fam.get("usages") or []
|
|
280
|
+
modes: dict[str, dict[str, Any]] = {}
|
|
281
|
+
for u in usages:
|
|
282
|
+
mode = self._usage_mode(u)
|
|
283
|
+
cost = self._cost(u, tier)
|
|
284
|
+
if cost is None:
|
|
285
|
+
continue # not available to this tier
|
|
286
|
+
m = modes.setdefault(
|
|
287
|
+
mode,
|
|
288
|
+
{"mode": mode, "durations": set(), "aspects": set(),
|
|
289
|
+
"dispatchable": mode in _MODE_ENDPOINTS, "credits": cost},
|
|
290
|
+
)
|
|
291
|
+
if u.get("videoLengthSeconds"):
|
|
292
|
+
m["durations"].add(u["videoLengthSeconds"])
|
|
293
|
+
for a in u.get("supportedAspectRatios") or []:
|
|
294
|
+
m["aspects"].add(a)
|
|
295
|
+
m["credits"] = min(m["credits"], cost)
|
|
296
|
+
if not modes:
|
|
297
|
+
continue
|
|
298
|
+
out.append({
|
|
299
|
+
"id": fam.get("id"),
|
|
300
|
+
"object": "model",
|
|
301
|
+
"kind": "video",
|
|
302
|
+
"display_name": fam.get("displayName"),
|
|
303
|
+
"owned_by": "google-flow",
|
|
304
|
+
"modes": [
|
|
305
|
+
{**v, "durations": sorted(v["durations"]),
|
|
306
|
+
"aspects": sorted(v["aspects"])}
|
|
307
|
+
for v in modes.values()
|
|
308
|
+
],
|
|
309
|
+
})
|
|
310
|
+
return out
|
|
311
|
+
|
|
312
|
+
def image_models_api(self, service_tier: Optional[str] = None) -> list[dict]:
|
|
313
|
+
tier = service_tier or self._service_tier
|
|
314
|
+
out: list[dict[str, Any]] = []
|
|
315
|
+
for fam in self._image_families:
|
|
316
|
+
usages = fam.get("usages") or []
|
|
317
|
+
keys = [u.get("key") for u in usages if u.get("key")]
|
|
318
|
+
out.append({
|
|
319
|
+
"id": fam.get("id"),
|
|
320
|
+
"object": "model",
|
|
321
|
+
"kind": "image",
|
|
322
|
+
"display_name": fam.get("displayName"),
|
|
323
|
+
"owned_by": "google-flow",
|
|
324
|
+
"keys": keys,
|
|
325
|
+
})
|
|
326
|
+
return out
|
|
327
|
+
|
|
328
|
+
def audio_voices(self) -> list[dict[str, Any]]:
|
|
329
|
+
return list(self._audio_voices)
|
|
330
|
+
|
|
331
|
+
|
|
332
|
+
catalog = _Catalog()
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""flow-engine launcher CLI: setup wizard + supervisor + live dashboard."""
|
flow_engine/cli/app.py
ADDED
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
"""flow-engine CLI entrypoint.
|
|
2
|
+
|
|
3
|
+
Commands:
|
|
4
|
+
start supervise the engine + Cloudflare tunnel with a live dashboard (the one-liner)
|
|
5
|
+
serve run just the engine (foreground uvicorn) — for systemd / a custom supervisor
|
|
6
|
+
setup interactive wizard to store the engine key + tunnel token (~/.vrex-flow)
|
|
7
|
+
doctor check prerequisites and probe a running engine
|
|
8
|
+
|
|
9
|
+
Distributed Python-native: `uvx flow-engine start` (once published) or, from the
|
|
10
|
+
monorepo, `uvx --from 'git+ssh://…/multimodal.git#subdirectory=flow-engine' flow-engine start`.
|
|
11
|
+
"""
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import argparse
|
|
15
|
+
import shutil
|
|
16
|
+
import sys
|
|
17
|
+
|
|
18
|
+
from rich.console import Console
|
|
19
|
+
from rich.prompt import Prompt
|
|
20
|
+
|
|
21
|
+
from . import health
|
|
22
|
+
from .config import (
|
|
23
|
+
CONFIG_FILE,
|
|
24
|
+
DEFAULT_PORT,
|
|
25
|
+
DEFAULT_PUBLIC_BASE_URL,
|
|
26
|
+
FlowConfig,
|
|
27
|
+
load_config,
|
|
28
|
+
mask,
|
|
29
|
+
save_config,
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
console = Console()
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _run_wizard(cfg: FlowConfig) -> FlowConfig:
|
|
36
|
+
"""Prompt for the secrets/ports, keeping existing values as defaults."""
|
|
37
|
+
console.print("[bold]Vrex Flow Engine — setup[/] (stored at ~/.vrex-flow/config.json, chmod 600)\n")
|
|
38
|
+
key = Prompt.ask(
|
|
39
|
+
"Engine API key" + (f" [dim]({mask(cfg.engine_api_key)})[/]" if cfg.has_engine_key else ""),
|
|
40
|
+
password=True, default=cfg.engine_api_key, show_default=False,
|
|
41
|
+
)
|
|
42
|
+
token = Prompt.ask(
|
|
43
|
+
"Cloudflare tunnel token" + (f" [dim]({mask(cfg.tunnel_token)})[/]" if cfg.has_tunnel_token else ""),
|
|
44
|
+
password=True, default=cfg.tunnel_token, show_default=False,
|
|
45
|
+
)
|
|
46
|
+
port = Prompt.ask("HTTP port", default=str(cfg.http_port or DEFAULT_PORT))
|
|
47
|
+
base = Prompt.ask("Public base URL", default=cfg.public_base_url or DEFAULT_PUBLIC_BASE_URL)
|
|
48
|
+
cfg.engine_api_key = key.strip()
|
|
49
|
+
cfg.tunnel_token = token.strip()
|
|
50
|
+
cfg.http_port = int(port)
|
|
51
|
+
cfg.public_base_url = base.strip()
|
|
52
|
+
return cfg
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _cmd_setup(_: argparse.Namespace) -> int:
|
|
56
|
+
cfg = _run_wizard(load_config())
|
|
57
|
+
path = save_config(cfg)
|
|
58
|
+
console.print(f"\n[green]✓[/] saved to [bold]{path}[/]")
|
|
59
|
+
return 0
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _cmd_start(args: argparse.Namespace) -> int:
|
|
63
|
+
from .supervisor import Supervisor, preflight
|
|
64
|
+
|
|
65
|
+
cfg = load_config()
|
|
66
|
+
# CLI flags override stored/env values (non-interactive hosts).
|
|
67
|
+
if args.engine_key:
|
|
68
|
+
cfg.engine_api_key = args.engine_key
|
|
69
|
+
if args.tunnel_token:
|
|
70
|
+
cfg.tunnel_token = args.tunnel_token
|
|
71
|
+
if args.port:
|
|
72
|
+
cfg.http_port = args.port
|
|
73
|
+
if args.public_url:
|
|
74
|
+
cfg.public_base_url = args.public_url
|
|
75
|
+
|
|
76
|
+
with_tunnel = not args.no_tunnel
|
|
77
|
+
# Fill gaps interactively unless told not to; then persist for next time.
|
|
78
|
+
needs = not cfg.has_engine_key or (with_tunnel and not cfg.has_tunnel_token)
|
|
79
|
+
if needs and not args.non_interactive:
|
|
80
|
+
cfg = _run_wizard(cfg)
|
|
81
|
+
save_config(cfg)
|
|
82
|
+
|
|
83
|
+
if not preflight(cfg, with_tunnel):
|
|
84
|
+
return 1
|
|
85
|
+
return Supervisor(cfg, with_tunnel=with_tunnel).run()
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _cmd_serve(_: argparse.Namespace) -> int:
|
|
89
|
+
# Raw engine only (what `start` spawns as a child, and what systemd would use).
|
|
90
|
+
from flow_engine.main import main as serve_main
|
|
91
|
+
|
|
92
|
+
serve_main()
|
|
93
|
+
return 0
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _cmd_doctor(_: argparse.Namespace) -> int:
|
|
97
|
+
cfg = load_config()
|
|
98
|
+
ok = True
|
|
99
|
+
|
|
100
|
+
def line(good: bool, label: str, detail: str = "") -> None:
|
|
101
|
+
nonlocal ok
|
|
102
|
+
ok = ok and good
|
|
103
|
+
mark = "[green]✓[/]" if good else "[red]✗[/]"
|
|
104
|
+
console.print(f"{mark} {label}" + (f" [dim]{detail}[/]" if detail else ""))
|
|
105
|
+
|
|
106
|
+
line(sys.version_info >= (3, 10), "python ≥ 3.10", sys.version.split()[0])
|
|
107
|
+
line(bool(shutil.which("cloudflared")), "cloudflared installed", shutil.which("cloudflared") or "missing")
|
|
108
|
+
line(CONFIG_FILE.exists(), "config present", str(CONFIG_FILE))
|
|
109
|
+
line(cfg.has_engine_key, "engine API key set", mask(cfg.engine_api_key))
|
|
110
|
+
line(cfg.has_tunnel_token, "tunnel token set", mask(cfg.tunnel_token))
|
|
111
|
+
h = health.probe_engine(cfg.http_port)
|
|
112
|
+
if h:
|
|
113
|
+
line(True, f"engine responding on :{cfg.http_port}",
|
|
114
|
+
f"extension_connected={h.get('extension_connected')} instances={h.get('instances')}")
|
|
115
|
+
else:
|
|
116
|
+
console.print(f"[dim]·[/] engine not running on :{cfg.http_port} (start it with [bold]flow-engine start[/])")
|
|
117
|
+
return 0 if ok else 1
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
121
|
+
p = argparse.ArgumentParser(prog="flow-engine", description="Vrex Flow Engine launcher + supervisor")
|
|
122
|
+
sub = p.add_subparsers(dest="command")
|
|
123
|
+
|
|
124
|
+
ps = sub.add_parser("start", help="run engine + tunnel with a live dashboard")
|
|
125
|
+
ps.add_argument("--engine-key", help="engine API key (overrides stored/env)")
|
|
126
|
+
ps.add_argument("--tunnel-token", help="Cloudflare tunnel token (overrides stored/env)")
|
|
127
|
+
ps.add_argument("--port", type=int, help=f"HTTP port (default {DEFAULT_PORT})")
|
|
128
|
+
ps.add_argument("--public-url", help="public base URL")
|
|
129
|
+
ps.add_argument("--no-tunnel", action="store_true", help="run the engine only (skip cloudflared)")
|
|
130
|
+
ps.add_argument("--non-interactive", action="store_true", help="fail instead of prompting for missing secrets")
|
|
131
|
+
ps.set_defaults(func=_cmd_start)
|
|
132
|
+
|
|
133
|
+
sub.add_parser("serve", help="run just the engine (foreground uvicorn)").set_defaults(func=_cmd_serve)
|
|
134
|
+
sub.add_parser("setup", help="store engine key + tunnel token").set_defaults(func=_cmd_setup)
|
|
135
|
+
sub.add_parser("doctor", help="check prerequisites + probe a running engine").set_defaults(func=_cmd_doctor)
|
|
136
|
+
return p
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def main() -> None:
|
|
140
|
+
parser = build_parser()
|
|
141
|
+
args = parser.parse_args()
|
|
142
|
+
if not getattr(args, "command", None):
|
|
143
|
+
parser.print_help()
|
|
144
|
+
raise SystemExit(0)
|
|
145
|
+
raise SystemExit(args.func(args))
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
if __name__ == "__main__":
|
|
149
|
+
main()
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
"""Persistent CLI config for the flow-engine supervisor.
|
|
2
|
+
|
|
3
|
+
Stores the engine API key + Cloudflare tunnel token (both secrets) plus the
|
|
4
|
+
HTTP port and public base URL in ``~/.vrex-flow/config.json`` (chmod 0600).
|
|
5
|
+
The running engine itself does NOT read this file — the supervisor injects the
|
|
6
|
+
key into the child process env — so this is purely the launcher's own state.
|
|
7
|
+
"""
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import json
|
|
11
|
+
import os
|
|
12
|
+
import stat
|
|
13
|
+
from dataclasses import dataclass, fields
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
|
|
16
|
+
CONFIG_DIR = Path(os.getenv("VREX_FLOW_HOME") or (Path.home() / ".vrex-flow"))
|
|
17
|
+
CONFIG_FILE = CONFIG_DIR / "config.json"
|
|
18
|
+
LOGS_DIR = CONFIG_DIR / "logs"
|
|
19
|
+
|
|
20
|
+
DEFAULT_PORT = 8101
|
|
21
|
+
DEFAULT_PUBLIC_BASE_URL = "https://flow.getvrex.com"
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@dataclass
|
|
25
|
+
class FlowConfig:
|
|
26
|
+
"""Launcher state. `engine_api_key` + `tunnel_token` are secrets."""
|
|
27
|
+
|
|
28
|
+
engine_api_key: str = ""
|
|
29
|
+
tunnel_token: str = ""
|
|
30
|
+
http_port: int = DEFAULT_PORT
|
|
31
|
+
public_base_url: str = DEFAULT_PUBLIC_BASE_URL
|
|
32
|
+
|
|
33
|
+
@property
|
|
34
|
+
def has_engine_key(self) -> bool:
|
|
35
|
+
return bool(self.engine_api_key.strip())
|
|
36
|
+
|
|
37
|
+
@property
|
|
38
|
+
def has_tunnel_token(self) -> bool:
|
|
39
|
+
return bool(self.tunnel_token.strip())
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def load_config() -> FlowConfig:
|
|
43
|
+
"""Read the on-disk config, tolerating a missing file or unknown keys.
|
|
44
|
+
Env vars FLOW_ENGINE_API_KEY / CLOUDFLARE_TUNNEL_TOKEN override the file so a
|
|
45
|
+
non-interactive host can inject secrets without writing them to disk."""
|
|
46
|
+
cfg = FlowConfig()
|
|
47
|
+
if CONFIG_FILE.exists():
|
|
48
|
+
known = {f.name for f in fields(FlowConfig)}
|
|
49
|
+
data = json.loads(CONFIG_FILE.read_text())
|
|
50
|
+
cfg = FlowConfig(**{k: v for k, v in data.items() if k in known})
|
|
51
|
+
env_key = os.getenv("FLOW_ENGINE_API_KEY")
|
|
52
|
+
env_token = os.getenv("CLOUDFLARE_TUNNEL_TOKEN") or os.getenv("TUNNEL_TOKEN")
|
|
53
|
+
if env_key:
|
|
54
|
+
cfg.engine_api_key = env_key
|
|
55
|
+
if env_token:
|
|
56
|
+
cfg.tunnel_token = env_token
|
|
57
|
+
return cfg
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def save_config(cfg: FlowConfig) -> Path:
|
|
61
|
+
"""Persist the config as 0600 (it holds secrets). Returns the file path."""
|
|
62
|
+
CONFIG_DIR.mkdir(parents=True, exist_ok=True)
|
|
63
|
+
LOGS_DIR.mkdir(parents=True, exist_ok=True)
|
|
64
|
+
payload = {
|
|
65
|
+
"engine_api_key": cfg.engine_api_key,
|
|
66
|
+
"tunnel_token": cfg.tunnel_token,
|
|
67
|
+
"http_port": cfg.http_port,
|
|
68
|
+
"public_base_url": cfg.public_base_url,
|
|
69
|
+
}
|
|
70
|
+
# Write then tighten perms — never leave a world-readable window on secrets.
|
|
71
|
+
CONFIG_FILE.write_text(json.dumps(payload, indent=2) + "\n")
|
|
72
|
+
os.chmod(CONFIG_FILE, stat.S_IRUSR | stat.S_IWUSR) # 0600
|
|
73
|
+
return CONFIG_FILE
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def mask(secret: str) -> str:
|
|
77
|
+
"""Render a secret for display: keep a short prefix, hide the rest."""
|
|
78
|
+
s = (secret or "").strip()
|
|
79
|
+
if not s:
|
|
80
|
+
return "(unset)"
|
|
81
|
+
return f"{s[:6]}…{s[-2:]}" if len(s) > 10 else "•" * len(s)
|