1use async_trait::async_trait;
7use serde::{Deserialize, Serialize};
8use ts_rs::TS;
9
10use std::time::{Duration, Instant};
11
12use crate::error::AiMuxError;
13use crate::shared::{
14 AspectRatio, SharedHeaders, SharedProviderMetadata, SharedProviderOptions, Size, Warning,
15};
16use crate::{AbortSignal, retry, timeout};
17
18#[derive(Debug, Clone, Serialize, Deserialize, TS)]
22#[ts(export)]
23pub enum VideoFile {
24 File {
26 media_type: String,
28 data: VideoFileData,
30 },
31 Url {
33 url: String,
35 media_type: Option<String>,
37 },
38}
39
40#[derive(Debug, Clone, Serialize, Deserialize, TS)]
42#[ts(export)]
43pub enum VideoFileData {
44 Base64(String),
46 Binary(Vec<u8>),
48}
49
50#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, TS)]
54#[ts(export)]
55pub enum VideoFrameType {
56 FirstFrame,
58 LastFrame,
60}
61
62#[derive(Debug, Clone, Serialize, Deserialize, TS)]
66#[ts(export)]
67pub struct VideoFrameImage {
68 pub image: VideoFile,
70 pub frame_type: VideoFrameType,
72}
73
74#[derive(Debug, Clone, Serialize, Deserialize, TS)]
79#[ts(export)]
80pub enum VideoData {
81 Url { url: String, media_type: String },
83 Base64 { data: String, media_type: String },
85 Binary { data: Vec<u8>, media_type: String },
87}
88
89#[derive(Debug, Clone, Serialize, Deserialize, TS)]
93#[ts(export)]
94pub struct VideoCallOptions {
95 pub prompt: Option<String>,
97
98 #[serde(default = "default_video_n")]
103 pub n: u32,
104
105 pub aspect_ratio: Option<AspectRatio>,
107
108 pub resolution: Option<Size>,
110
111 pub duration: Option<u32>,
113
114 pub fps: Option<u32>,
116
117 #[ts(type = "number | null")]
119 pub seed: Option<u64>,
120
121 pub image: Option<VideoFile>,
123
124 pub frame_images: Option<Vec<VideoFrameImage>>,
126
127 pub input_references: Option<Vec<VideoFile>>,
129
130 pub generate_audio: Option<bool>,
132
133 #[serde(default)]
135 pub provider_options: SharedProviderOptions,
136
137 #[serde(skip)]
139 #[ts(skip)]
140 pub abort_signal: Option<AbortSignal>,
141
142 pub max_retries: Option<u32>,
144
145 pub poll: Option<VideoPollOptions>,
148
149 pub timeout: Option<crate::options::TimeoutConfiguration>,
151
152 pub headers: Option<SharedHeaders>,
154}
155
156fn default_video_n() -> u32 {
157 1
158}
159
160impl VideoCallOptions {
161 pub fn new(prompt: impl Into<String>) -> Self {
163 Self {
164 prompt: Some(prompt.into()),
165 n: 1,
166 aspect_ratio: None,
167 resolution: None,
168 duration: None,
169 fps: None,
170 seed: None,
171 image: None,
172 frame_images: None,
173 input_references: None,
174 generate_audio: None,
175 provider_options: SharedProviderOptions::new(),
176 abort_signal: None,
177 max_retries: None,
178 poll: None,
179 timeout: None,
180 headers: None,
181 }
182 }
183}
184
185#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, TS)]
187#[ts(export)]
188pub struct VideoPollOptions {
189 #[ts(type = "number | null")]
193 pub interval_ms: Option<u64>,
194 #[ts(type = "number | null")]
196 pub timeout_ms: Option<u64>,
197}
198
199#[derive(Debug, Clone, Serialize, Deserialize, TS)]
203#[ts(export)]
204pub struct VideoResult {
205 pub videos: Vec<VideoData>,
207
208 pub warnings: Vec<Warning>,
210
211 pub provider_metadata: Option<SharedProviderMetadata>,
213
214 pub response: VideoResponse,
216}
217
218#[derive(Debug, Clone, Default, Serialize, Deserialize, TS)]
220#[ts(export)]
221pub struct VideoResponse {
222 pub timestamp: Option<String>,
224 pub model_id: Option<String>,
226 pub headers: Option<SharedHeaders>,
228}
229
230#[derive(Debug, Clone)]
235pub struct VideoOperationStart {
236 pub operation: serde_json::Value,
239
240 pub warnings: Vec<Warning>,
242
243 pub provider_metadata: Option<SharedProviderMetadata>,
245
246 pub response: VideoResponse,
248}
249
250#[derive(Debug, Clone)]
255pub enum VideoOperationStatus {
256 Pending,
258 Completed(VideoResult),
260}
261
262#[derive(Debug, Clone, Copy)]
264pub struct VideoPollConfig {
265 pub interval: Duration,
267 pub timeout: Duration,
269}
270
271impl Default for VideoPollConfig {
272 fn default() -> Self {
273 Self {
275 interval: Duration::from_secs(5),
276 timeout: Duration::from_secs(600),
277 }
278 }
279}
280
281#[async_trait]
289pub trait VideoModel: Send + Sync {
290 fn specification_version(&self) -> &'static str {
292 "v4"
293 }
294
295 fn provider(&self) -> &str;
297
298 fn model_id(&self) -> &str;
300
301 fn retry_config(&self) -> retry::RetryConfig {
302 retry::RetryConfig::default()
303 }
304
305 fn poll_config(&self) -> VideoPollConfig {
308 VideoPollConfig::default()
309 }
310
311 fn max_videos_per_call(&self) -> Option<u32>;
315
316 async fn do_start(&self, options: &VideoCallOptions)
321 -> Result<VideoOperationStart, AiMuxError>;
322
323 async fn do_status(
328 &self,
329 operation: &serde_json::Value,
330 options: &VideoCallOptions,
331 ) -> Result<VideoOperationStatus, AiMuxError>;
332}
333
334pub async fn generate_video(
360 model: &dyn VideoModel,
361 options: VideoCallOptions,
362) -> Result<VideoResult, AiMuxError> {
363 let timeout = timeout::OperationTimeout::new(options.timeout.unwrap_or_default())?;
364 let abort_signal = options.abort_signal.clone();
365 timeout::run(
366 start_and_poll(model, options),
367 abort_signal.as_ref(),
368 timeout,
369 )
370 .await
371}
372
373async fn start_and_poll(
374 model: &dyn VideoModel,
375 options: VideoCallOptions,
376) -> Result<VideoResult, AiMuxError> {
377 if options.n == 0 {
378 return Err(AiMuxError::InvalidArgument(
379 "video generation `n` must be at least 1".to_string(),
380 ));
381 }
382
383 let abort_signal = options.abort_signal.clone();
384 let retries = retry::prepare_retries(
385 options.max_retries,
386 model.retry_config(),
387 abort_signal.clone(),
388 );
389
390 let mut poll = model.poll_config();
391 if let Some(overrides) = options.poll {
392 if let Some(ms) = overrides.interval_ms {
393 poll.interval = Duration::from_millis(ms);
394 }
395 if let Some(ms) = overrides.timeout_ms {
396 poll.timeout = Duration::from_millis(ms);
397 }
398 }
399
400 let max_per_call = model.max_videos_per_call().unwrap_or(options.n).max(1);
404 let last = options.n.div_ceil(max_per_call) - 1;
405 let batches = (0..=last).map(|i| {
406 let mut batch_options = options.clone();
407 if last > 0
408 && let Some(headers) = batch_options.headers.as_mut()
409 {
410 for (name, key) in headers.iter_mut() {
413 if name.eq_ignore_ascii_case("idempotency-key") {
414 *key = format!("{key}:batch:{i}");
415 }
416 }
417 }
418 batch_options.n = if i < last {
419 max_per_call
420 } else {
421 match options.n % max_per_call {
422 0 => max_per_call,
423 remainder => remainder,
424 }
425 };
426 start_and_poll_one_batch(model, batch_options, &retries, poll, abort_signal.clone())
427 });
428
429 let mut results = futures::future::try_join_all(batches).await?.into_iter();
432 let mut merged = results.next().expect("at least one batch");
433 for result in results {
434 merged.videos.extend(result.videos);
435 merged.warnings.extend(result.warnings);
436 merged.provider_metadata =
437 merge_provider_metadata(merged.provider_metadata, result.provider_metadata);
438 }
439 Ok(merged)
440}
441
442async fn start_and_poll_one_batch(
447 model: &dyn VideoModel,
448 options: VideoCallOptions,
449 retries: &retry::PreparedRetries,
450 poll: VideoPollConfig,
451 abort_signal: Option<AbortSignal>,
452) -> Result<VideoResult, AiMuxError> {
453 let mut start_options = options.clone();
458 let headers = start_options.headers.get_or_insert_with(SharedHeaders::new);
459 if !headers
460 .keys()
461 .any(|k| k.eq_ignore_ascii_case("idempotency-key"))
462 {
463 headers.insert(
464 "idempotency-key".to_string(),
465 format!("aimux_vid_{:016x}", rand::random::<u64>()),
466 );
467 }
468
469 let start = retries.retry(|| model.do_start(&start_options)).await?;
470
471 let poll_started = Instant::now();
472 loop {
473 let elapsed = poll_started.elapsed();
474 if elapsed >= poll.timeout {
475 return Err(AiMuxError::Timeout(format!(
476 "Video generation timed out after {:?}.",
477 poll.timeout
478 )));
479 }
480 retry::delay(
481 poll.interval.min(poll.timeout - elapsed),
482 abort_signal.as_ref(),
483 )
484 .await?;
485
486 if poll_started.elapsed() >= poll.timeout {
487 return Err(AiMuxError::Timeout(format!(
488 "Video generation timed out after {:?}.",
489 poll.timeout
490 )));
491 }
492
493 let status = retries
497 .retry(|| model.do_status(&start.operation, &options))
498 .await?;
499 match status {
500 VideoOperationStatus::Pending => {}
501 VideoOperationStatus::Completed(mut result) => {
502 let mut warnings = start.warnings;
504 warnings.append(&mut result.warnings);
505 result.warnings = warnings;
506 result.provider_metadata =
507 merge_provider_metadata(start.provider_metadata, result.provider_metadata);
508 return Ok(result);
509 }
510 }
511 }
512}
513
514fn merge_provider_metadata(
523 a: Option<SharedProviderMetadata>,
524 b: Option<SharedProviderMetadata>,
525) -> Option<SharedProviderMetadata> {
526 let Some(mut merged) = a else { return b };
527 for (provider, b_value) in b.into_iter().flatten() {
528 let value = match (merged.remove(&provider), b_value) {
529 (Some(serde_json::Value::Object(mut a_obj)), serde_json::Value::Object(b_obj)) => {
530 a_obj.extend(b_obj);
531 serde_json::Value::Object(a_obj)
532 }
533 (_, b_value) => b_value,
534 };
535 merged.insert(provider, value);
536 }
537 Some(merged)
538}
539
540#[cfg(test)]
541mod tests {
542 use std::sync::Mutex;
543 use std::sync::atomic::{AtomicU32, Ordering};
544
545 use super::*;
546 use crate::error::ApiCallError;
547
548 enum StatusStep {
550 RetryableFailure,
551 Pending,
552 Complete,
553 }
554
555 struct ScriptedVideoModel {
556 starts: AtomicU32,
557 peak_concurrent_starts: AtomicU32,
560 in_flight_starts: AtomicU32,
561 statuses: AtomicU32,
562 script: Vec<StatusStep>,
563 start_failures: u32,
564 start_idempotency_keys: Mutex<Vec<Option<String>>>,
565 status_idempotency_keys: Mutex<Vec<Option<String>>>,
566 }
567
568 impl ScriptedVideoModel {
569 fn new(script: Vec<StatusStep>) -> Self {
570 Self {
571 starts: AtomicU32::new(0),
572 peak_concurrent_starts: AtomicU32::new(0),
573 in_flight_starts: AtomicU32::new(0),
574 statuses: AtomicU32::new(0),
575 script,
576 start_failures: 0,
577 start_idempotency_keys: Mutex::new(Vec::new()),
578 status_idempotency_keys: Mutex::new(Vec::new()),
579 }
580 }
581
582 fn with_start_failures(mut self, failures: u32) -> Self {
583 self.start_failures = failures;
584 self
585 }
586 }
587
588 fn idempotency_key(options: &VideoCallOptions) -> Option<String> {
589 options.headers.as_ref().and_then(|headers| {
590 headers
591 .iter()
592 .find(|(name, _)| name.eq_ignore_ascii_case("idempotency-key"))
593 .map(|(_, value)| value.clone())
594 })
595 }
596
597 #[async_trait]
598 impl VideoModel for ScriptedVideoModel {
599 fn provider(&self) -> &str {
600 "test"
601 }
602
603 fn model_id(&self) -> &str {
604 "scripted"
605 }
606
607 fn retry_config(&self) -> crate::retry::RetryConfig {
608 crate::retry::RetryConfig {
609 initial_delay: Duration::from_millis(1),
610 ..crate::retry::RetryConfig::default()
611 }
612 }
613
614 fn max_videos_per_call(&self) -> Option<u32> {
615 Some(1)
616 }
617
618 async fn do_start(
619 &self,
620 options: &VideoCallOptions,
621 ) -> Result<VideoOperationStart, AiMuxError> {
622 let attempt = self.starts.fetch_add(1, Ordering::SeqCst);
623 let in_flight = self.in_flight_starts.fetch_add(1, Ordering::SeqCst) + 1;
624 self.peak_concurrent_starts
625 .fetch_max(in_flight, Ordering::SeqCst);
626 tokio::task::yield_now().await;
629 self.in_flight_starts.fetch_sub(1, Ordering::SeqCst);
630 let key = idempotency_key(options);
631 self.start_idempotency_keys
632 .lock()
633 .expect("start key capture lock should not be poisoned")
634 .push(key.clone());
635 assert!(
637 key.is_some(),
638 "do_start should receive an idempotency-key header"
639 );
640 if attempt < self.start_failures {
641 return Err(AiMuxError::ApiCall(Box::new(ApiCallError {
642 status_code: Some(503),
643 is_retryable: true,
644 ..ApiCallError::new(
645 "start unavailable",
646 "https://test/start",
647 serde_json::json!({}),
648 )
649 })));
650 }
651 Ok(VideoOperationStart {
652 operation: serde_json::json!({ "task_id": "t-1" }),
653 warnings: vec![Warning::Other {
654 message: "from start".to_string(),
655 }],
656 provider_metadata: None,
657 response: VideoResponse::default(),
658 })
659 }
660
661 async fn do_status(
662 &self,
663 operation: &serde_json::Value,
664 options: &VideoCallOptions,
665 ) -> Result<VideoOperationStatus, AiMuxError> {
666 assert_eq!(operation["task_id"], "t-1");
667 self.status_idempotency_keys
668 .lock()
669 .expect("status key capture lock should not be poisoned")
670 .push(idempotency_key(options));
671 let i = self.statuses.fetch_add(1, Ordering::SeqCst) as usize;
672 match self.script.get(i).unwrap_or(&StatusStep::Complete) {
673 StatusStep::RetryableFailure => Err(AiMuxError::ApiCall(Box::new(ApiCallError {
674 status_code: Some(429),
675 is_retryable: true,
676 ..ApiCallError::new("rate limited", "https://test", serde_json::json!({}))
677 }))),
678 StatusStep::Pending => Ok(VideoOperationStatus::Pending),
679 StatusStep::Complete => Ok(VideoOperationStatus::Completed(VideoResult {
680 videos: vec![VideoData::Url {
681 url: "https://cdn/video.mp4".to_string(),
682 media_type: "video/mp4".to_string(),
683 }],
684 warnings: vec![Warning::Other {
685 message: "from status".to_string(),
686 }],
687 provider_metadata: None,
688 response: VideoResponse::default(),
689 })),
690 }
691 }
692 }
693
694 fn fast_poll_options() -> VideoCallOptions {
695 let mut options = VideoCallOptions::new("a cat");
696 options.poll = Some(VideoPollOptions {
697 interval_ms: Some(1),
698 timeout_ms: Some(2_000),
699 });
700 options
701 }
702
703 #[tokio::test]
707 async fn transient_status_failure_never_restarts_the_task() {
708 let model = ScriptedVideoModel::new(vec![
709 StatusStep::Pending,
710 StatusStep::RetryableFailure,
711 StatusStep::Complete,
712 ]);
713 let result = generate_video(&model, fast_poll_options()).await.unwrap();
714 assert_eq!(model.starts.load(Ordering::SeqCst), 1);
715 assert_eq!(model.statuses.load(Ordering::SeqCst), 3);
716 assert!(
717 model
718 .status_idempotency_keys
719 .lock()
720 .unwrap()
721 .iter()
722 .all(Option::is_none),
723 "the Core-minted start key must not be forwarded to status calls"
724 );
725 assert_eq!(result.videos.len(), 1);
726 }
727
728 #[tokio::test]
729 async fn start_retry_reuses_one_idempotency_key() {
730 let model = ScriptedVideoModel::new(vec![StatusStep::Complete]).with_start_failures(1);
731 let mut options = fast_poll_options();
732 options.max_retries = Some(1);
733
734 let result = generate_video(&model, options).await.unwrap();
735
736 assert_eq!(model.starts.load(Ordering::SeqCst), 2);
737 assert_eq!(result.videos.len(), 1);
738 let keys = model
739 .start_idempotency_keys
740 .lock()
741 .expect("start key capture lock should not be poisoned");
742 assert_eq!(keys.len(), 2);
743 assert!(keys[0].is_some());
744 assert_eq!(keys[0], keys[1]);
745 assert_eq!(
746 model
747 .status_idempotency_keys
748 .lock()
749 .expect("status key capture lock should not be poisoned")
750 .as_slice(),
751 &[None]
752 );
753 }
754
755 #[tokio::test]
756 async fn caller_idempotency_key_is_preserved_for_start_and_status() {
757 let model = ScriptedVideoModel::new(vec![StatusStep::Complete]);
758 let mut options = fast_poll_options();
759 options
760 .headers
761 .get_or_insert_with(SharedHeaders::new)
762 .insert("Idempotency-Key".to_string(), "caller-key".to_string());
763
764 generate_video(&model, options).await.unwrap();
765
766 assert_eq!(
767 model
768 .start_idempotency_keys
769 .lock()
770 .expect("start key capture lock should not be poisoned")
771 .as_slice(),
772 &[Some("caller-key".to_string())]
773 );
774 assert_eq!(
775 model
776 .status_idempotency_keys
777 .lock()
778 .expect("status key capture lock should not be poisoned")
779 .as_slice(),
780 &[Some("caller-key".to_string())]
781 );
782 }
783
784 #[tokio::test]
785 async fn start_warnings_precede_completion_warnings() {
786 let model = ScriptedVideoModel::new(vec![StatusStep::Complete]);
787 let result = generate_video(&model, fast_poll_options()).await.unwrap();
788 let messages: Vec<String> = result.warnings.iter().map(|w| format!("{w:?}")).collect();
789 assert!(messages[0].contains("from start"), "{messages:?}");
790 assert!(messages[1].contains("from status"), "{messages:?}");
791 }
792
793 #[test]
794 fn merge_provider_metadata_unions_same_provider_key_across_phases() {
795 let phase = |value| Some(SharedProviderMetadata::from([("fal".to_string(), value)]));
796 let merged = merge_provider_metadata(
797 phase(serde_json::json!({ "job_id": "job-1", "region": "us-east" })),
798 phase(serde_json::json!({ "region": "eu-west", "seed": 42 })),
799 )
800 .expect("both phases reported metadata");
801
802 assert_eq!(
805 merged["fal"],
806 serde_json::json!({ "job_id": "job-1", "region": "eu-west", "seed": 42 })
807 );
808 }
809
810 #[tokio::test]
811 async fn poll_timeout_fails_a_generation_that_never_completes() {
812 let model = ScriptedVideoModel::new(vec![]);
813 struct AlwaysPending(ScriptedVideoModel);
815 #[async_trait]
816 impl VideoModel for AlwaysPending {
817 fn provider(&self) -> &str {
818 "test"
819 }
820 fn model_id(&self) -> &str {
821 "pending"
822 }
823 fn max_videos_per_call(&self) -> Option<u32> {
824 Some(1)
825 }
826 async fn do_start(
827 &self,
828 options: &VideoCallOptions,
829 ) -> Result<VideoOperationStart, AiMuxError> {
830 self.0.do_start(options).await
831 }
832 async fn do_status(
833 &self,
834 _operation: &serde_json::Value,
835 _options: &VideoCallOptions,
836 ) -> Result<VideoOperationStatus, AiMuxError> {
837 Ok(VideoOperationStatus::Pending)
838 }
839 }
840 let model = AlwaysPending(model);
841 let mut options = VideoCallOptions::new("a cat");
842 options.poll = Some(VideoPollOptions {
843 interval_ms: Some(1),
844 timeout_ms: Some(20),
845 });
846 let error = generate_video(&model, options).await.unwrap_err();
847 assert!(
848 matches!(error, AiMuxError::Timeout(_)),
849 "expected Timeout, got {error:?}"
850 );
851 }
852
853 #[tokio::test]
854 async fn poll_timeout_reached_during_delay_skips_status_call() {
855 let model = ScriptedVideoModel::new(vec![StatusStep::Complete]);
856 let mut options = VideoCallOptions::new("a cat");
857 options.poll = Some(VideoPollOptions {
858 interval_ms: Some(100),
859 timeout_ms: Some(10),
860 });
861
862 let error = generate_video(&model, options).await.unwrap_err();
863
864 assert!(matches!(error, AiMuxError::Timeout(_)));
865 assert_eq!(model.starts.load(Ordering::SeqCst), 1);
866 assert_eq!(model.statuses.load(Ordering::SeqCst), 0);
867 assert!(
868 model
869 .status_idempotency_keys
870 .lock()
871 .expect("status key capture lock should not be poisoned")
872 .is_empty()
873 );
874 }
875
876 #[tokio::test]
877 async fn n_zero_is_rejected_before_any_network_call() {
878 let model = ScriptedVideoModel::new(vec![StatusStep::Complete]);
879 let mut options = fast_poll_options();
880 options.n = 0;
881
882 let error = generate_video(&model, options).await.unwrap_err();
883
884 assert!(
885 matches!(error, AiMuxError::InvalidArgument(_)),
886 "expected InvalidArgument, got {error:?}"
887 );
888 assert_eq!(
889 model.starts.load(Ordering::SeqCst),
890 0,
891 "n=0 must not start a billed request"
892 );
893 }
894
895 #[tokio::test]
896 async fn n_above_max_per_call_splits_into_concurrent_batches() {
897 let model = ScriptedVideoModel::new(vec![]);
902 let mut options = fast_poll_options();
903 options.n = 3;
904
905 let result = generate_video(&model, options).await.unwrap();
906
907 assert_eq!(model.starts.load(Ordering::SeqCst), 3);
908 assert_eq!(result.videos.len(), 3);
909 assert_eq!(
910 result.warnings.len(),
911 6,
912 "start + status warnings per batch"
913 );
914 assert!(
915 model.peak_concurrent_starts.load(Ordering::SeqCst) > 1,
916 "batches must overlap, not run one after another"
917 );
918 let keys = model
919 .start_idempotency_keys
920 .lock()
921 .expect("start key capture lock should not be poisoned");
922 let unique: std::collections::HashSet<_> = keys.iter().collect();
923 assert_eq!(unique.len(), 3, "one key per batch: {keys:?}");
924 }
925
926 #[tokio::test]
927 async fn caller_key_is_distinct_per_batch_and_stable_across_retries_and_replays() {
928 let model = ScriptedVideoModel::new(vec![]).with_start_failures(1);
929 let mut options = fast_poll_options();
930 options.n = 3;
931 options
932 .headers
933 .get_or_insert_with(SharedHeaders::new)
934 .insert("Idempotency-Key".into(), "caller-request-1".into());
935 generate_video(&model, options.clone()).await.unwrap();
936 let unique = {
937 let keys = model.start_idempotency_keys.lock().unwrap();
938 let unique: std::collections::HashSet<_> = keys.iter().cloned().collect();
939 assert_eq!(unique.len(), 3, "distinct batches share keys: {keys:?}");
940 assert_eq!(
941 keys.len(),
942 4,
943 "the failed batch must reuse its key on retry"
944 );
945 unique
946 };
947
948 let replay = ScriptedVideoModel::new(vec![]);
949 generate_video(&replay, options).await.unwrap();
950 let replay_keys = replay.start_idempotency_keys.lock().unwrap();
951 assert_eq!(
952 replay_keys
953 .iter()
954 .cloned()
955 .collect::<std::collections::HashSet<_>>(),
956 unique
957 );
958 }
959}