-
Notifications
You must be signed in to change notification settings - Fork 7
feat(api-proxy): modernize to single-shot fixed pricing with HF text-to-image demo #41
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from 3 commits
Commits
Show all changes
9 commits
Select commit
Hold shift + click to select a range
91e6239
feat(api-proxy): modernize to single-shot fixed pricing with HF text-…
rickstaa 2507f5f
fix(api-proxy): align app port with the other examples (8989)
rickstaa 1f74465
docs(api-proxy): drop nonstandard README bits for template consistency
rickstaa 4a2ead9
docs(api-proxy): clarify who holds the upstream token
rickstaa 529b3a8
refactor(api-proxy): attach as static runner, the operator-offered pa…
rickstaa 87c9733
docs(api-proxy): route HF_TOKEN through .env like vllm
rickstaa 3d2c9ba
docs(api-proxy): make README self-contained
rickstaa 328463f
style(api-proxy): match vllm's environment list syntax
rickstaa bcca218
docs(api-proxy): intro notes static vs dynamic; link api-proxy repo a…
rickstaa File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,29 @@ | ||
| # 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 | ||
|
|
||
| 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 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). | ||
| 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. | ||
| MAX_PRICE_PER_UNIT=0.000111USD |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,2 @@ | ||
| # Client output | ||
| api-proxy-out.jpg |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,20 @@ | ||
| # api-proxy example app (http server). | ||
| FROM python:3.12-slim | ||
|
|
||
| # Flush stdout/stderr immediately so output isn't block-buffered in `docker logs`. | ||
| ENV PYTHONUNBUFFERED=1 | ||
|
|
||
| RUN apt-get update \ | ||
| && apt-get install -y --no-install-recommends git \ | ||
| && rm -rf /var/lib/apt/lists/* | ||
|
|
||
| # livepeer-gateway SDK isn't on PyPI yet; install from Git. | ||
| RUN pip install --no-cache-dir \ | ||
| "livepeer-gateway @ git+https://github.com/livepeer/livepeer-python-gateway@ja/live-runner" | ||
|
|
||
| WORKDIR /app | ||
| COPY runner.py client.py ./ | ||
|
|
||
| EXPOSE 8989 | ||
|
|
||
| ENTRYPOINT ["python", "runner.py"] | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,59 @@ | ||
| # API-proxy app (resell an upstream API) | ||
|
|
||
| Wraps an existing HTTP API so it's reachable, and payable, through the Livepeer network. `POST /proxy` takes a JSON envelope describing the upstream call and returns the upstream response — the app runs no model and knows nothing about what it forwards. 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 | dynamic (self-registers via the SDK) | | ||
| | 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 **dynamically registered**: it self-registers with the orchestrator via `register_runner` ([runner.py](runner.py)) and exposes a single `POST /proxy`, reverse-proxied through the orchestrator. Each 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. There is no session to manage: the orchestrator reserves one per call and releases it when the response returns. Grep `# Livepeer:` in either file to see the exact calls. | ||
|
|
||
| ## Proxying an API — what this shows | ||
|
|
||
| Most real apps don't host models; they call an API. This example shows that a runner can be exactly that call: the same thin proxy you would deploy anywhere, registered on the network unchanged. | ||
|
|
||
| The interesting part is **who holds the key**. The upstream credential (`UPSTREAM_TOKEN`) lives with the runner operator; the app injects it as a Bearer token on every forward and drops any `Authorization` a caller sends. Callers never see an API key — they discover the app and pay **per call through Livepeer**, while the operator pays the upstream and sets `PRICE` 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 (compare [`vllm`](../vllm), where open-ended sessions make per-second metering the better fit). | ||
|
|
||
| ## 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`) 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. 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, pricing | ||
| 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. | ||
|
|
||
| ## 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 | ||
| UPSTREAM_TOKEN=hf_... uv run runner.py --orchestrator https://localhost:8935 --orchSecret abcdef | ||
| uv run client.py --prompt "a watercolor painting of a llama writing code" | ||
| ``` |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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()) |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,34 @@ | ||
| # On-chain payment overlay for api-proxy. 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. Then pay through the signer: | ||
| # uv run client.py --prompt "..." \ | ||
| # --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 | ||
| - --upstream=https://router.huggingface.co | ||
| # Billed once per call (fixed pricing); price cap in .env.example. | ||
| - --price=${PRICE} |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,31 @@ | ||
| # End-to-end offchain demo: orchestrator + api-proxy app. | ||
| # | ||
| # The orchestrator service is defined once in ../compose.orchestrator.yml and | ||
| # pulled in with `extends`; this file only adds the app. Needs HF_TOKEN (the | ||
| # upstream credential the app injects) from the shell or a local .env. Once up, | ||
| # call it from the host with the SDK: | ||
| # HF_TOKEN=hf_... docker compose up -d --build | ||
| # uv run client.py --prompt "..." --discovery https://localhost:8935/discovery | ||
|
|
||
| services: | ||
| orchestrator: | ||
| extends: | ||
| file: ../compose.orchestrator.yml | ||
| service: orchestrator | ||
|
|
||
| app: | ||
| build: . | ||
| container_name: example_apps_api_proxy | ||
| environment: | ||
| # The operator-held upstream credential (huggingface.co → settings → tokens). | ||
| UPSTREAM_TOKEN: ${HF_TOKEN:?set HF_TOKEN in the shell or .env} | ||
| # 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 | ||
| - --upstream=https://router.huggingface.co |
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.