Skip to content
OpenRelay is in early access, and the /v1 API is stable. New capabilities ship in the changelog.
GuidesInference APIsBatch API

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.jsonl

CLI: 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:

FieldTypeDescription
idstringA per-result id (batch_req_<custom_id>), so two lines that share a custom_id share it too.
custom_idstringThe custom_id from the matching input request. Use it to join results back to inputs.
responseobject or nullOn success: { status_code, body }, where body is the full endpoint response (e.g. a chat completion). Null on failure.
errorobject or nullOn 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
}
FieldTypeDescription
openrelayobjectNamespace 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_roundsintegerHow 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. An openrelay.tool_config record whose model answered immediately is byte-for-byte identical to a record with no tool_config at all.
  • Never on error lines, and never on a record without openrelay.tool_config.
  • Alongside a usage object that is summed across every turn of the record, which is what you are billed for. tool_rounds is 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 a custom_id appears on more than one line of the input, every record carrying it is rejected with duplicate_custom_id, the first one included, and none of them runs. Their error lines share the custom_id and the id (batch_req_<custom_id>), and the line number in each message tells them apart. Because none of them runs, a custom_id that 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 no custom_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.

On this page