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,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
|
+
}
|