Skip to content
Open
Show file tree
Hide file tree
Changes from 65 commits
Commits
Show all changes
68 commits
Select commit Hold shift + click to select a range
8058120
Revert address_hex
j0sh May 15, 2026
4305580
Add LiveRunner registration
j0sh May 7, 2026
a712cf0
Add trickle create / remove API
j0sh May 7, 2026
2b1b00c
Add decode / demux callbacks
j0sh May 8, 2026
be92279
Update to session-based runner auth
j0sh May 11, 2026
d7bb4ca
ruff
j0sh May 11, 2026
db20786
Add output bytes callback, improve callback cleanup
j0sh May 12, 2026
9401537
Handle orchestrator restarts in heartbeat
j0sh May 12, 2026
0c87e75
Refactor runner trickle handling
j0sh May 12, 2026
400b4f3
Add methods to start / stop persistent runner sessions
j0sh May 12, 2026
e25dda2
Async-native json requests
j0sh May 13, 2026
dac0296
Add runner discovery
j0sh May 13, 2026
93c05a6
Get rid of code in orchestrator.py
j0sh May 13, 2026
78dc085
Add runner selection
j0sh May 13, 2026
7b04ba3
Add echo and text examples
j0sh May 14, 2026
65a7e2b
Add live runner payments.
j0sh May 14, 2026
0772b32
Rename session_url to runner_url where appropriate
j0sh May 14, 2026
c2b3b6f
live_runner: unify session and single-shot runner calls
j0sh May 15, 2026
a99d188
Thread body and method through to selection
j0sh May 15, 2026
d9b983d
Preserve payment session, simplify payments flow
j0sh May 15, 2026
d00951e
Make runner discovery async, support orch discovery
j0sh May 15, 2026
f48707b
Port Scope to runners
j0sh May 15, 2026
43d3c28
Clean up echo examples
j0sh May 15, 2026
92d9517
Add ping-pong websocket example
j0sh May 18, 2026
a14d749
Add channel reader callbacks
j0sh May 20, 2026
97e4fc5
Runner session callbacks
j0sh May 21, 2026
9f4fe33
Rename 'session' mode to 'persistent'
j0sh May 21, 2026
d3ecb91
Check for 401 instead of message string in heartbeat re-register
j0sh May 21, 2026
a2ec6e1
Handle Scope serverless and runners separately
j0sh May 21, 2026
8ec09b3
Accept LiveRunnerSessionRequest in stop helper
j0sh May 21, 2026
5a7e832
Pass in runner session token to stop helper
j0sh May 26, 2026
1b1b766
Retry if starting a Scope runner fails
j0sh May 27, 2026
2eda0ff
Propagate actual error during selection
j0sh May 27, 2026
d363e5e
Don't parse payment results
j0sh May 29, 2026
795d402
Add internal_url to trickle responses, bump Python minimum version
j0sh May 29, 2026
b72d72d
Add proxy API
j0sh Jul 11, 2026
ca10a3a
Use live pricing API
j0sh Jul 16, 2026
e60c298
feat: forward orchestrators list through reserve_session (#37)
rickstaa Jul 17, 2026
34cae50
fix(byoc): per-signer payment type (dual-path) — rescue from prod ima…
seanhanca Jul 18, 2026
4dd69cb
fix(live-runner): per-signer payment type on the LR path (dual-path s…
seanhanca Jul 18, 2026
a23dfe9
Add metadata field
j0sh Jul 21, 2026
29c1783
Make target_url optional in proxy API
j0sh Jul 21, 2026
30ea2ec
Add default proxy support
j0sh Jul 22, 2026
34aee7c
Fix echo runner example
j0sh Jul 22, 2026
04404a1
Revert "fix(live-runner): per-signer payment type on the LR path (dua…
j0sh Jul 22, 2026
fe1d86a
Revert "fix(byoc): per-signer payment type (dual-path) — rescue from …
j0sh Jul 22, 2026
ce7a0a3
Add fixed pricing
j0sh Jul 24, 2026
2dbc865
Add tests and CI
j0sh Jul 30, 2026
a242302
Add SSE support to single-shot calls (#25)
rickstaa Jul 27, 2026
77a0945
More tests
j0sh Jul 31, 2026
e267285
Stabilize decoder cleanup test
j0sh Jul 31, 2026
db9c461
ruff
j0sh Jul 31, 2026
a01d574
Address Copilot review comments
j0sh Jul 31, 2026
37844c2
Address Copilot follow-up comments
j0sh Jul 31, 2026
0fa8963
Disable repo-wide Ruff check temporarily
j0sh Jul 31, 2026
3700a70
feat: return raw bytes from call_runner for non-JSON responses
rickstaa Jul 29, 2026
f45cb26
refactor: match single-document JSON by media subtype
rickstaa Jul 30, 2026
7b4bdb2
docs: trim result field comment and helper docstring
rickstaa Jul 30, 2026
62f4bd4
refactor: name the non-JSON body field `content`
rickstaa Jul 30, 2026
0233659
fix: preserve JSON response encoding
j0sh Jul 31, 2026
f474038
refactor: keep request_data return shape
j0sh Jul 31, 2026
b97ab53
refactor: make request body helper internal
j0sh Jul 31, 2026
a7054f7
fix(call_runner): stop asking apps for JSON
rickstaa Jul 30, 2026
2f29404
Update Live Runner header expectations
j0sh Jul 31, 2026
995f93f
Keep metered sessions funded for single-shot and reserved streams (#53)
rickstaa Aug 3, 2026
7253350
Add app field to signer
j0sh Aug 3, 2026
a560fb5
Add __str__ to
j0sh Aug 3, 2026
45830bf
Merge remote-tracking branch 'github/ja/live-runner' into ja/live-runner
j0sh Aug 3, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 41 additions & 0 deletions .github/workflows/tests.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
name: Tests

on:
pull_request:
push:
branches:
- main

permissions:
contents: read

jobs:
ruff:
# disabled until after a repo-wide ruff pass
if: ${{ false }}
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- name: Install uv
uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0
with:
enable-cache: true
python-version: "3.12"
- name: Install pinned lint dependencies
run: uv sync --locked --group lint
- name: Run Ruff
run: uv run --frozen --group lint ruff check .

pytest:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔒 Security & Privacy | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== workflow excerpt =="
if [ -f .github/workflows/tests.yml ]; then
  cat -n .github/workflows/tests.yml
else
  echo ".github/workflows/tests.yml not found"
fi

echo
echo "== checkout usages =="
rg -n "actions/checkout|persist-credentials|pytest|pull_request|permissions:" .github/workflows || true

echo
echo "== workflow files list =="
git ls-files .github/workflows

Repository: livepeer/livepeer-python-gateway

Length of output: 1465


🌐 Web query:

actions/checkout default persist-credentials true README

💡 Result:

In the actions/checkout GitHub Action, the persist-credentials input controls whether the authentication token or SSH key is configured in the local git configuration [1][2][3]. When set to true (which remains the default), the credentials are made available to subsequent steps in the job to allow authenticated git operations [1][2]. The action automatically removes these credentials during the post-job cleanup phase [1][2]. Starting with version 6.0.0, the action improved security by storing these credentials in a separate file under the $RUNNER_TEMP directory instead of writing them directly into the.git/config file [2][4]. While there has been community discussion and proposals to change the default value of persist-credentials to false to minimize security risks [5][6], the default value remains true in the current official documentation and action configuration [2][3]. Users who wish to disable this behavior and prevent credentials from being available to other steps in their workflow can explicitly set persist-credentials: false [1][2].

Citations:


Disable checkout credential persistence

This workflow runs pull-request code via pytest and does not need authenticated Git operations after checkout. Set persist-credentials: false so the checkout token is not made available to later test steps.

Proposed fix
-      - uses: actions/checkout@v7
+      - uses: actions/checkout@v7
+        with:
+          persist-credentials: false
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
- uses: actions/checkout@v7
- uses: actions/checkout@v7
with:
persist-credentials: false
🧰 Tools
🪛 zizmor (1.28.0)

[warning] 16-16: credential persistence through GitHub Actions artifacts (artipacked): does not set persist-credentials: false

(artipacked)

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In @.github/workflows/tests.yml at line 16, Update the actions/checkout step in
the tests workflow to set persist-credentials to false, ensuring the checkout
token is unavailable to subsequent pytest and test steps.

Source: Linters/SAST tools

- name: Install uv
uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0
with:
enable-cache: true
python-version: "3.12"
- name: Install locked test dependencies
run: uv sync --locked --group test
- name: Run pytest with coverage
run: uv run --frozen --group test pytest --cov=livepeer_gateway --cov-branch --cov-report=term-missing
22 changes: 22 additions & 0 deletions README
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,28 @@ uv sync --extra dev
uv run generate-lp-rpc
```

## Tests

Install the locked test dependencies and run the complete pytest suite:

```
uv sync --locked --group test
uv run --group test pytest
```

Pass a test file or node ID to pytest for a focused run:

```
uv run --group test pytest tests/test_live_runner.py
uv run --group test pytest tests/test_live_runner.py::TestLiveRunnerHelpers::test_parse_go_duration
```

Run the suite with the configured line and branch coverage:

```
uv run --group test pytest --cov=livepeer_gateway --cov-branch --cov-report=term-missing
```

## Usage Examples

First install dependencies for example code
Expand Down
40 changes: 40 additions & 0 deletions examples/echo/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
# Echo Live Runner Demo

This example demonstrates:

* Runner registration
* Video input - taken from a local file
* Video output - echoed to output with blur applied
* Parameter updates - adjust the amount of blur

Start go-livepeer:

```sh
./livepeer -orchestrator -useLiveRunners -serviceAddr localhost:8935 -v 99 -orchSecret abcdef
```

Start the runner:

```sh
uv run examples/echo/runner.py --orchestrator https://localhost:8935 --orchSecret abcdef
```
Comment on lines +18 to +20

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Use the configured HTTP orchestrator URL.

The command starts go-livepeer without TLS but invokes the runner with https://localhost:8935; registration cannot connect. Change this to http://localhost:8935 unless TLS setup instructions are added.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@examples/echo/README.md` around lines 18 - 20, Update the example command in
the README to pass the configured non-TLS orchestrator URL, changing the
runner’s --orchestrator value from https://localhost:8935 to
http://localhost:8935. Keep the existing command and other arguments unchanged.


Run the client with a local sample input (`~/samples/bbb_720p.mp4`):

```sh
uv run examples/echo/client.py --blur ~/samples/bbb_720p.mp4
```

The resulting file is stored at echo-out.ts. To use a different file
or redirect to stdout for live playback:

```sh
uv run client.py --blur --output - ~/samples/bbb_720p.mp4 | ffplay -
```

The client discovers the `livepeer-sample/echo` runner automatically. To use a
different orchestrator or discovery endpoint:

```sh
uv run examples/echo/client.py --discovery http://localhost:8935/discovery --blur ~/samples/bbb_720p.mp4
```
162 changes: 162 additions & 0 deletions examples/echo/client.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,162 @@
#!/usr/bin/env python3
from __future__ import annotations

import argparse
import asyncio
import sys
import time
from contextlib import nullcontext, suppress
from pathlib import Path

import av

from livepeer_gateway.errors import LivepeerGatewayError
from livepeer_gateway.live_runner import stop_runner_session
from livepeer_gateway.media_output import MediaOutput
from livepeer_gateway.media_publish import MediaPublish
from livepeer_gateway.http import post_json
from livepeer_gateway.selection import reserve_session

DEFAULT_DISCOVERY = "http://localhost:8935/discovery"
ECHO_APP_ID = "livepeer-sample/echo"
DEFAULT_OUTPUT = "echo-out.ts"
BLUR_UPDATE_INTERVAL_S = 0.01
MAX_BLUR_RADIUS = 100


def _log(*args: object) -> None:
print(*args, file=sys.stderr)


def _parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(description="Run the proxied echo Live Runner demo.")
parser.add_argument("input")
parser.add_argument("--discovery", default=DEFAULT_DISCOVERY)
parser.add_argument("--output", default=DEFAULT_OUTPUT)
parser.add_argument("--radius", type=int, default=75)
parser.add_argument("--max-frames", type=int, default=0, help="Stop after this many input video frames (0 = full file).")
parser.add_argument("--blur", action="store_true", help="Sweep blur radius while publishing the sample.")
return parser.parse_args()


def _channel_url(echo_response: dict[str, object], name: str) -> str:
url = echo_response.get(name)
if not isinstance(url, str) or not url:
raise LivepeerGatewayError(f"echo response missing {name!r} url")
return url


async def _publish_video(
input_path: Path,
publish_url: str,
*,
max_frames: int = 0,
app_url: str = "",
blur: bool = False,
) -> None:
input_ = av.open(str(input_path))
try:
if not input_.streams.video:
raise LivepeerGatewayError(f"No video stream found in input file: {input_path}")
publisher = MediaPublish(publish_url)
prev_pts_time: float | None = None
prev_wall: float | None = None
next_update_pts_time: float | None = None
blur_radius = 0
blur_direction = 1

try:
for index, frame in enumerate(input_.decode(video=0), start=1):
if max_frames > 0 and index > max_frames:
break
current_pts_time = None
if frame.pts is not None and frame.time_base is not None:
current_pts_time = float(frame.pts * frame.time_base)
if next_update_pts_time is None:
next_update_pts_time = current_pts_time

while (
blur
and app_url
and current_pts_time is not None
and next_update_pts_time is not None
and current_pts_time >= next_update_pts_time
):
await post_json(f"{app_url.rstrip('/')}/update", {"mode": "blur", "radius": blur_radius})
if blur_radius == MAX_BLUR_RADIUS:
blur_direction = -1
elif blur_radius == 0:
blur_direction = 1
blur_radius += blur_direction
next_update_pts_time += BLUR_UPDATE_INTERVAL_S
Comment thread
coderabbitai[bot] marked this conversation as resolved.

if (
prev_pts_time is not None
and prev_wall is not None
and current_pts_time is not None
):
delta_s = current_pts_time - prev_pts_time
elapsed_s = time.monotonic() - prev_wall
sleep_s = max(0.0, delta_s - elapsed_s)
if sleep_s > 0:
await asyncio.sleep(sleep_s)

if current_pts_time is not None:
prev_pts_time = current_pts_time
prev_wall = time.monotonic()

await publisher.write_frame(frame)
finally:
await publisher.close()
finally:
input_.close()


async def main() -> None:
args = _parse_args()
input_path = Path(args.input).expanduser()
output_stdout = args.output.strip().lower() in {"-", "stdout"}
output_path = None if output_stdout else Path(args.output).expanduser()
if not input_path.exists():
raise SystemExit(f"input file does not exist: {input_path}")

session = None

try:
session = await reserve_session(discovery_url=args.discovery, app=ECHO_APP_ID)
_log("runner_url:", session.runner.url if session.runner is not None else session.runner_url)
_log("session_id:", session.session_id)
_log("app_url:", session.app_url)

echo = await post_json(f"{session.app_url.rstrip('/')}/echo", {"radius": args.radius})
in_url = _channel_url(echo, "in")
out_url = _channel_url(echo, "out")
_log("in:", in_url)
_log("out:", out_url)

with nullcontext(sys.stdout.buffer) if output_stdout else output_path.open("wb") as fh:
def _write_chunk(chunk: bytes) -> None:
fh.write(chunk)
if output_stdout:
fh.flush()

async with MediaOutput(out_url, on_bytes=_write_chunk):
await _publish_video(
input_path,
in_url,
max_frames=max(0, args.max_frames),
app_url=session.app_url,
blur=args.blur,
)
_log("publish complete; waiting for output to drain...")
fh.flush()
Comment on lines +143 to +152

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Wait for output consumption before closing it.

Line 151 claims to drain output, but the context exits immediately afterward and closes MediaOutput; transformed frames still in transit can be truncated. Await stream completion or its callback tasks after _publish_video() and before leaving the context.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@examples/echo/client.py` around lines 143 - 152, Update the MediaOutput
context flow around _publish_video so it awaits output stream completion or all
pending _write_chunk callback tasks before exiting the context. Keep the
existing publish-complete log, and ensure MediaOutput is not closed until
transformed frames have been fully consumed.

except LivepeerGatewayError as exc:
raise SystemExit(f"ERROR: {exc}") from exc
finally:
if session is not None:
with suppress(Exception):
await stop_runner_session(session)


if __name__ == "__main__":
asyncio.run(main())
Loading