Skip to content

feat: return raw bytes from call_runner for non-JSON responses - #51

Merged
j0sh merged 7 commits into
ja/live-runnerfrom
rs/call-runner-raw-bytes
Jul 31, 2026
Merged

feat: return raw bytes from call_runner for non-JSON responses#51
j0sh merged 7 commits into
ja/live-runnerfrom
rs/call-runner-raw-bytes

Conversation

@rickstaa

@rickstaa rickstaa commented Jul 29, 2026

Copy link
Copy Markdown
Member

Closes the last gap after #25: the buffered call_runner path required every response to be a JSON object, so binary-returning apps (an image, say — the hf-inference text-to-image API returns raw image/jpeg bytes) forced a base64-in-JSON shim into every runner and client. Streaming already handled bytes (stream=Trueaiter_bytes()); this brings the buffered path to parity.

What

  • http.request_data() — same request semantics and error mapping as request_json, but returns (body_bytes, content_type) without assuming JSON. request_json is now a thin wrapper over it (fetch + parse), behavior unchanged.
  • call_runner (non-stream): single-document JSON — application/json or an RFC 6839 +json suffix — parses into result.data exactly as before (including the strict object check and session_id pickup). Any other content type returns result.content (bytes) + result.content_type instead of raising "expected JSON object".
  • LiveRunnerCallResult: new optional content and content_type fields; content_type is also set on JSON results. content is None for JSON — b"" is a valid empty binary body, so None is the unambiguous "this was JSON" sentinel.
  • Payment path untouched: the 402 challenge raises on status before any body handling, so paid calls work identically for JSON and binary responses.

Compatibility

Strictly additive: every response that succeeded before behaves identically. The only changed cases previously raised — a non-JSON content type now succeeds with bytes.

