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

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

FieldTypeDescription
idstringThe batch id (batch_…).
objectstringAlways "batch".
endpointstringThe endpoint the requests target, e.g. /v1/chat/completions.
input_file_idstringThe input file the batch runs. For inline requests, an input file the gateway synthesized from them.
completion_windowstringAlways "24h".
prioritystringThe scheduling tier the batch runs and is billed at: "standard" or "expedited". See Priority.
statusstringThe lifecycle status. See Status lifecycle.
modelstringThe model every record runs: the first accepted record's, set when the batch is created.
output_file_idstringPresent when there are successful results (the file of response lines).
error_file_idstringPresent when there are failed results (the file of error lines), the records rejected at create included.
created_atintegerUnix seconds when the batch was submitted.
in_progress_atinteger or nullUnix seconds when the batch's first requests started running. null while the batch waits for GPU capacity. See Queued or running.
expires_atintegerUnix seconds when the 24-hour window ends.
completed_at / failed_at / expired_at / cancelled_atintegerUnix seconds the batch reached that terminal status (whichever applies).
request_countsobject{ 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.
errorsobjectPresent 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.
usageobjectRolled-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.
metadataobjectThe string-to-string map you attached at creation.

Create a batch

POST https://inference.openrelay.inc/v1/batches
FieldTypeDescription
input_file_idstringThe id of an uploaded purpose: "batch" file. Provide either this or requests, not both.
requestsarrayInline 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.
endpointstring (required)One of /v1/chat/completions, /v1/completions, or /v1/embeddings.
completion_windowstring (required)Must be "24h".
modelstringOptional. 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.
prioritystringScheduling 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.
metadataobjectOptional 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:

FieldTypeDescription
rejected_countintegerHow 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_recordsarrayThe first 100 rejected records, in input order: { line, custom_id, code, message }.
rejected_records_file_idstringA 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:

codeThe record
invalid_jsonis not valid JSON, or is not an object whose custom_id, method and url are strings.
missing_custom_idhas no custom_id.
duplicate_custom_idshares 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_urlnames a url other than the batch's endpoint.
invalid_methodnames a method other than POST.
missing_bodyhas no body.
invalid_bodyhas a body that is not a JSON object, or that repeats a reserved key (openrelay, tool_config or workflow).
streaming_unsupportedsets "stream": true.
tool_config_movedcarries the retired bare top-level tool_config; nest it under openrelay. See Tool calling.
invalid_tool_configcarries an openrelay.tool_config that breaks its rules, such as a non-HTTPS or internal url.
invalid_workflowcarries an openrelay.workflow that breaks its rules. See Workflows.
model_requirednames no model, and the batch's model field names none that batch serves.
model_not_offerednames a model batch does not serve for your organization. The message lists the ones it does.
model_not_allowednames a model the API key that created the batch may not use.
tool_calling_unsupportedasks 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_mismatchruns 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:

StatuscodeWhen
409feature_disabledA 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.
400input_too_largeThe input holds more than 50,000 records.
400input_unreadableThe input file's content cannot be read. Upload it again.
400input_file_changedThe input file's content is not what was uploaded. Upload it again.
503catalog_unavailable, flags_unavailableThe 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 retryThe response
Same key and body, after the first create answered 200The 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 running409 idempotency_key_in_use, with Retry-After: 1. Repeat it a second later.
Same key, another body422 idempotency_key_reused. Nothing is created.
Same key, after the first create failed with any other statusRuns 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 started409 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:

TierSchedulingPrice
standard (default)Runs when capacity frees.The batch rate: 50% of the Sync rate.
expeditedAsks 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_9f8e7d6c5b4a

Returns your batches newest first.

QueryDescription
limitPage size, 1 to 100. Defaults to 20.
afterA 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}/cancel

Cancels 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 POST

Cancelling 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}/metadata

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

StatusMeaning
validatingThe batch is being prepared to run. Its records were already checked at create.
in_progressThe batch is queued for GPU capacity or running its requests. in_progress_at tells the two apart.
finalizingThe run finished; the result files are being assembled.
completedDone. output_file_id (and error_file_id, if any) are ready.
failedThe 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.
expiredThe 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".
cancellingA cancel was requested; the batch is winding down.
cancelledThe 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.

On this page