Skip to main content

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}