Batches
Create, get, list, and cancel batches. The batch object, inline vs file input, the status lifecycle, and pagination.
A batch runs a set of inference requests asynchronously against one endpoint. You create it from an uploaded file or from inline requests, poll it to a terminal status, then download its result files.
The batch object
| Field | Type | Description |
|---|---|---|
id | string | The batch id (batch_…). |
object | string | Always "batch". |
endpoint | string | The endpoint the requests target, e.g. /v1/chat/completions. |
input_file_id | string | The input file the batch runs. For inline requests, an input file the gateway synthesized from them. |
completion_window | string | Always "24h". |
priority | string | The scheduling tier the batch runs and is billed at: "standard" or "expedited". See Priority. |
status | string | The lifecycle status. See Status lifecycle. |
model | string | The model every record runs: the first accepted record's, set when the batch is created. |
output_file_id | string | Present when there are successful results (the file of response lines). |
error_file_id | string | Present when there are failed results (the file of error lines), the records rejected at create included. |
created_at | integer | Unix seconds when the batch was submitted. |
in_progress_at | integer or null | Unix seconds when the batch's first requests started running. null while the batch waits for GPU capacity. See Queued or running. |
expires_at | integer | Unix seconds when the 24-hour window ends. |
completed_at / failed_at / expired_at / cancelled_at | integer | Unix seconds the batch reached that terminal status (whichever applies). |
request_counts | object | { total, completed, failed }. Set at create: total is every record in the input and failed starts at the records the create rejected. Updated as work progresses. |
errors | object | Present only on a failed batch: { object: "list", data: [{ code, message }] }, why the batch could not run. Records that failed one by one are in the error file instead. |
usage | object | Rolled-up { input_tokens, output_tokens, cost_nano_usd }. Present once non-zero. cost_nano_usd is the customer price in nanodollars (1e-9 USD), priced at the batch rate for the batch's priority. |
metadata | object | The string-to-string map you attached at creation. |
Create a batch
POST https://inference.openrelay.inc/v1/batches| Field | Type | Description |
|---|---|---|
input_file_id | string | The id of an uploaded purpose: "batch" file. Provide either this or requests, not both. |
requests | array | Inline request objects (the same shape as input file lines). The gateway stores them as a synthesized input file. Provide either this or input_file_id. |
endpoint | string (required) | One of /v1/chat/completions, /v1/completions, or /v1/embeddings. |
completion_window | string (required) | Must be "24h". |
model | string | Optional. The model a record without body.model runs, when it is a model batch serves; otherwise it is ignored. The batch's model is the one its first accepted record runs. |
priority | string | Scheduling tier: "standard" (default) or "expedited". Fixed for the life of the batch and echoed on every batch object. "expedited" is available to every organization; if an operator has turned it off for yours, the create returns 409 feature_disabled with priority "expedited" is not enabled for this organization. Any other value is a 400. See Priority. |
metadata | object | Optional string-to-string map, echoed back on the batch. |
Every record is checked when you create the batch: records that break a rule
are rejected and the rest are accepted. The create returns
the batch at status validating, with rejected_count, the number of records
it rejected, and request_counts already set: total is every record in the
input, and the rejected records count as failed.
A key with a spend budget that is used up gets
402 key_budget_exceeded and creates nothing. The check is only whether the
budget is used up, so one batch can pass what is left of it; a batch already
running finishes, and its billed usage counts toward the key.
curl https://inference.openrelay.inc/v1/batches \
-H "Authorization: Bearer $OPENRELAY_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"input_file_id": "file-a1b2c3d4e5f6a7b8c9d0e1f2",
"endpoint": "/v1/chat/completions",
"completion_window": "24h",
"metadata": { "job": "nightly-summaries" }
}'{
"id": "batch_9f8e7d6c5b4a",
"object": "batch",
"endpoint": "/v1/chat/completions",
"input_file_id": "file-a1b2c3d4e5f6a7b8c9d0e1f2",
"completion_window": "24h",
"priority": "standard",
"status": "validating",
"model": "openrelay/gemma-4-31b",
"created_at": 1752460801,
"in_progress_at": null,
"expires_at": 1752547201,
"request_counts": { "total": 2, "completed": 0, "failed": 0 },
"metadata": { "job": "nightly-summaries" }
}CLI: orl batches create --input-file-id file-a1b2c3d4e5f6a7b8c9d0e1f2 --endpoint /v1/chat/completions (or --input-file requests.jsonl to upload and create in one command; completion-window defaults to 24h).
Rejected records
The create checks every record before anything runs: its shape, its
openrelay extensions, and its model. A record that breaks a rule is
rejected, never runs and is never billed; the rest of the batch runs. The
create's 200 carries fields that appear only on the create answer:
rejected_count, and when the create rejects records, the other two:
| Field | Type | Description |
|---|---|---|
rejected_count | integer | How many records the create rejected. Read the rejected count here, not from request_counts.failed, which also counts the records that failed running when the create answered after its batch already ran. |
rejected_records | array | The first 100 rejected records, in input order: { line, custom_id, code, message }. |
rejected_records_file_id | string | A file holding every rejected record, one { line, custom_id, code, message } object per line, in input order. Its purpose is batch_rejections; read it with GET /v1/files/{id}/content. It is kept for 30 days. |
line is the record's line in the input file, counting from 1 and counting
blank lines, so it is the number your editor shows; for inline requests it is
the request's position in requests, from 1. custom_id is empty when the
line could not be read as a record (invalid_json) or names none. One whose
JSON-escaped form is longer than 512 bytes is cut short and ends in ....
{
"id": "batch_9f8e7d6c5b4a",
"object": "batch",
"status": "validating",
"model": "openrelay/gemma-4-31b",
"request_counts": { "total": 4, "completed": 0, "failed": 3 },
"rejected_count": 3,
"rejected_records": [
{ "line": 1, "custom_id": "req-1", "code": "duplicate_custom_id", "message": "custom_id appears more than once" },
{ "line": 2, "custom_id": "req-2", "code": "streaming_unsupported", "message": "\"stream\": true is not supported in batch" },
{ "line": 4, "custom_id": "req-1", "code": "duplicate_custom_id", "message": "custom_id appears more than once" }
],
"rejected_records_file_id": "file-0f1e2d3c4b5a69788796a5b4",
"…": "…"
}The batch's error file lists the rejected records too, once the batch finishes.
When no record can run, the create returns 400 no_valid_records, creates no
batch, and carries the same fields under error, with the request_counts the
batch would have had:
{
"error": {
"message": "all 2 records were rejected; see rejected_records",
"type": "invalid_request_error",
"code": "no_valid_records",
"rejected_records": [
{ "line": 1, "custom_id": "req-1", "code": "model_not_offered", "message": "model \"openrelay/unknown\" is not offered for batch (offered: openrelay/bge-m3, openrelay/deepseek-ocr-2, openrelay/gemma-4-31b)" },
{ "line": 2, "custom_id": "", "code": "invalid_json", "message": "invalid character 'o' in literal null (expecting 'u')" }
],
"rejected_records_file_id": "file-6a5b4c3d2e1f0a9b8c7d6e5f",
"rejected_count": 2,
"request_counts": { "total": 2, "completed": 0, "failed": 2 }
}
}A record is rejected with one of these codes:
code | The record |
|---|---|
invalid_json | is not valid JSON, or is not an object whose custom_id, method and url are strings. |
missing_custom_id | has no custom_id. |
duplicate_custom_id | shares its custom_id with another line of the input. Every record carrying that custom_id is rejected, the first one included, whatever else it breaks. |
invalid_url | names a url other than the batch's endpoint. |
invalid_method | names a method other than POST. |
missing_body | has no body. |
invalid_body | has a body that is not a JSON object, or that repeats a reserved key (openrelay, tool_config or workflow). |
streaming_unsupported | sets "stream": true. |
tool_config_moved | carries the retired bare top-level tool_config; nest it under openrelay. See Tool calling. |
invalid_tool_config | carries an openrelay.tool_config that breaks its rules, such as a non-HTTPS or internal url. |
invalid_workflow | carries an openrelay.workflow that breaks its rules. See Workflows. |
model_required | names no model, and the batch's model field names none that batch serves. |
model_not_offered | names a model batch does not serve for your organization. The message lists the ones it does. |
model_not_allowed | names a model the API key that created the batch may not use. |
tool_calling_unsupported | asks for tool calls (a non-empty tools without "tool_choice": "none", a tool_choice other than "none", or openrelay.tool_config) on a model that cannot emit them. |
model_mismatch | runs a different model from the batch's. One batch runs one model: the first accepted record's. |
Some answers refuse the whole create instead, and create nothing:
| Status | code | When |
|---|---|---|
409 | feature_disabled | A record carries openrelay.workflow and workflow records are not enabled for your organization, or priority is "expedited" and it is turned off for your organization. |
400 | input_too_large | The input holds more than 50,000 records. |
400 | input_unreadable | The input file's content cannot be read. Upload it again. |
400 | input_file_changed | The input file's content is not what was uploaded. Upload it again. |
503 | catalog_unavailable, flags_unavailable | The model catalog, or your organization's feature settings, could not be read yet. Retry. |
A 409 feature_disabled comes before any record is checked. "priority": "expedited" is refused before the rest of the request is checked or the input is read. A record carrying openrelay.workflow anywhere in the input refuses the create whatever the other records hold, even past the 50,000-record limit or when the input cannot be read to its end after that record, so the input gets no input_too_large, read error, no_valid_records or rejected records instead.
CLI: orl batches create prints the batch on stdout and, when the create
rejected records, a preview of up to 10 of them and the rejections file id on
stderr (exit 0); --rejections-out rejections.jsonl writes every rejection
to a file. When no record can run the error carries the preview (exit 2), and
a 409 feature_disabled exits 6.
Inline requests
For small jobs, skip the upload and pass requests inline. Each element is one
request object, the same shape as an input file line:
curl https://inference.openrelay.inc/v1/batches \
-H "Authorization: Bearer $OPENRELAY_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"endpoint": "/v1/chat/completions",
"completion_window": "24h",
"requests": [
{"custom_id": "req-1", "method": "POST", "url": "/v1/chat/completions", "body": {"model": "openrelay/gemma-4-31b", "messages": [{"role": "user", "content": "Name three primary colors."}]}}
]
}'Provide exactly one of input_file_id or requests: sending both, or
neither, returns 400. Inline requests are capped at 50,000 records like an
uploaded file.
Retry a create safely
A create that timed out or lost its connection may still have made the batch,
and a plain retry would run and bill the work twice. Send an Idempotency-Key
header with a value you choose once per batch (1 to 512 bytes, such as a
UUID), and send the same key with the same body on every retry:
curl https://inference.openrelay.inc/v1/batches \
-H "Authorization: Bearer $OPENRELAY_API_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: nightly-summaries-2026-10-15" \
-d '{
"input_file_id": "file-a1b2c3d4e5f6a7b8c9d0e1f2",
"endpoint": "/v1/chat/completions",
"completion_window": "24h"
}'| The retry | The response |
|---|---|
Same key and body, after the first create answered 200 | The same 200 and batch again, with the header Idempotent-Replayed: true. No second batch runs. |
| Same key and body, while the first create is still running | 409 idempotency_key_in_use, with Retry-After: 1. Repeat it a second later. |
| Same key, another body | 422 idempotency_key_reused. Nothing is created. |
| Same key, after the first create failed with any other status | Runs as a new create: only a 200 is kept, so a retry after a 400, a 429 or a 503 is a new attempt. |
Same key, after a 503 orchestrator_unavailable sent when we could not tell whether the batch started | 409 idempotency_key_in_use for up to two minutes, then a new create. The batch from the 503 does not run, unless we also failed to record that it failed; to be sure before you create again, list your batches (GET /v1/batches). |
The 200 is kept for 24 hours. Keys belong to your organization, and the body
must be byte-identical, so keep the bytes you sent the first time. A retry is
checked like any create first: it needs batch.write and passes the balance
check. The full rules are in Idempotent creates.
File uploads take no key: a repeated upload stores a second file, with a new
id, which costs nothing until a batch runs it. To retry a create after an
upload, reuse the first file's input_file_id: a new id is a different body
under the same key, and gets a 422.
CLI: orl batches create --idempotency-key nightly-summaries-2026-10-15 sends
the key. It also tags the batch with it, so a retry of --input-file finds the
first batch before uploading the file again.
Priority
A batch runs at one of two tiers, chosen at create and fixed for its life:
| Tier | Scheduling | Price |
|---|---|---|
standard (default) | Runs when capacity frees. | The batch rate: 50% of the Sync rate. |
expedited | Asks for the next capacity slot that frees on that model, ahead of waiting standard batches. | The Sync rate. The batch discount does not apply. |
Expedited is a queue preference, not a deadline: there is no SLA, no credit if it is slow, and a running batch is never interrupted to make room for it. Everything else about the batch surface (file input, JSONL results, the 50,000-record cap, the 24-hour window) is identical between tiers.
Expedited is available to every organization. An operator can turn it off
for a single organization; if yours is turned off, a create with
"priority": "expedited" returns 409 feature_disabled with the message
priority "expedited" is not enabled for this organization and creates
nothing, rather than quietly running at standard.
curl https://inference.openrelay.inc/v1/batches \
-H "Authorization: Bearer $OPENRELAY_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"input_file_id": "file-a1b2c3d4e5f6a7b8c9d0e1f2",
"endpoint": "/v1/chat/completions",
"completion_window": "24h",
"priority": "expedited"
}'priority is an OpenRelay field, so the OpenAI SDKs send it through
extra_body:
batch = client.batches.create(
input_file_id="file-a1b2c3d4e5f6a7b8c9d0e1f2",
endpoint="/v1/chat/completions",
completion_window="24h",
extra_body={"priority": "expedited"},
)Read the tier back off the priority field on the response rather than
assuming your request was honored. On the CLI it is
orl batches create --priority expedited.
Get a batch
GET https://inference.openrelay.inc/v1/batches/{id}curl https://inference.openrelay.inc/v1/batches/batch_9f8e7d6c5b4a \
-H "Authorization: Bearer $OPENRELAY_API_KEY"Returns the batch object, or 404 if the id is unknown to your organization.
Poll this until status is terminal. CLI: orl batches get batch_9f8e7d6c5b4a.
List batches
GET https://inference.openrelay.inc/v1/batches?limit=20&after=batch_9f8e7d6c5b4aReturns your batches newest first.
| Query | Description |
|---|---|
limit | Page size, 1 to 100. Defaults to 20. |
after | A batch id to page after (the last_id of the previous page). |
{
"object": "list",
"data": [ { "id": "batch_9f8e7d6c5b4a", "object": "batch", "status": "completed", "…": "…" } ],
"first_id": "batch_9f8e7d6c5b4a",
"last_id": "batch_9f8e7d6c5b4a",
"has_more": false
}To page, pass the previous response's last_id as after; stop when has_more
is false. CLI: orl batches list --limit 20 --after batch_9f8e7d6c5b4a.
Cancel a batch
POST https://inference.openrelay.inc/v1/batches/{id}/cancelCancels a batch that is still running. In-flight requests finish, unstarted
requests are not started, partial results are written, and the batch settles to
cancelled within a few minutes. The response echoes the batch with status
cancelling.
curl https://inference.openrelay.inc/v1/batches/batch_9f8e7d6c5b4a/cancel \
-H "Authorization: Bearer $OPENRELAY_API_KEY" \
-X POSTCancelling a batch that has already reached a terminal status returns 409.
CLI: orl batches cancel batch_9f8e7d6c5b4a.
Update metadata
POST https://inference.openrelay.inc/v1/batches/{id}/metadataReplaces the batch's metadata map (labels, tags, notes). Unlike cancel, this
is editable at any time, including after the batch is terminal: metadata is
your own data, not lifecycle state, so you can re-tag and organize finished jobs.
The whole desired map is sent (send the full set you want, not a delta); an empty
object clears all metadata. Keys are capped at 64 characters and values at 512,
with at most 32 keys.
The dashboard uses a reserved tags key (a comma-separated list) to render
color-coded tag chips and drive the filters on the Batch page, so tags you set
over the API show up there too.
curl https://inference.openrelay.inc/v1/batches/batch_9f8e7d6c5b4a/metadata \
-H "Authorization: Bearer $OPENRELAY_API_KEY" \
-H "Content-Type: application/json" \
-d '{ "metadata": { "tags": "nightly-eval,customer-42", "job": "nightly-summaries" } }'The response echoes the full batch object with the updated metadata. A batch
owned by another organization (or an unknown id) returns 404.
Status lifecycle
A batch moves through these statuses, matching the OpenAI Batch API exactly:
| Status | Meaning |
|---|---|
validating | The batch is being prepared to run. Its records were already checked at create. |
in_progress | The batch is queued for GPU capacity or running its requests. in_progress_at tells the two apart. |
finalizing | The run finished; the result files are being assembled. |
completed | Done. output_file_id (and error_file_id, if any) are ready. |
failed | The batch could not run, for example because its input file changed after the create, or the GPU capacity running it was lost. errors says why. |
expired | The 24-hour window elapsed before the batch finished. Completed work is kept and billed; unfinished requests are written to the error file with code: "batch_expired". |
cancelling | A cancel was requested; the batch is winding down. |
cancelled | The batch was cancelled. |
completed, failed, expired, and cancelled are terminal.
Queued or running
A batch reads in_progress from the moment validation passes, including while
it waits for a free GPU slot on its model, so OpenAI SDK clients see only the
statuses they expect. in_progress_at is what separates the wait from the run:
it is null while the batch is queued and becomes the time its first requests
started once a slot frees.
{ "status": "in_progress", "created_at": 1759395600, "in_progress_at": null }The gap between created_at and in_progress_at is how long the batch
queued, which is the wait expedited priority shortens. Nothing is
charged while a batch waits, and the 24-hour window runs from created_at, so
time spent queued counts against it.
Files
Upload batch input files as JSONL (a direct multipart upload for smaller files, a presigned URL for large ones), then read file metadata and content.
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.