OpenAI Batch vs Anthropic Message Batches: a routing-first comparison

OpenAI Batch and Anthropic Message Batches look almost identical from a distance. Both offer roughly a 50% discount on input and output tokens versus the synchronous endpoint. Both target a 24-hour completion window. Both accept a JSONL-shaped workload with a per-request custom_id for stable row identity. The differences live in shape, limits, and operational quirks — and those are exactly the things you care about when you're routing across both. This is a comparison written from the perspective of an operator who has to ingest, dispatch, monitor, and normalize results from each. Shape The biggest structural difference is how you hand the work over. OpenAI is a two-step process: first you upload the JSONL to the Files API, then you create a batch that references the resulting input_file_id. Anthropic is one step: you POST the requests array inline. The operational implication is real. For OpenAI, the ingestion path is upload-bound: a 200 MB file means a large multipart upload and an extra round-trip before the batch even starts validating. For Anthropic, the ingestion path is parse-bound: a 256 MB JSON body is a chunky parse on both ends, and you need to stream the request to stay under proxy limits. Limits Per-batch ceilings differ in both axes — file size and request count. If a job is on the boundary — say, 70,000 rows — OpenAI forces a split into two batches, while Anthropic takes it as a single batch. That changes how you track progress: two batch IDs to poll vs one. It also changes how partial failures look: a failed OpenAI half-batch is a 35,000-row failure unit; a failed Anthropic batch is a 70,000-row failure unit. Status lifecycle Status names matter when you normalize. A routing layer has to map provider-specific statuses to a stable internal state machine, and the two providers don't agree on either the names or the granularity. OpenAI exposes more batch-level granularity (you can tell validating from in_progress from finalizing), but a single completed state doesn't tell you whether every row succeeded. Anthropic uses a flat batch status — ended is the terminal state regardless of outcome — and pushes the per-row outcome into request-level fields you read from the results. An OpenAI batch can be completed and still contain failed rows. An Anthropic batch is ended whether every request succeeded, errored, was canceled, or expired. Normalize both a batch-level state and a per-row state, or you'll silently lose row-level failures. Result delivery Results come back as JSONL in both cases, but the delivery mechanism is different. OpenAI returns an output_file_id. You download it via the Files API and stream the JSONL. Anthropic returns a results_url. You GET that URL and stream the JSONL directly. If you have a single artifact pipeline that needs to deliver results to a customer-owned R2 or S3 bucket, this difference shows up. The OpenAI path has an extra hop — download from Files, re-stream to the artifact target. The Anthropic path is a direct proxy. Neither is a problem at small volume; both add latency and bytes at large volume. Things that look the same but aren't Both APIs support custom_id on each row. Both let you set a stable identifier you can use to match results back to inputs. The semantics, though, are slightly different. Uniqueness scope — OpenAI requires custom_id to be unique within the file. Anthropic requires it unique within the batch request. For mixed-model jobs that get split across providers, you have to decide whether to namespace IDs per lane or carry them through verbatim. Error handling — OpenAI emits failed rows into a separate error_file_id, sibling to the output file. Anthropic interleaves errored results into the same .jsonl with a per-row result.type of errored. Two reconciliation strategies for the same outcome. Retry behavior — neither provider retries failed rows automatically. If a row fails because of a provider-side transient (rate limit overflow, capacity, an internal 500), it…