Skip to content

Commit 6e8ab16

Browse files
rickstaaclaude
andcommitted
feat(api-proxy): offer two priced models instead of one
The README claimed a second model was "more config, not code" without showing it. Run nginx.conf.template twice with a different MODEL, give each its own runners.json entry, app id, and price, and let the client pick with --app. FLUX.1 schnell is cheaper than SD3 medium, so the two prices differ for a reason a caller can see. Each entry keeps its own capacity: the work happens upstream, so the capabilities do not contend. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SD4o2Evku53Nb6zfgAdWxV
1 parent 04cf899 commit 6e8ab16

7 files changed

Lines changed: 87 additions & 41 deletions

File tree

README.md

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -37,14 +37,14 @@ Need a schema that isn't here? [Open an issue](https://github.com/livepeer/runne
3737

3838
## Examples
3939

40-
| Example | Goal | Registration | Mode | Transport | Pricing |
41-
| ---------------------------------------------------- | ------------------------------------------------------------------------------------- | ------------ | ----------- | ----------------- | ------- |
42-
| [`hello-world`](./hello-world) | The simplest app: one request, one response | dynamic | single-shot | HTTP (JSON) | fixed |
43-
| [`tiles`](./tiles) | Capacity fan-out — one call per tile | dynamic | single-shot | HTTP (base64 PNG) | fixed |
44-
| [`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 |
45-
| [`echo`](./echo) | Realtime video, transformed and echoed back | dynamic | persistent | trickle | hour |
46-
| [`vllm`](./vllm) | Drop-in OpenAI API; the client stays unmodified | static | single-shot | HTTP + SSE | hour |
47-
| [`realtime-transcription`](./realtime-transcription) | Audio up, transcripts back, on one socket | dynamic | persistent | WebSocket | hour |
40+
| Example | Goal | Registration | Mode | Transport | Pricing |
41+
| ---------------------------------------------------- | ---------------------------------------------------------------------------------------- | ------------ | ----------- | ----------------- | ------- |
42+
| [`hello-world`](./hello-world) | The simplest app: one request, one response | dynamic | single-shot | HTTP (JSON) | fixed |
43+
| [`tiles`](./tiles) | Capacity fan-out — one call per tile | dynamic | single-shot | HTTP (base64 PNG) | fixed |
44+
| [`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 |
45+
| [`echo`](./echo) | Realtime video, transformed and echoed back | dynamic | persistent | trickle | hour |
46+
| [`vllm`](./vllm) | Drop-in OpenAI API; the client stays unmodified | static | single-shot | HTTP + SSE | hour |
47+
| [`realtime-transcription`](./realtime-transcription) | Audio up, transcripts back, on one socket | dynamic | persistent | WebSocket | hour |
4848

4949
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.
5050

api-proxy/.env.example

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -22,9 +22,10 @@ ORCH_ETH_PASSWORD=your-operator-keystore-password
2222
# Registered orch = ticket recipient (-ethOrchAddr); empty = use the operating key.
2323
ORCH_ONCHAIN_ADDR=0xYourRegisteredOrchestrator
2424

25-
# The runner's price lives in runners.json (static runner): USD billed once per
26-
# call (fixed pricing). Keep it under ~0.0019: the signer signs at most 100
25+
# Each runner's price lives in runners.json (static runner): USD billed once per
26+
# call (fixed pricing). Keep each under ~0.0019: the signer signs at most 100
2727
# tickets per payment and the demo orchestrator runs -ticketEV=1e10.
28-
# Signer's max-price cap (payer side) is per billing unit, here one call, so it
29-
# must exceed the runners.json price.
28+
# Signer's max-price cap (payer side) is per billing unit, here one call, and it
29+
# is one cap for every capability, so it must exceed the highest runners.json
30+
# price (sd3, not flux).
3031
MAX_PRICE_PER_UNIT=0.000111USD

api-proxy/README.md

Lines changed: 28 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,50 +1,59 @@
11
# API-proxy app (a runner that is pure config)
22

3-
The live runner can also **pass calls through to an API that runs somewhere else** — here the **Hugging Face text-to-image inference API**. This example's runner is a **stock nginx**: [nginx.conf.template](nginx.conf.template) forwards each call to one pinned model URL and injects the operator's token. There is **no app code at all** — the orchestrator operator offers a hosted model ([Stable Diffusion 3 medium](https://huggingface.co/stabilityai/stable-diffusion-3-medium-diffusers) by default) as a paid capability with two config files.
3+
The live runner can also **pass calls through to an API that runs somewhere else** — here the **Hugging Face text-to-image inference API**. This example's runner is a **stock nginx**: [nginx.conf.template](nginx.conf.template) forwards each call to one pinned model URL and injects the operator's token. There is **no app code at all** — the orchestrator operator offers two hosted models ([Stable Diffusion 3 medium](https://huggingface.co/stabilityai/stable-diffusion-3-medium-diffusers) and [FLUX.1 schnell](https://huggingface.co/black-forest-labs/FLUX.1-schnell)) as **two separately priced capabilities**, with nothing but config.
44

5-
| | |
6-
| ------------ | -------------------------------------------- |
7-
| App id | `livepeer-example/stable-diffusion-3-medium` |
8-
| Runner mode | single-shot |
9-
| Registration | static (orchestrator config + health poll) |
10-
| Transport | HTTP (HF payload in, JPEG bytes out) |
11-
| Pricing | fixed (one price per call) |
12-
| Port | 8989 |
5+
| | |
6+
| ------------ | ------------------------------------------------------------------------------- |
7+
| App ids | `livepeer-example/stable-diffusion-3-medium`, `livepeer-example/flux-1-schnell` |
8+
| Runner mode | single-shot |
9+
| Registration | static (orchestrator config + health poll) |
10+
| Transport | HTTP (HF payload in, JPEG bytes out) |
11+
| Pricing | fixed, per capability (0.0001 and 0.00004 USD per image) |
12+
| Port | 8989 (each nginx service) |
1313

1414
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). 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.
1515

1616
## How it's wired
1717

18-
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` (an nginx `return 200`). The `/proxy` location proxies to the pinned model URL (`MODEL` in [compose.yml](compose.yml)) with `Authorization: Bearer <HF_TOKEN>` added. The caller's body is the [Hugging Face text-to-image payload](https://huggingface.co/docs/inference-providers/tasks/text-to-image) forwarded verbatim — `{"inputs": "<prompt>"}` — and the image comes back as **raw JPEG bytes**. The client calls it with `runner_selector``call_runner` ([client.py](client.py)) — discover, then one **single-shot** call per image, reading the bytes from `result.content`; the orchestrator reserves a session per call and releases it when the response returns. Grep `# Livepeer:` in client.py to see the exact calls.
18+
The apps are attached as **static runners**: the orchestrator reads [runners.json](runners.json) via `-liveRunnerConfig` — app id, runner URL, single-shot mode, and the fixed price, one entry per capability — and health-polls each `/health` (an nginx `return 200`). The `/proxy` location proxies to the pinned model URL (`MODEL` in [compose.yml](compose.yml)) with `Authorization: Bearer <HF_TOKEN>` added. The caller's body is the [Hugging Face text-to-image payload](https://huggingface.co/docs/inference-providers/tasks/text-to-image) forwarded verbatim — `{"inputs": "<prompt>"}` — and the image comes back as **raw JPEG bytes**. The client calls it with `runner_selector``call_runner` ([client.py](client.py)) — discover, then one **single-shot** call per image, reading the bytes from `result.content`; the orchestrator reserves a session per call and releases it when the response returns. Grep `# Livepeer:` in client.py to see the exact calls.
19+
20+
**One service, one model, one capability.** `compose.yml` runs the same `nginx.conf.template` twice with a different `MODEL`, and `runners.json` gives each its own app id and price. `--app` on the client picks which one to call.
1921

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

22-
Everything is operator-side config. `runners.json` names the capability and sets the **fixed per-image price**; the nginx config pins the model URL and holds the credential (`HF_TOKEN`, from `.env`). The pinned URL is also the security model: the operator's credential can only be spent on exactly the offered model. The config pins the method to `POST` and drops the caller's query string too, so the body is the only thing a caller controls: they choose nothing but the prompt, and never see an API key. They discover the capability and pay **per image through Livepeer**, while the operator pays the upstream and prices above the per-image upstream cost.
24+
Everything is operator-side config. `runners.json` names each capability and sets its **fixed per-image price**; the nginx config pins the model URL and holds the credential (`HF_TOKEN`, from `.env`). The pinned URL is also the security model: the operator's credential can only be spent on exactly the models offered. The config pins the method to `POST` and drops the caller's query string too, so the body is the only thing a caller controls: they choose nothing but the prompt, and never see an API key. They discover a capability and pay **per image through Livepeer**, while the operator pays the upstream and prices above the per-image upstream cost.
2325

24-
The app id names the model, not the proxy, because that is what callers discover: they match it exactly, so it has to say what they get. Swapping `MODEL` means renaming the app id with it.
26+
**A capability is a product, not a parameter.** FLUX.1 schnell is faster and cheaper than SD3 medium, so it is its own app id at its own price rather than a flag on one shared endpoint. Callers pick between them the same way they pick between orchestrators: discovery filters on `app`, matched exactly, so the app id has to say what you get. Swapping a `MODEL` means renaming its app id with it, and offering a third model is the same change once more — one `runners.json` entry, one nginx service.
2527

26-
Offering a second model is more config, not code: one more `runners.json` entry (its own app id and price) plus one more nginx service with a different `MODEL`.
28+
**Each entry carries its own capacity.** That is the right shape here: the two capabilities do not contend, because the work happens upstream at Hugging Face and nginx is only forwarding bytes. Runners that genuinely share a resource — several models resident on one GPU — are a different problem, since the orchestrator has no way to know that two registrations sit on the same card ([go-livepeer#4015](https://github.com/livepeer/go-livepeer/issues/4015)).
2729

2830
**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.
2931

30-
> [!NOTE]
31-
> 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.
32+
## Pinned here, dynamic in livepeer/api-proxy
33+
34+
Both capabilities here are fixed at deploy time: two nginx services and two `runners.json` entries, changed by editing config and restarting. That is the whole point of a **static** runner, and it is the right trade when the offering is stable.
35+
36+
Registration can also be **dynamic**. [livepeer/api-proxy](https://github.com/livepeer/api-proxy) is one process that serves many endpoints, adding each at runtime with a CLI or a dashboard — one `register_runner` per endpoint, each its own priced capability, with no orchestrator config to touch and no restart. It also stores the upstream keys encrypted and reports per-endpoint request stats, which is what an operator running more than a handful of these actually needs.
37+
38+
Same idea, opposite ends of the same axis: pin two and read the config, or run one process and enable endpoints as you go.
3239

3340
## Run offchain (free)
3441

3542
```sh
3643
cp .env.example .env # fill in HF_TOKEN; ignore the on-chain block
3744
docker compose up -d
38-
curl -sk https://localhost:8935/discovery | jq '.[].runners[].app' # confirm livepeer-example/stable-diffusion-3-medium registered
45+
curl -sk https://localhost:8935/discovery | jq '.[].runners[] | {app, price_info}' # both capabilities, each with its price
3946
uv run client.py --prompt "a watercolor painting of a llama writing code"
47+
uv run client.py --app livepeer-example/flux-1-schnell \
48+
--prompt "a watercolor painting of a llama writing code" --output flux-out.jpg
4049
docker compose down
4150
```
4251

43-
`compose.yml` brings up an orchestrator (`-useLiveRunners -liveRunnerConfig`) and the nginx runner. The client sends one prompt through the orchestrator and writes `api-proxy-out.jpg`.
52+
`compose.yml` brings up an orchestrator (`-useLiveRunners -liveRunnerConfig`) and the two nginx runners. The client sends one prompt through the orchestrator per call and writes the image; `--app` chooses the model and `--output` keeps the two results apart.
4453

4554
## Run on-chain (paid)
4655

47-
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.
56+
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 capability called. For the required RPC and wallets see [On-chain (paid) setup](../README.md#on-chain-paid-setup) in the repo README.
4857

4958
```sh
5059
cp .env.example .env # fill in HF_TOKEN, RPC, network, keystore paths, accounts
@@ -55,4 +64,4 @@ uv run client.py --prompt "a watercolor painting of a llama writing code" \
5564
docker compose -f compose.yml -f compose.onchain.yml down
5665
```
5766

58-
Each call is one paid single-shot session — the orchestrator reserves it, takes one fixed payment, and releases it when the response returns.
67+
Each call is one paid single-shot session — the orchestrator reserves it, takes one fixed payment, and releases it when the response returns. Call the cheaper capability with `--app livepeer-example/flux-1-schnell` and the payment is smaller, which is the whole reason each model is its own app. The signer's `MAX_PRICE_PER_UNIT` is a single cap across capabilities, so it has to clear the **highest** price in `runners.json`.

api-proxy/client.py

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,10 @@
55
({"inputs": "<prompt>"}); the runner forwards it verbatim and the image comes
66
back as raw JPEG bytes in `result.content`.
77
8+
The operator offers two models, so `--app` picks which one to call. Discovery
9+
matches app ids exactly, which is the whole reason each model has its own: it is
10+
what a caller selects and pays for.
11+
812
Livepeer integration (grep `# Livepeer:`):
913
1. runner_selector() — discover orchestrators advertising the app
1014
2. call_runner() — call the app through the orchestrator; a non-JSON response
@@ -25,7 +29,8 @@
2529
from livepeer_gateway.selection import runner_selector
2630

2731
DEFAULT_DISCOVERY = "https://localhost:8935/discovery"
28-
APP_ID = "livepeer-example/stable-diffusion-3-medium"
32+
DEFAULT_APP = "livepeer-example/stable-diffusion-3-medium"
33+
FLUX_APP = "livepeer-example/flux-1-schnell" # the other one this demo offers
2934
DEFAULT_OUTPUT = "api-proxy-out.jpg"
3035

3136
log = logging.getLogger("api-proxy-client")
@@ -37,6 +42,11 @@ def _parse_args() -> argparse.Namespace:
3742
"--prompt", default="a watercolor painting of a llama writing code"
3843
)
3944
parser.add_argument("--output", default=DEFAULT_OUTPUT, help="output image path")
45+
parser.add_argument(
46+
"--app",
47+
default=DEFAULT_APP,
48+
help=f"app id to call; this demo also offers {FLUX_APP}",
49+
)
4050
parser.add_argument("--discovery", default=DEFAULT_DISCOVERY)
4151
parser.add_argument(
4252
"--signer", default="", help="Remote signer base URL (on-chain/paid path)."
@@ -52,10 +62,10 @@ async def main() -> None:
5262
try:
5363
cursor = await runner_selector( # Livepeer: 1
5464
discovery_url=args.discovery, # omit if the signer does discovery itself
55-
app=APP_ID,
65+
app=args.app,
5666
)
5767
runner = cursor.candidates[0]
58-
log.info("app_url=%s", runner.url)
68+
log.info("app=%s app_url=%s", args.app, runner.url)
5969

6070
result = await call_runner( # Livepeer: 2
6171
runner=runner, # discovery metadata tells call_runner the price unit

api-proxy/compose.yml

Lines changed: 19 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
1-
# End-to-end offchain demo: orchestrator + the api-proxy "app" — a stock nginx
2-
# attached as a static runner via runners.json (-liveRunnerConfig). There is no
3-
# app code at all: nginx.conf.template is the whole runner.
1+
# End-to-end offchain demo: orchestrator + the api-proxy "app" — two stock nginx
2+
# services attached as static runners via runners.json (-liveRunnerConfig). There
3+
# is no app code at all: nginx.conf.template is the whole runner, and each service
4+
# is one capability because it pins one model.
45
#
56
# Needs HF_TOKEN (the upstream credential nginx injects) in a local .env — copy
67
# .env.example. Once up, call it from the host with the SDK:
@@ -28,9 +29,11 @@ services:
2829
volumes:
2930
- ./runners.json:/config/runners.json:ro
3031

31-
app:
32+
# One service per capability. Both run the same template and the same
33+
# credential; only MODEL differs, and runners.json prices them separately.
34+
sd3:
3235
image: nginx:1.27-alpine
33-
container_name: example_apps_api_proxy
36+
container_name: example_apps_api_proxy_sd3
3437
environment:
3538
# The operator-held upstream credential (huggingface.co → settings → tokens).
3639
- HF_TOKEN=${HF_TOKEN:?set HF_TOKEN in .env (copy .env.example)}
@@ -39,3 +42,14 @@ services:
3942
- MODEL=stabilityai/stable-diffusion-3-medium-diffusers
4043
volumes:
4144
- ./nginx.conf.template:/etc/nginx/templates/default.conf.template:ro
45+
46+
flux:
47+
image: nginx:1.27-alpine
48+
container_name: example_apps_api_proxy_flux
49+
environment:
50+
- HF_TOKEN=${HF_TOKEN:?set HF_TOKEN in .env (copy .env.example)}
51+
# A faster, cheaper model than sd3 — which is why it is a separate
52+
# capability at a separate price rather than a flag on the first one.
53+
- MODEL=black-forest-labs/FLUX.1-schnell
54+
volumes:
55+
- ./nginx.conf.template:/etc/nginx/templates/default.conf.template:ro

0 commit comments

Comments
 (0)