From a59561cddf6697ca84bed3946bcc3dc2f8ca3906 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Sat, 23 May 2026 21:51:42 -0400 Subject: [PATCH] cost edits --- .../api/requests/videos/video_edit/cost.rs | 350 ++++++++++++++---- .../requests/videos/video_edit/video_edit.rs | 43 +++ .../requests/videos/video_extension/cost.rs | 293 ++++++++++++--- .../videos/video_extension/video_extension.rs | 43 +++ .../requests/videos/video_generation/cost.rs | 13 +- ...t_source_duration_cost_calculator_trait.rs | 48 ++- 6 files changed, 640 insertions(+), 150 deletions(-) diff --git a/crates/api_clients/grok_api_client/src/api/requests/videos/video_edit/cost.rs b/crates/api_clients/grok_api_client/src/api/requests/videos/video_edit/cost.rs index 742501efa7..a62c3a75dc 100644 --- a/crates/api_clients/grok_api_client/src/api/requests/videos/video_edit/cost.rs +++ b/crates/api_clients/grok_api_client/src/api/requests/videos/video_edit/cost.rs @@ -1,55 +1,83 @@ use crate::api::requests::videos::video_edit::video_edit::VideoEditRequest; use crate::api::requests::videos::video_generation::cost::{ output_mills_per_second, + DEFAULT_VIDEO_DURATION_SECONDS, INPUT_MILLS_PER_SECOND_OF_SOURCE_VIDEO, }; use crate::api::traits::grok_request_cost_calculator_trait::{GrokRequestCostCalculator, UsdMills}; use crate::api::traits::grok_request_source_duration_cost_calculator_trait::GrokRequestSourceDurationCostCalculator; use crate::api::types::video_types::video_resolution::VideoResolution; -// `video_edit` cost depends entirely on the source video's duration: +/// Fallback resolution used by the base [`GrokRequestCostCalculator`] impl +/// when the caller hasn't supplied the source's actual resolution. Picks +/// the more expensive tier (720p) so estimates don't undershoot. +const ASSUMED_SOURCE_RESOLUTION: VideoResolution = VideoResolution::SevenTwentyP; + +// `video_edit` cost depends entirely on the source video's properties: // -// Input: $0.01/sec × source_duration (10 mills/sec) -// Output: $0.07/sec × source_duration (70 mills/sec, since -// output mirrors the source resolution capped at 720p, and -// output duration mirrors source duration per xAI docs) +// Input: 10 mills/sec × source_duration (resolution-independent) +// Output: output_mills_per_second(res) × source_duration +// - 480p source → 50 mills/sec output +// - 720p source → 70 mills/sec output // -// Neither piece is knowable from the `VideoEditRequest` alone — the -// `source_video` field only points at a URL/file_id, not at metadata. The -// trait impl therefore returns 0 so callers don't get a misleading bound. +// xAI documents that video edits re-render the ENTIRE source video — output +// duration mirrors source duration, output resolution mirrors source +// resolution (capped at 720p, which is also our max-supported variant). // -// If you need a real estimate, fetch the source's metadata out-of-band -// (e.g. `ffprobe` for a local file, or HEAD + Range read for a remote MP4) -// and compute: +// Neither source duration nor source resolution is in the request body — the +// `source_video` field only points at a URL/file_id. // -// mills = source_duration_seconds * (INPUT_MILLS_PER_SECOND_OF_SOURCE_VIDEO -// + output_mills_per_second(VideoResolution::SevenTwentyP)) -// = source_duration_seconds * (10 + 70) -// = source_duration_seconds * 80 +// Two impls cover the cost-estimation gradient: +// +// - `GrokRequestCostCalculator::calculate_cost_in_mills` falls back to a +// conservative default (8s source @ 720p, matching xAI's +// /v1/videos/generations default) so callers without source metadata +// get a non-zero estimate. Returning 0 would silently misrepresent the +// cost as free. +// +// - `GrokRequestSourceDurationCostCalculator::calculate_cost_in_mills_with_source_duration` +// takes the actual source duration AND resolution from the caller +// (typically via ffprobe or an upstream generate call) and returns the +// exact billed amount. impl GrokRequestCostCalculator for VideoEditRequest { - /// **Always returns 0.** Cost cannot be computed from the request body - /// — see the module-level comment for the formula you'd apply if you - /// have the source video's duration available out-of-band. + /// Uses [`VideoEditRequest::source_video_duration_seconds`] when set; + /// otherwise falls back to a conservative default ([`DEFAULT_VIDEO_DURATION_SECONDS`] + /// = 8s, matching xAI's `/v1/videos/generations` default). /// - /// If you DO have the source duration, use + /// Source resolution is always assumed to be 720p (the upper-tier rate, + /// picked so estimates don't undershoot) since the request can't carry + /// it. If you need to override the resolution assumption, call /// [`GrokRequestSourceDurationCostCalculator::calculate_cost_in_mills_with_source_duration`] - /// instead. + /// directly. + /// + /// Formula: `secs × (10 input + 70 output@720p) = 80 × secs` mills. + /// + /// At the 8-second default: 8 × 80 = **640 mills (64¢)**. fn calculate_cost_in_mills(&self) -> UsdMills { - 0 + let secs = self.source_video_duration_seconds.unwrap_or(DEFAULT_VIDEO_DURATION_SECONDS); + self.calculate_cost_in_mills_with_source_duration(secs, ASSUMED_SOURCE_RESOLUTION) } } impl GrokRequestSourceDurationCostCalculator for VideoEditRequest { - /// Output duration mirrors the source, output resolution mirrors the - /// source capped at 720p. Both input and output are billed per second of - /// source duration: + /// Both input and output are billed per second of source duration; output + /// rate depends on source resolution (which equals output resolution for + /// video edits, capped at 720p). /// - /// total = source_duration_seconds × (INPUT + OUTPUT_720P) - /// = source_duration_seconds × (10 + 70) = 80 mills/sec - fn calculate_cost_in_mills_with_source_duration(&self, source_duration_seconds: u32) -> UsdMills { + /// total = source_duration_seconds × (10 mills/sec input + /// + output_mills_per_second(res)) + /// + /// Examples: + /// - 480p source: (10 + 50) × secs = 60 mills/sec + /// - 720p source: (10 + 70) × secs = 80 mills/sec + fn calculate_cost_in_mills_with_source_duration( + &self, + source_duration_seconds: u32, + source_resolution: VideoResolution, + ) -> UsdMills { let secs = source_duration_seconds as u64; - let per_second = INPUT_MILLS_PER_SECOND_OF_SOURCE_VIDEO + output_mills_per_second(VideoResolution::SevenTwentyP); + let per_second = INPUT_MILLS_PER_SECOND_OF_SOURCE_VIDEO + output_mills_per_second(source_resolution); per_second * secs } } @@ -64,41 +92,137 @@ mod tests { VideoEditRequest { prompt: "test edit".to_string(), source_video, + source_video_duration_seconds: None, model, user: None, } } + // Default base-trait estimate assumes 8s source @ 720p, so: + // 8 × (10 input + 70 output) = 640 mills = 64¢ + const EXPECTED_BASE_ESTIMATE_MILLS: u64 = 640; + const EXPECTED_BASE_ESTIMATE_CENTS: u64 = 64; + #[test] - fn always_returns_zero() { + fn base_estimate_is_conservative_eight_seconds_seven_twenty_p() { let cases = [ VideoSource::Url("https://example.com/v.mp4".to_string()), VideoSource::FileId("file_abc".to_string()), ]; for source in cases { let req = make_request(source, None); - assert_eq!(req.calculate_cost_in_mills(), 0); - assert_eq!(req.calculate_cost_in_cents(), 0); + assert_eq!(req.calculate_cost_in_mills(), EXPECTED_BASE_ESTIMATE_MILLS); + assert_eq!(req.calculate_cost_in_cents(), EXPECTED_BASE_ESTIMATE_CENTS); } } #[test] - fn zero_regardless_of_model() { - let req_default = make_request(VideoSource::Url("u".to_string()), None); - let req_known = make_request(VideoSource::Url("u".to_string()), Some(VideoModel::GrokImagineVideo)); - let req_custom = make_request(VideoSource::Url("u".to_string()), - Some(VideoModel::Custom("future".to_string()))); - assert_eq!(req_default.calculate_cost_in_mills(), 0); - assert_eq!(req_known.calculate_cost_in_mills(), 0); - assert_eq!(req_custom.calculate_cost_in_mills(), 0); + fn base_estimate_is_independent_of_model() { + let r_default = make_request(VideoSource::Url("u".to_string()), None).calculate_cost_in_mills(); + let r_known = make_request(VideoSource::Url("u".to_string()), Some(VideoModel::GrokImagineVideo)).calculate_cost_in_mills(); + let r_custom = make_request(VideoSource::Url("u".to_string()), + Some(VideoModel::Custom("future".to_string()))).calculate_cost_in_mills(); + assert_eq!(r_default, EXPECTED_BASE_ESTIMATE_MILLS); + assert_eq!(r_known, EXPECTED_BASE_ESTIMATE_MILLS); + assert_eq!(r_custom, EXPECTED_BASE_ESTIMATE_MILLS); } #[test] - fn zero_regardless_of_prompt_or_user() { + fn base_estimate_is_independent_of_prompt_or_user() { let mut req = make_request(VideoSource::Url("u".to_string()), None); req.prompt = "an enormously long prompt with many tokens".repeat(100); req.user = Some("user-id".to_string()); - assert_eq!(req.calculate_cost_in_mills(), 0); + assert_eq!(req.calculate_cost_in_mills(), EXPECTED_BASE_ESTIMATE_MILLS); + } + + #[test] + fn base_estimate_equals_source_duration_method_with_defaults() { + // The base impl must agree with the source-duration-aware impl when + // called with the documented default assumptions (8s, 720p). + let req = make_request(VideoSource::Url("u".to_string()), None); + assert_eq!( + req.calculate_cost_in_mills(), + req.calculate_cost_in_mills_with_source_duration(8, VideoResolution::SevenTwentyP), + ); + } + + #[test] + fn base_estimate_is_nonzero() { + // Sanity guard against accidentally regressing to the old return-0 footgun. + let req = make_request(VideoSource::Url("u".to_string()), None); + assert!(req.calculate_cost_in_mills() > 0); + assert!(req.calculate_cost_in_cents() > 0); + } + + // ── source_video_duration_seconds hint ── + + mod duration_hint { + use super::*; + + #[test] + fn hint_overrides_default_duration() { + let mut req = make_request(VideoSource::Url("u".to_string()), None); + req.source_video_duration_seconds = Some(5); + // 5 × 80 = 400 mills (vs the 640 mills default) + assert_eq!(req.calculate_cost_in_mills(), 400); + assert_eq!(req.calculate_cost_in_cents(), 40); + } + + #[test] + fn one_second_hint() { + let mut req = make_request(VideoSource::Url("u".to_string()), None); + req.source_video_duration_seconds = Some(1); + assert_eq!(req.calculate_cost_in_mills(), 80); // 1 × 80 + assert_eq!(req.calculate_cost_in_cents(), 8); + } + + #[test] + fn fifteen_second_hint() { + let mut req = make_request(VideoSource::Url("u".to_string()), None); + req.source_video_duration_seconds = Some(15); + assert_eq!(req.calculate_cost_in_mills(), 1200); // 15 × 80 + assert_eq!(req.calculate_cost_in_cents(), 120); + } + + #[test] + fn hint_matches_explicit_source_duration_method_at_720p() { + // The hint-driven base method should agree with the explicit + // source-duration method at the assumed (720p) resolution. + for secs in 0u32..=30 { + let mut req = make_request(VideoSource::Url("u".to_string()), None); + req.source_video_duration_seconds = Some(secs); + assert_eq!( + req.calculate_cost_in_mills(), + req.calculate_cost_in_mills_with_source_duration(secs, VideoResolution::SevenTwentyP), + "secs={secs}", + ); + } + } + + #[test] + fn unset_hint_falls_back_to_default() { + let req = make_request(VideoSource::Url("u".to_string()), None); + assert_eq!(req.source_video_duration_seconds, None); + // Default = 8s @ 720p = 640 mills. + assert_eq!(req.calculate_cost_in_mills(), 640); + } + + #[test] + fn zero_second_hint_costs_zero() { + // Edge case: 0-second source is meaningless but shouldn't panic. + let mut req = make_request(VideoSource::Url("u".to_string()), None); + req.source_video_duration_seconds = Some(0); + assert_eq!(req.calculate_cost_in_mills(), 0); + } + + #[test] + fn hint_scales_linearly() { + for secs in 1u32..=20 { + let mut req = make_request(VideoSource::Url("u".to_string()), None); + req.source_video_duration_seconds = Some(secs); + assert_eq!(req.calculate_cost_in_mills(), 80 * secs as u64, "secs={secs}"); + } + } } // ── Source-duration-aware variant ── @@ -106,52 +230,138 @@ mod tests { mod with_source_duration { use super::*; + // ── 720p source (output capped at 720p, so 70 + 10 = 80 mills/sec) ── + + mod source_720p { + use super::*; + const RES: VideoResolution = VideoResolution::SevenTwentyP; + + #[test] + fn five_second_source() { + // 80 × 5 = 400 mills = 40¢ + let req = make_request(VideoSource::Url("u".to_string()), None); + assert_eq!(req.calculate_cost_in_mills_with_source_duration(5, RES), 400); + assert_eq!(req.calculate_cost_in_cents_with_source_duration(5, RES), 40); + } + + #[test] + fn one_second_source() { + // 80 × 1 = 80 mills = 8¢ + let req = make_request(VideoSource::Url("u".to_string()), None); + assert_eq!(req.calculate_cost_in_mills_with_source_duration(1, RES), 80); + assert_eq!(req.calculate_cost_in_cents_with_source_duration(1, RES), 8); + } + + #[test] + fn ten_second_source_is_eighty_cents() { + // 80 × 10 = 800 mills = 80¢ + let req = make_request(VideoSource::Url("u".to_string()), None); + assert_eq!(req.calculate_cost_in_mills_with_source_duration(10, RES), 800); + assert_eq!(req.calculate_cost_in_cents_with_source_duration(10, RES), 80); + } + + #[test] + fn scales_linearly() { + let req = make_request(VideoSource::Url("u".to_string()), None); + for secs in 1u32..=60 { + assert_eq!(req.calculate_cost_in_mills_with_source_duration(secs, RES), 80 * secs as u64, "secs={secs}"); + } + } + } + + // ── 480p source (output at 480p, so 50 + 10 = 60 mills/sec) ── + + mod source_480p { + use super::*; + const RES: VideoResolution = VideoResolution::FourEightyP; + + #[test] + fn five_second_source() { + // 60 × 5 = 300 mills = 30¢ + let req = make_request(VideoSource::Url("u".to_string()), None); + assert_eq!(req.calculate_cost_in_mills_with_source_duration(5, RES), 300); + assert_eq!(req.calculate_cost_in_cents_with_source_duration(5, RES), 30); + } + + #[test] + fn one_second_source() { + // 60 × 1 = 60 mills = 6¢ + let req = make_request(VideoSource::Url("u".to_string()), None); + assert_eq!(req.calculate_cost_in_mills_with_source_duration(1, RES), 60); + assert_eq!(req.calculate_cost_in_cents_with_source_duration(1, RES), 6); + } + + #[test] + fn ten_second_source_is_sixty_cents() { + // 60 × 10 = 600 mills = 60¢ + let req = make_request(VideoSource::Url("u".to_string()), None); + assert_eq!(req.calculate_cost_in_mills_with_source_duration(10, RES), 600); + assert_eq!(req.calculate_cost_in_cents_with_source_duration(10, RES), 60); + } + + #[test] + fn scales_linearly() { + let req = make_request(VideoSource::Url("u".to_string()), None); + for secs in 1u32..=60 { + assert_eq!(req.calculate_cost_in_mills_with_source_duration(secs, RES), 60 * secs as u64, "secs={secs}"); + } + } + } + + // ── Cross-resolution comparisons ── + #[test] - fn five_second_source() { - // 80 × 5 = 400 mills = 40¢ + fn zero_second_source_costs_zero_regardless_of_resolution() { let req = make_request(VideoSource::Url("u".to_string()), None); - assert_eq!(req.calculate_cost_in_mills_with_source_duration(5), 400); - assert_eq!(req.calculate_cost_in_cents_with_source_duration(5), 40); + assert_eq!(req.calculate_cost_in_mills_with_source_duration(0, VideoResolution::FourEightyP), 0); + assert_eq!(req.calculate_cost_in_mills_with_source_duration(0, VideoResolution::SevenTwentyP), 0); } #[test] - fn one_second_source() { - // 80 × 1 = 80 mills = 8¢ + fn higher_resolution_costs_more() { let req = make_request(VideoSource::Url("u".to_string()), None); - assert_eq!(req.calculate_cost_in_mills_with_source_duration(1), 80); - assert_eq!(req.calculate_cost_in_cents_with_source_duration(1), 8); - } - - #[test] - fn zero_second_source_costs_zero() { - let req = make_request(VideoSource::Url("u".to_string()), None); - assert_eq!(req.calculate_cost_in_mills_with_source_duration(0), 0); - } - - #[test] - fn scales_linearly_with_source_duration() { - let req = make_request(VideoSource::Url("u".to_string()), None); - for secs in 1u32..=60 { - assert_eq!(req.calculate_cost_in_mills_with_source_duration(secs), 80 * secs as u64, "secs={secs}"); + for secs in 1u32..=15 { + let lo = req.calculate_cost_in_mills_with_source_duration(secs, VideoResolution::FourEightyP); + let hi = req.calculate_cost_in_mills_with_source_duration(secs, VideoResolution::SevenTwentyP); + assert!(lo < hi, "secs={secs}: 480p ({lo}) should be < 720p ({hi})"); } } #[test] - fn ten_second_source_is_one_dollar() { - // 80 × 10 = 800 mills = 80¢ + fn matrix_of_duration_and_resolution() { + // (duration, resolution, expected_mills) + let cases: &[(u32, VideoResolution, u64)] = &[ + ( 1, VideoResolution::FourEightyP, 60), + ( 5, VideoResolution::FourEightyP, 300), + (10, VideoResolution::FourEightyP, 600), + (15, VideoResolution::FourEightyP, 900), + (30, VideoResolution::FourEightyP, 1800), + ( 1, VideoResolution::SevenTwentyP, 80), + ( 5, VideoResolution::SevenTwentyP, 400), + (10, VideoResolution::SevenTwentyP, 800), + (15, VideoResolution::SevenTwentyP, 1200), + (30, VideoResolution::SevenTwentyP, 2400), + ]; let req = make_request(VideoSource::Url("u".to_string()), None); - assert_eq!(req.calculate_cost_in_mills_with_source_duration(10), 800); - assert_eq!(req.calculate_cost_in_cents_with_source_duration(10), 80); + for &(secs, res, expected) in cases { + assert_eq!( + req.calculate_cost_in_mills_with_source_duration(secs, res), + expected, + "secs={secs} res={res:?}", + ); + } } #[test] fn independent_of_source_kind() { let url_req = make_request(VideoSource::Url("u".to_string()), None); let file_req = make_request(VideoSource::FileId("f".to_string()), None); - assert_eq!( - url_req.calculate_cost_in_mills_with_source_duration(7), - file_req.calculate_cost_in_mills_with_source_duration(7), - ); + for res in [VideoResolution::FourEightyP, VideoResolution::SevenTwentyP] { + assert_eq!( + url_req.calculate_cost_in_mills_with_source_duration(7, res), + file_req.calculate_cost_in_mills_with_source_duration(7, res), + ); + } } } } diff --git a/crates/api_clients/grok_api_client/src/api/requests/videos/video_edit/video_edit.rs b/crates/api_clients/grok_api_client/src/api/requests/videos/video_edit/video_edit.rs index 54a2c17974..7630154867 100644 --- a/crates/api_clients/grok_api_client/src/api/requests/videos/video_edit/video_edit.rs +++ b/crates/api_clients/grok_api_client/src/api/requests/videos/video_edit/video_edit.rs @@ -31,6 +31,18 @@ pub struct VideoEditRequest { /// Source video to modify (xAI reads this as the input). pub source_video: VideoSource, + /// **Cost-estimation hint — NOT sent to xAI, has zero effect on the + /// actual HTTP request.** Duration in whole seconds of the video at + /// [`source_video`](Self::source_video). When set, the + /// [`crate::api::traits::grok_request_cost_calculator_trait::GrokRequestCostCalculator`] + /// impl uses it for an accurate cost estimate; when `None`, the impl + /// falls back to a conservative default (xAI's 8-second generation + /// default). Populate this field if you already know the source's + /// runtime (e.g. from ffprobe or an upstream generate call) so internal + /// cost tracking stays correct. + #[serde(skip_serializing_if = "Option::is_none")] + pub source_video_duration_seconds: Option, + /// Model identifier. Defaults to [`VideoModel::GrokImagineVideo`] when `None`. /// Use [`VideoModel::Custom`] for identifiers not yet listed in the enum. #[serde(skip_serializing_if = "Option::is_none")] @@ -170,6 +182,7 @@ mod tests { request: VideoEditRequest { prompt: "p".to_string(), source_video: VideoSource::FileId("file_abc".to_string()), + source_video_duration_seconds: None, model: None, user: None, }, @@ -177,6 +190,35 @@ mod tests { let json = serde_json::to_string(&args.request).unwrap(); assert!(!json.contains("secret_must_not_leak")); assert!(json.contains("\"source_video\":{\"FileId\":\"file_abc\"}")); + // Hint omitted when None. + assert!(!json.contains("source_video_duration_seconds")); + } + + #[test] + fn source_duration_hint_serializes_when_set() { + let req = VideoEditRequest { + prompt: "p".to_string(), + source_video: VideoSource::Url("u".to_string()), + source_video_duration_seconds: Some(12), + model: None, + user: None, + }; + let json = serde_json::to_string(&req).unwrap(); + assert!(json.contains("\"source_video_duration_seconds\":12")); + } + + #[test] + fn source_duration_hint_is_not_in_wire_body() { + // The internal wire body (what actually gets POSTed to xAI) doesn't + // carry the hint — it's intentionally absent from VideoEditRequestBody. + let body = VideoEditRequestBody { + prompt: "p".to_string(), + video: VideoSourceRef { url: Some("u".to_string()), file_id: None }, + model: None, + user: None, + }; + let json = serde_json::to_string(&body).unwrap(); + assert!(!json.contains("source_video_duration_seconds")); } #[test] @@ -202,6 +244,7 @@ mod tests { request: VideoEditRequest { prompt: "Change the lighting to midnight. Starry sky with the mily way overhead. Add some shooting stars. Everything is glowing under the starlight.".to_string(), source_video: VideoSource::Url(ANGRY_SHIBA_VIDEO_URL.to_string()), + source_video_duration_seconds: None, model: None, user: None, }, diff --git a/crates/api_clients/grok_api_client/src/api/requests/videos/video_extension/cost.rs b/crates/api_clients/grok_api_client/src/api/requests/videos/video_extension/cost.rs index 49ec5dc5f3..d7819773ba 100644 --- a/crates/api_clients/grok_api_client/src/api/requests/videos/video_extension/cost.rs +++ b/crates/api_clients/grok_api_client/src/api/requests/videos/video_extension/cost.rs @@ -27,28 +27,47 @@ use crate::api::types::video_types::video_resolution::VideoResolution; const DEFAULT_EXTENSION_DURATION_SECONDS: u32 = 6; impl GrokRequestCostCalculator for VideoExtensionRequest { - /// Estimates the **output** portion only. The input portion depends on the - /// source video's duration, which isn't in the request; callers should - /// add it separately if they need a true total. + /// Output portion is always known (extension duration × 70 mills/sec at + /// the assumed 720p output). /// - /// If you DO have the source duration, use + /// Input portion is included **only when** + /// [`VideoExtensionRequest::source_video_duration_seconds`] is set — + /// otherwise the source duration is unknown and we'd be guessing. + /// + /// Formula: + /// - hint unset: `extension_duration × 70` + /// - hint set: `extension_duration × 70 + source_duration × 10` + /// + /// If you have the source resolution too (480p vs 720p), call /// [`GrokRequestSourceDurationCostCalculator::calculate_cost_in_mills_with_source_duration`] - /// instead — that returns the true total. + /// directly for an exact figure at the actual rate. fn calculate_cost_in_mills(&self) -> UsdMills { let extension_duration = self.duration.unwrap_or(DEFAULT_EXTENSION_DURATION_SECONDS) as u64; - - // Output inherits source resolution capped at 720p. Use 720p — both the - // worst case (most expensive) and the most common output for source - // videos generated by /v1/videos/generations at 720p. - output_mills_per_second(VideoResolution::SevenTwentyP) * extension_duration + let output_mills = output_mills_per_second(VideoResolution::SevenTwentyP) * extension_duration; + let input_mills = self + .source_video_duration_seconds + .map(|secs| INPUT_MILLS_PER_SECOND_OF_SOURCE_VIDEO * secs as u64) + .unwrap_or(0); + output_mills + input_mills } } impl GrokRequestSourceDurationCostCalculator for VideoExtensionRequest { - /// Full cost = output (extension portion, known from request) + input - /// (source duration × 10 mills/sec). - fn calculate_cost_in_mills_with_source_duration(&self, source_duration_seconds: u32) -> UsdMills { - let output_mills = self.calculate_cost_in_mills(); + /// Full cost: + /// + /// output: output_mills_per_second(source_resolution) × extension_duration + /// input: 10 mills/sec × source_duration + /// + /// Output mirrors source resolution (capped at 720p). The `source_resolution` + /// parameter should be the source's actual resolution; since [`VideoResolution`] + /// has only 480p and 720p, "capping at 720p" is implicit. + fn calculate_cost_in_mills_with_source_duration( + &self, + source_duration_seconds: u32, + source_resolution: VideoResolution, + ) -> UsdMills { + let extension_duration = self.duration.unwrap_or(DEFAULT_EXTENSION_DURATION_SECONDS) as u64; + let output_mills = output_mills_per_second(source_resolution) * extension_duration; let input_mills = INPUT_MILLS_PER_SECOND_OF_SOURCE_VIDEO * source_duration_seconds as u64; output_mills + input_mills } @@ -70,6 +89,7 @@ mod tests { VideoExtensionRequest { prompt: "test".to_string(), source_video: source, + source_video_duration_seconds: None, model, duration, } @@ -167,67 +187,220 @@ mod tests { mod with_source_duration { use super::*; + // ── 720p source (output 70 mills/sec) ── + + mod source_720p { + use super::*; + const RES: VideoResolution = VideoResolution::SevenTwentyP; + + #[test] + fn five_extension_ten_source() { + // output: 70 × 5 = 350 mills; input: 10 × 10 = 100; total 450 + let req = make_request(Some(5), url_source(), None); + assert_eq!(req.calculate_cost_in_mills_with_source_duration(10, RES), 450); + assert_eq!(req.calculate_cost_in_cents_with_source_duration(10, RES), 45); + } + + #[test] + fn default_extension_five_source() { + // default ext=6s: output 70 × 6 = 420; input 10 × 5 = 50; total 470 + let req = make_request(None, url_source(), None); + assert_eq!(req.calculate_cost_in_mills_with_source_duration(5, RES), 470); + assert_eq!(req.calculate_cost_in_cents_with_source_duration(5, RES), 47); + } + + #[test] + fn ten_extension_zero_source() { + // output 70 × 10 = 700; input 0; total 700 + let req = make_request(Some(10), url_source(), None); + assert_eq!(req.calculate_cost_in_mills_with_source_duration(0, RES), 700); + } + + #[test] + fn source_duration_scales_linearly() { + let req = make_request(Some(5), url_source(), None); // output = 350 + for secs in 0u32..=30 { + let expected = 350 + 10 * secs as u64; + assert_eq!(req.calculate_cost_in_mills_with_source_duration(secs, RES), expected, "secs={secs}"); + } + } + + #[test] + fn extension_duration_scales_linearly() { + // Fix source at 10s (input portion = 100 mills). + for ext in 1u32..=10 { + let req = make_request(Some(ext), url_source(), None); + let expected = 70 * ext as u64 + 100; + assert_eq!(req.calculate_cost_in_mills_with_source_duration(10, RES), expected, "ext={ext}"); + } + } + } + + // ── 480p source (output 50 mills/sec) ── + + mod source_480p { + use super::*; + const RES: VideoResolution = VideoResolution::FourEightyP; + + #[test] + fn five_extension_ten_source() { + // output: 50 × 5 = 250 mills; input: 10 × 10 = 100; total 350 + let req = make_request(Some(5), url_source(), None); + assert_eq!(req.calculate_cost_in_mills_with_source_duration(10, RES), 350); + assert_eq!(req.calculate_cost_in_cents_with_source_duration(10, RES), 35); + } + + #[test] + fn default_extension_five_source() { + // default ext=6s: output 50 × 6 = 300; input 10 × 5 = 50; total 350 + let req = make_request(None, url_source(), None); + assert_eq!(req.calculate_cost_in_mills_with_source_duration(5, RES), 350); + assert_eq!(req.calculate_cost_in_cents_with_source_duration(5, RES), 35); + } + + #[test] + fn ten_extension_zero_source() { + // output 50 × 10 = 500; input 0; total 500 + let req = make_request(Some(10), url_source(), None); + assert_eq!(req.calculate_cost_in_mills_with_source_duration(0, RES), 500); + } + + #[test] + fn source_duration_scales_linearly() { + let req = make_request(Some(5), url_source(), None); // output = 250 + for secs in 0u32..=30 { + let expected = 250 + 10 * secs as u64; + assert_eq!(req.calculate_cost_in_mills_with_source_duration(secs, RES), expected, "secs={secs}"); + } + } + + #[test] + fn extension_duration_scales_linearly() { + // Fix source at 10s (input portion = 100 mills). + for ext in 1u32..=10 { + let req = make_request(Some(ext), url_source(), None); + let expected = 50 * ext as u64 + 100; + assert_eq!(req.calculate_cost_in_mills_with_source_duration(10, RES), expected, "ext={ext}"); + } + } + } + + // ── Cross-resolution comparisons ── + #[test] - fn five_extension_ten_source() { - // output: 70 × 5 = 350 mills; input: 10 × 10 = 100 mills; total: 450 + fn higher_resolution_costs_more_for_fixed_extension_and_source() { let req = make_request(Some(5), url_source(), None); - assert_eq!(req.calculate_cost_in_mills_with_source_duration(10), 450); - assert_eq!(req.calculate_cost_in_cents_with_source_duration(10), 45); - } - - #[test] - fn default_extension_5_source() { - // default ext=6s: output 70 × 6 = 420; input 10 × 5 = 50; total 470 - let req = make_request(None, url_source(), None); - assert_eq!(req.calculate_cost_in_mills_with_source_duration(5), 470); - assert_eq!(req.calculate_cost_in_cents_with_source_duration(5), 47); - } - - #[test] - fn ten_extension_zero_source() { - // output 70 × 10 = 700; input 10 × 0 = 0; total 700 - let req = make_request(Some(10), url_source(), None); - assert_eq!(req.calculate_cost_in_mills_with_source_duration(0), 700); - } - - #[test] - fn source_duration_scales_linearly() { - let req = make_request(Some(5), url_source(), None); // output = 350 - for secs in 0u32..=30 { - let expected = 350 + 10 * secs as u64; - assert_eq!(req.calculate_cost_in_mills_with_source_duration(secs), expected, "secs={secs}"); + for source_secs in 0u32..=15 { + let lo = req.calculate_cost_in_mills_with_source_duration(source_secs, VideoResolution::FourEightyP); + let hi = req.calculate_cost_in_mills_with_source_duration(source_secs, VideoResolution::SevenTwentyP); + assert!(lo < hi, + "source_secs={source_secs}: 480p ({lo}) should be < 720p ({hi})"); } } #[test] - fn extension_duration_scales_linearly() { - // Fix source at 10s (input portion = 100 mills). - for ext in 1u32..=10 { + fn matrix_of_extension_source_resolution() { + // (ext_duration, source_duration, source_resolution, expected_mills) + let cases: &[(u32, u32, VideoResolution, u64)] = &[ + // 480p + ( 1, 0, VideoResolution::FourEightyP, 50), // 50×1 + 0 + ( 1, 5, VideoResolution::FourEightyP, 100), // 50×1 + 50 + ( 5, 5, VideoResolution::FourEightyP, 300), // 50×5 + 50 + (10, 5, VideoResolution::FourEightyP, 550), // 50×10 + 50 + (10, 30, VideoResolution::FourEightyP, 800), // 50×10 + 300 + // 720p + ( 1, 0, VideoResolution::SevenTwentyP, 70), // 70×1 + 0 + ( 1, 5, VideoResolution::SevenTwentyP, 120), // 70×1 + 50 + ( 5, 5, VideoResolution::SevenTwentyP, 400), // 70×5 + 50 + (10, 5, VideoResolution::SevenTwentyP, 750), // 70×10 + 50 + (10, 30, VideoResolution::SevenTwentyP, 1000), // 70×10 + 300 + ]; + for &(ext, source, res, expected) in cases { let req = make_request(Some(ext), url_source(), None); - let expected = 70 * ext as u64 + 100; - assert_eq!(req.calculate_cost_in_mills_with_source_duration(10), expected, "ext={ext}"); + assert_eq!( + req.calculate_cost_in_mills_with_source_duration(source, res), + expected, + "ext={ext} source={source} res={res:?}", + ); } } #[test] - fn full_method_equals_output_only_plus_input() { - // The source-duration variant should always equal the base + input portion. - let req = make_request(Some(7), url_source(), None); - let base = req.calculate_cost_in_mills(); - for secs in 0u32..=20 { - let full = req.calculate_cost_in_mills_with_source_duration(secs); - assert_eq!(full, base + 10 * secs as u64, "secs={secs}"); + fn cents_uses_ceiling_division() { + // ext=1s @ 480p: output 50; input 10×1=10; total 60 mills = 6¢ + let req = make_request(Some(1), url_source(), None); + assert_eq!(req.calculate_cost_in_mills_with_source_duration(1, VideoResolution::FourEightyP), 60); + assert_eq!(req.calculate_cost_in_cents_with_source_duration(1, VideoResolution::FourEightyP), 6); + // ext=1s @ 720p: output 70; input 10; total 80 mills = 8¢ + assert_eq!(req.calculate_cost_in_mills_with_source_duration(1, VideoResolution::SevenTwentyP), 80); + assert_eq!(req.calculate_cost_in_cents_with_source_duration(1, VideoResolution::SevenTwentyP), 8); + } + } + + // ── source_video_duration_seconds hint ── + + mod duration_hint { + use super::*; + + #[test] + fn unset_hint_returns_output_only() { + // ext=5s @ 720p, no hint → just output portion: 70 × 5 = 350 + let req = make_request(Some(5), url_source(), None); + assert_eq!(req.source_video_duration_seconds, None); + assert_eq!(req.calculate_cost_in_mills(), 350); + } + + #[test] + fn set_hint_adds_input_portion() { + // ext=5s @ 720p, source=10s → output 350 + input 100 = 450 + let mut req = make_request(Some(5), url_source(), None); + req.source_video_duration_seconds = Some(10); + assert_eq!(req.calculate_cost_in_mills(), 450); + assert_eq!(req.calculate_cost_in_cents(), 45); + } + + #[test] + fn hint_zero_is_treated_as_zero_input() { + // ext=5s @ 720p, source=0 → output 350 + 0 = 350 + let mut req = make_request(Some(5), url_source(), None); + req.source_video_duration_seconds = Some(0); + assert_eq!(req.calculate_cost_in_mills(), 350); + } + + #[test] + fn default_extension_with_hint() { + // ext default=6s @ 720p (output 420), source=5s (input 50) → 470 + let mut req = make_request(None, url_source(), None); + req.source_video_duration_seconds = Some(5); + assert_eq!(req.calculate_cost_in_mills(), 470); + assert_eq!(req.calculate_cost_in_cents(), 47); + } + + #[test] + fn hint_at_720p_matches_explicit_source_duration_method() { + // The base impl assumes 720p output. The explicit source-duration + // method must agree at 720p for every hint value. + for ext in 1u32..=10 { + for source_secs in 0u32..=20 { + let mut req = make_request(Some(ext), url_source(), None); + req.source_video_duration_seconds = Some(source_secs); + assert_eq!( + req.calculate_cost_in_mills(), + req.calculate_cost_in_mills_with_source_duration(source_secs, VideoResolution::SevenTwentyP), + "ext={ext} source_secs={source_secs}", + ); + } } } #[test] - fn cents_is_ceil_of_mills_over_ten() { - // 75 mills (ext=1s, source=1/2s rounding edge case) - // Make sure ceiling division behaves right. - let req = make_request(Some(1), url_source(), None); // output = 70 mills - // input 10 × 1 = 10; total = 80 mills = 8¢ - assert_eq!(req.calculate_cost_in_mills_with_source_duration(1), 80); - assert_eq!(req.calculate_cost_in_cents_with_source_duration(1), 8); + fn hint_scales_linearly() { + // Fix ext=5s (output 350); vary hint from 0..30. + for source_secs in 0u32..=30 { + let mut req = make_request(Some(5), url_source(), None); + req.source_video_duration_seconds = Some(source_secs); + assert_eq!(req.calculate_cost_in_mills(), 350 + 10 * source_secs as u64, "source_secs={source_secs}"); + } } } } diff --git a/crates/api_clients/grok_api_client/src/api/requests/videos/video_extension/video_extension.rs b/crates/api_clients/grok_api_client/src/api/requests/videos/video_extension/video_extension.rs index 4e1cc050ac..534f6bead0 100644 --- a/crates/api_clients/grok_api_client/src/api/requests/videos/video_extension/video_extension.rs +++ b/crates/api_clients/grok_api_client/src/api/requests/videos/video_extension/video_extension.rs @@ -31,6 +31,18 @@ pub struct VideoExtensionRequest { /// Source video to extend. pub source_video: VideoExtensionSource, + /// **Cost-estimation hint — NOT sent to xAI, has zero effect on the + /// actual HTTP request.** Duration in whole seconds of the video at + /// [`source_video`](Self::source_video). When set, the + /// [`crate::api::traits::grok_request_cost_calculator_trait::GrokRequestCostCalculator`] + /// impl includes the input-side billing (10 mills/sec × source duration) + /// in the estimate; when `None`, the impl returns the output portion + /// only (extension duration × output rate). Populate this field if you + /// already know the source's runtime (e.g. from ffprobe or an upstream + /// generate call) so internal cost tracking reflects the true total. + #[serde(skip_serializing_if = "Option::is_none")] + pub source_video_duration_seconds: Option, + /// Model identifier. Defaults to [`VideoModel::GrokImagineVideo`] when `None`. /// Use [`VideoModel::Custom`] for identifiers not yet listed in the enum. #[serde(skip_serializing_if = "Option::is_none")] @@ -170,6 +182,7 @@ mod tests { request: VideoExtensionRequest { prompt: "p".to_string(), source_video: VideoExtensionSource::Url("u".to_string()), + source_video_duration_seconds: None, model: None, duration: Some(5), }, @@ -178,6 +191,35 @@ mod tests { assert!(!json.contains("secret_must_not_leak")); assert!(json.contains("\"duration\":5")); assert!(json.contains("\"source_video\":{\"Url\":\"u\"}")); + // Hint omitted when None. + assert!(!json.contains("source_video_duration_seconds")); + } + + #[test] + fn source_duration_hint_serializes_when_set() { + let req = VideoExtensionRequest { + prompt: "p".to_string(), + source_video: VideoExtensionSource::Url("u".to_string()), + source_video_duration_seconds: Some(7), + model: None, + duration: Some(5), + }; + let json = serde_json::to_string(&req).unwrap(); + assert!(json.contains("\"source_video_duration_seconds\":7")); + } + + #[test] + fn source_duration_hint_is_not_in_wire_body() { + // The internal wire body (what actually gets POSTed to xAI) doesn't + // carry the hint — it's intentionally absent from VideoExtensionRequestBody. + let body = VideoExtensionRequestBody { + prompt: "p".to_string(), + video: VideoExtensionSourceRef { url: Some("u".to_string()), file_id: None }, + model: None, + duration: Some(5), + }; + let json = serde_json::to_string(&body).unwrap(); + assert!(!json.contains("source_video_duration_seconds")); } #[test] @@ -203,6 +245,7 @@ mod tests { request: VideoExtensionRequest { prompt: "Continue the walk down the same street".to_string(), source_video: VideoExtensionSource::Url(ANGRY_SHIBA_VIDEO_URL.to_string()), + source_video_duration_seconds: None, model: None, duration: Some(5), }, diff --git a/crates/api_clients/grok_api_client/src/api/requests/videos/video_generation/cost.rs b/crates/api_clients/grok_api_client/src/api/requests/videos/video_generation/cost.rs index 3eb210de63..987fab6956 100644 --- a/crates/api_clients/grok_api_client/src/api/requests/videos/video_generation/cost.rs +++ b/crates/api_clients/grok_api_client/src/api/requests/videos/video_generation/cost.rs @@ -106,11 +106,12 @@ mod tests { use super::*; #[test] - fn fully_default_is_8s_480p() { - // 50 × 8 = 400 mills = 40¢ + fn fully_default_is_8s_720p() { + // Default duration 8s, default resolution 720p (per our defaults in cost.rs). + // 70 × 8 = 560 mills = 56¢ let req = make_request(None, None, None, None); - assert_eq!(req.calculate_cost_in_mills(), 400); - assert_eq!(req.calculate_cost_in_cents(), 40); + assert_eq!(req.calculate_cost_in_mills(), 560); + assert_eq!(req.calculate_cost_in_cents(), 56); } #[test] @@ -207,9 +208,9 @@ mod tests { } #[test] - fn default_resolution_is_480p() { + fn default_resolution_is_720p() { let r1 = make_request(Some(5), None, None, None); - let r2 = make_request(Some(5), Some(VideoResolution::FourEightyP), None, None); + let r2 = make_request(Some(5), Some(VideoResolution::SevenTwentyP), None, None); assert_eq!(r1.calculate_cost_in_mills(), r2.calculate_cost_in_mills()); } } diff --git a/crates/api_clients/grok_api_client/src/api/traits/grok_request_source_duration_cost_calculator_trait.rs b/crates/api_clients/grok_api_client/src/api/traits/grok_request_source_duration_cost_calculator_trait.rs index 41e59b40e2..7b36f2f181 100644 --- a/crates/api_clients/grok_api_client/src/api/traits/grok_request_source_duration_cost_calculator_trait.rs +++ b/crates/api_clients/grok_api_client/src/api/traits/grok_request_source_duration_cost_calculator_trait.rs @@ -1,31 +1,51 @@ -//! Source-duration-aware cost calculator for video endpoints that -//! reference an existing video (whose duration is NOT in the request body). +//! Source-video-aware cost calculator for video endpoints that reference +//! an existing video whose duration AND resolution are NOT in the request +//! body. //! //! Implemented by: //! - [`crate::api::requests::videos::video_edit::video_edit::VideoEditRequest`] //! - [`crate::api::requests::videos::video_extension::video_extension::VideoExtensionRequest`] //! -//! Callers who have the source video's duration (from ffprobe, an upstream +//! Callers who have the source video's properties (from ffprobe, an upstream //! generate request that produced it, or similar) can use this to get a -//! true total cost. Callers who don't can fall back to -//! [`crate::api::traits::grok_request_cost_calculator_trait::GrokRequestCostCalculator`] -//! — `video_edit` returns 0 there; `video_extension` returns the output -//! portion only. +//! true total cost. use crate::api::traits::grok_request_cost_calculator_trait::{UsdCents, UsdMills}; +use crate::api::types::video_types::video_resolution::VideoResolution; /// Companion to `GrokRequestCostCalculator` for requests whose true cost -/// depends on a source video duration that isn't part of the request body. +/// depends on a source video's duration and resolution — neither of which +/// is part of the request body. +/// +/// xAI bills: +/// - source video as INPUT at 10 mills/sec (rate is independent of resolution) +/// - rendered OUTPUT at `output_mills_per_second(resolution)`, where +/// `resolution` mirrors the source capped at 720p pub trait GrokRequestSourceDurationCostCalculator { /// Estimated total cost rounded up to the nearest whole cent. - fn calculate_cost_in_cents_with_source_duration(&self, source_duration_seconds: u32) -> UsdCents { - self.calculate_cost_in_mills_with_source_duration(source_duration_seconds).div_ceil(10) + fn calculate_cost_in_cents_with_source_duration( + &self, + source_duration_seconds: u32, + source_resolution: VideoResolution, + ) -> UsdCents { + self + .calculate_cost_in_mills_with_source_duration(source_duration_seconds, source_resolution) + .div_ceil(10) } /// Estimated total cost in mills. Implementations should override this. /// - /// `source_duration_seconds` is the runtime of the source video at the - /// `source_video` field's URL/file_id, in whole seconds. xAI bills source - /// video as input at 10 mills/sec regardless of resolution. - fn calculate_cost_in_mills_with_source_duration(&self, source_duration_seconds: u32) -> UsdMills; + /// `source_duration_seconds`: runtime of the source video in whole + /// seconds. xAI bills this at 10 mills/sec regardless of resolution. + /// + /// `source_resolution`: resolution of the source video. xAI's edit and + /// extension endpoints produce output at this resolution (capped at + /// 720p), so the caller should pass the *effective* output resolution — + /// i.e. `min(source_resolution, 720p)`. Since [`VideoResolution`] only + /// has `480p` and `720p` variants, "capping at 720p" is automatic. + fn calculate_cost_in_mills_with_source_duration( + &self, + source_duration_seconds: u32, + source_resolution: VideoResolution, + ) -> UsdMills; }