providers_kit/batch.rs
1//! Model-bound batch submission and provider-owned lifecycle values.
2//!
3//! Submit [`BatchSubmitRequest`] through an exact [`crate::ModelBackend`]; use the
4//! Provider for status, cancellation, listing, and bounded result retrieval.
5//! [`BatchInput::custom_id`](crate::batch::BatchInput::custom_id) preserves caller correlation. A completed [`BatchJob`]
6//! does not imply every [`BatchResult`] succeeded. Failed rows retain explicit
7//! remote evidence as data; download and normalization failures remain outer errors.
8//!
9//! # Observe or cancel an owned job
10//!
11//! Submission belongs to [`crate::ModelBackend::submit_batch`]; observation belongs
12//! to its Provider. Retain the exact returned job id in application durable state
13//! before waiting. The following service example resumes **an already owned job**
14//! without creating another. It requires OpenAI credentials and that exact job id.
15//!
16//! Set `cancel_requested` to request cancellation once. The returned acknowledgement
17//! is not proof of terminality: both paths keep polling the exact job under one
18//! application deadline. Completion is per job; each result row has its own outcome.
19//!
20//! ```no_run
21//! use std::{num::NonZeroUsize, time::Duration};
22//! use providers_kit::{Provider, ProviderBackend, ProviderConfig};
23//! use providers_kit::config::{ApiKey, OpenAiConfig, ProviderKind, TransportConfig};
24//! use providers_kit::batch::{BatchResult, BatchStatus};
25//! # #[tokio::main(flavor = "current_thread")]
26//! # async fn main() -> Result<(), Box<dyn std::error::Error>> {
27//! let provider = Provider::from_config(ProviderConfig::new(
28//! ProviderKind::OpenAi(OpenAiConfig::new(ApiKey::new("replace-with-your-key")?)),
29//! TransportConfig::default(),
30//! ))?;
31//! let owned_job_id = "replace-with-your-recorded-job-id".to_owned();
32//! let cancel_requested = false;
33//! let work = tokio::time::timeout(Duration::from_secs(24 * 60 * 60), async {
34//! if cancel_requested {
35//! let acknowledgement = provider.cancel_batch(owned_job_id.clone()).await?;
36//! assert_eq!(acknowledgement.id, owned_job_id);
37//! }
38//! let terminal = loop {
39//! let job = provider.batch_status(owned_job_id.clone()).await?;
40//! assert_eq!(job.id, owned_job_id);
41//! match job.status {
42//! BatchStatus::Queued | BatchStatus::Running => {
43//! tokio::time::sleep(Duration::from_secs(60)).await;
44//! }
45//! _ => break job,
46//! }
47//! };
48//! match terminal.status {
49//! BatchStatus::Completed => {
50//! let rows = provider.batch_results(owned_job_id.clone(), NonZeroUsize::new(8 * 1024 * 1024).unwrap()).await?;
51//! for row in rows {
52//! match row {
53//! BatchResult::Succeeded { custom_id, output } => {
54//! // Reconcile custom_id against the retained submission inventory.
55//! println!("{custom_id}: {:?}", output.finish_reason);
56//! }
57//! BatchResult::Failed { custom_id, error } => println!("{custom_id}: {:?}", error.kind()),
58//! BatchResult::Cancelled { custom_id } => println!("{custom_id}: cancelled"),
59//! BatchResult::Expired { custom_id } => println!("{custom_id}: expired"),
60//! }
61//! }
62//! }
63//! status => println!("Owned job reached {status:?}"),
64//! }
65//! Ok::<(), providers_kit::KitError>(())
66//! }).await;
67//! let shutdown = provider.shutdown().await;
68//! // On timeout/error, retain owned_job_id for later reconciliation. Never infer
69//! // remote deletion or cancellation from shutdown or from dropping this waiter.
70//! work??;
71//! shutdown?;
72//! # Ok(())
73//! # }
74//! ```
75//!
76//! The portable job does not expose every provider-generated file identity. Remote
77//! artifact reconciliation remains separate; delete only independently established
78//! owned file ids via [`crate::ProviderBackend::delete_file`]. No example here claims
79//! that terminal job status or local Provider shutdown deletes those files.
80//!
81//! OpenAI `batch_status` uses the configured bounded transport retry policy for
82//! eligible status-read failures. All attempts share the original request deadline;
83//! zero retries disables them. The internal lookup in `batch_results` remains
84//! fail-fast, as do mutations. Polling above is an application lifecycle decision,
85//! not a generation retry or a reset of a request deadline.
86
87use std::collections::BTreeSet;
88use std::num::NonZeroUsize;
89
90use serde::{Deserialize, Deserializer, Serialize};
91
92use crate::KitError;
93use crate::error::RemoteFailure;
94use crate::error::ValidationViolation;
95use crate::generation::{GenerateRequest, GenerateResponse, GenerationContinuation, Usage};
96
97/// One validated generation row in an inline batch.
98///
99/// # Examples
100///
101/// ```
102/// use providers_kit::{batch::BatchInput, generation::{GenerateRequest, Message}};
103/// let row = BatchInput::new("row-1", GenerateRequest::<()>::new(vec![Message::user_text("Summarize")?])?)?;
104/// assert_eq!(row.custom_id(), "row-1");
105/// assert_eq!(row.request().messages().len(), 1);
106///
107/// # Ok::<(), providers_kit::KitError>(())
108/// ```
109///
110#[derive(Clone, Debug, PartialEq, Serialize)]
111#[serde(bound(serialize = ""), deny_unknown_fields)]
112pub struct BatchInput<C = GenerationContinuation> {
113 custom_id: String,
114 request: GenerateRequest<C>,
115}
116
117impl<'de, C> Deserialize<'de> for BatchInput<C> {
118 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
119 where
120 D: Deserializer<'de>,
121 {
122 #[derive(Deserialize)]
123 #[serde(bound(deserialize = ""), deny_unknown_fields)]
124 struct Wire<C> {
125 custom_id: String,
126 request: GenerateRequest<C>,
127 }
128 let wire = Wire::deserialize(deserializer)?;
129 Self::new(wire.custom_id, wire.request).map_err(serde::de::Error::custom)
130 }
131}
132
133impl<C> BatchInput<C> {
134 /// Creates a row with a non-empty caller correlation identifier.
135 ///
136 /// # Errors
137 ///
138 /// Returns an input error for an empty or whitespace-only correlation id. Submission additionally validates uniqueness across rows.
139 ///
140 /// # Examples
141 ///
142 /// ```
143 /// use providers_kit::{batch::BatchInput, generation::{GenerateRequest, Message}};
144 /// let row = BatchInput::new("row-1", GenerateRequest::<()>::new(vec![Message::user_text("Summarize")?])?)?;
145 /// assert_eq!(row.custom_id(), "row-1");
146 /// assert_eq!(row.request().messages().len(), 1);
147 ///
148 /// # Ok::<(), providers_kit::KitError>(())
149 /// ```
150 ///
151 pub fn new(
152 custom_id: impl Into<String>,
153 request: GenerateRequest<C>,
154 ) -> Result<Self, KitError> {
155 let custom_id = custom_id.into();
156 if custom_id.trim().is_empty() {
157 return Err(KitError::invalid_input(
158 ValidationViolation::Empty {
159 field: "batch.custom_id",
160 },
161 "batch custom id must not be empty",
162 ));
163 }
164 Ok(Self { custom_id, request })
165 }
166
167 #[must_use]
168 /// Returns the caller-provided correlation identifier.
169 ///
170 /// # Examples
171 ///
172 /// ```
173 /// use providers_kit::{batch::BatchInput, generation::{GenerateRequest, Message}};
174 /// let row = BatchInput::new("row-1", GenerateRequest::<()>::new(vec![Message::user_text("Summarize")?])?)?;
175 /// assert_eq!(row.custom_id(), "row-1");
176 /// assert_eq!(row.request().messages().len(), 1);
177 ///
178 /// # Ok::<(), providers_kit::KitError>(())
179 /// ```
180 ///
181 pub fn custom_id(&self) -> &str {
182 &self.custom_id
183 }
184
185 #[must_use]
186 /// Returns the generation request for this row.
187 ///
188 /// # Examples
189 ///
190 /// ```
191 /// use providers_kit::{batch::BatchInput, generation::{GenerateRequest, Message}};
192 /// let row = BatchInput::new("row-1", GenerateRequest::<()>::new(vec![Message::user_text("Summarize")?])?)?;
193 /// assert_eq!(row.custom_id(), "row-1");
194 /// assert_eq!(row.request().messages().len(), 1);
195 ///
196 /// # Ok::<(), providers_kit::KitError>(())
197 /// ```
198 ///
199 pub const fn request(&self) -> &GenerateRequest<C> {
200 &self.request
201 }
202
203 fn try_map_continuation<D>(
204 self,
205 map: &mut impl FnMut(C) -> Result<D, KitError>,
206 ) -> Result<BatchInput<D>, KitError> {
207 Ok(BatchInput {
208 custom_id: self.custom_id,
209 request: self.request.try_map_continuation(map)?,
210 })
211 }
212}
213
214/// Model-bound batch input source.
215#[derive(Clone, Debug, PartialEq, Serialize)]
216#[serde(
217 bound(serialize = ""),
218 tag = "source",
219 rename_all = "snake_case",
220 deny_unknown_fields
221)]
222pub enum BatchSubmitRequest<C = GenerationContinuation> {
223 /// Inline normalized rows.
224 Inline {
225 /// Validated rows to submit.
226 rows: Vec<BatchInput<C>>,
227 },
228 /// Existing provider-native input file.
229 ProviderFile {
230 /// Exact provider-native file identifier.
231 file_id: String,
232 },
233}
234
235impl<'de, C> Deserialize<'de> for BatchSubmitRequest<C> {
236 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
237 where
238 D: Deserializer<'de>,
239 {
240 #[derive(Deserialize)]
241 #[serde(
242 bound(deserialize = ""),
243 tag = "source",
244 rename_all = "snake_case",
245 deny_unknown_fields
246 )]
247 enum Wire<C> {
248 Inline { rows: Vec<BatchInput<C>> },
249 ProviderFile { file_id: String },
250 }
251 let request = match Wire::deserialize(deserializer)? {
252 Wire::Inline { rows } => Self::Inline { rows },
253 Wire::ProviderFile { file_id } => Self::ProviderFile { file_id },
254 };
255 request.validate().map_err(serde::de::Error::custom)?;
256 Ok(request)
257 }
258}
259
260impl<C> BatchSubmitRequest<C> {
261 /// Maps continuation values carried by inline rows.
262 ///
263 /// # Errors
264 ///
265 /// Returns the first mapping error unchanged. File-backed requests have no inline continuation values to map.
266 ///
267 /// # Examples
268 ///
269 /// ```
270 /// use providers_kit::{batch::BatchInput, generation::{GenerateRequest, Message}};
271 /// let row = BatchInput::new("row-1", GenerateRequest::<()>::new(vec![Message::user_text("Summarize")?])?)?;
272 /// use providers_kit::batch::BatchSubmitRequest;
273 /// let batch = BatchSubmitRequest::Inline { rows: vec![row] };
274 /// let mapped: BatchSubmitRequest<String> = batch.try_map_continuation(|()| Ok("state".into()))?;
275 /// assert!(matches!(mapped, BatchSubmitRequest::Inline { rows } if rows[0].custom_id() == "row-1"));
276 ///
277 /// # Ok::<(), providers_kit::KitError>(())
278 /// ```
279 ///
280 pub fn try_map_continuation<D>(
281 self,
282 mut map: impl FnMut(C) -> Result<D, KitError>,
283 ) -> Result<BatchSubmitRequest<D>, KitError> {
284 Ok(match self {
285 Self::Inline { rows } => BatchSubmitRequest::Inline {
286 rows: rows
287 .into_iter()
288 .map(|row| row.try_map_continuation(&mut map))
289 .collect::<Result<_, _>>()?,
290 },
291 Self::ProviderFile { file_id } => BatchSubmitRequest::ProviderFile { file_id },
292 })
293 }
294
295 pub(crate) fn validate(&self) -> Result<(), KitError> {
296 match self {
297 Self::Inline { rows } => {
298 if rows.is_empty() {
299 return Err(KitError::invalid_input(
300 ValidationViolation::Empty {
301 field: "batch.rows",
302 },
303 "inline batch rows must not be empty",
304 ));
305 }
306 let mut ids = BTreeSet::new();
307 for row in rows {
308 if !ids.insert(row.custom_id()) {
309 return Err(KitError::invalid_input(
310 ValidationViolation::Duplicate {
311 field: "batch.custom_id",
312 },
313 format!("duplicate batch custom id `{}`", row.custom_id()),
314 ));
315 }
316 }
317 Ok(())
318 }
319 Self::ProviderFile { file_id } if file_id.trim().is_empty() => {
320 Err(KitError::invalid_input(
321 ValidationViolation::Empty {
322 field: "batch.file_id",
323 },
324 "batch file id must not be empty",
325 ))
326 }
327 Self::ProviderFile { .. } => Ok(()),
328 }
329 }
330}
331
332/// Provider-authored batch lifecycle state.
333#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
334#[serde(tag = "status", rename_all = "snake_case")]
335pub enum BatchStatus {
336 /// The provider accepted the batch and has not started processing it.
337 Queued,
338 /// The provider is processing the batch.
339 Running,
340 /// Every batch row reached a terminal state.
341 Completed,
342 /// The provider failed the batch.
343 Failed {
344 /// Provider-authored failures retained without exposing their native envelope.
345 errors: Vec<RemoteFailure>,
346 },
347 /// The batch was cancelled.
348 Cancelled,
349 /// The batch expired before completion.
350 Expired,
351}
352
353/// Provider-owned batch job metadata.
354///
355/// A terminal job can contain failed, cancelled, or expired rows. Inspect each
356/// [`BatchResult`] separately; cancellation success does not prove row success.
357///
358/// # Examples
359///
360/// ```
361/// use providers_kit::batch::{BatchJob, BatchStatus};
362/// let job = BatchJob { id: "owned-job".into(), status: BatchStatus::Queued, created_at: None, completed_at: None, usage: None };
363/// assert!(matches!(job.status, BatchStatus::Queued));
364/// ```
365///
366#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
367pub struct BatchJob {
368 /// Exact provider-native batch identifier.
369 pub id: String,
370 /// Current provider-authored lifecycle status.
371 pub status: BatchStatus,
372 /// Provider creation timestamp, when reported.
373 pub created_at: Option<String>,
374 /// Provider completion timestamp, when reported.
375 pub completed_at: Option<String>,
376 /// Aggregate token usage, when reported.
377 pub usage: Option<Usage>,
378}
379
380/// One terminal batch row.
381#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
382#[serde(
383 bound(serialize = "", deserialize = ""),
384 tag = "status",
385 rename_all = "snake_case"
386)]
387pub enum BatchResult<C = GenerationContinuation> {
388 /// A row completed successfully.
389 Succeeded {
390 /// Caller-provided row correlation identifier.
391 custom_id: String,
392 /// Normalized generation output.
393 output: Box<GenerateResponse<C>>,
394 },
395 /// A row failed.
396 Failed {
397 /// Caller-provided row correlation identifier.
398 custom_id: String,
399 /// Provider-authored row failure with explicitly accessed native evidence.
400 error: RemoteFailure,
401 },
402 /// A row was cancelled.
403 Cancelled {
404 /// Caller-provided row correlation identifier.
405 custom_id: String,
406 },
407 /// A row expired before completion.
408 Expired {
409 /// Caller-provided row correlation identifier.
410 custom_id: String,
411 },
412}
413
414impl<C> BatchResult<C> {
415 /// Maps continuation state carried by a successful row.
416 ///
417 /// # Errors
418 ///
419 /// Returns the mapping error for a successful row continuation. Failure, cancellation, and expiration rows retain their exact correlation and evidence.
420 ///
421 /// # Examples
422 ///
423 /// ```
424 /// use providers_kit::batch::BatchResult;
425 /// let row: BatchResult<()> = BatchResult::Cancelled { custom_id: "row-1".into() };
426 /// let mapped: BatchResult<String> = row.try_map_continuation(|()| Ok("state".into()))?;
427 /// assert!(matches!(mapped, BatchResult::Cancelled { custom_id } if custom_id == "row-1"));
428 ///
429 /// # Ok::<(), providers_kit::KitError>(())
430 /// ```
431 ///
432 pub fn try_map_continuation<D>(
433 self,
434 map: impl FnMut(C) -> Result<D, KitError>,
435 ) -> Result<BatchResult<D>, KitError> {
436 Ok(match self {
437 Self::Succeeded { custom_id, output } => BatchResult::Succeeded {
438 custom_id,
439 output: Box::new((*output).try_map_continuation(map)?),
440 },
441 Self::Failed { custom_id, error } => BatchResult::Failed { custom_id, error },
442 Self::Cancelled { custom_id } => BatchResult::Cancelled { custom_id },
443 Self::Expired { custom_id } => BatchResult::Expired { custom_id },
444 })
445 }
446}
447
448/// Validated batch-list pagination request.
449///
450/// Pagination cursors are opaque provider-owned values; a requested limit is not a
451/// guarantee that every provider supports the same pagination controls.
452///
453/// # Examples
454///
455/// ```
456/// use providers_kit::batch::BatchListRequest;
457/// let request = BatchListRequest::default().with_limit(std::num::NonZeroUsize::new(10).unwrap()).with_cursor("opaque-next")?;
458/// assert_eq!(request.limit().unwrap().get(), 10);
459/// assert_eq!(request.cursor(), Some("opaque-next"));
460///
461/// # Ok::<(), providers_kit::KitError>(())
462/// ```
463///
464#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize)]
465pub struct BatchListRequest {
466 limit: Option<NonZeroUsize>,
467 cursor: Option<String>,
468}
469
470impl BatchListRequest {
471 /// Sets a non-zero result limit.
472 ///
473 /// # Examples
474 ///
475 /// ```
476 /// use providers_kit::batch::BatchListRequest;
477 /// let request = BatchListRequest::default().with_limit(std::num::NonZeroUsize::new(10).unwrap()).with_cursor("opaque-next")?;
478 /// assert_eq!(request.limit().unwrap().get(), 10);
479 /// assert_eq!(request.cursor(), Some("opaque-next"));
480 ///
481 /// # Ok::<(), providers_kit::KitError>(())
482 /// ```
483 ///
484 #[must_use]
485 pub fn with_limit(mut self, limit: NonZeroUsize) -> Self {
486 self.limit = Some(limit);
487 self
488 }
489
490 /// Sets a non-empty provider-native continuation cursor.
491 ///
492 /// # Errors
493 ///
494 /// Returns an input error when the cursor is empty or whitespace-only. Nonblank cursor bytes are retained exactly; use only a cursor returned by the same provider operation.
495 ///
496 /// # Examples
497 ///
498 /// ```
499 /// use providers_kit::batch::BatchListRequest;
500 /// let request = BatchListRequest::default().with_limit(std::num::NonZeroUsize::new(10).unwrap()).with_cursor("opaque-next")?;
501 /// assert_eq!(request.limit().unwrap().get(), 10);
502 /// assert_eq!(request.cursor(), Some("opaque-next"));
503 ///
504 /// # Ok::<(), providers_kit::KitError>(())
505 /// ```
506 ///
507 pub fn with_cursor(mut self, cursor: impl Into<String>) -> Result<Self, KitError> {
508 let cursor = cursor.into();
509 if cursor.trim().is_empty() {
510 return Err(KitError::invalid_input(
511 ValidationViolation::Empty {
512 field: "batch.cursor",
513 },
514 "batch cursor must not be empty",
515 ));
516 }
517 self.cursor = Some(cursor);
518 Ok(self)
519 }
520
521 /// Returns the requested result limit.
522 ///
523 /// # Examples
524 ///
525 /// ```
526 /// use providers_kit::batch::BatchListRequest;
527 /// let request = BatchListRequest::default().with_limit(std::num::NonZeroUsize::new(10).unwrap()).with_cursor("opaque-next")?;
528 /// assert_eq!(request.limit().unwrap().get(), 10);
529 /// assert_eq!(request.cursor(), Some("opaque-next"));
530 ///
531 /// # Ok::<(), providers_kit::KitError>(())
532 /// ```
533 ///
534 #[must_use]
535 pub const fn limit(&self) -> Option<NonZeroUsize> {
536 self.limit
537 }
538
539 /// Returns the provider-native continuation cursor.
540 ///
541 /// # Examples
542 ///
543 /// ```
544 /// use providers_kit::batch::BatchListRequest;
545 /// let request = BatchListRequest::default().with_limit(std::num::NonZeroUsize::new(10).unwrap()).with_cursor("opaque-next")?;
546 /// assert_eq!(request.limit().unwrap().get(), 10);
547 /// assert_eq!(request.cursor(), Some("opaque-next"));
548 ///
549 /// # Ok::<(), providers_kit::KitError>(())
550 /// ```
551 ///
552 #[must_use]
553 pub fn cursor(&self) -> Option<&str> {
554 self.cursor.as_deref()
555 }
556
557 /// Consumes the request into its limit and continuation cursor.
558 ///
559 /// # Examples
560 ///
561 /// ```
562 /// use providers_kit::batch::BatchListRequest;
563 /// let request = BatchListRequest::default().with_limit(std::num::NonZeroUsize::new(10).unwrap()).with_cursor("opaque-next")?;
564 /// let (limit, cursor) = request.into_parts();
565 /// assert_eq!(limit.unwrap().get(), 10);
566 /// assert_eq!(cursor.as_deref(), Some("opaque-next"));
567 ///
568 /// # Ok::<(), providers_kit::KitError>(())
569 /// ```
570 ///
571 #[must_use]
572 pub fn into_parts(self) -> (Option<NonZeroUsize>, Option<String>) {
573 (self.limit, self.cursor)
574 }
575}
576
577impl<'de> Deserialize<'de> for BatchListRequest {
578 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
579 where
580 D: Deserializer<'de>,
581 {
582 #[derive(Deserialize)]
583 #[serde(deny_unknown_fields)]
584 struct Wire {
585 limit: Option<NonZeroUsize>,
586 cursor: Option<String>,
587 }
588 let wire = Wire::deserialize(deserializer)?;
589 let mut request = Self {
590 limit: wire.limit,
591 cursor: None,
592 };
593 if let Some(cursor) = wire.cursor {
594 request = request
595 .with_cursor(cursor)
596 .map_err(serde::de::Error::custom)?;
597 }
598 Ok(request)
599 }
600}
601
602/// One page of provider batch jobs.
603///
604/// # Examples
605///
606/// ```
607/// let page = providers_kit::batch::BatchPage { jobs: Vec::new(), next_cursor: None };
608/// assert!(page.jobs.is_empty());
609/// ```
610///
611#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
612pub struct BatchPage {
613 /// Batch jobs returned in this page.
614 pub jobs: Vec<BatchJob>,
615 /// Provider-native cursor for a subsequent page.
616 pub next_cursor: Option<String>,
617}