Skip to content

Commit 92bbf4a

Browse files
rickstaaclaude
andcommitted
feat: serve a generated OpenAPI spec from every Python runner
Discovery gives a caller an app id and a URL and nothing about the interface, so the only way to learn what an app accepts is to read its source. FastAPI derives the schema from the request and response models and serves it at /openapi.json, which the orchestrator proxies like any other endpoint. Converts the four examples that run a server of ours. vllm and api-proxy are static runners with no code of ours, so they cannot participate. Exploratory, to size the change rather than to merge as is. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent 3c32206 commit 92bbf4a

9 files changed

Lines changed: 215 additions & 191 deletions

File tree

echo/Dockerfile

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ RUN apt-get update \
1111
# livepeer-gateway SDK isn't on PyPI yet; install from Git. av (PyAV) ships its
1212
# own ffmpeg; opencv-python-headless needs no system GUI libs.
1313
RUN pip install --no-cache-dir \
14+
fastapi uvicorn \
1415
"livepeer-gateway @ git+https://github.com/livepeer/livepeer-python-gateway@main" \
1516
av opencv-python-headless aiohttp
1617

echo/pyproject.toml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@ version = "0.1.0"
44
description = "Echo (trickle realtime video) example app for the Livepeer network."
55
requires-python = ">=3.12"
66
dependencies = [
7+
"fastapi", # runner: routes + generated OpenAPI schema
8+
"uvicorn", # runner: ASGI server
79
"av", # client + runner: decode/encode video frames
810
"opencv-python-headless", # runner: gray/invert/blur transforms
911
"numpy", # runner: robot ring modulation

echo/runner.py

Lines changed: 120 additions & 118 deletions
Original file line numberDiff line numberDiff line change
@@ -18,15 +18,16 @@
1818

1919
import argparse
2020
import asyncio
21-
import json
2221
import logging
23-
from contextlib import suppress
22+
from contextlib import asynccontextmanager, suppress
2423
from dataclasses import dataclass
25-
from typing import Any
24+
from typing import Any, Literal
2625

2726
import av
2827
import numpy as np
29-
from aiohttp import web
28+
import uvicorn
29+
from fastapi import FastAPI, HTTPException, Request
30+
from pydantic import BaseModel, Field
3031

3132
from livepeer_gateway.live_runner import register_runner
3233
from livepeer_gateway.media_decode import AudioDecodedMediaFrame, VideoDecodedMediaFrame
@@ -42,7 +43,6 @@
4243

4344
DEFAULT_HOST = "127.0.0.1"
4445
DEFAULT_PORT = 8989
45-
MODES = frozenset({"echo", "gray", "invert", "blur", "robot"})
4646
# "robot" multiplies each sample by a sine at this frequency; the sample count is
4747
# unchanged, so audio stays in sync with video.
4848
ROBOT_HZ = 220.0
@@ -104,23 +104,35 @@ def _parse_args() -> argparse.Namespace:
104104
return parser.parse_args()
105105

106106

107-
def _session_id(request: web.Request) -> str:
107+
def _session_id(request: Request) -> str:
108108
session_id = request.headers.get("Livepeer-Session-Id", "").strip()
109109
if not session_id:
110-
raise web.HTTPBadRequest(text="missing Livepeer-Session-Id header")
110+
raise HTTPException(
111+
status_code=400, detail="missing Livepeer-Session-Id header"
112+
)
111113
return session_id
112114

113115

114-
def _parse_mode(payload: dict[str, Any]) -> ModeState:
115-
mode = str(payload.get("mode", "echo")).strip().lower()
116-
if mode not in MODES:
117-
raise web.HTTPBadRequest(text=f"mode must be one of {sorted(MODES)}")
118-
radius = payload.get("radius", 7)
119-
try:
120-
radius_int = int(radius)
121-
except (TypeError, ValueError) as exc:
122-
raise web.HTTPBadRequest(text="radius must be an integer") from exc
123-
return ModeState(mode=mode, radius=max(1, min(99, radius_int)))
116+
class EchoRequest(BaseModel):
117+
mode: Literal["echo", "gray", "invert", "blur", "robot"] = "echo"
118+
# The client sweeps 0..100; clamped rather than rejected, as before.
119+
radius: int = Field(7, description="Blur strength; blur mode only.")
120+
audio: bool = Field(False, description="Publish an audio track (robot needs one).")
121+
122+
123+
class UpdateRequest(BaseModel):
124+
mode: Literal["echo", "gray", "invert", "blur", "robot"] = "echo"
125+
radius: int = 7
126+
127+
128+
class SessionResponse(BaseModel):
129+
session: str
130+
in_: str = Field(..., alias="in")
131+
out: str
132+
mode: str
133+
radius: int | None = None
134+
135+
model_config = {"populate_by_name": True}
124136

125137

126138
def _odd_kernel(radius: int) -> int:
@@ -181,119 +193,109 @@ def _transform_frame(
181193
return out
182194

183195

184-
async def _handle_echo(request: web.Request) -> web.Response:
185-
global state
186-
session_id = _session_id(request)
187-
188-
if state is not None:
189-
if state.session_id != session_id:
190-
raise web.HTTPConflict(text="echo runner already has an active session")
191-
return web.json_response(state.to_json())
192-
193-
# Pass the request so the SDK opens channels using the orchestrator's
194-
# Session-Control header, whose URLs are reachable from the runner's network.
195-
channels = await request.app["registration"].create_trickle_channels( # Livepeer: 2
196-
request,
197-
[
198-
{"name": "in", "mime_type": "video/mp2t"},
199-
{"name": "out", "mime_type": "video/mp2t"},
200-
],
201-
)
202-
by_name = {channel["name"]: channel for channel in channels}
203-
if "in" not in by_name or "out" not in by_name:
204-
raise web.HTTPInternalServerError(
205-
text="orchestrator did not return in/out channels"
196+
def build_app(args: argparse.Namespace) -> FastAPI:
197+
@asynccontextmanager
198+
async def _lifespan(app: FastAPI):
199+
app.state.registration = await register_runner( # Livepeer: 1
200+
args.orchestrator,
201+
secret=args.orchSecret,
202+
runner_url=args.runner_url,
203+
app="livepeer-example/echo",
204+
mode="persistent",
205+
price=args.price,
206+
)
207+
log.info(
208+
"registered runner_id=%s orchestrator=%s",
209+
app.state.registration.runner_id,
210+
app.state.registration.orchestrator_url,
211+
)
212+
yield
213+
await _close_pipeline()
214+
with suppress(Exception):
215+
await app.state.registration.close() # Livepeer: 3
216+
217+
app = FastAPI(title="livepeer-example/echo", version="0.1.0", lifespan=_lifespan)
218+
219+
@app.post("/echo", response_model=SessionResponse, response_model_by_alias=True)
220+
async def echo(body: EchoRequest, request: Request) -> dict[str, Any]:
221+
global state
222+
session_id = _session_id(request)
223+
224+
if state is not None:
225+
if state.session_id != session_id:
226+
raise HTTPException(409, "echo runner already has an active session")
227+
return state.to_json()
228+
229+
# Pass the request so the SDK opens channels using the orchestrator's
230+
# Session-Control header, whose URLs are reachable from the runner's network.
231+
channels = (
232+
await request.app.state.registration.create_trickle_channels( # Livepeer: 2
233+
request,
234+
[
235+
{"name": "in", "mime_type": "video/mp2t"},
236+
{"name": "out", "mime_type": "video/mp2t"},
237+
],
238+
)
239+
)
240+
by_name = {channel["name"]: channel for channel in channels}
241+
if "in" not in by_name or "out" not in by_name:
242+
raise HTTPException(500, "orchestrator did not return in/out channels")
243+
244+
mode = ModeState(mode=body.mode, radius=max(1, min(99, body.radius)))
245+
# Tracks are declared upfront and the container waits for a first frame on
246+
# each, so only declare audio when the client says it is sending some.
247+
tracks: list[VideoOutputConfig | AudioOutputConfig] = [VideoOutputConfig()]
248+
if body.audio:
249+
tracks.append(AudioOutputConfig())
250+
# internal_url: runner-reachable address (same as public url on a shared net).
251+
publisher = MediaPublish(
252+
by_name["out"].get("internal_url", by_name["out"]["url"]),
253+
config=MediaPublishConfig(tracks=tracks),
206254
)
207255

208-
# for production apps, handle errors
209-
payload = json.loads(await request.read())
210-
mode = _parse_mode(payload)
211-
# Tracks are declared upfront and the container waits for a first frame on each,
212-
# so only declare audio when the client says it is sending some.
213-
tracks: list[VideoOutputConfig | AudioOutputConfig] = [VideoOutputConfig()]
214-
send_audio = payload.get("audio", False)
215-
if not isinstance(send_audio, bool):
216-
raise web.HTTPBadRequest(text="audio must be a boolean")
217-
if send_audio:
218-
tracks.append(AudioOutputConfig())
219-
# internal_url: runner-reachable address (same as the public url on a shared net).
220-
publisher = MediaPublish(
221-
by_name["out"].get("internal_url", by_name["out"]["url"]),
222-
config=MediaPublishConfig(tracks=tracks),
223-
)
224-
225-
async def _on_frame(decoded) -> None:
226-
frame = _transform_frame(decoded, mode)
227-
if frame is not None:
228-
await publisher.write_frame(frame)
229-
230-
output = MediaOutput(
231-
by_name["in"].get("internal_url", by_name["in"]["url"]), on_frame=_on_frame
232-
)
233-
234-
# Hand public channel urls to the client, so it can send/receive media.
235-
state = EchoSession(
236-
session_id=session_id,
237-
in_url=by_name["in"]["url"],
238-
out_url=by_name["out"]["url"],
239-
mode=mode,
240-
output=output,
241-
publisher=publisher,
242-
)
243-
for task in output.callback_tasks():
244-
task.add_done_callback(lambda _task: asyncio.create_task(_close_pipeline()))
245-
log.info("started echo session %s", session_id)
246-
return web.json_response(state.to_json())
256+
async def _on_frame(decoded) -> None:
257+
frame = _transform_frame(decoded, mode)
258+
if frame is not None:
259+
await publisher.write_frame(frame)
247260

261+
output = MediaOutput(
262+
by_name["in"].get("internal_url", by_name["in"]["url"]), on_frame=_on_frame
263+
)
248264

249-
async def _handle_update(request: web.Request) -> web.Response:
250-
session_id = _session_id(request)
251-
if state is None:
252-
raise web.HTTPNotFound(text="echo session not started")
253-
if state.session_id != session_id:
254-
raise web.HTTPConflict(text="echo runner has a different active session")
265+
# Hand public channel urls to the client, so it can send/receive media.
266+
state = EchoSession(
267+
session_id=session_id,
268+
in_url=by_name["in"]["url"],
269+
out_url=by_name["out"]["url"],
270+
mode=mode,
271+
output=output,
272+
publisher=publisher,
273+
)
274+
for task in output.callback_tasks():
275+
task.add_done_callback(lambda _t: asyncio.create_task(_close_pipeline()))
276+
log.info("started echo session %s", session_id)
277+
return state.to_json()
278+
279+
@app.post("/update", response_model=SessionResponse, response_model_by_alias=True)
280+
async def update(body: UpdateRequest, request: Request) -> dict[str, Any]:
281+
session_id = _session_id(request)
282+
if state is None:
283+
raise HTTPException(404, "echo session not started")
284+
if state.session_id != session_id:
285+
raise HTTPException(409, "echo runner has a different active session")
286+
state.mode.mode = body.mode
287+
state.mode.radius = max(1, min(99, body.radius))
288+
return state.to_json()
255289

256-
# for production apps, handle errors
257-
mode = _parse_mode(json.loads(await request.read()))
258-
state.mode.mode = mode.mode
259-
state.mode.radius = mode.radius
260-
return web.json_response(state.to_json())
290+
return app
261291

262292

263293
def main() -> None:
264294
logging.basicConfig(
265295
level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s"
266296
)
267297
args = _parse_args()
268-
269-
async def _on_startup(app: web.Application) -> None:
270-
app["registration"] = await register_runner( # Livepeer: 1
271-
args.orchestrator,
272-
secret=args.orchSecret,
273-
runner_url=args.runner_url,
274-
app="livepeer-example/echo",
275-
mode="persistent", # realtime trickle streaming is a held-open session
276-
# Metered: the session is billed per second of wall-clock for as long
277-
# as the client holds it, which is what a live stream costs.
278-
price=args.price, # USD per hour
279-
)
280-
log.info(
281-
"registered runner_id=%s orchestrator=%s",
282-
app["registration"].runner_id,
283-
app["registration"].orchestrator_url,
284-
)
285-
286-
async def _on_cleanup(app: web.Application) -> None:
287-
await _close_pipeline()
288-
with suppress(Exception):
289-
await app["registration"].close() # Livepeer: 3
290-
291-
app = web.Application()
292-
app.router.add_post("/echo", _handle_echo)
293-
app.router.add_post("/update", _handle_update)
294-
app.on_startup.append(_on_startup)
295-
app.on_cleanup.append(_on_cleanup)
296-
web.run_app(app, host=args.host, port=DEFAULT_PORT)
298+
uvicorn.run(build_app(args), host=args.host, port=DEFAULT_PORT, access_log=False)
297299

298300

299301
if __name__ == "__main__":

realtime-transcription/Dockerfile

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ RUN apt-get update \
1212
# faster-whisper) loads cuBLAS/cuDNN from the nvidia wheels, so no CUDA base image
1313
# is needed -- the host driver comes in via the compose `deploy` reservation.
1414
RUN pip install --no-cache-dir \
15+
fastapi uvicorn websockets \
1516
faster-whisper numpy \
1617
nvidia-cublas-cu12 nvidia-cudnn-cu12 \
1718
"livepeer-gateway @ git+https://github.com/livepeer/livepeer-python-gateway@main"

realtime-transcription/pyproject.toml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,9 @@ version = "0.1.0"
44
description = "Streaming speech-to-text (WebSocket) example app for the Livepeer network."
55
requires-python = ">=3.12"
66
dependencies = [
7+
"fastapi", # runner: routes + generated OpenAPI schema
8+
"uvicorn", # runner: ASGI server
9+
"websockets", # runner: uvicorn WebSocket support
710
"faster-whisper", # runner: streaming Whisper ASR
811
"numpy", # runner: PCM buffers + energy VAD
912
"aiohttp",

0 commit comments

Comments
 (0)