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,202 @@
1
+ """Per-instance media store.
2
+
3
+ Extracted from the module-level ``_registry`` dict in media.py so each pool
4
+ instance owns an isolated in-memory registry. On-disk bytes are written to a
5
+ per-account subdir (``storage/media/<account_id>/``) to prevent cross-tenant
6
+ file collisions at the OS path layer.
7
+ """
8
+ from __future__ import annotations
9
+
10
+ import logging
11
+ import threading
12
+ from pathlib import Path
13
+ from typing import Any, Optional
14
+
15
+ import httpx
16
+
17
+ from flow_engine.config import STORAGE_DIR
18
+ from flow_engine.media import (
19
+ Asset,
20
+ _ALLOWED_URL_PREFIXES,
21
+ _EXT_BY_MIME,
22
+ _MEDIA_ID_RE,
23
+ _mime_from_ext,
24
+ _url_allowed,
25
+ is_valid_media_id,
26
+ normalize_media_id,
27
+ )
28
+
29
+ logger = logging.getLogger(__name__)
30
+
31
+
32
+ class MediaStore:
33
+ """Thread-safe media registry + disk cache, one per PoolInstance.
34
+
35
+ Each instance writes cached bytes under ``storage/media/<account_id>/``
36
+ so accounts never share or accidentally serve each other's files.
37
+ """
38
+
39
+ def __init__(self, account_id: str = "default") -> None:
40
+ self._registry: dict[str, Asset] = {}
41
+ self._lock = threading.Lock()
42
+ # Per-account subdir prevents cross-tenant file collisions.
43
+ self._cache_dir: Path = STORAGE_DIR / "media" / account_id
44
+ self._cache_dir.mkdir(parents=True, exist_ok=True)
45
+
46
+ # ── helpers ────────────────────────────────────────────────────────────
47
+
48
+ def _cache_glob(self, media_id: str) -> Optional[Path]:
49
+ if not is_valid_media_id(media_id):
50
+ return None
51
+ for p in self._cache_dir.glob(f"{media_id}.*"):
52
+ if p.is_file():
53
+ return p
54
+ return None
55
+
56
+ def _get(self, media_id: str) -> Optional[Asset]:
57
+ with self._lock:
58
+ return self._registry.get(media_id)
59
+
60
+ # ── write ──────────────────────────────────────────────────────────────
61
+
62
+ def ingest_urls(self, urls: list[dict[str, Any]]) -> int:
63
+ """Register Asset rows keyed by uuid media_id. Returns count touched."""
64
+ touched = 0
65
+ with self._lock:
66
+ for entry in urls or []:
67
+ if not isinstance(entry, dict):
68
+ continue
69
+ media_id = entry.get("media_id") or entry.get("mediaId")
70
+ url = entry.get("url")
71
+ kind = entry.get("mediaType") or entry.get("kind") or "image"
72
+ if not isinstance(media_id, str) or not is_valid_media_id(media_id):
73
+ continue
74
+ if not _url_allowed(url):
75
+ logger.warning(
76
+ "media_store: skip non-allowed url for %s: %r",
77
+ media_id, (url or "")[:80],
78
+ )
79
+ continue
80
+ row = self._registry.get(media_id)
81
+ if row is None:
82
+ self._registry[media_id] = Asset(media_id=media_id, url=url, kind=kind)
83
+ else:
84
+ row.url = url
85
+ if not row.kind:
86
+ row.kind = kind
87
+ touched += 1
88
+ if touched:
89
+ logger.info("media_store: ingested %d url(s)", touched)
90
+ return touched
91
+
92
+ def ingest_inline_bytes(
93
+ self,
94
+ media_id: str,
95
+ data: bytes,
96
+ *,
97
+ kind: str = "video",
98
+ mime: str = "video/mp4",
99
+ ) -> bool:
100
+ """Cache pre-fetched media bytes (workflow-mode base64 MP4) and mark
101
+ the Asset as locally available."""
102
+ if not is_valid_media_id(media_id) or not data:
103
+ return False
104
+ ext = _EXT_BY_MIME.get(mime, ".mp4")
105
+ path = self._cache_dir / f"{media_id}{ext}"
106
+ try:
107
+ path.write_bytes(data)
108
+ except OSError as exc:
109
+ logger.error("media_store: failed to write inline cache %s: %s", path, exc)
110
+ return False
111
+ with self._lock:
112
+ row = self._registry.get(media_id) or Asset(media_id=media_id, kind=kind)
113
+ row.local_path = str(path)
114
+ row.mime = mime
115
+ if not row.kind:
116
+ row.kind = kind
117
+ self._registry[media_id] = row
118
+ logger.info("media_store: ingested %d inline bytes for %s", len(data), media_id)
119
+ return True
120
+
121
+ # ── read ───────────────────────────────────────────────────────────────
122
+
123
+ async def fetch_and_cache(
124
+ self, media_id: str
125
+ ) -> Optional[tuple[bytes, str, Path]]:
126
+ """Fetch bytes from the signed URL, cache to disk, return
127
+ ``(bytes, mime, path)``; else ``None``."""
128
+ if not is_valid_media_id(media_id):
129
+ return None
130
+ row = self._get(media_id)
131
+ if row is None or not row.url:
132
+ return None
133
+ url = row.url
134
+ if not _url_allowed(url):
135
+ logger.warning("media_store: refusing non-allowed URL: %s", url[:60])
136
+ return None
137
+ try:
138
+ async with httpx.AsyncClient(timeout=30.0) as client:
139
+ resp = await client.get(url)
140
+ except Exception as exc: # noqa: BLE001
141
+ logger.warning("media_store: GCS fetch failed for %s: %s", media_id, exc)
142
+ return None
143
+ if resp.status_code != 200:
144
+ logger.warning(
145
+ "media_store: GCS fetch %s returned %d", media_id, resp.status_code
146
+ )
147
+ return None
148
+ mime = (
149
+ resp.headers.get("content-type", "application/octet-stream")
150
+ .split(";")[0]
151
+ .strip()
152
+ )
153
+ if not (mime.startswith("image/") or mime.startswith("video/")):
154
+ logger.warning(
155
+ "media_store: refusing non-media mime from GCS: %s (media_id=%s)",
156
+ mime, media_id,
157
+ )
158
+ return None
159
+ ext = _EXT_BY_MIME.get(mime, ".bin")
160
+ path = self._cache_dir / f"{media_id}{ext}"
161
+ try:
162
+ path.write_bytes(resp.content)
163
+ except OSError as exc:
164
+ logger.error("media_store: failed to write cache %s: %s", path, exc)
165
+ return None
166
+ with self._lock:
167
+ r = self._registry.get(media_id)
168
+ if r is not None:
169
+ r.local_path = str(path)
170
+ r.mime = mime
171
+ return resp.content, mime, path
172
+
173
+ async def ensure_cached(self, media_id: str) -> Optional[Path]:
174
+ """Return a local file path for the media, fetching on first access."""
175
+ media_id = normalize_media_id(media_id)
176
+ cached = self._cache_glob(media_id)
177
+ if cached is not None:
178
+ return cached
179
+ got = await self.fetch_and_cache(media_id)
180
+ return got[2] if got else None
181
+
182
+ def status(self, media_id: str) -> dict:
183
+ media_id = normalize_media_id(media_id)
184
+ if not is_valid_media_id(media_id):
185
+ return {"available": False, "has_url": False, "reason": "invalid_id"}
186
+ cached = self._cache_glob(media_id)
187
+ if cached is not None:
188
+ return {"available": True, "has_url": True, "mime": _mime_from_ext(cached.suffix)}
189
+ row = self._get(media_id)
190
+ if row is None:
191
+ return {"available": False, "has_url": False, "reason": "unknown_media"}
192
+ if row.url:
193
+ return {"available": False, "has_url": True, "reason": "not_cached_yet"}
194
+ return {"available": False, "has_url": False, "reason": "no_url_yet"}
195
+
196
+ def media_mime(self, media_id: str) -> str:
197
+ media_id = normalize_media_id(media_id)
198
+ cached = self._cache_glob(media_id)
199
+ if cached is not None:
200
+ return _mime_from_ext(cached.suffix)
201
+ row = self._get(media_id)
202
+ return row.mime if (row and row.mime) else "application/octet-stream"
@@ -0,0 +1 @@
1
+ """OpenAI-compatible HTTP surface mapped onto Google Flow generation."""
@@ -0,0 +1,59 @@
1
+ """Shared helpers for the OpenAI-compatible routes."""
2
+ from __future__ import annotations
3
+
4
+ import base64
5
+ from typing import Optional
6
+
7
+ from fastapi import Header, HTTPException
8
+
9
+ from flow_engine import media as media_service
10
+ from flow_engine.config import API_KEY, PUBLIC_BASE_URL
11
+
12
+
13
+ def require_api_key(authorization: Optional[str] = Header(default=None)) -> Optional[str]:
14
+ """FastAPI dependency. When FLOWPROXY_API_KEY is set, enforce a bearer
15
+ token and return it (used later as the pool routing key). No-op otherwise."""
16
+ if not API_KEY:
17
+ return None
18
+ token = ""
19
+ if authorization and authorization.lower().startswith("bearer "):
20
+ token = authorization[7:].strip()
21
+ if token != API_KEY:
22
+ raise HTTPException(status_code=401, detail="invalid_api_key")
23
+ return token
24
+
25
+
26
+ def media_url(media_id: str) -> str:
27
+ """Absolute, client-fetchable URL for a Flow media id."""
28
+ return f"{PUBLIC_BASE_URL.rstrip('/')}/media/{media_service.normalize_media_id(media_id)}"
29
+
30
+
31
+ async def media_b64(media_id: str) -> Optional[str]:
32
+ """Base64 of the cached media bytes (for response_format='b64_json')."""
33
+ path = await media_service.ensure_cached(media_id)
34
+ if path is None:
35
+ return None
36
+ return base64.b64encode(path.read_bytes()).decode("ascii")
37
+
38
+
39
+ # OpenAI `size` ("WxH") → Flow image aspect-ratio enum.
40
+ def image_aspect_from_size(size: Optional[str]) -> str:
41
+ if not isinstance(size, str) or "x" not in size:
42
+ return "IMAGE_ASPECT_RATIO_LANDSCAPE"
43
+ try:
44
+ w, h = (int(x) for x in size.lower().split("x", 1))
45
+ except ValueError:
46
+ return "IMAGE_ASPECT_RATIO_LANDSCAPE"
47
+ if w == h:
48
+ return "IMAGE_ASPECT_RATIO_SQUARE"
49
+ return "IMAGE_ASPECT_RATIO_PORTRAIT" if h > w else "IMAGE_ASPECT_RATIO_LANDSCAPE"
50
+
51
+
52
+ def video_aspect_from_size(size: Optional[str]) -> str:
53
+ if not isinstance(size, str) or "x" not in size:
54
+ return "VIDEO_ASPECT_RATIO_LANDSCAPE"
55
+ try:
56
+ w, h = (int(x) for x in size.lower().split("x", 1))
57
+ except ValueError:
58
+ return "VIDEO_ASPECT_RATIO_LANDSCAPE"
59
+ return "VIDEO_ASPECT_RATIO_PORTRAIT" if h > w else "VIDEO_ASPECT_RATIO_LANDSCAPE"
@@ -0,0 +1,134 @@
1
+ """POST /v1/images/generations — OpenAI-compatible image generation.
2
+
3
+ Maps onto Flow's synchronous ``batchGenerateImages`` (gen_image).
4
+
5
+ Only ``NANO_BANANA_2`` is accepted as the image model. Passing any other
6
+ non-null model value returns HTTP 400. Omitting ``model`` (or passing null)
7
+ silently forces ``NANO_BANANA_2``.
8
+
9
+ Non-standard extras accepted in the body:
10
+ - ``model``: must be "NANO_BANANA_2" or omitted
11
+ - ``project``: existing Flow project id (else flow_engine's default project)
12
+ - ``ref_media_ids``: Flow media ids to condition on (reference images)
13
+ - ``ref_images``: raw reference images (data URL / http(s) URL / base64) that
14
+ flow_engine uploads for you and conditions on — merged with ``ref_media_ids``
15
+ """
16
+ from __future__ import annotations
17
+
18
+ import logging
19
+ import time
20
+ from typing import Any, Optional
21
+
22
+ from fastapi import APIRouter, Depends, HTTPException, Request
23
+ from pydantic import BaseModel
24
+
25
+ from flow_engine import ingest
26
+ from flow_engine.openai._util import (
27
+ image_aspect_from_size,
28
+ media_b64,
29
+ media_url,
30
+ require_api_key,
31
+ )
32
+ from flow_engine.posthog_client import capture_engine, capture_exception, elapsed_ms, start_time
33
+ from flow_engine.session import get_default_project, get_sdk, require_ready
34
+
35
+ logger = logging.getLogger(__name__)
36
+ router = APIRouter(prefix="/v1/images", tags=["images"])
37
+
38
+ # The only accepted image model. Any other non-null value → HTTP 400.
39
+ ALLOWED_IMAGE_MODEL = "NANO_BANANA_2"
40
+
41
+
42
+ class ImageRequest(BaseModel):
43
+ prompt: str
44
+ model: Optional[str] = None
45
+ n: int = 1
46
+ size: Optional[str] = None
47
+ response_format: str = "url" # "url" | "b64_json"
48
+ # flow_engine extensions
49
+ project: Optional[str] = None
50
+ ref_media_ids: Optional[list[str]] = None
51
+ ref_images: Optional[list[str]] = None # raw refs — uploaded for you
52
+
53
+
54
+ @router.post("/generations")
55
+ async def generate_images(
56
+ req: ImageRequest,
57
+ request: Request,
58
+ api_key: Optional[str] = Depends(require_api_key),
59
+ ) -> dict[str, Any]:
60
+ if not req.prompt or not req.prompt.strip():
61
+ raise HTTPException(status_code=400, detail="prompt is required")
62
+
63
+ # Enforce pinned model — reject any explicitly-passed non-allowed value.
64
+ if req.model is not None and req.model != ALLOWED_IMAGE_MODEL:
65
+ raise HTTPException(
66
+ status_code=400,
67
+ detail=f"only model={ALLOWED_IMAGE_MODEL!r} is supported",
68
+ )
69
+ # Always use the pinned model regardless of what was passed.
70
+ image_model = ALLOWED_IMAGE_MODEL
71
+
72
+ t0 = start_time()
73
+ capture_engine(request, "engine_request_started", {"model": image_model, "n": req.n})
74
+
75
+ try:
76
+ inst, tier = require_ready(api_key)
77
+ project_id = req.project or await get_default_project(api_key)
78
+
79
+ sdk = get_sdk(api_key)
80
+ ref_ids = list(req.ref_media_ids or [])
81
+ ref_ids += await ingest.resolve_image_inputs(sdk, project_id, req.ref_images)
82
+
83
+ result = await sdk.gen_image(
84
+ prompt=req.prompt.strip(),
85
+ project_id=project_id,
86
+ aspect_ratio=image_aspect_from_size(req.size),
87
+ paygate_tier=tier,
88
+ ref_media_ids=ref_ids or None,
89
+ variant_count=max(1, min(int(req.n or 1), 4)),
90
+ image_model=image_model,
91
+ )
92
+ if result.get("error"):
93
+ capture_engine(request, "engine_request_error", {
94
+ "error": str(result["error"])[:200],
95
+ "latency_ms": elapsed_ms(t0),
96
+ })
97
+ raise HTTPException(status_code=502, detail=str(result["error"])[:200])
98
+
99
+ entries = [e for e in (result.get("media_entries") or []) if isinstance(e, dict)]
100
+ url_entries = [e for e in entries if e.get("url")]
101
+ if url_entries:
102
+ # Ingest into the per-instance media store.
103
+ inst.media.ingest_urls(url_entries)
104
+
105
+ data: list[dict[str, Any]] = []
106
+ for e in entries:
107
+ mid = e.get("media_id")
108
+ if not isinstance(mid, str):
109
+ continue
110
+ if req.response_format == "b64_json":
111
+ b64 = await media_b64(mid)
112
+ data.append({"b64_json": b64})
113
+ else:
114
+ data.append({"url": media_url(mid)})
115
+
116
+ if not data:
117
+ capture_engine(request, "engine_request_error", {
118
+ "error": "no media returned",
119
+ "latency_ms": elapsed_ms(t0),
120
+ })
121
+ raise HTTPException(status_code=502, detail="no media returned by Flow")
122
+
123
+ except HTTPException:
124
+ raise
125
+ except Exception as exc:
126
+ capture_exception(request, exc, {"latency_ms": elapsed_ms(t0)})
127
+ raise
128
+
129
+ capture_engine(request, "engine_request_completed", {
130
+ "latency_ms": elapsed_ms(t0),
131
+ "returned": len(data),
132
+ "model": image_model,
133
+ })
134
+ return {"created": int(time.time()), "data": data}
@@ -0,0 +1,38 @@
1
+ """GET /v1/models — dynamic catalog sourced from Flow's projectInitialData.
2
+
3
+ Lists the real video/image model families available to the signed-in account
4
+ (filtered to the user's service tier), plus the preset audio voices. Pass
5
+ ``?refresh=1`` to force a re-fetch of the upstream catalog.
6
+ """
7
+ from __future__ import annotations
8
+
9
+ import time
10
+ from typing import Any, Optional
11
+
12
+ from fastapi import APIRouter, Depends
13
+
14
+ from flow_engine.catalog import catalog
15
+ from flow_engine.openai._util import require_api_key
16
+ from flow_engine.session import ensure_catalog, require_ready
17
+
18
+ router = APIRouter(prefix="/v1", tags=["models"])
19
+
20
+
21
+ @router.get("/models")
22
+ async def list_models(
23
+ refresh: bool = False, _: Optional[str] = Depends(require_api_key)
24
+ ) -> dict[str, Any]:
25
+ require_ready()
26
+ await ensure_catalog(force=refresh)
27
+ created = int(time.time())
28
+ data = catalog.image_models_api() + catalog.video_models_api()
29
+ for m in data:
30
+ m["created"] = created
31
+ return {"object": "list", "data": data}
32
+
33
+
34
+ @router.get("/audio/voices")
35
+ async def list_voices(_: Optional[str] = Depends(require_api_key)) -> dict[str, Any]:
36
+ require_ready()
37
+ await ensure_catalog()
38
+ return {"object": "list", "data": catalog.audio_voices()}
@@ -0,0 +1,70 @@
1
+ """POST /v1/flow/uploads — turn raw media bytes into a Flow media id.
2
+
3
+ Generation endpoints need Flow media ids (reference images for veo-3.1, a start
4
+ frame for i2v, or a reference video for v2v/edit/extend). This wraps
5
+ ``flow_sdk.upload_image`` / ``flow_sdk.upload_video`` so a client can upload a
6
+ file once and reuse the returned ``media_id`` across requests.
7
+
8
+ Routing is by mime: ``video/*`` goes through the resumable video-upload flow,
9
+ everything else through ``uploadImage``. (Most callers don't need this endpoint
10
+ at all — the image/video generation routes accept raw ``ref_images`` /
11
+ ``start_images`` and upload inline.)
12
+
13
+ Accepts EITHER a JSON body ``{image_base64, mime_type, file_name}`` OR a
14
+ multipart file upload.
15
+ """
16
+ from __future__ import annotations
17
+
18
+ import base64
19
+ from typing import Any, Optional
20
+
21
+ from fastapi import APIRouter, Depends, File, Form, HTTPException, UploadFile
22
+
23
+ from flow_engine.openai._util import require_api_key
24
+ from flow_engine.session import get_default_project, get_sdk, require_ready
25
+
26
+ router = APIRouter(prefix="/v1/flow", tags=["uploads"])
27
+
28
+
29
+ @router.post("/uploads")
30
+ async def upload(
31
+ file: Optional[UploadFile] = File(default=None),
32
+ image_base64: Optional[str] = Form(default=None),
33
+ mime_type: Optional[str] = Form(default=None),
34
+ file_name: Optional[str] = Form(default=None),
35
+ project: Optional[str] = Form(default=None),
36
+ api_key: Optional[str] = Depends(require_api_key),
37
+ ) -> dict[str, Any]:
38
+ _, _tier = require_ready(api_key)
39
+ if file is not None:
40
+ raw = await file.read()
41
+ b64 = base64.b64encode(raw).decode("ascii")
42
+ mime = mime_type or file.content_type or "image/png"
43
+ name = file_name or file.filename
44
+ elif image_base64:
45
+ b64 = image_base64.split(",", 1)[-1] # tolerate data: prefix
46
+ mime = mime_type or "image/png"
47
+ name = file_name
48
+ else:
49
+ raise HTTPException(status_code=400, detail="provide a file or image_base64")
50
+
51
+ is_video = mime.lower().startswith("video/")
52
+ name = name or ("upload.mp4" if is_video else "upload.png")
53
+
54
+ project_id = project or await get_default_project(api_key)
55
+ sdk = get_sdk(api_key)
56
+ if is_video:
57
+ result = await sdk.upload_video(
58
+ video_base64=b64, mime_type=mime, project_id=project_id, file_name=name
59
+ )
60
+ else:
61
+ result = await sdk.upload_image(
62
+ image_base64=b64, mime_type=mime, project_id=project_id, file_name=name
63
+ )
64
+ if result.get("error"):
65
+ raise HTTPException(status_code=502, detail=str(result["error"])[:200])
66
+ return {
67
+ "media_id": result.get("media_id"),
68
+ "project": project_id,
69
+ "kind": "video" if is_video else "image",
70
+ }