[Platform] Add batch processing support#1802
Conversation
|
friendly ping @tacman |
4661d1f to
5a8e019
Compare
|
Hi there, by the first look I don't really get why we need separate infrastructure here and cannot reuse the Can we do something like this instead? // regular platform setup via factory
$platform = PlatformFactory::create(env('OPENAI_API_KEY'));
$inputs = [
new BatchInput('req-1', new MessageBag(Message::ofUser('What is the capital of France?'))),
new BatchInput('req-2', new MessageBag(Message::ofUser('What is the capital of Germany?'))),
];
$result = $platform->invoke('gpt-4o-mini', $inputs, [
'max_tokens' => 50,
'batch' => true, // if needed?
]);
$job = $result->asBatchJob();
// Poll until complete
while (!$job->isTerminal()) {
sleep(5);
}
// Stream results
foreach ($job->getResults() as $result) {
if ($result->isSuccess()) {
echo $result->getId().': '.$result->getContent();
}
} |
Hi! thanks for taking the time to look at this.
The part we weren't sure about is the output: unlike all existing clients where results come from the same HTTP response as the request, batch results arrive from a completely separate endpoint, much later The HTTP response only contains job metadata (id, status, counts), a if you have something in mind for that part? |
|
@tacman review :
|
We have a similar issue with the However, I agree that there needs to be a second part of infrastructure after the initial platform call - but I would like to see the initial call as integrated in the Platform as possible |
|
Hi! Version 0.7 has been released. This PR has changelog/upgrade entries under the Please update the following files to place your entries under the next version 0.8:
For Thank you! 🤖 This comment was generated automatically by an agent. |
5a8e019 to
85f5612
Compare
|
Hey, @chr-hertel , I see lots of activity and I assume you're prepping another release. Do you have any thoughts on this PR? Would you consider including it in the next release, so that we can kick the tires a bit before the 1.0 release? I know not many people use batch, but it'd be great if the platform at least had the interface for it so we could write provider-specific implementations. Thanks. |
@camilleislasse it is easy to get your fork in sync with main or 0.9? @chr-hertel any feedback on this? "Not planned" is okay, then we can brainstorm about another solution besides trying to keep @camilleislasse 's fork in sync. Batch really is pretty cool. |
Still the same answer, not blocking for v1.0 from my point of view |
|
Thanks @chr-hertel — that's clear. Let me play back what I'm hearing so we build the right thing this time (@camilleislasse — does this match how you'd want to approach it?): Submit side — integrate into $result = $platform->invoke('gpt-4o-mini', $inputs, ['batch' => true]);
$job = $result->asBatchJob();
Phase two — separate, and persistable. $job = $batchManager->refresh($job); // re-fetch status by id
foreach ($batchManager->fetchResults($job) as $r) { ... }That answers the one thing we were stuck on earlier — we didn't want to embed a client in the result, and this avoids it. On #2029: agreed it's the better substrate. It's been sitting as a draft for a while though — would it help if I carved out slice 1 (the Either way, if you can confirm the two shapes above, I'll start lining up the rework of #1802 to match (assuming @camilleislasse is on board). |
Thanks @tacman, on board with both shapes.I have 2 question before I start, just to make sure I build the right thing: #2029: would it make sense to keep batch moving independently for now? ['batch' => true] can route against the current dispatch, and we'd re-hang it on Endpoint once slice 1 lands. Happy either way (just want to avoid blocking if #2029 stays in flight for a while) Once these are settled I'll rework #1802 to match |
|
@camilleislasse great, let's go. 1. Independent of #2029 — yes. Build on the current dispatch now; don't wait. The public surface is just 2. One scoping ask: let's keep the first PR to the submit side only ( |
85f5612 to
c294e8f
Compare
|
Thanks @tacman, that's clear. Went with all three of your points and pushed the full rework here so you can see the whole shape.
One shape note: I put Routing is inline via I've put the whole thing here in one piece so the overall direction is easy to see. No rush, but whenever you get a chance, a quick glance to check the direction feels right would be great, and then I'm happy to split it into smaller PRs. |
|
Thanks Camille, this looks right. I went through the diff:
Direction's confirmed from my side. A few things before you split it up:
Once we're aligned on the split, happy to review PR #1 (submit side) in detail. |
|
Thanks @tacman, on your four points:
One open thing I'd like your input on: model eligibility. Right now I express it via I also had a look at your ai-batch-bundle and saw you let the caller pick the model directly, with no per-model check. That's simpler and might be the cleaner path here too (route by provider, let the API reject unsupported models) rather than maintaining a |
|
@camilleislasse — on model eligibility, I'd keep Every other capability in The actual problem isn't the mechanism, it's that OpenAI has no canonical list to seed it from. I wouldn't try to solve that by enumerating the full eligible set up front — that's guaranteed to go stale the moment OpenAI ships a new model. Treat it like the rest of the catalog: start with what's confirmed in the docs ( One forward note, not a blocker: you pointed out batch is per-endpoint on OpenAI, not per-model. Now that Otherwise this is ready for the split into the smaller PRs we discussed. |
539c7ab to
0ee79fc
Compare
| $questions = [ | ||
| 'req-1' => 'What is the capital of France? Answer in one word.', | ||
| 'req-2' => 'What is the capital of Germany? Answer in one word.', | ||
| 'req-3' => 'What is the capital of Spain? Answer in one word.', | ||
| ]; | ||
|
|
||
| $inputs = (static function () use ($questions): Generator { | ||
| foreach ($questions as $id => $question) { | ||
| yield new BatchInput($id, new MessageBag(Message::ofUser($question))); | ||
| } | ||
| })(); |
There was a problem hiding this comment.
why is it an generator and not plain array?
| $questions = [ | |
| 'req-1' => 'What is the capital of France? Answer in one word.', | |
| 'req-2' => 'What is the capital of Germany? Answer in one word.', | |
| 'req-3' => 'What is the capital of Spain? Answer in one word.', | |
| ]; | |
| $inputs = (static function () use ($questions): Generator { | |
| foreach ($questions as $id => $question) { | |
| yield new BatchInput($id, new MessageBag(Message::ofUser($question))); | |
| } | |
| })(); | |
| $inputs = [ | |
| new BatchInput('req-1', new MessageBag(Message::ofUser('What is the capital of France? Answer in one word.'))), | |
| new BatchInput('req-2', new MessageBag(Message::ofUser('What is the capital of Germany? Answer in one word.'))), | |
| new BatchInput('req-3', new MessageBag(Message::ofUser('What is the capital of Spain? Answer in one word.'))), | |
| ]; |
| /** | ||
| * @param iterable<BatchInput> $inputs | ||
| * @param array<string, mixed> $options | ||
| */ | ||
| private function invokeBatch(Model $model, iterable $inputs, array $options): DeferredResult | ||
| { | ||
| unset($options['batch']); | ||
| $options = array_merge($model->getOptions(), $options); | ||
|
|
||
| if (isset($options['tools'])) { | ||
| $options['tools'] = $this->contract->createToolOption($options['tools'], $model); | ||
| } | ||
|
|
||
| $requests = (function () use ($inputs, $model, $options): \Generator { | ||
| foreach ($inputs as $input) { | ||
| if (!$input instanceof BatchInput) { | ||
| throw new RuntimeException(\sprintf('A batch invocation expects "%s" instances as input, got "%s".', BatchInput::class, get_debug_type($input))); | ||
| } | ||
|
|
||
| yield [ | ||
| 'id' => $input->getId(), | ||
| 'payload' => $this->contract->createRequestPayload($model, $input->getInput(), $options), | ||
| ]; | ||
| } | ||
| })(); | ||
|
|
||
| foreach ($this->batchSubmitClients as $batchSubmitClient) { | ||
| if ($batchSubmitClient->supports($model)) { | ||
| $rawResult = $batchSubmitClient->submitBatch($model, $requests, $options); | ||
|
|
||
| foreach ($this->resultConverters as $resultConverter) { | ||
| if ($resultConverter instanceof BatchResultConverterInterface && $resultConverter->supports($model)) { | ||
| return new DeferredResult($resultConverter, $rawResult, $options); | ||
| } | ||
| } | ||
|
|
||
| throw new RuntimeException(\sprintf('No batch result converter registered for model "%s" (%s) in provider "%s".', $model->getName(), $model::class, $this->name)); | ||
| } | ||
| } | ||
|
|
||
| throw new RuntimeException(\sprintf('No batch submit client registered for model "%s" (%s) in provider "%s".', $model->getName(), $model::class, $this->name)); | ||
| } | ||
|
|
There was a problem hiding this comment.
can we wire this over the main ModelClient of the corresponding model instead?

Batch Processing
Summary
Adds asynchronous batch processing to the Platform component, integrated directly into the existing
Platformrather than as a parallel stack.Submission goes through the regular invocation flow:
Platform::invoke($model, $inputs, ['batch' => true])returns aDeferredResultwhoseasBatchJob()yields aBatchJob. Polling and result fetching live in a separateBatchManager, so theBatchJobstays a plain value object (no HTTP client) and can be persisted between requests.The
['batch' => true]routing lives inline inProviderfor now, isolated so it can re-hang on theEndpointconcept (#2235) later without a BC break.New classes
Batch\BatchInput- a single request within a batch submissionBatch\BatchJob- immutable, persistable snapshot of a batch job (id, status, counts)Batch\BatchResult- result of a single request within a batchBatch\BatchStatus- canonical status enum (PROCESSING, COMPLETED, FAILED, CANCELLED, EXPIRED)Batch\BatchJobResult- result type unwrapped byDeferredResult::asBatchJob()Batch\BatchManager/BatchManagerInterface- polling and result fetching:refresh(),canFetchResults(),fetchResults(),cancel()Batch\BatchSubmitClientInterface/BatchClientInterface- provider HTTP contracts for submit and post-submissionBatch\BatchResultConverterInterface- marks the converter that builds aBatchJobResultCapability::BATCH- capability flag for models eligible to batchProvider support
OpenAI - uploads a JSONL file via the Files API and creates the batch job (Responses endpoint); streams output and error files line by line, with partial recovery for cancelled/expired batches.
Anthropic - streams the request body as a JSON array to avoid loading all requests in memory; uses the Message Batches API.
AiBundle integration
The submit side needs no bundle change: it goes through
Factory::createProvider, soinvoke(['batch' => true])works out of the box. TheBatchManageris registered per provider asai.batch_manager.openai/ai.batch_manager.anthropic, andBatchManagerInterfaceis aliased for autowiring when a single batch-capable platform is configured, mirroring the platform services.Usage
Open question: model eligibility
Eligibility is expressed via
Capability::BATCHon catalog models and checked by the bridge clients.Anthropic is simple: all active Claude models support the Message Batches API, so all of them are marked.
OpenAI is trickier: batch is defined per endpoint, not per model, and there is no machine-readable list (models.dev does not expose it, and the docs say "widely available across most of our models, but not all, refer to the model reference"). For now only
gpt-4o-miniis marked, to keep things focused. Open to input on the cleanest way to declare the full eligible set (a curated list, deriving it from output modalities, or something else).Note
This PR is the full-picture reference. The plan is to peel it off into small PRs.