Skip to main content

aimux_core/
video_model.rs

1//! The `VideoModel` trait — the provider-facing interface for video generation.
2//!
3//! Aligned with Vercel AI SDK `VideoModelV4`
4//! (`reference/ai/packages/provider/src/video-model/v4/`).
5
6use 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/// A video or image file used for video editing or image-to-video generation.
19///
20/// Aligned with V4 `VideoModelV4File`.
21#[derive(Debug, Clone, Serialize, Deserialize, TS)]
22#[ts(export)]
23pub enum VideoFile {
24    /// Inline file data (base64 or binary) with an explicit media type.
25    File {
26        /// IANA media type, e.g. `"video/mp4"` or `"image/png"`.
27        media_type: String,
28        /// File data as a base64 string or binary bytes.
29        data: VideoFileData,
30    },
31    /// A URL pointing to the file.
32    Url {
33        /// The URL of the video or image file.
34        url: String,
35        /// The media type of the referenced file, when known.
36        media_type: Option<String>,
37    },
38}
39
40/// File payload for a [`VideoFile::File`]: base64 string or raw bytes.
41#[derive(Debug, Clone, Serialize, Deserialize, TS)]
42#[ts(export)]
43pub enum VideoFileData {
44    /// Base64-encoded string.
45    Base64(String),
46    /// Raw binary bytes.
47    Binary(Vec<u8>),
48}
49
50/// The role a frame image plays in video generation.
51///
52/// Aligned with V4 `VideoModelV4FrameType`.
53#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, TS)]
54#[ts(export)]
55pub enum VideoFrameType {
56    /// The starting frame the model animates from.
57    FirstFrame,
58    /// The ending frame the model animates towards.
59    LastFrame,
60}
61
62/// A role-tagged image input for image-to-video and first-last-frame generation.
63///
64/// Aligned with V4 `VideoModelV4FrameImage`.
65#[derive(Debug, Clone, Serialize, Deserialize, TS)]
66#[ts(export)]
67pub struct VideoFrameImage {
68    /// The image file used for this frame.
69    pub image: VideoFile,
70    /// Which frame this image represents.
71    pub frame_type: VideoFrameType,
72}
73
74/// Generated video data: a URL, base64-encoded string, or binary data.
75///
76/// Aligned with V4 `VideoModelV4VideoData`. Most providers return URLs due to
77/// large file sizes.
78#[derive(Debug, Clone, Serialize, Deserialize, TS)]
79#[ts(export)]
80pub enum VideoData {
81    /// Video available as a URL (most common).
82    Url { url: String, media_type: String },
83    /// Video as a base64-encoded string.
84    Base64 { data: String, media_type: String },
85    /// Video as binary data.
86    Binary { data: Vec<u8>, media_type: String },
87}
88
89/// Options passed to [`VideoModel::do_start`] and [`VideoModel::do_status`].
90///
91/// Aligned with V4 `VideoModelV4CallOptions`.
92#[derive(Debug, Clone, Serialize, Deserialize, TS)]
93#[ts(export)]
94pub struct VideoCallOptions {
95    /// Text prompt for the video generation. `None` when not required.
96    pub prompt: Option<String>,
97
98    /// Number of videos to generate. Default `1`; most models only support
99    /// `n = 1` due to computational cost.
100    // serde default: typed binding structs omit unset fields, and a missing
101    // `n` must mean 1, not a hard deserialization failure at the FFI boundary.
102    #[serde(default = "default_video_n")]
103    pub n: u32,
104
105    /// Aspect ratio, in `{width}:{height}` format (e.g. `"16:9"`).
106    pub aspect_ratio: Option<AspectRatio>,
107
108    /// Resolution, in `{width}x{height}` format (e.g. `"1280x720"`).
109    pub resolution: Option<Size>,
110
111    /// Duration of the video in seconds. Typically 3–10 seconds.
112    pub duration: Option<u32>,
113
114    /// Frames per second. Common values: 24, 30, 60.
115    pub fps: Option<u32>,
116
117    /// Seed for deterministic generation. `None` uses a random seed.
118    #[ts(type = "number | null")]
119    pub seed: Option<u64>,
120
121    /// Input image for image-to-video generation (the starting frame).
122    pub image: Option<VideoFile>,
123
124    /// Role-tagged image inputs for first-last-frame generation.
125    pub frame_images: Option<Vec<VideoFrameImage>>,
126
127    /// Reference inputs for reference-to-video generation (images or videos).
128    pub input_references: Option<Vec<VideoFile>>,
129
130    /// Whether the model should generate audio alongside the video.
131    pub generate_audio: Option<bool>,
132
133    /// Additional provider-specific options, keyed by provider name.
134    #[serde(default)]
135    pub provider_options: SharedProviderOptions,
136
137    /// Abort signal for cancelling the operation.
138    #[serde(skip)]
139    #[ts(skip)]
140    pub abort_signal: Option<AbortSignal>,
141
142    /// Per-call retry override. `None` uses the model default.
143    pub max_retries: Option<u32>,
144
145    /// Per-call poll pacing override for the start/status flow. Unset fields
146    /// fall back to the model's [`VideoModel::poll_config`].
147    pub poll: Option<VideoPollOptions>,
148
149    /// Per-call operation timeout.
150    pub timeout: Option<crate::options::TimeoutConfiguration>,
151
152    /// Additional HTTP headers to send with the request.
153    pub headers: Option<SharedHeaders>,
154}
155
156fn default_video_n() -> u32 {
157    1
158}
159
160impl VideoCallOptions {
161    /// Create options with a prompt and `n = 1`, all other fields defaulted.
162    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/// Per-call poll pacing for video generation (AI SDK `generateVideo` `poll`).
186#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, TS)]
187#[ts(export)]
188pub struct VideoPollOptions {
189    /// Delay between consecutive status checks, in milliseconds.
190    // `number`, not the `bigint` ts-rs infers from u64: the JS bindings pass
191    // options through `JSON.stringify`, which throws on BigInt.
192    #[ts(type = "number | null")]
193    pub interval_ms: Option<u64>,
194    /// Maximum total time to wait for completion, in milliseconds.
195    #[ts(type = "number | null")]
196    pub timeout_ms: Option<u64>,
197}
198
199/// The final result of a video generation operation.
200///
201/// Aligned with V4 `VideoModelV4Result`.
202#[derive(Debug, Clone, Serialize, Deserialize, TS)]
203#[ts(export)]
204pub struct VideoResult {
205    /// Generated videos as URLs, base64 strings, or binary data.
206    pub videos: Vec<VideoData>,
207
208    /// Warnings for the call, e.g. unsupported features.
209    pub warnings: Vec<Warning>,
210
211    /// Additional provider-specific metadata, keyed by provider name.
212    pub provider_metadata: Option<SharedProviderMetadata>,
213
214    /// Response information for telemetry and debugging.
215    pub response: VideoResponse,
216}
217
218/// Response information for a video generation call.
219#[derive(Debug, Clone, Default, Serialize, Deserialize, TS)]
220#[ts(export)]
221pub struct VideoResponse {
222    /// Timestamp for the start of the generated response (ISO 8601 string).
223    pub timestamp: Option<String>,
224    /// The ID of the model that was used to generate the response.
225    pub model_id: Option<String>,
226    /// Response headers.
227    pub headers: Option<SharedHeaders>,
228}
229
230/// Result of starting an asynchronous video generation via
231/// [`VideoModel::do_start`].
232///
233/// Aligned with V4 `VideoModelV4OperationStartResult`.
234#[derive(Debug, Clone)]
235pub struct VideoOperationStart {
236    /// JSON-serializable opaque reference passed to [`VideoModel::do_status`]
237    /// to check the status of the generation (e.g. a task ID or poll URL).
238    pub operation: serde_json::Value,
239
240    /// Warnings for the start call, e.g. unsupported features.
241    pub warnings: Vec<Warning>,
242
243    /// Additional provider-specific metadata from the start call.
244    pub provider_metadata: Option<SharedProviderMetadata>,
245
246    /// Response information for telemetry and debugging.
247    pub response: VideoResponse,
248}
249
250/// Status of an asynchronous video generation, from [`VideoModel::do_status`].
251///
252/// Aligned with V4 `VideoModelV4OperationStatusResult`; its `error` arm maps
253/// to `Err(AiMuxError)` (a terminally failed task should be non-retryable).
254#[derive(Debug, Clone)]
255pub enum VideoOperationStatus {
256    /// The generation is still in progress; poll again later.
257    Pending,
258    /// The generation is complete.
259    Completed(VideoResult),
260}
261
262/// Pacing for the Core-owned status poll loop.
263#[derive(Debug, Clone, Copy)]
264pub struct VideoPollConfig {
265    /// Delay between consecutive `do_status` calls.
266    pub interval: Duration,
267    /// Maximum total time to wait for the generation to complete.
268    pub timeout: Duration,
269}
270
271impl Default for VideoPollConfig {
272    fn default() -> Self {
273        // AI SDK generate-video defaults (intervalMs: 5000, timeoutMs: 600_000).
274        Self {
275            interval: Duration::from_secs(5),
276            timeout: Duration::from_secs(600),
277        }
278    }
279}
280
281/// The unified video generation model trait (provider-facing).
282///
283/// Aligned with V4 `VideoModelV4`'s asynchronous `doStart`/`doStatus` flow.
284/// Every current provider API is task-based (create, then poll), so the
285/// synchronous `doGenerate` arm is not offered: the polling loop lives in
286/// [`generate_video`], and each phase is retried independently against the
287/// same operation reference.
288#[async_trait]
289pub trait VideoModel: Send + Sync {
290    /// Specification version (always `"v4"`).
291    fn specification_version(&self) -> &'static str {
292        "v4"
293    }
294
295    /// Provider name, e.g. `"fal"`.
296    fn provider(&self) -> &str;
297
298    /// Provider-specific model ID, e.g. `"kling-video"`.
299    fn model_id(&self) -> &str;
300
301    fn retry_config(&self) -> retry::RetryConfig {
302        retry::RetryConfig::default()
303    }
304
305    /// Poll pacing for this model. Defaults to the AI SDK values; providers
306    /// with configurable polling should surface their configuration here.
307    fn poll_config(&self) -> VideoPollConfig {
308        VideoPollConfig::default()
309    }
310
311    /// Limit of how many videos can be generated in a single API call.
312    ///
313    /// `None` means no fixed limit. Most video models only support `1`.
314    fn max_videos_per_call(&self) -> Option<u32>;
315
316    /// Start an asynchronous video generation and return an opaque operation
317    /// reference for [`Self::do_status`].
318    ///
319    /// Naming: the `do_` prefix prevents accidental direct usage by users.
320    async fn do_start(&self, options: &VideoCallOptions)
321    -> Result<VideoOperationStart, AiMuxError>;
322
323    /// Check the status of a generation started with [`Self::do_start`].
324    ///
325    /// A task that failed server-side should be reported as a non-retryable
326    /// `Err`, not `Pending`, so the poll loop stops immediately.
327    async fn do_status(
328        &self,
329        operation: &serde_json::Value,
330        options: &VideoCallOptions,
331    ) -> Result<VideoOperationStatus, AiMuxError>;
332}
333
334/// User-facing video generation with Core-owned retry, polling, batching, and
335/// timeout.
336///
337/// Orchestration (AI SDK `generateVideo`, mirroring `generateImage`'s
338/// batching): `options.n` is split into `VideoModel::max_videos_per_call`
339/// sized batches, and every batch runs a full, independent `do_start`/poll
340/// cycle **concurrently** (the AI SDK batches images/videos via
341/// `Promise.all`, not sequentially) — each with its own idempotency key, so a
342/// retried batch never collides with another batch's replay. Within a batch,
343/// `do_start` is retried as one unit, then `do_status` is polled — each poll
344/// retried independently — against that batch's operation reference, so a
345/// transient poll failure never re-creates the billed task. Batch results are
346/// flattened back in batch order; provider metadata across batches is
347/// deep-merged the same way start/completion metadata is within a batch (see
348/// `merge_provider_metadata`).
349///
350/// # Errors
351///
352/// Returns `InvalidArgument` when `options.n == 0` (checked before any
353/// network call), or the first batch's provider failure, retry exhaustion,
354/// poll or operation timeout, or caller abort — Rust drops the other
355/// in-flight batches' futures on the first error, unlike `Promise.all`,
356/// which lets sibling settles run to completion; their partial network
357/// effects (e.g. an already-started but abandoned batch) are the same
358/// either way, since nothing here reconnects to or cancels a provider job.
359pub 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    // Split `n` into `max_per_call`-sized batches (AI SDK `generateVideo` /
401    // `generateImage`): every batch but the last is full-sized, the last one
402    // takes the remainder (or a full batch when `n` divides evenly).
403    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            // Distinct batches must not deduplicate each other. Deriving the
411            // suffix from the batch index also keeps caller-key replays stable.
412            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    // `n >= 1` was checked above, so at least one batch ran; the first
430    // batch's `response` represents the call for telemetry purposes.
431    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
442/// Run one `do_start` + poll cycle for a single provider-sized batch.
443///
444/// Each batch mints its own idempotency key (AI SDK batching semantics): a
445/// batch, not the whole `n`-video request, is the unit of idempotent replay.
446async 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    // `do_start` is billable: mint one idempotency key per logical start,
454    // OUTSIDE the retry closure, so providers that honor this header can
455    // deduplicate a replay where the first attempt succeeded but its response
456    // was lost. A caller-supplied key wins (AI SDK generate-video parity).
457    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        // Matching the AI SDK, the poll budget paces the loop between status
494        // checks; a hung status GET is already bounded by the per-exchange
495        // response guard in provider-utils, and the retry count is finite.
496        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                // Start-call warnings/metadata precede the completion's own.
503                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
514/// Merge two phases' provider metadata one level deep.
515///
516/// A plain `entry().or_insert()` at the provider-key level drops every field
517/// the other phase set once both phases report the *same* provider key — e.g.
518/// a start call's `job_id` disappearing once the completion call also reports
519/// metadata under `x_provider`. Objects under a shared key are unioned
520/// instead, with `b` (the later phase) winning a field collision. Anything
521/// that is not a pair of objects has nothing to union, so `b` replaces `a`.
522fn 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    /// `do_status` behavior per call index, cycled through in order.
549    enum StatusStep {
550        RetryableFailure,
551        Pending,
552        Complete,
553    }
554
555    struct ScriptedVideoModel {
556        starts: AtomicU32,
557        /// High-water mark of `do_start` calls in flight at once, so a test
558        /// can assert batch concurrency without a wall-clock threshold.
559        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            // Yield so concurrent batches actually overlap here rather than
627            // each running to completion on its first poll.
628            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            // The Core-minted idempotency key must reach the start request.
636            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    /// The core guarantee of the do_start/do_status split: a transient poll
704    /// failure retries the status check against the same operation and never
705    /// re-creates the billed task.
706    #[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        // `job_id` is start-only and must survive instead of being dropped by
803        // an `entry().or_insert()` collision on the shared provider key.
804        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        // Script that always reports Pending.
814        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        // `ScriptedVideoModel::max_videos_per_call()` is 1, so n=3 must run
898        // three independent start/poll cycles, concurrently, each with its
899        // own idempotency key (a batch, not the whole request, is the unit of
900        // idempotent replay).
901        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}