From 167e8b84aeec3b13dc059fead3a6020449f25d9d Mon Sep 17 00:00:00 2001 From: Rick Staa Date: Thu, 30 Jul 2026 21:05:22 +0200 Subject: [PATCH 01/12] feat(echo): run on-chain with metered payments echo was the one example without an on-chain path, and the pricing column in the repo README was blank for it. A live stream is exactly what metered pricing exists for: the session is billed per second for as long as the client holds it, with no call boundary to bill against. Registering a price makes the orchestrator meter the session, so the client keeps it funded for its lifetime and lets go of the funding before releasing it. The upfront 402 payment only covers the signer's preroll, so a stream that runs longer is the case this example puts on-chain. Needs livepeer/livepeer-python-gateway#53: before it, a reserved session could not pay again and died at the preroll. Co-Authored-By: Claude Opus 5 (1M context) --- README.md | 14 +++---- echo/.env.example | 28 +++++++++++++ echo/README.md | 19 ++++++++- echo/client.py | 85 +++++++++++++++++++++++----------------- echo/compose.onchain.yml | 34 ++++++++++++++++ echo/runner.py | 9 +++++ 6 files changed, 146 insertions(+), 43 deletions(-) create mode 100644 echo/.env.example create mode 100644 echo/compose.onchain.yml diff --git a/README.md b/README.md index 505213c..619a192 100644 --- a/README.md +++ b/README.md @@ -37,13 +37,13 @@ Need a schema that isn't here? [Open an issue](https://github.com/livepeer/runne ## Examples -| Example | Goal | Registration | Mode | Transport | Pricing | -| ------------------------------ | ------------------------------------------------------------------------------------- | ------------ | ---------------------------------- | ----------------- | ----------------- | -| [`hello-world`](./hello-world) | The simplest app: one request, one response | dynamic | single-shot | HTTP (JSON) | fixed | -| [`tiles`](./tiles) | Capacity fan-out — one call per tile | dynamic | single-shot | HTTP (base64 PNG) | fixed | -| [`api-proxy`](./api-proxy) | Pass calls through to a hosted API — the operator holds the key, callers pay per call | static | single-shot | HTTP (JPEG bytes) | fixed | -| [`echo`](./echo) | Realtime video, transformed and echoed back | dynamic | persistent | trickle | — (offchain only) | -| [`vllm`](./vllm) | Drop-in OpenAI API; the client stays unmodified | static | persistent (single-shot by nature) | HTTP + SSE | hour | +| Example | Goal | Registration | Mode | Transport | Pricing | +| ------------------------------ | ------------------------------------------------------------------------------------- | ------------ | ---------------------------------- | ----------------- | ------- | +| [`hello-world`](./hello-world) | The simplest app: one request, one response | dynamic | single-shot | HTTP (JSON) | fixed | +| [`tiles`](./tiles) | Capacity fan-out — one call per tile | dynamic | single-shot | HTTP (base64 PNG) | fixed | +| [`api-proxy`](./api-proxy) | Pass calls through to a hosted API — the operator holds the key, callers pay per call | static | single-shot | HTTP (JPEG bytes) | fixed | +| [`echo`](./echo) | Realtime video, transformed and echoed back | dynamic | persistent | trickle | hour | +| [`vllm`](./vllm) | Drop-in OpenAI API; the client stays unmodified | static | persistent (single-shot by nature) | HTTP + SSE | hour | Start with `hello-world` (the smallest end-to-end path); the others each layer on one new idea. More will follow, including a full example that exercises every feature. Each is self-contained and runs **offchain** (free, no wallet); most also run **on-chain** (paid) — see each README. diff --git a/echo/.env.example b/echo/.env.example new file mode 100644 index 0000000..5d4c7a5 --- /dev/null +++ b/echo/.env.example @@ -0,0 +1,28 @@ +# Copy to .env (gitignored) and fill in. Never commit secrets. +# Keystore dirs: absolute paths OUTSIDE this repo, mounted read-only. + +NETWORK=arbitrum-one-mainnet +ETH_RPC_URL=https://arb1.arbitrum.io/rpc + +# Signer (payer): needs an on-chain deposit + reserve. +SIGNER_KEYSTORE_DIR=/absolute/path/to/signer-keystore +SIGNER_ETH_ACCT=0xYourSignerAddress +SIGNER_ETH_PASSWORD=your-signer-keystore-password + +# Orchestrator operating key (split-key): needs ETH for gas to redeem tickets. +ORCH_KEYSTORE_DIR=/absolute/path/to/operator-keystore +ORCH_ETH_ACCT=0xYourOperatorAddress +ORCH_ETH_PASSWORD=your-operator-keystore-password +# Registered orch = ticket recipient (-ethOrchAddr); empty = use the operating key. +ORCH_ONCHAIN_ADDR=0xYourRegisteredOrchestrator + +# Runner price (on-chain): USD per hour, metered per second for as long as the +# client holds the session. Keep it modest: each payment covers one billing +# interval (5s by default), and the signer signs at most 100 tickets per payment +# against the demo orchestrator's -ticketEV=1e9. At 0.10/hour one payment is +# ~0.00014 USD, well inside that. +PRICE=0.10 +# Signer's max-price cap (payer side), compared per billing unit. Metered here, +# so the unit is one second and this must exceed PRICE / 3600 +# (0.000111USD is ~0.40 USD/hour). +MAX_PRICE_PER_UNIT=0.000111USD diff --git a/echo/README.md b/echo/README.md index 73a7681..8061274 100644 --- a/echo/README.md +++ b/echo/README.md @@ -8,7 +8,7 @@ A realtime video app on the Livepeer network: it receives a live video stream ov | Runner mode | persistent (held-open session) | | Registration | dynamic (self-registers via the SDK) | | Transport | trickle (realtime video in/out) | -| Pricing | none (offchain only) | +| Pricing | hour (metered per second) | | Port | 8989 | Prerequisites (Docker, `uv`, the not-yet-released SDK) and the shared setup are in the [repo README](../README.md). @@ -65,3 +65,20 @@ The `ffplay` low-delay flags (`-fflags nobuffer -flags low_delay -framedrop`) ke - `--radius N` sets the initial blur strength, `--max-frames N` stops early. Stop the stack with `docker compose down`. + +## Run on-chain (paid) + +Layer `compose.onchain.yml` to run the orchestrator on-chain with a remote signer paying for the session. This example showcases **metered pricing**: `PRICE` is USD per hour, billed per second for as long as the client holds the session, the natural fit for a stream with no fixed length. For the required RPC and wallets see [On-chain (paid) setup](../README.md#on-chain-paid-setup) in the repo README. + +```sh +cp .env.example .env # fill in RPC, network, keystore paths, accounts, pricing +docker compose -f compose.yml -f compose.onchain.yml up -d --build +uv run client.py ~/samples/bbb_720p.mp4 --mode blur \ + --discovery https://localhost:8935/discovery \ + --signer http://localhost:7936 +docker compose -f compose.yml -f compose.onchain.yml down +``` + +A metered session pays **more than once**. The upfront payment that answers the 402 challenge only buys the signer's preroll (about ten seconds), while the orchestrator keeps debiting every few seconds and releases the session on the first debit it cannot cover. So `reserve_session` keeps the session funded in the background for as long as the client holds it, and leaving the client's `async with session` block stops that before the session is released. A stream that outlives the preroll is the whole point of this example on-chain: watch `docker compose logs -f orchestrator` and you should see repeated payments, not one. + +That is the difference from `hello-world` and `tiles`, whose fixed pricing settles the entire bill upfront and starts no ticker. diff --git a/echo/client.py b/echo/client.py index 0c10f3e..8e7d830 100644 --- a/echo/client.py +++ b/echo/client.py @@ -6,9 +6,15 @@ chain `ffmpeg -> client -> ffplay`. Livepeer integration (grep `# Livepeer:`): - 1. reserve_session() — discover the runner, reserve a session + 1. reserve_session() — discover the runner, reserve a session. On the paid + path it answers the 402 challenge and then keeps the + session funded in the background, because the price is + metered: the orchestrator debits every few seconds and + releases the session on the first debit it cannot cover. 2. MediaPublish/MediaOutput — publish frames to `in`, read echoed frames from `out` - 3. stop_runner_session() — end the session (settles payment on-chain) + 3. stop_runner_session() — end the session. Leaving the `async with` stops the + funding first, so nothing is paid for a session that + is about to go away. """ from __future__ import annotations @@ -51,6 +57,9 @@ def _parse_args() -> argparse.Namespace: ), ) parser.add_argument("--discovery", default=DEFAULT_DISCOVERY) + parser.add_argument( + "--signer", default="", help="Remote signer base URL (on-chain/paid path)." + ) parser.add_argument( "--output", default=DEFAULT_OUTPUT, @@ -189,41 +198,47 @@ async def main() -> None: session = None try: - session = await reserve_session( - discovery_url=args.discovery, app=APP_ID - ) # Livepeer: 1 + session = await reserve_session( # Livepeer: 1 + discovery_url=args.discovery, + app=APP_ID, + signer_url=args.signer.strip() or None, + ) log.info("session_id=%s app_url=%s", session.session_id, session.app_url) - echo = await post_json( - f"{session.app_url.rstrip('/')}/echo", - {"radius": args.radius, "mode": args.mode}, - ) - in_url = _channel_url(echo, "in") - out_url = _channel_url(echo, "out") - log.info("in=%s out=%s", in_url, 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 - ): # Livepeer: 2 (read echoed frames) - await _publish_video( - input_source, - in_url, - max_frames=max(0, args.max_frames), - app_url=session.app_url, - mode=args.mode, - blur_period=args.blur_period, - ) - log.info("publish complete; waiting for output to drain...") - fh.flush() + # The session funds itself while it is held; leaving this block stops that. + async with session: + echo = await post_json( + f"{session.app_url.rstrip('/')}/echo", + {"radius": args.radius, "mode": args.mode}, + ) + in_url = _channel_url(echo, "in") + out_url = _channel_url(echo, "out") + log.info("in=%s out=%s", in_url, 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 + ): # Livepeer: 2 (read echoed frames) + await _publish_video( + input_source, + in_url, + max_frames=max(0, args.max_frames), + app_url=session.app_url, + mode=args.mode, + blur_period=args.blur_period, + ) + log.info("publish complete; waiting for output to drain...") + fh.flush() except LivepeerGatewayError as exc: raise SystemExit(f"ERROR: {exc}") from exc finally: diff --git a/echo/compose.onchain.yml b/echo/compose.onchain.yml new file mode 100644 index 0000000..897f1b1 --- /dev/null +++ b/echo/compose.onchain.yml @@ -0,0 +1,34 @@ +# On-chain payment overlay for echo. Layer it on the offchain base: +# docker compose -f compose.yml -f compose.onchain.yml up -d --build +# +# Adds the shared remote signer, re-points the orchestrator on-chain (see +# ../compose.onchain.yml), and registers the app with a price so the orchestrator +# issues a payment challenge. Requires a local .env (gitignored); copy .env.example +# and fill it in. The session is metered, so a stream is billed for as long as it +# runs. Then pay through the signer: +# uv run client.py ~/samples/clip.mp4 --mode blur \ +# --discovery https://localhost:8935/discovery \ +# --signer http://localhost:7936 + +services: + signer: + extends: + file: ../compose.onchain.yml + service: signer + ports: + - "7936:7936" + + orchestrator: + extends: + file: ../compose.onchain.yml + service: orchestrator + + # Re-declare the command to advertise a price (base file registers free). + app: + command: + - --host=0.0.0.0 + - --orchestrator=https://orchestrator:8935 + - --orchSecret=abcdef + - --runner-url=http://app:8989 + # Billed per second of session (metered); price cap in .env.example. + - --price=${PRICE} diff --git a/echo/runner.py b/echo/runner.py index dcf023b..43291da 100644 --- a/echo/runner.py +++ b/echo/runner.py @@ -86,6 +86,12 @@ def _parse_args() -> argparse.Namespace: parser.add_argument("--orchSecret", default="abcdef") parser.add_argument("--runner-url", default=f"http://{DEFAULT_HOST}:{DEFAULT_PORT}") parser.add_argument("--host", default=DEFAULT_HOST) + parser.add_argument( + "--price", + type=float, + default=0, + help="Runner price in USD per hour (0 = free, the offchain default).", + ) return parser.parse_args() @@ -222,6 +228,9 @@ async def _on_startup(app: web.Application) -> None: runner_url=args.runner_url, app="livepeer-example/echo", mode="persistent", # realtime trickle streaming is a held-open session + # Metered: the session is billed per second of wall-clock for as long + # as the client holds it, which is what a live stream costs. + price=args.price, # USD per hour ) log.info( "registered runner_id=%s orchestrator=%s", From eb229cb9a24b05ed2d6f419188d3c246f7fddb12 Mon Sep 17 00:00:00 2001 From: Rick Staa Date: Thu, 30 Jul 2026 21:13:16 +0200 Subject: [PATCH 02/12] docs(echo): use a generated sample clip, not a personal path The commands pointed at ~/samples/bbb_720p.mp4, which only exists on one machine, so the first thing a reader runs fails. Generate the clip with ffmpeg instead: no download to rot, and -t sets the length, which matters on-chain where the stream has to outlive the preroll. Co-Authored-By: Claude Opus 5 (1M context) --- echo/README.md | 9 +++++---- echo/compose.onchain.yml | 2 +- echo/compose.yml | 2 +- 3 files changed, 7 insertions(+), 6 deletions(-) diff --git a/echo/README.md b/echo/README.md index 8061274..280fb66 100644 --- a/echo/README.md +++ b/echo/README.md @@ -30,10 +30,11 @@ curl -sk https://localhost:8935/discovery | jq '.[].runners[].app' # confirm l The input is a file path, or `-` to read an MPEG-TS stream from stdin (so you can pipe in anything ffmpeg produces); the output is a file, or `-` to write the echoed stream to stdout (pipe it to a player). -**From a file** — writes the result to `echo-out.ts`: +**From a file** — writes the result to `echo-out.ts`. Any video works; make one if you have nothing to hand (`-t` sets the length): ```sh -uv run client.py --mode blur --discovery https://localhost:8935/discovery ~/samples/bbb_720p.mp4 +ffmpeg -f lavfi -i testsrc=size=1280x720:rate=30 -t 30 -c:v libx264 -preset ultrafast -pix_fmt yuv420p sample.mp4 +uv run client.py --mode blur --discovery https://localhost:8935/discovery sample.mp4 ``` **Live from ffmpeg's test pattern** — no file needed; watch the test counter echo back in real time: @@ -73,12 +74,12 @@ Layer `compose.onchain.yml` to run the orchestrator on-chain with a remote signe ```sh cp .env.example .env # fill in RPC, network, keystore paths, accounts, pricing docker compose -f compose.yml -f compose.onchain.yml up -d --build -uv run client.py ~/samples/bbb_720p.mp4 --mode blur \ +uv run client.py sample.mp4 --mode blur \ --discovery https://localhost:8935/discovery \ --signer http://localhost:7936 docker compose -f compose.yml -f compose.onchain.yml down ``` -A metered session pays **more than once**. The upfront payment that answers the 402 challenge only buys the signer's preroll (about ten seconds), while the orchestrator keeps debiting every few seconds and releases the session on the first debit it cannot cover. So `reserve_session` keeps the session funded in the background for as long as the client holds it, and leaving the client's `async with session` block stops that before the session is released. A stream that outlives the preroll is the whole point of this example on-chain: watch `docker compose logs -f orchestrator` and you should see repeated payments, not one. +A metered session pays **more than once**. The upfront payment that answers the 402 challenge only buys the signer's preroll (about ten seconds), while the orchestrator keeps debiting every few seconds and releases the session on the first debit it cannot cover. So `reserve_session` keeps the session funded in the background for as long as the client holds it, and leaving the client's `async with session` block stops that before the session is released. A stream that outlives the preroll is the whole point of this example on-chain, so use a clip of at least a few tens of seconds (the 30s `sample.mp4` above is enough): watch `docker compose logs -f orchestrator` and you should see repeated payments, not one. That is the difference from `hello-world` and `tiles`, whose fixed pricing settles the entire bill upfront and starts no ticker. diff --git a/echo/compose.onchain.yml b/echo/compose.onchain.yml index 897f1b1..ad5a928 100644 --- a/echo/compose.onchain.yml +++ b/echo/compose.onchain.yml @@ -6,7 +6,7 @@ # issues a payment challenge. Requires a local .env (gitignored); copy .env.example # and fill it in. The session is metered, so a stream is billed for as long as it # runs. Then pay through the signer: -# uv run client.py ~/samples/clip.mp4 --mode blur \ +# uv run client.py sample.mp4 --mode blur \ # --discovery https://localhost:8935/discovery \ # --signer http://localhost:7936 diff --git a/echo/compose.yml b/echo/compose.yml index 23591f9..cdaa6ef 100644 --- a/echo/compose.yml +++ b/echo/compose.yml @@ -4,7 +4,7 @@ # pulled in with `extends`; this file only adds the app. Once up, drive it from # the host with the SDK client and a sample video: # docker compose up -d --build -# uv run client.py --blur ~/samples/clip.mp4 # writes echo-out.ts +# uv run client.py --mode blur sample.mp4 # writes echo-out.ts services: orchestrator: From 6ab8d513a41e4cc5e0842dcdec99ee67b6998f6b Mon Sep 17 00:00:00 2001 From: Rick Staa Date: Thu, 30 Jul 2026 21:18:03 +0200 Subject: [PATCH 03/12] docs(echo): note that the pipes need --signer on-chain The on-chain section showed only the file command, so running the ffmpeg or webcam pipe against a paid stack fails at the payment challenge and ffmpeg reports a broken pipe, which reads like a camera problem rather than an unpaid caller. Co-Authored-By: Claude Opus 5 (1M context) --- echo/README.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/echo/README.md b/echo/README.md index 280fb66..b47b2f9 100644 --- a/echo/README.md +++ b/echo/README.md @@ -80,6 +80,8 @@ uv run client.py sample.mp4 --mode blur \ docker compose -f compose.yml -f compose.onchain.yml down ``` +The ffmpeg and webcam pipes above work the same way on-chain: add `--signer` to the `client.py` in the pipeline. Without it the client stops at the payment challenge (`Live runner paid call requires signer_url`), which closes the pipe and leaves ffmpeg reporting `Broken pipe` — the paid stack refusing an unpaid caller, not a broken camera. + A metered session pays **more than once**. The upfront payment that answers the 402 challenge only buys the signer's preroll (about ten seconds), while the orchestrator keeps debiting every few seconds and releases the session on the first debit it cannot cover. So `reserve_session` keeps the session funded in the background for as long as the client holds it, and leaving the client's `async with session` block stops that before the session is released. A stream that outlives the preroll is the whole point of this example on-chain, so use a clip of at least a few tens of seconds (the 30s `sample.mp4` above is enough): watch `docker compose logs -f orchestrator` and you should see repeated payments, not one. That is the difference from `hello-world` and `tiles`, whose fixed pricing settles the entire bill upfront and starts no ticker. From 1d1f46b7b509b7aae1fa19ab97c65d23ccda5544 Mon Sep 17 00:00:00 2001 From: Rick Staa Date: Tue, 4 Aug 2026 12:24:10 +0200 Subject: [PATCH 04/12] chore(compose): run the demo stack on a go-livepeer master build Metered sessions need the session-scoped payment URL that go-livepeer #4008 adds to the runner payment challenge. It landed after v0.9.0, so on the release tag the SDK cannot keep a session funded and echo stops at reservation. Pin the orchestrator and signer to the master build that carries it, and re-pin once a release ships with the change. Co-Authored-By: Claude Opus 5 (1M context) --- compose.onchain.yml | 3 ++- compose.orchestrator.yml | 7 ++++++- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/compose.onchain.yml b/compose.onchain.yml index cf5df7b..71e1a50 100644 --- a/compose.onchain.yml +++ b/compose.onchain.yml @@ -13,7 +13,8 @@ services: signer: - image: livepeer/go-livepeer:v0.9.0 + # Master build, matching the orchestrator; see compose.orchestrator.yml. + image: livepeer/go-livepeer:sha-cc49228 container_name: example_apps_signer command: - -remoteSigner diff --git a/compose.orchestrator.yml b/compose.orchestrator.yml index 1381e55..396d5e1 100644 --- a/compose.orchestrator.yml +++ b/compose.orchestrator.yml @@ -10,10 +10,15 @@ # service: orchestrator # # The go-livepeer image entrypoint is `livepeer`, so `command:` is flags only. +# +# Image: a master build, not a release tag. Metered sessions need the +# session-scoped payment URL in the runner payment challenge (go-livepeer #4008), +# which landed after v0.9.0; without it the SDK cannot keep a session funded. +# Re-pin to the release tag once one ships with that change. services: orchestrator: - image: livepeer/go-livepeer:v0.9.0 + image: livepeer/go-livepeer:sha-cc49228 container_name: example_apps_orchestrator # Two distinct addresses: # serviceAddr advertised to external clients in discovery responses, From 87f2773c95f110c0d020d67d19086529292fa262 Mon Sep 17 00:00:00 2001 From: Rick Staa Date: Tue, 4 Aug 2026 12:24:24 +0200 Subject: [PATCH 05/12] fix(compose): raise ticketEV so a metered payment fits the ticket cap The signer signs at most 100 tickets per payment, and a payment needs fee / ticketEV of them. A metered session pays for a 10s window upfront, so at -ticketEV=1e9 the demo orchestrator topped out around 0.067 USD per hour and echo's advertised 0.10 asked for 150 tickets, which the signer refused. Raise it to 1e10: echo now signs 15 tickets upfront and 6 per top-up, and the ceiling moves to about 0.67 USD per hour. Correct the ticket math in echo's .env.example, which claimed the old value had room to spare. Co-Authored-By: Claude Opus 5 (1M context) --- compose.onchain.yml | 7 ++++++- echo/.env.example | 8 ++++---- 2 files changed, 10 insertions(+), 5 deletions(-) diff --git a/compose.onchain.yml b/compose.onchain.yml index 71e1a50..1ca5a95 100644 --- a/compose.onchain.yml +++ b/compose.onchain.yml @@ -51,7 +51,12 @@ services: # Price comes from the app's registration; -pricePerUnit only sets the # unused legacy base price, but an on-chain O won't boot without it. - -pricePerUnit=0 - - -ticketEV=1000000000 + # Expected value per ticket. It sets how many tickets one payment needs + # (fee / ticketEV), and the signer refuses to sign more than 100 at once. + # A metered session pays for a 10s window upfront, so at 1e9 the ceiling + # would be about 0.067 USD per hour, under what echo advertises. 1e10 + # leaves room: echo's 0.10 per hour signs 15 tickets upfront, 6 per top-up. + - -ticketEV=10000000000 - -maxFaceValue=4000000000000000 - -monitor=false - -v=6 diff --git a/echo/.env.example b/echo/.env.example index 5d4c7a5..82eeb3a 100644 --- a/echo/.env.example +++ b/echo/.env.example @@ -17,10 +17,10 @@ ORCH_ETH_PASSWORD=your-operator-keystore-password ORCH_ONCHAIN_ADDR=0xYourRegisteredOrchestrator # Runner price (on-chain): USD per hour, metered per second for as long as the -# client holds the session. Keep it modest: each payment covers one billing -# interval (5s by default), and the signer signs at most 100 tickets per payment -# against the demo orchestrator's -ticketEV=1e9. At 0.10/hour one payment is -# ~0.00014 USD, well inside that. +# client holds the session. Keep it modest: the signer signs at most 100 tickets +# per payment, and a payment needs fee / ticketEV of them. The first one covers +# a 10s window, so against the demo orchestrator's -ticketEV=1e10 the ceiling is +# about 0.67 USD per hour. 0.10 signs 15 tickets upfront and 6 per top-up. PRICE=0.10 # Signer's max-price cap (payer side), compared per billing unit. Metered here, # so the unit is one second and this must exceed PRICE / 3600 From 433b8ff4e58c13e2c7edb97972b95aea811f3bb5 Mon Sep 17 00:00:00 2001 From: Rick Staa Date: Tue, 4 Aug 2026 12:58:30 +0200 Subject: [PATCH 06/12] fix(compose): carry the ticketEV bump into every on-chain example api-proxy and vllm restate the whole orchestrator command to add -liveRunnerConfig, since `extends` cannot append a flag, so each kept its own -ticketEV and never saw the shared bump. vllm is metered, so it would have hit the signer's 100 ticket cap the moment its price rose above about 0.067 USD per hour. Bump both, and correct the ticket math the fixed price examples quote in their .env.example. Also say in the README that the demos now run a master build rather than the v0.9.0 release image. Co-Authored-By: Claude Opus 5 (1M context) --- README.md | 2 +- api-proxy/.env.example | 4 ++-- api-proxy/compose.onchain.yml | 2 +- hello-world/.env.example | 4 ++-- tiles/.env.example | 4 ++-- vllm/compose.onchain.yml | 2 +- 6 files changed, 9 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index 619a192..ca5c89f 100644 --- a/README.md +++ b/README.md @@ -110,7 +110,7 @@ Each example is self-contained and its README has the run commands. Everything b ### Prerequisites -- **Docker** for the end-to-end demos. They use the mainline `livepeer/go-livepeer:v0.9.0` release image, so there is nothing to build. +- **Docker** for the end-to-end demos, so there is nothing to build. They currently run a mainline `livepeer/go-livepeer` master build rather than a release image: metered pricing needs the session-scoped payment URL added after v0.9.0 ([#4008](https://github.com/livepeer/go-livepeer/pull/4008)). They move back to a release tag once one carries it. - **Python 3.12+** and [`uv`](https://docs.astral.sh/uv/) for the client. - The **`livepeer-gateway` SDK** from the `ja/live-runner` branch (not yet on PyPI): diff --git a/api-proxy/.env.example b/api-proxy/.env.example index 12445e9..78e3d6a 100644 --- a/api-proxy/.env.example +++ b/api-proxy/.env.example @@ -23,8 +23,8 @@ ORCH_ETH_PASSWORD=your-operator-keystore-password ORCH_ONCHAIN_ADDR=0xYourRegisteredOrchestrator # The runner's price lives in runners.json (static runner): USD billed once per -# call (fixed pricing). Keep it under ~0.00019: the signer signs at most 100 -# tickets per payment and the demo orchestrator runs -ticketEV=1e9. +# call (fixed pricing). Keep it under ~0.0019: the signer signs at most 100 +# tickets per payment and the demo orchestrator runs -ticketEV=1e10. # Signer's max-price cap (payer side) is per billing unit, here one call, so it # must exceed the runners.json price. MAX_PRICE_PER_UNIT=0.000111USD diff --git a/api-proxy/compose.onchain.yml b/api-proxy/compose.onchain.yml index 8d9a957..34bb20c 100644 --- a/api-proxy/compose.onchain.yml +++ b/api-proxy/compose.onchain.yml @@ -40,7 +40,7 @@ services: # Price comes from the app's registration; -pricePerUnit only sets the # unused legacy base price, but an on-chain O won't boot without it. - -pricePerUnit=0 - - -ticketEV=1000000000 + - -ticketEV=10000000000 - -maxFaceValue=4000000000000000 - -liveRunnerConfig=/config/runners.json - -monitor=false diff --git a/hello-world/.env.example b/hello-world/.env.example index 91be315..2fd1e72 100644 --- a/hello-world/.env.example +++ b/hello-world/.env.example @@ -17,8 +17,8 @@ ORCH_ETH_PASSWORD=your-operator-keystore-password ORCH_ONCHAIN_ADDR=0xYourRegisteredOrchestrator # Runner price (on-chain): USD billed once per call (fixed pricing). -# Keep under ~0.00019: the signer signs at most 100 tickets per payment, -# and the demo orchestrator runs -ticketEV=1e9 (numTickets = fee / ticketEV). +# Keep under ~0.0019: the signer signs at most 100 tickets per payment, +# and the demo orchestrator runs -ticketEV=1e10 (numTickets = fee / ticketEV). PRICE=0.0001 # Signer's max-price cap (payer side), compared per billing unit. With fixed # pricing the unit is one call, so this must exceed PRICE. diff --git a/tiles/.env.example b/tiles/.env.example index 2d70a87..b986fe5 100644 --- a/tiles/.env.example +++ b/tiles/.env.example @@ -17,8 +17,8 @@ ORCH_ETH_PASSWORD=your-operator-keystore-password ORCH_ONCHAIN_ADDR=0xYourRegisteredOrchestrator # Runner price (on-chain): USD billed once per tile (fixed pricing). -# Keep under ~0.00019: the signer signs at most 100 tickets per payment, -# and the demo orchestrator runs -ticketEV=1e9 (numTickets = fee / ticketEV). +# Keep under ~0.0019: the signer signs at most 100 tickets per payment, +# and the demo orchestrator runs -ticketEV=1e10 (numTickets = fee / ticketEV). PRICE=0.0001 # Signer's max-price cap (payer side), compared per billing unit. With fixed # pricing the unit is one tile call, so this must exceed PRICE. diff --git a/vllm/compose.onchain.yml b/vllm/compose.onchain.yml index 3177589..e1ed801 100644 --- a/vllm/compose.onchain.yml +++ b/vllm/compose.onchain.yml @@ -39,7 +39,7 @@ services: # Price comes from the app's registration; -pricePerUnit only sets the # unused legacy base price, but an on-chain O won't boot without it. - -pricePerUnit=0 - - -ticketEV=1000000000 + - -ticketEV=10000000000 - -maxFaceValue=4000000000000000 - -liveRunnerConfig=/config/runners.json - -monitor=false From ab9855654b235a92399abe49ae70f5d43a509bf0 Mon Sep 17 00:00:00 2001 From: Rick Staa Date: Tue, 4 Aug 2026 13:03:09 +0200 Subject: [PATCH 07/12] docs(echo): match the section structure of the other examples hello-world and tiles both explain their one idea in a "what this shows" section and close with a Run without Docker recipe; echo had neither, so its distinguishing idea (a session the client holds open, which is what makes billing a lifecycle) only appeared inside the on-chain section. Add both, in the same order the other examples use. Co-Authored-By: Claude Opus 5 (1M context) --- echo/README.md | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/echo/README.md b/echo/README.md index b47b2f9..4f0361f 100644 --- a/echo/README.md +++ b/echo/README.md @@ -19,6 +19,18 @@ The app is **dynamically registered**: it self-registers with the orchestrator v echo registers **dynamically** as the natural fit for a stateful app that already embeds the SDK (heartbeats, capacity, lifecycle). Trickle itself isn't tied to dynamic, though: `create_trickle_channels` rides the orchestrator's per-request `Livepeer-Session-Control` header, so a static runner exposing the same endpoints could open channels too. +## Held-open sessions — what this shows + +**A trickle session is a pipe the client holds open, not a call it makes.** `hello-world`, `tiles` and `api-proxy` each answer one request and are finished. echo reserves a session once and then streams through it: frames go into the `in` channel and come back transformed from `out` continuously, with no request boundary in between. + +Two things follow from that, and they are what this example exists to show: + +- **The runner keeps state.** Each session owns its current transform and blur radius, which is why `POST /update` can change the effect mid-stream while frames keep flowing. A single-shot runner has nowhere to keep that between calls. +- **Billing becomes a lifecycle.** There is no call to bill against, so the session is metered per second for as long as it is held, and payment repeats for the life of the stream instead of settling once. The [on-chain section](#run-on-chain-paid) below exercises exactly that. + +> [!NOTE] +> The session ends when the client releases it, not when the input runs out, which is why the client calls `stop_runner_session` on the way out. echo registers the default capacity of 1, so one held session occupies the runner and `/discovery` reports `capacity_available: 0` until it is released. See [`tiles`](../tiles) for what capacity does under fan-out. + ## Run offchain (free) Start the stack and confirm the runner registered: @@ -85,3 +97,15 @@ The ffmpeg and webcam pipes above work the same way on-chain: add `--signer` to A metered session pays **more than once**. The upfront payment that answers the 402 challenge only buys the signer's preroll (about ten seconds), while the orchestrator keeps debiting every few seconds and releases the session on the first debit it cannot cover. So `reserve_session` keeps the session funded in the background for as long as the client holds it, and leaving the client's `async with session` block stops that before the session is released. A stream that outlives the preroll is the whole point of this example on-chain, so use a clip of at least a few tens of seconds (the 30s `sample.mp4` above is enough): watch `docker compose logs -f orchestrator` and you should see repeated payments, not one. That is the difference from `hello-world` and `tiles`, whose fixed pricing settles the entire bill upfront and starts no ticker. + +## Run without Docker + +Start an orchestrator built from go-livepeer `v0.9.0` or newer (see [Build from source](https://docs.livepeer.org/v1/orchestrators/guides/install-go-livepeer#build-from-source)), then the app and client directly: + +```sh +./livepeer -orchestrator -useLiveRunners -serviceAddr localhost:8935 -orchSecret abcdef -v 6 +uv run runner.py --orchestrator https://localhost:8935 --orchSecret abcdef +uv run client.py --mode blur sample.mp4 +``` + +The paid path needs a newer orchestrator than the offchain one: metered sessions rely on the session-scoped payment URL added after `v0.9.0` ([#4008](https://github.com/livepeer/go-livepeer/pull/4008)), which is why the compose files pin a master build. From d2fb4bd0b06eb5cc25e545e71134a3d0493941e1 Mon Sep 17 00:00:00 2001 From: Rick Staa Date: Thu, 6 Aug 2026 10:37:31 +0200 Subject: [PATCH 08/12] docs(echo): condense the pricing comments in .env.example Same ceiling and the same cap rule, said in half the lines. Co-Authored-By: Claude Opus 5 (1M context) --- echo/.env.example | 13 +++++-------- 1 file changed, 5 insertions(+), 8 deletions(-) diff --git a/echo/.env.example b/echo/.env.example index 82eeb3a..8c588bc 100644 --- a/echo/.env.example +++ b/echo/.env.example @@ -16,13 +16,10 @@ ORCH_ETH_PASSWORD=your-operator-keystore-password # Registered orch = ticket recipient (-ethOrchAddr); empty = use the operating key. ORCH_ONCHAIN_ADDR=0xYourRegisteredOrchestrator -# Runner price (on-chain): USD per hour, metered per second for as long as the -# client holds the session. Keep it modest: the signer signs at most 100 tickets -# per payment, and a payment needs fee / ticketEV of them. The first one covers -# a 10s window, so against the demo orchestrator's -ticketEV=1e10 the ceiling is -# about 0.67 USD per hour. 0.10 signs 15 tickets upfront and 6 per top-up. +# Runner price (on-chain): USD per hour, metered per second while the client +# holds the session. Keep under ~0.67: the signer signs at most 100 tickets +# per payment, and the demo orchestrator runs -ticketEV=1e10 (fee / ticketEV). PRICE=0.10 -# Signer's max-price cap (payer side), compared per billing unit. Metered here, -# so the unit is one second and this must exceed PRICE / 3600 -# (0.000111USD is ~0.40 USD/hour). +# Signer's max-price cap (payer side), per billing unit. Metered here, so the +# unit is one second and must exceed PRICE / 3600 (0.000111USD is ~0.40/hour). MAX_PRICE_PER_UNIT=0.000111USD From e90154ded213bc3b9aa32568614faa6e6feb8a4e Mon Sep 17 00:00:00 2001 From: Rick Staa Date: Thu, 6 Aug 2026 10:37:42 +0200 Subject: [PATCH 09/12] docs(echo): note when the signer can serve discovery The on-chain command passes both --discovery and --signer, which reads as if the signer takes part in discovery. It does not: the SDK only falls back to signer discovery when no discovery URL is given, and the demo signer runs without -remoteDiscovery anyway. Co-Authored-By: Claude Opus 5 (1M context) --- echo/README.md | 2 ++ echo/client.py | 2 +- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/echo/README.md b/echo/README.md index 4f0361f..40b054d 100644 --- a/echo/README.md +++ b/echo/README.md @@ -92,6 +92,8 @@ uv run client.py sample.mp4 --mode blur \ docker compose -f compose.yml -f compose.onchain.yml down ``` +Both flags are here because the demo signer runs without `-remoteDiscovery`. A signer started with that flag serves `/discover-orchestrators` itself, and the SDK falls back to it whenever no discovery URL is given (here that takes `--discovery ''`, since the flag defaults to the local orchestrator). + The ffmpeg and webcam pipes above work the same way on-chain: add `--signer` to the `client.py` in the pipeline. Without it the client stops at the payment challenge (`Live runner paid call requires signer_url`), which closes the pipe and leaves ffmpeg reporting `Broken pipe` — the paid stack refusing an unpaid caller, not a broken camera. A metered session pays **more than once**. The upfront payment that answers the 402 challenge only buys the signer's preroll (about ten seconds), while the orchestrator keeps debiting every few seconds and releases the session on the first debit it cannot cover. So `reserve_session` keeps the session funded in the background for as long as the client holds it, and leaving the client's `async with session` block stops that before the session is released. A stream that outlives the preroll is the whole point of this example on-chain, so use a clip of at least a few tens of seconds (the 30s `sample.mp4` above is enough): watch `docker compose logs -f orchestrator` and you should see repeated payments, not one. diff --git a/echo/client.py b/echo/client.py index 8e7d830..526d2c2 100644 --- a/echo/client.py +++ b/echo/client.py @@ -199,7 +199,7 @@ async def main() -> None: try: session = await reserve_session( # Livepeer: 1 - discovery_url=args.discovery, + discovery_url=args.discovery, # omit if the signer does discovery itself app=APP_ID, signer_url=args.signer.strip() or None, ) From fdedf2667e667e9fc92e1d625217c148a0c6ec70 Mon Sep 17 00:00:00 2001 From: Rick Staa Date: Thu, 6 Aug 2026 11:16:58 +0200 Subject: [PATCH 10/12] docs(compose): trim the ticketEV comment Keep the rule and one worked number. Why 1e9 was too low belongs in the commit that raised it, not in the file. Co-Authored-By: Claude Opus 5 (1M context) --- compose.onchain.yml | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/compose.onchain.yml b/compose.onchain.yml index 1ca5a95..de3f6aa 100644 --- a/compose.onchain.yml +++ b/compose.onchain.yml @@ -51,11 +51,9 @@ services: # Price comes from the app's registration; -pricePerUnit only sets the # unused legacy base price, but an on-chain O won't boot without it. - -pricePerUnit=0 - # Expected value per ticket. It sets how many tickets one payment needs - # (fee / ticketEV), and the signer refuses to sign more than 100 at once. - # A metered session pays for a 10s window upfront, so at 1e9 the ceiling - # would be about 0.067 USD per hour, under what echo advertises. 1e10 - # leaves room: echo's 0.10 per hour signs 15 tickets upfront, 6 per top-up. + # Expected value per ticket. A payment needs fee / ticketEV tickets and the + # signer signs at most 100 at once, which is what caps the price. At 1e10 + # echo's 0.10 USD per hour signs 15 tickets upfront, 6 per top-up. - -ticketEV=10000000000 - -maxFaceValue=4000000000000000 - -monitor=false From 5a3c92d2728956d8508fed826052010a69d78a8b Mon Sep 17 00:00:00 2001 From: Rick Staa Date: Thu, 6 Aug 2026 11:20:46 +0200 Subject: [PATCH 11/12] docs(echo): mark the sample clip step as optional The prose said "make one if you have nothing to hand" while the block made one unconditionally. Put the condition on the line it applies to. Co-Authored-By: Claude Opus 5 (1M context) --- echo/README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/echo/README.md b/echo/README.md index 40b054d..0788ca2 100644 --- a/echo/README.md +++ b/echo/README.md @@ -42,10 +42,10 @@ curl -sk https://localhost:8935/discovery | jq '.[].runners[].app' # confirm l The input is a file path, or `-` to read an MPEG-TS stream from stdin (so you can pipe in anything ffmpeg produces); the output is a file, or `-` to write the echoed stream to stdout (pipe it to a player). -**From a file** — writes the result to `echo-out.ts`. Any video works; make one if you have nothing to hand (`-t` sets the length): +**From a file** — writes the result to `echo-out.ts`. Any video works; the first command makes a 30s one (`-t` sets the length): ```sh -ffmpeg -f lavfi -i testsrc=size=1280x720:rate=30 -t 30 -c:v libx264 -preset ultrafast -pix_fmt yuv420p sample.mp4 +ffmpeg -f lavfi -i testsrc=size=1280x720:rate=30 -t 30 -c:v libx264 -preset ultrafast -pix_fmt yuv420p sample.mp4 # skip if you have a video uv run client.py --mode blur --discovery https://localhost:8935/discovery sample.mp4 ``` From f375a306232d8ac79db4bbaccfa19b15d2f2e707 Mon Sep 17 00:00:00 2001 From: Rick Staa Date: Thu, 6 Aug 2026 11:23:17 +0200 Subject: [PATCH 12/12] docs(echo): drop the duplicated on-chain paragraphs The signer discovery note is true of every example, so it belongs in the repo README, not here. The closing contrast restated what this section and tiles already say about metered vs fixed. Co-Authored-By: Claude Opus 5 (1M context) --- echo/README.md | 4 ---- 1 file changed, 4 deletions(-) diff --git a/echo/README.md b/echo/README.md index 0788ca2..7d383ce 100644 --- a/echo/README.md +++ b/echo/README.md @@ -92,14 +92,10 @@ uv run client.py sample.mp4 --mode blur \ docker compose -f compose.yml -f compose.onchain.yml down ``` -Both flags are here because the demo signer runs without `-remoteDiscovery`. A signer started with that flag serves `/discover-orchestrators` itself, and the SDK falls back to it whenever no discovery URL is given (here that takes `--discovery ''`, since the flag defaults to the local orchestrator). - The ffmpeg and webcam pipes above work the same way on-chain: add `--signer` to the `client.py` in the pipeline. Without it the client stops at the payment challenge (`Live runner paid call requires signer_url`), which closes the pipe and leaves ffmpeg reporting `Broken pipe` — the paid stack refusing an unpaid caller, not a broken camera. A metered session pays **more than once**. The upfront payment that answers the 402 challenge only buys the signer's preroll (about ten seconds), while the orchestrator keeps debiting every few seconds and releases the session on the first debit it cannot cover. So `reserve_session` keeps the session funded in the background for as long as the client holds it, and leaving the client's `async with session` block stops that before the session is released. A stream that outlives the preroll is the whole point of this example on-chain, so use a clip of at least a few tens of seconds (the 30s `sample.mp4` above is enough): watch `docker compose logs -f orchestrator` and you should see repeated payments, not one. -That is the difference from `hello-world` and `tiles`, whose fixed pricing settles the entire bill upfront and starts no ticker. - ## Run without Docker Start an orchestrator built from go-livepeer `v0.9.0` or newer (see [Build from source](https://docs.livepeer.org/v1/orchestrators/guides/install-go-livepeer#build-from-source)), then the app and client directly: