Skip to content
Merged
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 @@ -16,6 +16,7 @@ on:
- "hello-world/**"
- "echo/**"
- "tiles/**"
- "api-proxy/**"
- ".github/workflows/images.yml"
pull_request:
paths: *image_paths
Expand All @@ -34,7 +35,7 @@ jobs:
strategy:
fail-fast: false
matrix:
example: [hello-world, echo, tiles]
example: [hello-world, echo, tiles, api-proxy]
steps:
- uses: actions/checkout@v7

Expand Down
28 changes: 15 additions & 13 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,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`)
- **HTTP** request/response — the common case. (`hello-world`, `tiles`, `api-proxy`)
- **HTTP + SSE** — streamed / token responses. (`vllm`)
- **Trickle** — continuous realtime video in/out. (`echo`)
- **WebSocket** — long-lived bidirectional sessions. (external: `scope`)
Expand All @@ -37,12 +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 |
| [`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 (JSON envelope) | 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 |

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.

Expand All @@ -53,7 +54,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`)
- **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`)
- **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 All @@ -74,7 +75,7 @@ flowchart LR
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`, `vllm`)
- **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. (`hello-world`, `tiles`)
- **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. (`hello-world`, `tiles`, `api-proxy`)

> [!NOTE]
> The `vllm` example is single-shot by nature but stays **persistent** for now: it meters per second across a reserved session, and true per-token billing is brokerage for the gateway/signer layer.
Expand All @@ -83,7 +84,7 @@ Chosen _at_ registration (above); **defaults to `persistent`** — set on both `

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`)
- **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`)
- **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`, `vllm`)

Each example's `client.py` shows its exact calls — grep `# Livepeer:` to find them.
Expand All @@ -92,9 +93,10 @@ Each example's `client.py` shows its exact calls — grep `# Livepeer:` to find

Apps that integrate the live runner and live in their own repos — production deployments and standalone examples alike. This table is links-only: the code, CI, and support stay with the author.

| Project | What it is | Transport |
| -------------------------------------------------------------------------- | ------------------------------------------------ | ------------------- |
| [daydreamlive/scope](https://github.com/daydreamlive/scope/tree/ja/runner) | Real-time AI video with downloadable LoRA models | WebSocket + trickle |
| Project | What it is | Transport |
| -------------------------------------------------------------------------- | -------------------------------------------------------------------------------------- | ------------------- |
| [daydreamlive/scope](https://github.com/daydreamlive/scope/tree/ja/runner) | Real-time AI video with downloadable LoRA models | WebSocket + trickle |
| [livepeer/api-proxy](https://github.com/livepeer/api-proxy) | Attach several API endpoints dynamically — key storage and request stats for operators | HTTP |

Built one? [Open a PR](https://github.com/livepeer/runner-app-examples/compare) that adds a row. To make your repo easy to find, follow the community convention:

Expand Down
31 changes: 31 additions & 0 deletions api-proxy/.env.example
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
# Copy to .env (gitignored) and fill in. Never commit secrets.
# Keystore dirs: absolute paths OUTSIDE this repo, mounted read-only.

# Upstream credential the app injects (required, offchain too):
# huggingface.co → settings → tokens.
HF_TOKEN=hf_your_token

# --- On-chain (paid) only below; offchain ignores these. ---

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

# 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.
#
# Signer's max-price cap (payer side), compared per billing unit. With fixed
# pricing the unit is one call, so this must exceed the runners.json price.
MAX_PRICE_PER_UNIT=0.000111USD
2 changes: 2 additions & 0 deletions api-proxy/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
# Client output
api-proxy-out.jpg
15 changes: 15 additions & 0 deletions api-proxy/Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
# api-proxy example app (http server). No Livepeer code — the orchestrator
# attaches it statically via runners.json.
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 aiohttp

WORKDIR /app
COPY runner.py ./

EXPOSE 8989

ENTRYPOINT ["python", "runner.py"]
52 changes: 52 additions & 0 deletions api-proxy/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
# API-proxy app (passthrough to an upstream API)

The live runner can also **pass calls through to an API that runs somewhere else** — a hosted model API, a SaaS endpoint, a service on your own infrastructure. The orchestrator operator attaches the proxy as a runner — **statically or dynamically; this example uses static** — and offers the upstream as a paid capability on the network: `POST /proxy` forwards a JSON envelope to the upstream and returns its response. The demo upstream is the **Hugging Face text-to-image inference API** ([Stable Diffusion 3 medium](https://huggingface.co/stabilityai/stable-diffusion-3-medium-diffusers) by default), but `--upstream` points it at any REST API.

| | |
| ------------ | ------------------------------------------ |
| App id | `livepeer-example/api-proxy` |
| Runner mode | single-shot |
| Registration | static (orchestrator config + health poll) |
| Transport | HTTP (JSON envelope in, JSON/base64 out) |
| Port | 8989 |

Prerequisites (Docker, `uv`, and the not-yet-released `livepeer-gateway` SDK — pinned in `pyproject.toml`) and the shared on-chain/payment setup live in the [repo README](../README.md). The demo upstream additionally needs a **Hugging Face API token** (`HF_TOKEN`, from [huggingface.co → settings → tokens](https://huggingface.co/settings/tokens)) with inference-provider credits.

## How it's wired

The app is attached as a **static runner**: the orchestrator reads [runners.json](runners.json) via `-liveRunnerConfig` — app id, runner URL, single-shot mode, and the fixed price — and health-polls `/health`. There is **no Livepeer code in the app**: [runner.py](runner.py) is a plain aiohttp service. Each `/proxy` call forwards `{"method", "path", "headers", "json"}` to `<upstream>/<path>` and returns `{"status", "headers", "body"}` for text upstream bodies or `{"status", "headers", "body_b64"}` for binary ones (a generated image, say). The client calls it with `runner_selector` → `call_runner` ([client.py](client.py)) — discover, then one **single-shot** call per request; the orchestrator reserves a session per call and releases it when the response returns. Grep `# Livepeer:` in client.py to see the exact calls.

## Offering an API as a capability — what this shows

Everything the operator sets lives on the operator's side. `runners.json` names the capability, the proxy's URL, and the **fixed per-call price**; the upstream credential (`UPSTREAM_TOKEN`, fed from `HF_TOKEN` by the compose files) sits in the app's environment, and the app injects it as a Bearer header on every forward — any `Authorization` a caller sends is dropped. Callers need no API key of their own: they discover the capability and pay **per call through Livepeer**, while the operator pays the upstream and prices above the per-call upstream cost.

**Fixed pricing** is the natural fit: one call is one bounded unit of work, so the runner bills one flat price per call instead of metering time.

> [!NOTE]
> Registration can also be **dynamic**: an operator tool can `register_runner` several API endpoints at runtime, each as its own priced capability, without touching the orchestrator config. See [livepeer/api-proxy](https://github.com/livepeer/api-proxy) for an example of dynamic endpoint registration, with key storage and request stats for orchestrator operators.

## Run offchain (free)

```sh
HF_TOKEN=hf_... docker compose up -d --build
curl -sk https://localhost:8935/discovery | jq '.[].runners[].app' # confirm livepeer-example/api-proxy registered
uv run client.py --prompt "a watercolor painting of a llama writing code"
docker compose down
```

`compose.yml` brings up an orchestrator (`-useLiveRunners -liveRunnerConfig`) and the app (proxying `https://router.huggingface.co`). The client builds the envelope for one text-to-image call, sends it through the orchestrator, and writes `api-proxy-out.jpg`.

## Run on-chain (paid)

Layer `compose.onchain.yml` to run the orchestrator on-chain with a remote signer paying each call — one fixed payment per image, at the price `runners.json` advertises. 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 HF_TOKEN, RPC, network, keystore paths, accounts
docker compose -f compose.yml -f compose.onchain.yml up -d --build
uv run client.py --prompt "a watercolor painting of a llama writing code" \
--discovery https://localhost:8935/discovery \
--signer http://localhost:7936
docker compose -f compose.yml -f compose.onchain.yml down
```

Each call is one paid single-shot session — the orchestrator reserves it, takes one fixed payment, and releases it when the response returns.
97 changes: 97 additions & 0 deletions api-proxy/client.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
#!/usr/bin/env python3
"""api-proxy client: discover a runner, proxy a text-to-image call, save the image.

Builds the generic /proxy envelope for one concrete upstream — the Hugging Face
text-to-image inference API — and decodes the binary response. Any other REST
API is the same envelope with a different method/path/json.

Livepeer integration (grep `# Livepeer:`):
1. runner_selector() — discover orchestrators advertising the app
2. call_runner() — call the app through the orchestrator; on the paid path it
answers the 402 payment challenge inline (one fixed payment
per call). Single-shot needs no reserve/stop: the
orchestrator reserves the session for this one request and
releases it when the response returns.
"""

from __future__ import annotations

import argparse
import asyncio
import base64
import logging
from pathlib import Path

from livepeer_gateway.errors import LivepeerGatewayError
from livepeer_gateway.live_runner import call_runner
from livepeer_gateway.selection import runner_selector

DEFAULT_DISCOVERY = "https://localhost:8935/discovery"
APP_ID = "livepeer-example/api-proxy"
DEFAULT_MODEL = "stabilityai/stable-diffusion-3-medium-diffusers"
DEFAULT_OUTPUT = "api-proxy-out.jpg"

log = logging.getLogger("api-proxy-client")


def _parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(description="Run the api-proxy Live Runner demo.")
parser.add_argument(
"--prompt", default="a watercolor painting of a llama writing code"
)
parser.add_argument(
"--model",
default=DEFAULT_MODEL,
help="Hugging Face text-to-image model (the upstream path).",
)
parser.add_argument("--output", default=DEFAULT_OUTPUT, help="output image path")
parser.add_argument("--discovery", default=DEFAULT_DISCOVERY)
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()
try:
cursor = await runner_selector( # Livepeer: 1
discovery_url=args.discovery, app=APP_ID
)
runner = cursor.candidates[0]
log.info("app_url=%s", runner.url)

# The envelope the app forwards upstream: here a Hugging Face
# text-to-image call, but any method/path/json works.
envelope = {
"method": "POST",
"path": f"/hf-inference/models/{args.model}",
"json": {"inputs": args.prompt},
}
result = await call_runner( # Livepeer: 2
runner=runner, # discovery metadata tells call_runner the price unit
runner_url=runner.url.rstrip("/") + "/proxy",
payload=envelope,
signer_url=args.signer.strip() or None,
timeout=120.0, # a hosted diffusion model can take tens of seconds
)

status = result.data.get("status")
if status != 200:
body = result.data.get("body") or result.data.get("error")
raise LivepeerGatewayError(f"upstream returned {status}: {body}")
b64 = result.data.get("body_b64")
if not isinstance(b64, str) or not b64:
raise LivepeerGatewayError("upstream response was not binary (no body_b64)")
out_path = Path(args.output).expanduser()
out_path.write_bytes(base64.b64decode(b64))
log.info("wrote %s", out_path)
except LivepeerGatewayError as exc:
raise SystemExit(f"ERROR: {exc}") from exc


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