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}