Content-type matching is by media subtype rather than a substring test, so vendor types (application/problem+json, application/vnd.acme.v1+json) parse without being listed, while multi-document formats that json.loads cannot parse (application/jsonl — this repo's own trickle-channel default — plus application/x-ndjson, application/json-seq) return bytes instead of raising. All four flipped classifications were previously errors.

One edge note: a server returning JSON under a non-JSON content type (e.g. bare text/plain) lands in content instead of being parsed; aiohttp's json_response (what the examples use) always sets application/json.

The field is named content, not raw, because raw already means "the original JSON dict" on LiveRunnerSessionEvent, LiveRunnerInstance, and LiveVideoToVideoresult.content would otherwise sit one dot from result.runner.raw with the opposite meaning. It also matches the requests/httpx convention for a body as bytes.

Why

With this, a runner that fronts a bytes-returning API needs no transform at all — the example-apps api-proxy can drop its base64 envelope, and a runner can literally be a stock nginx proxy_pass (see livepeer/runner-app-examples#45).

Tested

tests/test_call_runner_raw.py (7 cases against a live aiohttp server): JSON unchanged incl. session_id, +json vendor type parsed, binary → content + content_type, ndjson → content, invalid JSON with a JSON content type still raises (and names the content type), JSON array still rejected, HTTP errors still raise. Full suite passes (16).

@coderabbitai

coderabbitai Bot commented Jul 29, 2026

Copy link
Copy Markdown

Important

Review skipped

Auto reviews are disabled on base/target branches other than the default branch.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 7a10517c-df5c-4e94-8928-d25256b038bd

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

rickstaa added a commit to livepeer/runner-app-examples that referenced this pull request Jul 30, 2026
stream=True is not the natural shape for one bounded call returning a
120 KB image, it is there because the buffered path still assumes a
JSON object. Say so at the call site and point at the SDK change and
the follow-up issue that let it go away.

Refs #47, livepeer/livepeer-python-gateway#51

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
rickstaa added a commit to livepeer/runner-app-examples that referenced this pull request Jul 30, 2026
stream=True is not the natural shape for one bounded call returning a
120 KB image, it is there because the buffered path still assumes a
JSON object. Say so at the call site and point at the SDK change and
the follow-up issue that let it go away.

Refs #47, livepeer/livepeer-python-gateway#51

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@rickstaa
rickstaa requested a review from j0sh July 30, 2026 09:15
@rickstaa

Copy link
Copy Markdown
Member Author

@j0sh I did add tests but we don't have an agreed testing framework setup. I can also remove these for now and we add this later.

rickstaa added a commit to livepeer/runner-app-examples that referenced this pull request Jul 30, 2026
…bytes (#45)

nginx.conf.template is the whole runner, so the example's "no Livepeer
code in the app" ends at no app code at all. runner.py and the Dockerfile
are gone, and api-proxy leaves the CI image matrix.

The config is the security model: it pins the model URL, the method, the
credential and Accept, and strips the caller's query string, leaving the
body as the only caller controlled input. The app id names the model, not
the proxy, since callers match it exactly.

The client reads raw JPEG bytes with call_runner(stream=True) until
livepeer/livepeer-python-gateway#51 lands.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@j0sh

j0sh commented Jul 30, 2026

Copy link
Copy Markdown
Collaborator

Is this streamable? If not, is that a regression? Eg. #25

@j0sh

j0sh commented Jul 31, 2026

Copy link
Copy Markdown
Collaborator

Merged the latest ja/live-runner into this branch.

The response refactor introduced an error-mapping regression: when a JSON response contained bytes that could not be decoded as a valid JSON encoding, json.loads(body) raised UnicodeDecodeError, which escaped directly from both request_json() and call_runner() instead of being reported as the documented LivepeerGatewayError. Both paths now catch UnicodeDecodeError alongside JSONDecodeError and consistently wrap either failure as LivepeerGatewayError.

The buffered response helper is now internal as _request_body, since it is an implementation seam shared only by request_json and call_runner, not a complete public HTTP response API. The seven affected call_runner tests now mock _request_body instead of the no-longer-called request_json. Added coverage verifies that invalid JSON byte encoding raises LivepeerGatewayError through both request_json and call_runner rather than leaking UnicodeDecodeError.

Verification:

  • Complete CI-equivalent pytest run with branch coverage: 219 passed

rickstaa and others added 7 commits July 31, 2026 14:08
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Content-type detection was a substring test (`"json" in content_type`),
which is right for `+json` types only by accident and wrong for
multi-document formats: `application/jsonl` (this repo's own trickle
channel default), `application/x-ndjson`, and `application/json-seq`
all matched and then failed in json.loads, so a working runner fronting
a streaming API got "did not return valid JSON" instead of its bytes —
the same bug class this branch set out to fix.

Match on the media subtype instead, via aiohttp's own mimetype parser:
`application/json` or the RFC 6839 `+json` structured suffix. Vendor
types (`application/vnd.acme.v1+json`) keep parsing without being
listed; multi-document bodies fall through to `raw`. Only four
classifications change, all previously raising.

Also fold the raw early return into the single existing return, so
`payment_session=None if payment_type == "fixed" else payment_session`
stays in one place — payment-type handling here has been reverted twice
and there is no paid-binary test to catch the two sites drifting. The
`session_id` expression reduces to the early return's behavior when
`data` is empty, so this is equivalent.

Add `content_type` to the JSON parse error: it is the fact that routed
the response into parsing, and without it a misclassification is
undebuggable from the message alone.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`content_type` is self-documenting from the field name; the invariant
worth stating is that a populated `raw` means an empty `data`. The
subtype helper matches its bare neighbors in this module.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`raw` already means "the original JSON dict, un-normalized" on three
dataclasses in this SDK — LiveRunnerSessionEvent.raw, LiveRunnerInstance.raw,
LiveVideoToVideo.raw — and examples dump it with json.dumps(x.raw). A
`raw: Optional[bytes]` on LiveRunnerCallResult overloads the name to mean the
opposite, sharply so on one expression chain: `result.raw` (bytes) sitting
one dot from `result.runner.raw` (dict).

`content` matches its sibling `content_type` and the ecosystem convention for
a response body as bytes (requests/httpx `.content`) — where `.raw` instead
means an unread stream object, so the old name actively misled. Nothing
consumes the field yet (runner-app-examples#45 and api-proxy both use the
streaming path), so this is free now and permanent once released.

Semantics unchanged: still `Optional[bytes] = None`, since `b""` is a valid
empty body and `None` is the only unambiguous "this was JSON" sentinel. Also
renames the local `raw` to `body` — it holds bytes, while `raw` in this
codebase reads as a dict.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@j0sh
j0sh force-pushed the rs/call-runner-raw-bytes branch from 5c061bb to b97ab53 Compare July 31, 2026 21:08
@j0sh
j0sh merged commit b97ab53 into main Jul 31, 2026
4 checks passed
@j0sh
j0sh deleted the rs/call-runner-raw-bytes branch July 31, 2026 21:11
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants