Results
The Batch API output and error file formats: one JSONL line per request, joined back to your inputs by custom_id, and how to reconcile usage.
When a batch reaches completed (or expired with partial work), it produces
up to two result files, referenced on the batch object:
output_file_id: successful requests, one line each.error_file_id: failed requests, one line each. Present only if at least one request failed.
Download either with the Files content endpoint:
curl https://inference.openrelay.inc/v1/files/file-batch_9f8e7d6c5b4a-output/content \
-H "Authorization: Bearer $OPENRELAY_API_KEY" \
-o results.jsonlCLI: orl files content get file-batch_9f8e7d6c5b4a-output -o results.jsonl.
Result line shape
Every line in both files is one result object, matching the OpenAI batch output shape:
| Field | Type | Description |
|---|---|---|
id | string | A per-result id (batch_req_<custom_id>), so two lines that share a custom_id share it too. |
custom_id | string | The custom_id from the matching input request. Use it to join results back to inputs. |
response | object or null | On success: { status_code, body }, where body is the full endpoint response (e.g. a chat completion). Null on failure. |
error | object or null | On failure: { code, message, status_code, body }. Null on success. |
Exactly one of response and error is set on each line. The result files are
not ordered to match the input file; always join by custom_id.
Output file line (success)
{
"id": "batch_req_req-1",
"custom_id": "req-1",
"response": {
"status_code": 200,
"body": {
"id": "chatcmpl-abc123",
"object": "chat.completion",
"model": "openrelay/gemma-4-31b",
"choices": [
{ "index": 0, "message": { "role": "assistant", "content": "Red, blue, and yellow." }, "finish_reason": "stop" }
],
"usage": { "prompt_tokens": 17, "completion_tokens": 8, "total_tokens": 25 }
}
},
"error": null
}Output file line, tool-calling record
A record submitted with openrelay.tool_config
whose model actually tool-called carries one extra field in response.body:
{
"id": "batch_req_req-2",
"custom_id": "req-2",
"response": {
"status_code": 200,
"body": {
"id": "chatcmpl-def456",
"object": "chat.completion",
"model": "openrelay/gemma-4-31b",
"choices": [
{ "index": 0, "message": { "role": "assistant", "content": "The invoice total is $4,210." }, "finish_reason": "stop" }
],
"usage": { "prompt_tokens": 2140, "completion_tokens": 96, "total_tokens": 2236 },
"openrelay": { "tool_rounds": 3 }
}
},
"error": null
}| Field | Type | Description |
|---|---|---|
openrelay | object | Namespace for OpenRelay response extensions. Ignore any key you do not recognize: this object is additive, and new keys may appear without a breaking change. The request side uses the same namespace: body.openrelay.tool_config is where you ask for tool calling. |
openrelay.tool_rounds | integer | How many tool round-trips the worker ran for this record, so the answer came from tool_rounds + 1 model turns. |
Where it appears:
- Only on output lines, inside
response.body, and only when at least one tool round ran. Anopenrelay.tool_configrecord whose model answered immediately is byte-for-byte identical to a record with notool_configat all. - Never on error lines, and never on a record without
openrelay.tool_config. - Alongside a
usageobject that is summed across every turn of the record, which is what you are billed for.tool_roundsis how you tell a legitimately expensive agentic record from an anomalous one.
The field is on the response body only. openrelay.tool_config itself is
stripped before every model invocation and never appears in either result
file, and an openrelay object left empty by that strip is removed with it.
Request and response now share one namespace word: you ask under
body.openrelay, and you read the answer's metadata under
response.body.openrelay. A record still carrying the retired bare top-level
tool_config fails with tool_config_moved; see the migration note in
Tool calling in a batch.
Error file line (failure)
{
"id": "batch_req_req-7",
"custom_id": "req-7",
"response": null,
"error": {
"code": "invalid_request_error",
"message": "messages: field required",
"status_code": 400
}
}A request whose 24-hour window elapsed before it ran gets an error line with
code: "batch_expired". A plain or tool-calling record whose model call did not
return its response within the
30-minute limit per model call gets an error
line with code: "request_time_limit" and is not retried; a request a
workflow makes comes back to its
callback instead.
Records rejected at create
A record the create rejected
(for example one with "stream": true, which is not allowed in a batch) never
runs, and it is also an error line, with the rejection's code and message
and no status_code. The error file has no line field, so the message
starts with the record's line in the input, line N: , counted like the
create answer's line field (from 1, blank lines included):
{
"id": "batch_req_req-2",
"custom_id": "req-2",
"response": null,
"error": {
"code": "streaming_unsupported",
"message": "line 2: \"stream\": true is not supported in batch"
}
}The create answer and the rejections file carry the same message without the
prefix, beside line. The line number is how you find two kinds of rejected
record that custom_id cannot point to on its own:
-
A repeated
custom_id. When acustom_idappears on more than one line of the input, every record carrying it is rejected withduplicate_custom_id, the first one included, and none of them runs. Their error lines share thecustom_idand theid(batch_req_<custom_id>), and the line number in each message tells them apart. Because none of them runs, acustom_idthat names a create rejection never also names a line in the output file. -
A record with no
custom_id. A line that could not be read as a record (invalid_json), or that names nocustom_id(missing_custom_id), has no id to join on: its error line has"custom_id": ""and"id": "batch_req_", and the line number is the only pointer back to the input:{ "id": "batch_req_", "custom_id": "", "response": null, "error": { "code": "invalid_json", "message": "line 3: invalid character 'o' in literal null (expecting 'u')" } }
The error line of a record that ran and failed carries no line number. The
rejections file (rejected_records_file_id on the create answer) holds the
create's rejections with line as a field, and can be read as soon as the
create answers.
Reconciling usage
Each successful response.body carries its own usage object, billed exactly
like a Sync request but at the batch rate (50% of the Sync rate at
standard priority). For a record that tool-called,
that usage is the sum across all tool_rounds + 1 turns, so it is the whole
record's cost in one place rather than the last turn's. The batch object also
exposes a rolled-up usage (input_tokens, output_tokens, cost_nano_usd)
so you can read total spend without summing every line. See
Pricing & billing.