Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
3 changes: 2 additions & 1 deletion .github/workflows/images.yml
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ on:
- "hello-world/**"
- "echo/**"
- "tiles/**"
- "notepad/**"
- "realtime-transcription/**"
- ".github/workflows/images.yml"
pull_request:
Expand All @@ -41,7 +42,7 @@ jobs:
strategy:
fail-fast: false
matrix:
example: [hello-world, echo, tiles, realtime-transcription]
example: [hello-world, echo, tiles, notepad, realtime-transcription]
steps:
- uses: actions/checkout@v7

Expand Down
9 changes: 5 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ flowchart LR

The orchestrator is a **transparent reverse proxy**: every endpoint you expose is passed through to your app unchanged, so you write an ordinary service and it runs on the network as-is. The transports supported today:

- **HTTP** request/response — the common case. (`hello-world`, `tiles`, `api-proxy`)
- **HTTP** request/response — the common case. (`hello-world`, `tiles`, `api-proxy`, `notepad`)
- **HTTP + SSE** — streamed / token responses. (`vllm`)
- **Trickle** — continuous realtime video in/out. (`echo`)
- **WebSocket** — long-lived bidirectional sessions. (`realtime-transcription`)
Expand All @@ -51,6 +51,7 @@ Need a transport that isn't here? [Open an issue](https://github.com/livepeer/ru
| [`tiles`](./tiles) | Capacity fan-out — one call per tile | dynamic | single-shot | HTTP (base64 PNG) | fixed |
| [`api-proxy`](./api-proxy) | Pass calls through to hosted APIs — the operator holds the key, one capability per model | static | single-shot | HTTP (JPEG bytes) | fixed |
| [`echo`](./echo) | Realtime video, transformed and echoed back | dynamic | persistent | trickle | hour |
| [`notepad`](./notepad) | Held-open HTTP session with process-local state | dynamic | persistent | HTTP (JSON) | hour |
| [`vllm`](./vllm) | Drop-in OpenAI API; the client stays unmodified | static | single-shot | HTTP + SSE | hour |
| [`realtime-transcription`](./realtime-transcription) | Audio up, transcripts back, on one socket | dynamic | persistent | WebSocket | hour |

