fusillade_core/batch/mod.rs
1//! File and batch types for grouping requests.
2//!
3//! This module defines types for:
4//! - Files: Collections of request templates
5//! - Request templates: Mutable request definitions
6//! - Batches: Execution triggers for files
7//!
8//! See request/ for the types for requests, since they have their logic more tightly coupled to
9//! their models.
10
11use chrono::{DateTime, Utc};
12use serde::{Deserialize, Serialize};
13use std::fmt;
14use std::str::FromStr;
15use uuid::Uuid;
16
17/// Unique identifier for a file.
18#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
19#[serde(transparent)]
20pub struct FileId(pub Uuid);
21
22impl From<Uuid> for FileId {
23 fn from(uuid: Uuid) -> Self {
24 FileId(uuid)
25 }
26}
27
28impl std::ops::Deref for FileId {
29 type Target = Uuid;
30 fn deref(&self) -> &Self::Target {
31 &self.0
32 }
33}
34
35impl std::fmt::Display for FileId {
36 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
37 write!(f, "{}", &self.0.to_string()[..8])
38 }
39}
40
41/// Unique identifier for a batch.
42#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize)]
43#[serde(transparent)]
44pub struct BatchId(pub Uuid);
45
46impl From<Uuid> for BatchId {
47 fn from(uuid: Uuid) -> Self {
48 BatchId(uuid)
49 }
50}
51
52impl std::ops::Deref for BatchId {
53 type Target = Uuid;
54 fn deref(&self) -> &Self::Target {
55 &self.0
56 }
57}
58
59impl std::fmt::Display for BatchId {
60 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
61 write!(f, "{}", self.0)
62 }
63}
64
65/// Unique identifier for a request template.
66#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize)]
67#[serde(transparent)]
68pub struct TemplateId(pub Uuid);
69
70impl From<Uuid> for TemplateId {
71 fn from(uuid: Uuid) -> Self {
72 TemplateId(uuid)
73 }
74}
75
76impl std::ops::Deref for TemplateId {
77 type Target = Uuid;
78 fn deref(&self) -> &Self::Target {
79 &self.0
80 }
81}
82
83impl std::fmt::Display for TemplateId {
84 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
85 write!(f, "{}", &self.0.to_string()[..8])
86 }
87}
88
89/// Purpose for which a file was created.
90#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
91#[serde(rename_all = "snake_case")]
92pub enum Purpose {
93 /// File contains batch API request templates
94 Batch,
95 /// Virtual file that streams batch output (completed requests)
96 BatchOutput,
97 /// Virtual file that streams batch errors (failed requests)
98 BatchError,
99}
100
101/// Type of batch output file for lookup purposes.
102#[derive(Debug, Clone, Copy, PartialEq, Eq)]
103pub enum OutputFileType {
104 /// Output file containing completed requests
105 Output,
106 /// Error file containing failed requests
107 Error,
108}
109
110impl fmt::Display for Purpose {
111 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
112 match self {
113 Purpose::Batch => write!(f, "batch"),
114 Purpose::BatchOutput => write!(f, "batch_output"),
115 Purpose::BatchError => write!(f, "batch_error"),
116 }
117 }
118}
119
120impl FromStr for Purpose {
121 type Err = String;
122
123 fn from_str(s: &str) -> Result<Self, Self::Err> {
124 match s.to_lowercase().as_str() {
125 "batch" => Ok(Purpose::Batch),
126 "batch_output" => Ok(Purpose::BatchOutput),
127 "batch_error" => Ok(Purpose::BatchError),
128 _ => Err(format!("Invalid purpose: {}", s)),
129 }
130 }
131}
132
133/// File status tracking lifecycle and health.
134///
135/// Status tracks the file's lifecycle:
136/// - `Processed`: Successfully uploaded and parsed into templates (users can access)
137/// - `Error`: Failed to process during upload (only visible to admins with SystemAccess)
138/// - `Deleted`: Soft-deleted by user (metadata retained for audit, only visible to admins)
139/// - `Expired`: Past its expiration date (metadata retained for audit, only visible to admins)
140#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
141#[serde(rename_all = "lowercase")]
142pub enum FileStatus {
143 /// File was successfully processed and templates created
144 Processed,
145 /// File processing failed (see error_message for details)
146 Error,
147 /// File was soft-deleted by user (metadata retained for audit)
148 Deleted,
149 /// File has passed its expiration date
150 Expired,
151}
152
153impl fmt::Display for FileStatus {
154 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
155 match self {
156 FileStatus::Processed => write!(f, "processed"),
157 FileStatus::Error => write!(f, "error"),
158 FileStatus::Deleted => write!(f, "deleted"),
159 FileStatus::Expired => write!(f, "expired"),
160 }
161 }
162}
163
164impl FromStr for FileStatus {
165 type Err = String;
166
167 fn from_str(s: &str) -> Result<Self, Self::Err> {
168 match s.to_lowercase().as_str() {
169 "processed" => Ok(FileStatus::Processed),
170 "error" => Ok(FileStatus::Error),
171 "deleted" => Ok(FileStatus::Deleted),
172 "expired" => Ok(FileStatus::Expired),
173 _ => Err(format!("Invalid file status: {}", s)),
174 }
175 }
176}
177
178/// A file containing a collection of request templates.
179#[derive(Debug, Clone, Serialize)]
180pub struct File {
181 pub id: FileId,
182 pub name: String,
183 pub description: Option<String>,
184 pub size_bytes: i64,
185 pub status: FileStatus,
186 pub error_message: Option<String>,
187 pub purpose: Option<Purpose>,
188 pub expires_at: Option<DateTime<Utc>>,
189 pub deleted_at: Option<DateTime<Utc>>,
190 pub uploaded_by: Option<String>,
191 pub created_at: DateTime<Utc>,
192 pub updated_at: DateTime<Utc>,
193 pub size_finalized: bool,
194 /// API key UUID that created this file, for per-member attribution within orgs
195 pub api_key_id: Option<Uuid>,
196 /// Connection UUID when file was ingested from an external source.
197 pub source_connection_id: Option<Uuid>,
198 /// Original external file key/path within the source connection scope.
199 pub source_external_key: Option<String>,
200}
201
202/// A request template defining how to make a request.
203///
204/// Templates are mutable, but requests snapshot the template state
205/// at execution time.
206#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
207pub struct RequestTemplate {
208 pub id: TemplateId,
209 pub file_id: FileId,
210 pub custom_id: Option<String>, // OpenAI Batch API custom identifier
211 pub endpoint: String,
212 pub method: String,
213 pub path: String,
214 pub body: String,
215 pub model: String,
216 pub api_key: String,
217 pub created_at: DateTime<Utc>,
218 pub updated_at: DateTime<Utc>,
219}
220
221/// Input for creating a new request template.
222#[derive(Debug, Clone, PartialEq, Eq, Serialize, serde::Deserialize)]
223pub struct RequestTemplateInput {
224 pub custom_id: Option<String>, // OpenAI Batch API custom identifier
225 pub endpoint: String,
226 pub method: String,
227 pub path: String,
228 pub body: String,
229 pub model: String,
230 pub api_key: String,
231}
232
233/// Batch output item - represents a completed request in OpenAI format.
234#[derive(Debug, Clone, Serialize, serde::Deserialize)]
235pub struct BatchOutputItem {
236 /// Request ID
237 pub id: String,
238 /// Custom ID from the original request
239 pub custom_id: Option<String>,
240 /// Response details
241 pub response: BatchResponseDetails,
242 /// Error (should be null for successful responses)
243 pub error: Option<serde_json::Value>,
244}
245
246/// Batch error item - represents a failed request in OpenAI format.
247#[derive(Debug, Clone, Serialize, serde::Deserialize)]
248pub struct BatchErrorItem {
249 /// Request ID
250 pub id: String,
251 /// Custom ID from the original request
252 pub custom_id: Option<String>,
253 /// Response (should be null for errors)
254 pub response: Option<serde_json::Value>,
255 /// Error details
256 pub error: BatchErrorDetails,
257}
258
259/// Response details for a batch output item.
260#[derive(Debug, Clone, Serialize, serde::Deserialize)]
261pub struct BatchResponseDetails {
262 /// HTTP status code
263 pub status_code: i16,
264 /// Request ID from the upstream API
265 pub request_id: Option<String>,
266 /// Response body (e.g., ChatCompletion, Embedding, etc.)
267 pub body: serde_json::Value,
268}
269
270/// Error details for a batch error item.
271#[derive(Debug, Clone, Serialize, serde::Deserialize)]
272pub struct BatchErrorDetails {
273 /// Error code
274 pub code: Option<String>,
275 /// Error message
276 pub message: String,
277}
278
279/// Enum for different types of file content that can be streamed.
280#[derive(Debug, Clone, Serialize)]
281#[serde(untagged)]
282pub enum FileContentItem {
283 /// Request template (for input files with purpose='batch')
284 Template(RequestTemplateInput),
285 /// Batch output (for output files with purpose='batch_output')
286 Output(BatchOutputItem),
287 /// Batch error (for error files with purpose='batch_error')
288 Error(BatchErrorItem),
289}
290
291/// Status of a batch result item.
292#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
293#[serde(rename_all = "snake_case")]
294pub enum BatchResultStatus {
295 /// Request is pending execution
296 Pending,
297 /// Request is currently being processed (claimed or processing)
298 InProgress,
299 /// Request completed successfully
300 Completed,
301 /// Request failed with an error
302 Failed,
303}
304
305/// Merged batch result item combining input, output, and status.
306/// Used for the Results view to show input/output pairs in a single row.
307#[derive(Debug, Clone, Serialize)]
308pub struct BatchResultItem {
309 /// Fusillade request ID (unique identifier)
310 pub id: String,
311 /// User-provided identifier (NOT unique - may be duplicated)
312 pub custom_id: Option<String>,
313 /// Model used for this request
314 pub model: String,
315 /// Original request body from the input template
316 pub input_body: serde_json::Value,
317 /// Full response object (choices, usage, etc.) for completed requests
318 pub response_body: Option<serde_json::Value>,
319 /// Error message for failed requests
320 pub error: Option<String>,
321 /// Current status of the request
322 pub status: BatchResultStatus,
323}
324
325/// Metadata for creating a file from a stream
326#[derive(Debug, Clone, Default, Serialize)]
327pub struct FileMetadata {
328 pub filename: Option<String>,
329 pub description: Option<String>,
330 pub purpose: Option<String>,
331 pub expires_after_anchor: Option<String>,
332 pub expires_after_seconds: Option<i64>,
333 pub size_bytes: Option<i64>,
334 pub uploaded_by: Option<String>,
335 /// API key UUID that created this file, for per-member attribution within orgs
336 pub api_key_id: Option<Uuid>,
337 /// Connection UUID when file was ingested from an external source (e.g. S3).
338 pub source_connection_id: Option<Uuid>,
339 /// Original external file key/path within the source connection scope.
340 pub source_external_key: Option<String>,
341}
342
343/// Filter parameters for listing files
344#[derive(Debug, Clone, Default)]
345pub struct FileFilter {
346 /// Filter by user who uploaded the file
347 /// TODO: We use a string here, because this crate is decoupled from the dwctl one which uses a
348 /// UUID. Is this fine? This just needs to be a unique identifier per user.
349 pub uploaded_by: Option<String>,
350 /// Filter by file status (processed, error, deleted, expired)
351 pub status: Option<String>,
352 /// Filter by purpose
353 pub purpose: Option<String>,
354 /// Search query for filename (case-insensitive substring match)
355 pub search: Option<String>,
356 /// Cursor for pagination (file ID to start after)
357 pub after: Option<FileId>,
358 /// Maximum number of results to return
359 pub limit: Option<usize>,
360 /// Filter by API key UUID(s) (for per-member attribution). Matches files
361 /// created with any of the given keys, supporting users with keys across multiple contexts.
362 /// An empty vec is treated as "no matches" (returns no results), not as "no filter".
363 pub api_key_ids: Option<Vec<Uuid>>,
364 /// Sort order (true = ascending, false = descending)
365 pub ascending: bool,
366}
367
368/// Filter parameters for listing batches
369#[derive(Debug, Clone, Default)]
370pub struct ListBatchesFilter {
371 /// Filter by batch creator
372 pub created_by: Option<String>,
373 /// Search query (matches metadata JSON text, filename, or batch ID)
374 pub search: Option<String>,
375 /// Cursor for pagination (batch ID to start after)
376 pub after: Option<BatchId>,
377 /// Maximum number of batches to return (defaults to 100 if not set)
378 pub limit: Option<i64>,
379 /// Filter by API key UUID(s) (for per-member attribution). Matches batches
380 /// created with any of the given keys, supporting users with keys across multiple contexts.
381 /// An empty vec is treated as "no matches" (returns no results), not as "no filter".
382 pub api_key_ids: Option<Vec<Uuid>>,
383 /// Filter by batch status (e.g. "completed", "in_progress", "failed")
384 pub status: Option<String>,
385 /// Only return batches created after this timestamp
386 pub created_after: Option<DateTime<Utc>>,
387 /// Only return batches created before this timestamp
388 pub created_before: Option<DateTime<Utc>>,
389 /// When true, sort active batches before terminal ones, with each group
390 /// sorted by created_at DESC. A batch is "active" when none of completed_at,
391 /// failed_at, cancelled_at, or cancelling_at are set. Cancelling batches are
392 /// treated as terminal because cancel_batch sets both cancelling_at and
393 /// cancelled_at atomically. Default false (pure chronological).
394 pub active_first: bool,
395 /// Only return batches whose completion window matches any value in this
396 /// list (e.g., `["24h"]` for batch tier, `["1h"]` for flex, `["0s"]` for
397 /// realtime tracking rows). `None` disables the filter; `Some(vec![])`
398 /// matches no rows.
399 pub completion_windows: Option<Vec<String>>,
400}
401
402/// Items that can be yielded from a file upload stream
403#[derive(Debug, Clone, Serialize)]
404pub enum FileStreamItem {
405 /// File metadata (should be first item in stream)
406 Metadata(FileMetadata),
407 /// A request template parsed from JSONL
408 Template(RequestTemplateInput),
409 /// Producer is aborting the stream. Fusillade should rollback and stop processing.
410 Abort,
411 /// Deprecated compatibility path for callers that still surface producer parse errors here.
412 #[deprecated(note = "Use FileStreamItem::Abort and retain the producer error locally instead")]
413 Error(String),
414}
415
416/// Result of creating a file from a stream.
417#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
418pub enum FileStreamResult {
419 /// Successfully created a file
420 Success(FileId),
421 /// Stream was aborted by the producer and the transaction was rolled back
422 Aborted,
423}
424
425/// Input parameters for creating a new batch.
426#[derive(Debug, Clone, Serialize, Deserialize)]
427pub struct BatchInput {
428 /// The file containing request templates
429 pub file_id: FileId,
430 /// The API endpoint to use for all requests (e.g., "/v1/chat/completions")
431 pub endpoint: String,
432 /// Completion window (e.g., "24h")
433 pub completion_window: String,
434 /// Optional metadata key-value pairs (OpenAI allows up to 16 pairs)
435 pub metadata: Option<serde_json::Value>,
436 /// User who created this batch (for ownership tracking)
437 pub created_by: Option<String>,
438 /// API key UUID that created this batch, for per-member attribution within orgs
439 pub api_key_id: Option<Uuid>,
440 /// API key secret for batch request execution. Used instead of the key in
441 /// request_templates so that billing is attributed to the batch creator,
442 /// not the file uploader.
443 pub api_key: Option<String>,
444 /// Total number of request templates in the file. Set at creation time so
445 /// the batch immediately reflects the expected request count before
446 /// population completes.
447 pub total_requests: Option<i64>,
448}
449
450/// A batch represents one execution of all of a file's templates.
451#[derive(Debug, Clone, Serialize)]
452pub struct Batch {
453 pub id: BatchId,
454 pub file_id: Option<FileId>,
455 pub created_at: DateTime<Utc>,
456 /// Metadata key-value pairs (OpenAI allows up to 16 pairs)
457 pub metadata: Option<serde_json::Value>,
458 /// Completion window (e.g., "24h")
459 pub completion_window: String,
460 /// The API endpoint to use for all requests (e.g., "/v1/chat/completions")
461 pub endpoint: String,
462 /// File ID containing the successful results
463 pub output_file_id: Option<FileId>,
464 /// File ID containing the error results
465 pub error_file_id: Option<FileId>,
466 /// User who created this batch
467 pub created_by: String,
468 /// When the batch will expire (created_at + completion_window)
469 /// This is required for queue prioritization and SLA monitoring
470 pub expires_at: DateTime<Utc>,
471 /// When batch cancellation was initiated
472 pub cancelling_at: Option<DateTime<Utc>>,
473 /// Batch-level errors (validation errors, system errors, etc.)
474 pub errors: Option<serde_json::Value>,
475
476 /// Status fields
477 pub total_requests: i64,
478 pub pending_requests: i64,
479 pub in_progress_requests: i64,
480 pub completed_requests: i64,
481 pub failed_requests: i64,
482 pub canceled_requests: i64,
483 pub requests_started_at: Option<DateTime<Utc>>,
484
485 /// Terminal state timestamps (set once when batch enters that state)
486 pub finalizing_at: Option<DateTime<Utc>>,
487 pub completed_at: Option<DateTime<Utc>>,
488 pub failed_at: Option<DateTime<Utc>>,
489 pub cancelled_at: Option<DateTime<Utc>>,
490
491 /// When batch was soft-deleted. NULL means active.
492 pub deleted_at: Option<DateTime<Utc>>,
493
494 /// When batch completion notification was sent. NULL means not yet notified.
495 pub notification_sent_at: Option<DateTime<Utc>>,
496
497 /// API key UUID that created this batch, for per-member attribution within orgs
498 pub api_key_id: Option<Uuid>,
499}
500
501/// A batch with extra context for notification emails (file metadata, model names).
502/// Returned by `poll_completed_batches` which joins the files and requests tables.
503#[derive(Debug, Clone)]
504pub struct BatchNotification {
505 pub batch: Batch,
506 pub model: String,
507 pub input_file_name: Option<String>,
508 pub input_file_description: Option<String>,
509}
510
511/// Status information for a batch, computed from its executions.
512#[derive(Debug, Clone, Serialize)]
513pub struct BatchStatus {
514 pub batch_id: BatchId,
515 pub file_id: Option<FileId>,
516 pub file_name: Option<String>,
517 pub total_requests: i64,
518 pub pending_requests: i64,
519 pub in_progress_requests: i64,
520 pub completed_requests: i64,
521 pub failed_requests: i64,
522 pub canceled_requests: i64,
523 pub started_at: Option<DateTime<Utc>>,
524 pub failed_at: Option<DateTime<Utc>>,
525 pub created_at: DateTime<Utc>,
526}
527
528impl BatchStatus {
529 /// Check if the batch has finished (all requests in terminal state).
530 pub fn is_finished(&self) -> bool {
531 self.completed_requests + self.failed_requests + self.canceled_requests
532 == self.total_requests
533 }
534
535 /// Check if the batch is still running.
536 pub fn is_running(&self) -> bool {
537 !self.is_finished()
538 }
539
540 /// Get OpenAI-compatible status string.
541 /// Maps internal state to OpenAI's status values:
542 /// - "validating" - batch just created, no requests started yet
543 /// - "in_progress" - batch is being processed
544 /// - "finalizing" - nearly all requests done (95%+ complete)
545 /// - "completed" - all requests in terminal state and at least one succeeded
546 /// - "failed" - all requests in terminal state and all failed
547 /// - "cancelled" - all requests cancelled
548 pub fn openai_status(&self) -> &'static str {
549 if self.failed_at.is_some() {
550 return "failed";
551 }
552
553 if self.started_at.is_none() {
554 // Batch hasn't been populated yet — total_requests may already be
555 // set from the template count, but no request rows exist.
556 return "validating";
557 }
558
559 let terminal_count =
560 self.completed_requests + self.failed_requests + self.canceled_requests;
561
562 if terminal_count == 0 {
563 // Populated but nothing terminal yet
564 "in_progress"
565 } else if terminal_count == self.total_requests {
566 // All done - determine outcome
567 if self.canceled_requests == self.total_requests {
568 "cancelled"
569 } else if self.completed_requests == 0 {
570 "failed"
571 } else {
572 "completed"
573 }
574 } else if terminal_count as f64 / self.total_requests as f64 >= 0.95 {
575 // Nearly done (95%+)
576 "finalizing"
577 } else {
578 // In progress
579 "in_progress"
580 }
581 }
582}
583
584/// Aggregated statistics for a model's templates in a file.
585/// Used for efficient cost estimation without streaming all template data.
586#[derive(Debug, Clone, Serialize, Deserialize)]
587pub struct ModelTemplateStats {
588 /// The model name
589 pub model: String,
590 /// Number of request templates using this model
591 pub request_count: i64,
592 /// Total size of all request bodies in bytes
593 pub total_body_bytes: i64,
594}