use async_trait::async_trait;
use serde::{Deserialize, Serialize};
use ts_rs::TS;
use std::time::{Duration, Instant};
use crate::error::AiMuxError;
use crate::shared::{
AspectRatio, SharedHeaders, SharedProviderMetadata, SharedProviderOptions, Size, Warning,
};
use crate::{AbortSignal, retry, timeout};
#[derive(Debug, Clone, Serialize, Deserialize, TS)]
#[ts(export)]
pub enum VideoFile {
File {
media_type: String,
data: VideoFileData,
},
Url {
url: String,
media_type: Option<String>,
},
}
#[derive(Debug, Clone, Serialize, Deserialize, TS)]
#[ts(export)]
pub enum VideoFileData {
Base64(String),
Binary(Vec<u8>),
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, TS)]
#[ts(export)]
pub enum VideoFrameType {
FirstFrame,
LastFrame,
}
#[derive(Debug, Clone, Serialize, Deserialize, TS)]
#[ts(export)]
pub struct VideoFrameImage {
pub image: VideoFile,
pub frame_type: VideoFrameType,
}
#[derive(Debug, Clone, Serialize, Deserialize, TS)]
#[ts(export)]
pub enum VideoData {
Url { url: String, media_type: String },
Base64 { data: String, media_type: String },
Binary { data: Vec<u8>, media_type: String },
}
#[derive(Debug, Clone, Serialize, Deserialize, TS)]
#[ts(export)]
pub struct VideoCallOptions {
pub prompt: Option<String>,
#[serde(default = "default_video_n")]
pub n: u32,
pub aspect_ratio: Option<AspectRatio>,
pub resolution: Option<Size>,
pub duration: Option<u32>,
pub fps: Option<u32>,
#[ts(type = "number | null")]
pub seed: Option<u64>,
pub image: Option<VideoFile>,
pub frame_images: Option<Vec<VideoFrameImage>>,
pub input_references: Option<Vec<VideoFile>>,
pub generate_audio: Option<bool>,
#[serde(default)]
pub provider_options: SharedProviderOptions,
#[serde(skip)]
#[ts(skip)]
pub abort_signal: Option<AbortSignal>,
pub max_retries: Option<u32>,
pub poll: Option<VideoPollOptions>,
pub timeout: Option<crate::options::TimeoutConfiguration>,
pub headers: Option<SharedHeaders>,
}
fn default_video_n() -> u32 {
1
}
impl VideoCallOptions {
pub fn new(prompt: impl Into<String>) -> Self {
Self {
prompt: Some(prompt.into()),
n: 1,
aspect_ratio: None,
resolution: None,
duration: None,
fps: None,
seed: None,
image: None,
frame_images: None,
input_references: None,
generate_audio: None,
provider_options: SharedProviderOptions::new(),
abort_signal: None,
max_retries: None,
poll: None,
timeout: None,
headers: None,
}
}
}
#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, TS)]
#[ts(export)]
pub struct VideoPollOptions {
#[ts(type = "number | null")]
pub interval_ms: Option<u64>,
#[ts(type = "number | null")]
pub timeout_ms: Option<u64>,
}
#[derive(Debug, Clone, Serialize, Deserialize, TS)]
#[ts(export)]
pub struct VideoResult {
pub videos: Vec<VideoData>,
pub warnings: Vec<Warning>,
pub provider_metadata: Option<SharedProviderMetadata>,
pub response: VideoResponse,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize, TS)]
#[ts(export)]
pub struct VideoResponse {
pub timestamp: Option<String>,
pub model_id: Option<String>,
pub headers: Option<SharedHeaders>,
}
#[derive(Debug, Clone)]
pub struct VideoOperationStart {
pub operation: serde_json::Value,
pub warnings: Vec<Warning>,
pub provider_metadata: Option<SharedProviderMetadata>,
pub response: VideoResponse,
}
#[derive(Debug, Clone)]
pub enum VideoOperationStatus {
Pending,
Completed(VideoResult),
}
#[derive(Debug, Clone, Copy)]
pub struct VideoPollConfig {
pub interval: Duration,
pub timeout: Duration,
}
impl Default for VideoPollConfig {
fn default() -> Self {
Self {
interval: Duration::from_secs(5),
timeout: Duration::from_secs(600),
}
}
}
#[async_trait]
pub trait VideoModel: Send + Sync {
fn specification_version(&self) -> &'static str {
"v4"
}
fn provider(&self) -> &str;
fn model_id(&self) -> &str;
fn retry_config(&self) -> retry::RetryConfig {
retry::RetryConfig::default()
}
fn poll_config(&self) -> VideoPollConfig {
VideoPollConfig::default()
}
fn max_videos_per_call(&self) -> Option<u32>;
async fn do_start(&self, options: &VideoCallOptions)
-> Result<VideoOperationStart, AiMuxError>;
async fn do_status(
&self,
operation: &serde_json::Value,
options: &VideoCallOptions,
) -> Result<VideoOperationStatus, AiMuxError>;
}
pub async fn generate_video(
model: &dyn VideoModel,
options: VideoCallOptions,
) -> Result<VideoResult, AiMuxError> {
let timeout = timeout::OperationTimeout::new(options.timeout.unwrap_or_default())?;
let abort_signal = options.abort_signal.clone();
timeout::run(
start_and_poll(model, options),
abort_signal.as_ref(),
timeout,
)
.await
}
async fn start_and_poll(
model: &dyn VideoModel,
options: VideoCallOptions,
) -> Result<VideoResult, AiMuxError> {
if options.n == 0 {
return Err(AiMuxError::InvalidArgument(
"video generation `n` must be at least 1".to_string(),
));
}
let abort_signal = options.abort_signal.clone();
let retries = retry::prepare_retries(
options.max_retries,
model.retry_config(),
abort_signal.clone(),
);
let mut poll = model.poll_config();
if let Some(overrides) = options.poll {
if let Some(ms) = overrides.interval_ms {
poll.interval = Duration::from_millis(ms);
}
if let Some(ms) = overrides.timeout_ms {
poll.timeout = Duration::from_millis(ms);
}
}
let max_per_call = model.max_videos_per_call().unwrap_or(options.n).max(1);
let last = options.n.div_ceil(max_per_call) - 1;
let batches = (0..=last).map(|i| {
let mut batch_options = options.clone();
if last > 0
&& let Some(headers) = batch_options.headers.as_mut()
{
for (name, key) in headers.iter_mut() {
if name.eq_ignore_ascii_case("idempotency-key") {
*key = format!("{key}:batch:{i}");
}
}
}
batch_options.n = if i < last {
max_per_call
} else {
match options.n % max_per_call {
0 => max_per_call,
remainder => remainder,
}
};
start_and_poll_one_batch(model, batch_options, &retries, poll, abort_signal.clone())
});
let mut results = futures::future::try_join_all(batches).await?.into_iter();
let mut merged = results.next().expect("at least one batch");
for result in results {
merged.videos.extend(result.videos);
merged.warnings.extend(result.warnings);
merged.provider_metadata =
merge_provider_metadata(merged.provider_metadata, result.provider_metadata);
}
Ok(merged)
}
async fn start_and_poll_one_batch(
model: &dyn VideoModel,
options: VideoCallOptions,
retries: &retry::PreparedRetries,
poll: VideoPollConfig,
abort_signal: Option<AbortSignal>,
) -> Result<VideoResult, AiMuxError> {
let mut start_options = options.clone();
let headers = start_options.headers.get_or_insert_with(SharedHeaders::new);
if !headers
.keys()
.any(|k| k.eq_ignore_ascii_case("idempotency-key"))
{
headers.insert(
"idempotency-key".to_string(),
format!("aimux_vid_{:016x}", rand::random::<u64>()),
);
}
let start = retries.retry(|| model.do_start(&start_options)).await?;
let poll_started = Instant::now();
loop {
let elapsed = poll_started.elapsed();
if elapsed >= poll.timeout {
return Err(AiMuxError::Timeout(format!(
"Video generation timed out after {:?}.",
poll.timeout
)));
}
retry::delay(
poll.interval.min(poll.timeout - elapsed),
abort_signal.as_ref(),
)
.await?;
if poll_started.elapsed() >= poll.timeout {
return Err(AiMuxError::Timeout(format!(
"Video generation timed out after {:?}.",
poll.timeout
)));
}
let status = retries
.retry(|| model.do_status(&start.operation, &options))
.await?;
match status {
VideoOperationStatus::Pending => {}
VideoOperationStatus::Completed(mut result) => {
let mut warnings = start.warnings;
warnings.append(&mut result.warnings);
result.warnings = warnings;
result.provider_metadata =
merge_provider_metadata(start.provider_metadata, result.provider_metadata);
return Ok(result);
}
}
}
}
fn merge_provider_metadata(
a: Option<SharedProviderMetadata>,
b: Option<SharedProviderMetadata>,
) -> Option<SharedProviderMetadata> {
let Some(mut merged) = a else { return b };
for (provider, b_value) in b.into_iter().flatten() {
let value = match (merged.remove(&provider), b_value) {
(Some(serde_json::Value::Object(mut a_obj)), serde_json::Value::Object(b_obj)) => {
a_obj.extend(b_obj);
serde_json::Value::Object(a_obj)
}
(_, b_value) => b_value,
};
merged.insert(provider, value);
}
Some(merged)
}
#[cfg(test)]
mod tests {
use std::sync::Mutex;
use std::sync::atomic::{AtomicU32, Ordering};
use super::*;
use crate::error::ApiCallError;
enum StatusStep {
RetryableFailure,
Pending,
Complete,
}
struct ScriptedVideoModel {
starts: AtomicU32,
peak_concurrent_starts: AtomicU32,
in_flight_starts: AtomicU32,
statuses: AtomicU32,
script: Vec<StatusStep>,
start_failures: u32,
start_idempotency_keys: Mutex<Vec<Option<String>>>,
status_idempotency_keys: Mutex<Vec<Option<String>>>,
}
impl ScriptedVideoModel {
fn new(script: Vec<StatusStep>) -> Self {
Self {
starts: AtomicU32::new(0),
peak_concurrent_starts: AtomicU32::new(0),
in_flight_starts: AtomicU32::new(0),
statuses: AtomicU32::new(0),
script,
start_failures: 0,
start_idempotency_keys: Mutex::new(Vec::new()),
status_idempotency_keys: Mutex::new(Vec::new()),
}
}
fn with_start_failures(mut self, failures: u32) -> Self {
self.start_failures = failures;
self
}
}
fn idempotency_key(options: &VideoCallOptions) -> Option<String> {
options.headers.as_ref().and_then(|headers| {
headers
.iter()
.find(|(name, _)| name.eq_ignore_ascii_case("idempotency-key"))
.map(|(_, value)| value.clone())
})
}
#[async_trait]
impl VideoModel for ScriptedVideoModel {
fn provider(&self) -> &str {
"test"
}
fn model_id(&self) -> &str {
"scripted"
}
fn retry_config(&self) -> crate::retry::RetryConfig {
crate::retry::RetryConfig {
initial_delay: Duration::from_millis(1),
..crate::retry::RetryConfig::default()
}
}
fn max_videos_per_call(&self) -> Option<u32> {
Some(1)
}
async fn do_start(
&self,
options: &VideoCallOptions,
) -> Result<VideoOperationStart, AiMuxError> {
let attempt = self.starts.fetch_add(1, Ordering::SeqCst);
let in_flight = self.in_flight_starts.fetch_add(1, Ordering::SeqCst) + 1;
self.peak_concurrent_starts
.fetch_max(in_flight, Ordering::SeqCst);
tokio::task::yield_now().await;
self.in_flight_starts.fetch_sub(1, Ordering::SeqCst);
let key = idempotency_key(options);
self.start_idempotency_keys
.lock()
.expect("start key capture lock should not be poisoned")
.push(key.clone());
assert!(
key.is_some(),
"do_start should receive an idempotency-key header"
);
if attempt < self.start_failures {
return Err(AiMuxError::ApiCall(Box::new(ApiCallError {
status_code: Some(503),
is_retryable: true,
..ApiCallError::new(
"start unavailable",
"https://test/start",
serde_json::json!({}),
)
})));
}
Ok(VideoOperationStart {
operation: serde_json::json!({ "task_id": "t-1" }),
warnings: vec![Warning::Other {
message: "from start".to_string(),
}],
provider_metadata: None,
response: VideoResponse::default(),
})
}
async fn do_status(
&self,
operation: &serde_json::Value,
options: &VideoCallOptions,
) -> Result<VideoOperationStatus, AiMuxError> {
assert_eq!(operation["task_id"], "t-1");
self.status_idempotency_keys
.lock()
.expect("status key capture lock should not be poisoned")
.push(idempotency_key(options));
let i = self.statuses.fetch_add(1, Ordering::SeqCst) as usize;
match self.script.get(i).unwrap_or(&StatusStep::Complete) {
StatusStep::RetryableFailure => Err(AiMuxError::ApiCall(Box::new(ApiCallError {
status_code: Some(429),
is_retryable: true,
..ApiCallError::new("rate limited", "https://test", serde_json::json!({}))
}))),
StatusStep::Pending => Ok(VideoOperationStatus::Pending),
StatusStep::Complete => Ok(VideoOperationStatus::Completed(VideoResult {
videos: vec![VideoData::Url {
url: "https://cdn/video.mp4".to_string(),
media_type: "video/mp4".to_string(),
}],
warnings: vec![Warning::Other {
message: "from status".to_string(),
}],
provider_metadata: None,
response: VideoResponse::default(),
})),
}
}
}
fn fast_poll_options() -> VideoCallOptions {
let mut options = VideoCallOptions::new("a cat");
options.poll = Some(VideoPollOptions {
interval_ms: Some(1),
timeout_ms: Some(2_000),
});
options
}
#[tokio::test]
async fn transient_status_failure_never_restarts_the_task() {
let model = ScriptedVideoModel::new(vec![
StatusStep::Pending,
StatusStep::RetryableFailure,
StatusStep::Complete,
]);
let result = generate_video(&model, fast_poll_options()).await.unwrap();
assert_eq!(model.starts.load(Ordering::SeqCst), 1);
assert_eq!(model.statuses.load(Ordering::SeqCst), 3);
assert!(
model
.status_idempotency_keys
.lock()
.unwrap()
.iter()
.all(Option::is_none),
"the Core-minted start key must not be forwarded to status calls"
);
assert_eq!(result.videos.len(), 1);
}
#[tokio::test]
async fn start_retry_reuses_one_idempotency_key() {
let model = ScriptedVideoModel::new(vec![StatusStep::Complete]).with_start_failures(1);
let mut options = fast_poll_options();
options.max_retries = Some(1);
let result = generate_video(&model, options).await.unwrap();
assert_eq!(model.starts.load(Ordering::SeqCst), 2);
assert_eq!(result.videos.len(), 1);
let keys = model
.start_idempotency_keys
.lock()
.expect("start key capture lock should not be poisoned");
assert_eq!(keys.len(), 2);
assert!(keys[0].is_some());
assert_eq!(keys[0], keys[1]);
assert_eq!(
model
.status_idempotency_keys
.lock()
.expect("status key capture lock should not be poisoned")
.as_slice(),
&[None]
);
}
#[tokio::test]
async fn caller_idempotency_key_is_preserved_for_start_and_status() {
let model = ScriptedVideoModel::new(vec![StatusStep::Complete]);
let mut options = fast_poll_options();
options
.headers
.get_or_insert_with(SharedHeaders::new)
.insert("Idempotency-Key".to_string(), "caller-key".to_string());
generate_video(&model, options).await.unwrap();
assert_eq!(
model
.start_idempotency_keys
.lock()
.expect("start key capture lock should not be poisoned")
.as_slice(),
&[Some("caller-key".to_string())]
);
assert_eq!(
model
.status_idempotency_keys
.lock()
.expect("status key capture lock should not be poisoned")
.as_slice(),
&[Some("caller-key".to_string())]
);
}
#[tokio::test]
async fn start_warnings_precede_completion_warnings() {
let model = ScriptedVideoModel::new(vec![StatusStep::Complete]);
let result = generate_video(&model, fast_poll_options()).await.unwrap();
let messages: Vec<String> = result.warnings.iter().map(|w| format!("{w:?}")).collect();
assert!(messages[0].contains("from start"), "{messages:?}");
assert!(messages[1].contains("from status"), "{messages:?}");
}
#[test]
fn merge_provider_metadata_unions_same_provider_key_across_phases() {
let phase = |value| Some(SharedProviderMetadata::from([("fal".to_string(), value)]));
let merged = merge_provider_metadata(
phase(serde_json::json!({ "job_id": "job-1", "region": "us-east" })),
phase(serde_json::json!({ "region": "eu-west", "seed": 42 })),
)
.expect("both phases reported metadata");
assert_eq!(
merged["fal"],
serde_json::json!({ "job_id": "job-1", "region": "eu-west", "seed": 42 })
);
}
#[tokio::test]
async fn poll_timeout_fails_a_generation_that_never_completes() {
let model = ScriptedVideoModel::new(vec![]);
struct AlwaysPending(ScriptedVideoModel);
#[async_trait]
impl VideoModel for AlwaysPending {
fn provider(&self) -> &str {
"test"
}
fn model_id(&self) -> &str {
"pending"
}
fn max_videos_per_call(&self) -> Option<u32> {
Some(1)
}
async fn do_start(
&self,
options: &VideoCallOptions,
) -> Result<VideoOperationStart, AiMuxError> {
self.0.do_start(options).await
}
async fn do_status(
&self,
_operation: &serde_json::Value,
_options: &VideoCallOptions,
) -> Result<VideoOperationStatus, AiMuxError> {
Ok(VideoOperationStatus::Pending)
}
}
let model = AlwaysPending(model);
let mut options = VideoCallOptions::new("a cat");
options.poll = Some(VideoPollOptions {
interval_ms: Some(1),
timeout_ms: Some(20),
});
let error = generate_video(&model, options).await.unwrap_err();
assert!(
matches!(error, AiMuxError::Timeout(_)),
"expected Timeout, got {error:?}"
);
}
#[tokio::test]
async fn poll_timeout_reached_during_delay_skips_status_call() {
let model = ScriptedVideoModel::new(vec![StatusStep::Complete]);
let mut options = VideoCallOptions::new("a cat");
options.poll = Some(VideoPollOptions {
interval_ms: Some(100),
timeout_ms: Some(10),
});
let error = generate_video(&model, options).await.unwrap_err();
assert!(matches!(error, AiMuxError::Timeout(_)));
assert_eq!(model.starts.load(Ordering::SeqCst), 1);
assert_eq!(model.statuses.load(Ordering::SeqCst), 0);
assert!(
model
.status_idempotency_keys
.lock()
.expect("status key capture lock should not be poisoned")
.is_empty()
);
}
#[tokio::test]
async fn n_zero_is_rejected_before_any_network_call() {
let model = ScriptedVideoModel::new(vec![StatusStep::Complete]);
let mut options = fast_poll_options();
options.n = 0;
let error = generate_video(&model, options).await.unwrap_err();
assert!(
matches!(error, AiMuxError::InvalidArgument(_)),
"expected InvalidArgument, got {error:?}"
);
assert_eq!(
model.starts.load(Ordering::SeqCst),
0,
"n=0 must not start a billed request"
);
}
#[tokio::test]
async fn n_above_max_per_call_splits_into_concurrent_batches() {
let model = ScriptedVideoModel::new(vec![]);
let mut options = fast_poll_options();
options.n = 3;
let result = generate_video(&model, options).await.unwrap();
assert_eq!(model.starts.load(Ordering::SeqCst), 3);
assert_eq!(result.videos.len(), 3);
assert_eq!(
result.warnings.len(),
6,
"start + status warnings per batch"
);
assert!(
model.peak_concurrent_starts.load(Ordering::SeqCst) > 1,
"batches must overlap, not run one after another"
);
let keys = model
.start_idempotency_keys
.lock()
.expect("start key capture lock should not be poisoned");
let unique: std::collections::HashSet<_> = keys.iter().collect();
assert_eq!(unique.len(), 3, "one key per batch: {keys:?}");
}
#[tokio::test]
async fn caller_key_is_distinct_per_batch_and_stable_across_retries_and_replays() {
let model = ScriptedVideoModel::new(vec![]).with_start_failures(1);
let mut options = fast_poll_options();
options.n = 3;
options
.headers
.get_or_insert_with(SharedHeaders::new)
.insert("Idempotency-Key".into(), "caller-request-1".into());
generate_video(&model, options.clone()).await.unwrap();
let unique = {
let keys = model.start_idempotency_keys.lock().unwrap();
let unique: std::collections::HashSet<_> = keys.iter().cloned().collect();
assert_eq!(unique.len(), 3, "distinct batches share keys: {keys:?}");
assert_eq!(
keys.len(),
4,
"the failed batch must reuse its key on retry"
);
unique
};
let replay = ScriptedVideoModel::new(vec![]);
generate_video(&replay, options).await.unwrap();
let replay_keys = replay.start_idempotency_keys.lock().unwrap();
assert_eq!(
replay_keys
.iter()
.cloned()
.collect::<std::collections::HashSet<_>>(),
unique
);
}
}