Expand All @@ -62,7 +63,7 @@ This set stays **minimal and curated**: it covers each value of the axes above (

How the app attaches to the orchestrator:

- **Dynamic** — the app self-registers via the SDK (`register_runner`) and heartbeats; the orchestrator drops it when heartbeats stop. Best for apps that come and go. (`hello-world`, `echo`, `realtime-transcription`)
- **Dynamic** — the app self-registers via the SDK (`register_runner`) and heartbeats; the orchestrator drops it when heartbeats stop. Best for apps that come and go. (`hello-world`, `echo`, `notepad`, `realtime-transcription`)
- **Static** — the orchestrator is configured with the app's URL in a `runners.json` and health-polls it; the app needs no SDK. Best for fixed, long-running deployments. (`vllm`, `api-proxy`)

The arrow flips: dynamic, the app announces itself; static, the orchestrator is told about a passive app:
Expand Down Expand Up @@ -92,15 +93,15 @@ Clients read it off the discovered runner, whose `raw` holds that runner's disco

Chosen _at_ registration (above); **defaults to `persistent`**, set on both `register_runner(...)` and in `runners.json`. The examples set it explicitly.

- **Persistent** — a held-open session the client reserves and releases, billed per second of wall-clock (or once, with fixed pricing). Best for realtime / streaming. (`echo`, `realtime-transcription`)
- **Persistent** — a held-open session the client reserves and releases, billed per second of wall-clock (or once, with fixed pricing). Best for realtime / streaming. (`echo`, `notepad`, `realtime-transcription`)
- **Single-shot** — one request in, one response out; the orchestrator reserves a session per call and releases it when the response returns, so the client manages no session at all. Best for batch / request-response. With metered pricing the call pays for as long as it runs, so the work need not be short. (`hello-world`, `tiles`, `api-proxy`, `vllm`)

## Calling your app

The client side depends on the runner's mode:

- **Single-shot** — **discover → call**: find the app via `runner_selector`, then one `call_runner`. The orchestrator reserves a session for the call and releases it when the response returns; on the paid path `call_runner` answers the 402 payment challenge inline. (`hello-world`, `tiles`, `api-proxy`, `vllm`)
- **Persistent** — **discover → reserve → call → release**: reserve a session (`reserve_session`), call it — `call_runner`, streamed frames, or a WebSocket, depending on transport — then release it (`stop_runner_session`), which settles payment on-chain. (`echo`, `realtime-transcription`)
- **Persistent** — **discover → reserve → call → release**: reserve a session (`reserve_session`), call it — `call_runner`, streamed frames, or a WebSocket, depending on transport — then release it (`stop_runner_session`), which settles payment on-chain. (`echo`, `notepad`, `realtime-transcription`)

Each example's `client.py` shows its exact calls: grep `# Livepeer:` to find them.

Expand Down
25 changes: 25 additions & 0 deletions notepad/.env.example
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
# 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 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), 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
15 changes: 15 additions & 0 deletions notepad/Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
# Notepad example app (persistent HTTP).
FROM python:3.12-slim

# Flush stdout/stderr immediately so output isn't block-buffered in `docker logs`.
ENV PYTHONUNBUFFERED=1

RUN pip install --no-cache-dir \
"livepeer-gateway>=1.0.0"

WORKDIR /app
COPY runner.py client.py ./

EXPOSE 8989

ENTRYPOINT ["python", "runner.py"]
66 changes: 66 additions & 0 deletions notepad/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
# Notepad app (persistent HTTP)

A held-open **HTTP** session that keeps one string in memory. `POST /set` writes it; `POST /get` reads it back. This is the missing cell in the examples table: **persistent + HTTP**, with no WebSocket and no trickle. It is also the regression target for Console `run_capability` with `endpoint` — and the reason that tool cannot reuse session state: each `runInference` reserves, calls once, and stops.

| | |
| ------------ | --------------------------------------------- |
| App id | `livepeer-example/notepad` |
| Runner mode | persistent (held-open HTTP session) |
| Registration | dynamic (self-registers via the SDK) |
| Transport | HTTP (JSON request/response, two round-trips) |
| Pricing | hour (metered while the session is held) |
| Port | 8989 |

Prerequisites (Docker, `uv`, and the [`livepeer-gateway` SDK](https://pypi.org/project/livepeer-gateway/)) and the shared on-chain/payment setup live in the [repo README](../README.md).

## How it's wired

The app is **dynamically registered** with `mode="persistent"` ([runner.py](runner.py)). Process-local `_note` **is** session state, because the orchestrator pins this runner to the reserved session. The client calls it with `reserve_session` → `POST /set` → `POST /get` → `stop_runner_session` ([client.py](client.py)). Grep `# Livepeer:` in either file to see the exact calls.

`run_capability` on Console (gateway-web `runInference`) can hit `/set` **or** `/get` with `endpoint`, but not both on the same session. Use this client when you need the two-call proof.

## Run offchain (free)

> [!TIP]
> Built locally by the compose file below, or run the published [`ghcr.io/livepeer/runner-example-notepad`](https://github.com/livepeer/runner-app-examples/pkgs/container/runner-example-notepad) with `docker compose up -d --pull always` — see [Images](../README.md#images).

```sh
docker compose up -d --build
curl -sk https://localhost:8935/discovery | jq '.[].runners[] | {app, mode}'
uv run client.py --text "hello from a held session"
# {'set': {'text': 'hello from a held session', 'revision': 1}, 'get': {'text': 'hello from a held session', 'revision': 1}}
docker compose down
```

## Run on-chain (paid)

```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 --text "hello" \
--discovery https://localhost:8935/discovery \
--signer http://localhost:7936
docker compose -f compose.yml -f compose.onchain.yml down
```

The session is **metered** (`unit` defaults to `hour`) for as long as the client holds it. Keep the demo short.

## Run without Docker

```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 --text "hello from a held session"
```

## Console MCP

```
run_capability({
capability: "livepeer-example/notepad",
endpoint: "/set",
inputs: { text: "hello" }
})
```

That pays a full reserve for one POST and then stops. A second `run_capability` to `/get` is a **new** session and returns empty text. That is expected.
77 changes: 77 additions & 0 deletions notepad/client.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
#!/usr/bin/env python3
"""notepad client: reserve a session, write then read, settle up.

Proves persistent HTTP: two POSTs against the same reserved session share
process-local state. gateway-web `runInference` cannot do this — it reserves,
calls once, and stops.

Livepeer integration (grep `# Livepeer:`):
1. reserve_session() — discover the runner, reserve a session
2. post_json(app_url/set) — write
3. post_json(app_url/get) — read (same session)
4. stop_runner_session() — end the session
"""

from __future__ import annotations

import argparse
import asyncio
import logging
from contextlib import suppress

from livepeer_gateway.errors import LivepeerGatewayError
from livepeer_gateway.http import post_json
from livepeer_gateway.live_runner import stop_runner_session
from livepeer_gateway.selection import reserve_session

DEFAULT_DISCOVERY = "https://localhost:8935/discovery"
APP_ID = "livepeer-example/notepad"

log = logging.getLogger("notepad-client")


def _parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(
description="Write then read a notepad Live Runner session."
)
parser.add_argument("--discovery", default=DEFAULT_DISCOVERY)
parser.add_argument("--text", default="hello from a held session")
parser.add_argument(
"--signer", default="", help="Remote signer base URL (on-chain/paid path)."
)
return parser.parse_args()


async def main() -> None:
logging.basicConfig(
level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s"
)
args = _parse_args()
session = None
try:
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)
async with session:
app_url = session.app_url.rstrip("/")
written = await post_json( # Livepeer: 2
f"{app_url}/set",
{"text": args.text},
)
read = await post_json(f"{app_url}/get", {}) # Livepeer: 3
print({"set": written, "get": read})
if read.get("text") != args.text:
raise SystemExit(f"ERROR: session did not keep state: {read}")
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) # Livepeer: 4


if __name__ == "__main__":
asyncio.run(main())
34 changes: 34 additions & 0 deletions notepad/compose.onchain.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
# On-chain payment overlay for notepad. 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 it is billed for as long as the
# client holds it. Then pay through the signer:
# uv run client.py --text "hello" \
# --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}
29 changes: 29 additions & 0 deletions notepad/compose.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
# End-to-end offchain demo: orchestrator + persistent-HTTP notepad.
#
# The orchestrator service is defined once in ../compose.orchestrator.yml and
# pulled in with `extends`; this file only adds the app. Once up, call it from
# the host with the SDK:
# docker compose up -d --build
# uv run client.py --text "hello from a held session"

services:
orchestrator:
extends:
file: ../compose.orchestrator.yml
service: orchestrator

app:
# `up` always builds; `--pull always` runs the published image instead.
image: ghcr.io/livepeer/runner-example-notepad:latest
pull_policy: build
build: .
container_name: example_apps_notepad
# Wait for the orchestrator's healthcheck so registration doesn't race its boot.
depends_on:
orchestrator:
condition: service_healthy
command:
- --host=0.0.0.0
- --orchestrator=https://orchestrator:8935
- --orchSecret=abcdef
- --runner-url=http://app:8989
8 changes: 8 additions & 0 deletions notepad/pyproject.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
[project]
name = "livepeer-notepad"
version = "0.1.0"
description = "Persistent HTTP notepad example app for the Livepeer network."
requires-python = ">=3.12"
dependencies = [
"livepeer-gateway>=1.0.0",
]
Loading
Loading