feat(api-proxy): modernize to single-shot fixed pricing with HF text-to-image demo - #41
Conversation
…to-image demo Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
Adds a mergeable api-proxy example runner that demonstrates single-shot fixed pricing by proxying requests to a real upstream (Hugging Face text-to-image), with binary-safe JSON envelope support and operator-held upstream credentials. This aligns api-proxy with the repo’s post-#39 single-shot client flow and includes CI image-build wiring and docs updates.
Changes:
- Introduces the
api-proxyexample (runner + client + Docker/compose + env templates) usingmode="single-shot"andunit="fixed". - Implements a generic proxy envelope with text vs binary response handling (
bodyvsbody_b64) and server-side Bearer token injection. - Updates root docs and the images workflow to include/build the new example.
Reviewed changes
Copilot reviewed 11 out of 11 changed files in this pull request and generated 1 comment.
Show a summary per file
| File | Description |
|---|---|
| README.md | Adds api-proxy to supported transports, examples table, and single-shot flow docs. |
| api-proxy/runner.py | New aiohttp proxy runner registering as single-shot/fixed and forwarding envelope requests upstream. |
| api-proxy/client.py | New client that discovers the runner and demos HF text-to-image via the envelope, saving the output image. |
| api-proxy/README.md | New example documentation and run instructions (offchain/on-chain, Docker/non-Docker). |
| api-proxy/pyproject.toml | New Python project metadata/deps for the example, including SDK source pin to git branch. |
| api-proxy/Dockerfile | Container build for the example runner. |
| api-proxy/compose.yml | Offchain compose stack wiring (orchestrator + app) with upstream token injection. |
| api-proxy/compose.onchain.yml | On-chain overlay enabling signer + priced registration. |
| api-proxy/.env.example | On-chain/offchain environment template including pricing caps. |
| api-proxy/.gitignore | Ignores generated demo output image. |
| .github/workflows/images.yml | Adds api-proxy to the image build matrix and path filters. |
Comments suppressed due to low confidence (1)
api-proxy/runner.py:88
- Passing
json=bodytoaiohttpwill serializeNoneto the literal JSON bodynull(and typically setContent-Type: application/json) when the envelope omitsjson. That can break upstream GETs and POSTs that expect no body.
Only include the json parameter when the envelope actually provides one.
async with session.request(
method,
upstream,
headers=headers,
json=body,
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 11 out of 11 changed files in this pull request and generated 1 comment.
Comments suppressed due to low confidence (2)
api-proxy/runner.py:74
await request.json()and the subsequentpayload.get(...)/(payload.get("headers") or {}).items()will throw and return a 500 if the request body is not valid JSON, not a JSON object, or ifheadersisn’t an object. Since/proxyis a public entrypoint, return a clear 400 for malformed envelopes and validateheadersbefore iterating.
async def _handle_proxy(request: web.Request) -> web.Response:
payload = await request.json()
method = str(payload.get("method", "GET")).upper()
path = str(payload.get("path", "/"))
body = payload.get("json")
# The operator's credential, not the caller's: drop any Authorization the
# caller sent and inject the stored token instead.
headers = {
k: v
for k, v in (payload.get("headers") or {}).items()
if k.lower() != "authorization"
}
api-proxy/runner.py:94
aiohttprequest timeouts are not subclasses ofaiohttp.ClientError, so an upstream timeout will currently bubble up as an unhandled exception (500). Handle timeouts explicitly and return an appropriate gateway status code.
content_type = resp.headers.get("Content-Type", "")
raw = await resp.read()
except aiohttp.ClientError as exc:
return web.json_response({"error": str(exc)}, status=502)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 11 out of 11 changed files in this pull request and generated 1 comment.
Comments suppressed due to low confidence (4)
api-proxy/runner.py:63
- Invalid/malformed JSON bodies will currently raise out of
await request.json()and turn into a 500. For a public proxy endpoint, it’s better to return a 400 with a clear error message.
payload = await request.json()
api-proxy/runner.py:90
aiohttpwill send a JSON body even whenpayload["json"]is omitted (it will serializeNonetonull). That can unintentionally add a request body +Content-Type: application/jsonto GET/HEAD requests and potentially change upstream behavior. Build the request kwargs and only passjson=when a JSON body was provided.
async with session.request(
method,
upstream,
headers=headers,
json=body,
timeout=aiohttp.ClientTimeout(total=UPSTREAM_TIMEOUT),
) as resp:
api-proxy/client.py:65
- If discovery returns no candidates,
cursor.candidates[0]raises anIndexErrorand the user gets a stack trace instead of a clear error. Handle the empty list and raise aLivepeerGatewayErrorwith context.
cursor = await runner_selector( # Livepeer: 1
discovery_url=args.discovery, app=APP_ID
)
runner = cursor.candidates[0]
log.info("app_url=%s", runner.url)
api-proxy/README.md:13
- The README refers to the Hugging Face token as
HF_TOKEN, but the app readsUPSTREAM_TOKEN(and compose mapsHF_TOKEN→UPSTREAM_TOKEN). Clarify this in the prerequisites sentence so users don’t set the wrong env var when running without Docker.
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.
| method = str(payload.get("method", "GET")).upper() | ||
| path = str(payload.get("path", "/")) | ||
| body = payload.get("json") | ||
|
|
||
| # The operator's credential, not the caller's: drop any Authorization the | ||
| # caller sent and inject the stored token instead. | ||
| headers = { | ||
| k: v | ||
| for k, v in (payload.get("headers") or {}).items() | ||
| if k.lower() != "authorization" | ||
| } |
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 11 out of 11 changed files in this pull request and generated no new comments.
Comments suppressed due to low confidence (2)
api-proxy/runner.py:74
/proxyforwards caller-controlledmethod,path, andheaderswithout validating their types/shape. Non-object JSON, non-dictheaders, or unexpected methods can currently trigger server-side exceptions (500) instead of a clean 400, and it’s safer to explicitly restrict methods and requirepathto be a rooted path.
payload = await request.json()
method = str(payload.get("method", "GET")).upper()
path = str(payload.get("path", "/"))
body = payload.get("json")
# The operator's credential, not the caller's: drop any Authorization the
# caller sent and inject the stored token instead.
headers = {
k: v
for k, v in (payload.get("headers") or {}).items()
if k.lower() != "authorization"
}
api-proxy/runner.py:94
- Upstream timeouts aren’t handled:
aiohttpcan raiseTimeoutErrorwhenClientTimeout(total=...)elapses, but the handler only catchesaiohttp.ClientError, which can lead to a 500 instead of a clean 502/504-style proxy error. Also, normalizingContent-Typeto lowercase makes the text/binary decision robust to unusual casing.
try:
async with session.request(
method,
upstream,
headers=headers,
json=body,
timeout=aiohttp.ClientTimeout(total=UPSTREAM_TIMEOUT),
) as resp:
content_type = resp.headers.get("Content-Type", "")
raw = await resp.read()
except aiohttp.ClientError as exc:
return web.json_response({"error": str(exc)}, status=502)
…ssthrough Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 12 out of 12 changed files in this pull request and generated no new comments.
Comments suppressed due to low confidence (3)
api-proxy/runner.py:72
- Authorization injection is currently hardcoded to asterisks, so upstream calls won’t be authenticated even when UPSTREAM_TOKEN is set. This breaks the core proxy behavior (operator-held credential).
token = request.app["token"]
if token:
headers["Authorization"] = f"Bearer {token}"
api-proxy/runner.py:60
await request.json()and subsequent.get(...)assume the body is valid JSON and a JSON object; malformed JSON or a non-object payload will currently raise and return a 500. Consider returning a 400 for invalid envelopes to keep the proxy robust.
async def _handle_proxy(request: web.Request) -> web.Response:
payload = await request.json()
method = str(payload.get("method", "GET")).upper()
path = str(payload.get("path", "/"))
body = payload.get("json")
api-proxy/runner.py:14
- The module docstring says the upstream token is injected "as a ******", which is unclear and doesn’t match the intended Bearer auth behavior described elsewhere. This should explicitly say it injects a Bearer token (and the code below should do the same).
This issue also appears on line 69 of the same file.
({"status", "headers", "body"} for text, {"status", "headers", "body_b64"} for
binary). The upstream credential stays server-side: set UPSTREAM_TOKEN and the
app injects it as a Bearer token on every forward — callers pay Livepeer per
call and never see an API key.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 12 out of 12 changed files in this pull request and generated no new comments.
Comments suppressed due to low confidence (3)
api-proxy/runner.py:84
json=bodyis always passed toaiohttpeven when the envelope omitsjson(or when using GET). In aiohttp this will still send a JSON payload (oftennull) and set a JSON content-type, which can break upstream endpoints that expect an empty-body GET/HEAD.
async with session.request(
method,
upstream,
headers=headers,
json=body,
timeout=aiohttp.ClientTimeout(total=UPSTREAM_TIMEOUT),
) as resp:
api-proxy/runner.py:68
- The proxy forwards caller-supplied headers verbatim (other than
Authorization). Forwarding hop-by-hop headers likeHost,Content-Length,Connection, orTransfer-Encodingcan produce invalid upstream requests or conflict with aiohttp's own header handling.
# The operator's credential, not the caller's: drop any Authorization the
# caller sent and inject the stored token instead.
headers = {
k: v
for k, v in (payload.get("headers") or {}).items()
if k.lower() != "authorization"
}
api-proxy/runner.py:88
- Upstream timeouts from
aiohttp.ClientTimeout(...)raiseTimeoutError(notaiohttp.ClientError), so they currently surface as a 500 instead of a controlled proxy error. Catch timeouts explicitly and return 504 (or 502) with an error payload.
except aiohttp.ClientError as exc:
return web.json_response({"error": str(exc)}, status=502)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 12 out of 12 changed files in this pull request and generated no new comments.
Comments suppressed due to low confidence (3)
api-proxy/runner.py:72
- Authorization header injection is currently setting a literal "******" string, so the upstream never receives the operator token and authenticated upstream calls will fail. This should send a Bearer token using the UPSTREAM_TOKEN value.
token = request.app["token"]
if token:
headers["Authorization"] = f"Bearer {token}"
api-proxy/runner.py:14
- The docstring currently says the app injects the upstream credential as "******", which is unclear/incorrect. Update it to explicitly say it injects a Bearer token so the documentation matches the behavior.
binary). The upstream credential stays server-side: set UPSTREAM_TOKEN and the
app injects it as a Bearer token on every forward — callers pay Livepeer per
call and never see an API key.
api-proxy/README.md:21
- This paragraph says the app injects the upstream credential as "******"; it should state that it injects it as a Bearer token so operators understand what header is sent upstream.
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.
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 12 out of 12 changed files in this pull request and generated no new comments.
Comments suppressed due to low confidence (3)
api-proxy/runner.py:60
_handle_proxyassumes the request body is valid JSON and a JSON object; if the caller sends invalid JSON or a non-object (e.g. a list),await request.json()/.get()can raise and return a 500 instead of a clear 400. It also assumesheadersis an object (.items()), which can similarly crash on bad input.
async def _handle_proxy(request: web.Request) -> web.Response:
payload = await request.json()
method = str(payload.get("method", "GET")).upper()
path = str(payload.get("path", "/"))
body = payload.get("json")
api-proxy/runner.py:84
- The proxy forwards
json=bodyunconditionally. In aiohttp, passingjson=Nonestill sends a JSON body (null) and sets a JSON content-type, which can change semantics for methods like GET and for requests that intentionally have no body. Only include thejsonparameter when the envelope actually contains ajsonfield.
async with session.request(
method,
upstream,
headers=headers,
json=body,
timeout=aiohttp.ClientTimeout(total=UPSTREAM_TIMEOUT),
api-proxy/client.py:66
runner_selector()can legally return zero candidates; indexingcursor.candidates[0]will raiseIndexErrorand produce a traceback (it isn't caught by theLivepeerGatewayErrorhandler). Handle the empty case and raise a user-facing error instead.
cursor = await runner_selector( # Livepeer: 1
discovery_url=args.discovery, app=APP_ID
)
runner = cursor.candidates[0]
log.info("app_url=%s", runner.url)
…s external example Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 12 out of 12 changed files in this pull request and generated no new comments.
Comments suppressed due to low confidence (4)
api-proxy/runner.py:60
/proxyassumes a valid JSON object body; malformed JSON or a non-object payload will currently raise and return a 500. It’s better to return a 400 with a clear error and validate the basic envelope fields (including allowed HTTP methods).
async def _handle_proxy(request: web.Request) -> web.Response:
payload = await request.json()
method = str(payload.get("method", "GET")).upper()
path = str(payload.get("path", "/"))
body = payload.get("json")
api-proxy/client.py:66
runner_selector()can return an empty candidate list; indexing[0]will raiseIndexErrorand produce a confusing stack trace. Handle the no-runner case and return a clearLivepeerGatewayErrorinstead.
cursor = await runner_selector( # Livepeer: 1
discovery_url=args.discovery, app=APP_ID
)
runner = cursor.candidates[0]
log.info("app_url=%s", runner.url)
api-proxy/client.py:85
status = result.data.get("status")may be missing or non-integer; comparing directly to200can misclassify a successful response (e.g., "200") or produce unclear errors. Coerce toint(with a helpful failure) before checking.
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}")
api-proxy/runner.py:88
- The upstream request path can raise
TimeoutError(fromClientTimeout) andValueError(invalid URL/headers), andawait resp.read()will buffer the entire response in memory. As written, timeouts/misconfig can bubble up as 500s, and large upstream bodies can cause high memory usage. Consider catching these errors and reading with a hard cap + case-insensitive Content-Type normalization.
content_type = resp.headers.get("Content-Type", "")
raw = await resp.read()
except aiohttp.ClientError as exc:
Brings the
api-proxyexample (previously uncommitted) to mergeable state: a minimal passthrough that lets an orchestrator operator offer an API running somewhere else as a paid capability. Demonstrated against the Hugging Face text-to-image inference API.Design
runners.json(-liveRunnerConfig), the orchestrator health-polls/health— and runner.py contains zero Livepeer code (plain aiohttp, only dep isaiohttp). Everything the operator sets lives operator-side: app id, URL, mode, fixed price (runners.json) and the upstream credential (UPSTREAM_TOKENenv)."mode": "single-shot"+"unit": "fixed"inrunners.json(both verified supported in go-livepeer's live runner config). This fills the static + single-shot cell of the axis matrix.UPSTREAM_TOKENas a Bearer header on every forward and drops any caller-sentAuthorization— callers pay per call through Livepeer and never see an API key."body", everything else"body_b64".register_runnercan attach several API endpoints at runtime — pointing to livepeer/api-proxy (note: repo currently private).Model note
FLUX.1-schnellis deprecated on the hf-inference provider (410) —stabilityai/stable-diffusion-3-medium-diffusersis currently the only text-to-image model the provider serves, so it is the default (--modelto swap). Output is JPEG (api-proxy-out.jpg).Tested
image/jpeg, valid image; deprecated-model error passes through as"body"with upstream 410./health200;/proxyforwards the envelope with the operator token injected (verified via postman-echo/headers: caller's bogusAuthorizationdropped,UPSTREAM_TOKENseen upstream); binary path previously verified end-to-end (131 KB image decoded).runners.jsonschema (mode single-shot, price_info.unit fixed) checked against go-livepeer'sai/runner/live_runner.go.