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