From 4ab13bb11a1bf47965f60733d07664bbe805c2ff Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Sat, 23 May 2026 21:25:46 -0400 Subject: [PATCH] cost calculators --- .../grok_api_client/src/api/mod.rs | 1 + .../api/requests/images/image_edit/cost.rs | 299 ++++++++++++++++ .../src/api/requests/images/image_edit/mod.rs | 1 + .../requests/images/image_generation/cost.rs | 249 +++++++++++++ .../requests/images/image_generation/mod.rs | 1 + .../api/requests/videos/video_edit/cost.rs | 157 +++++++++ .../src/api/requests/videos/video_edit/mod.rs | 1 + .../requests/videos/video_extension/cost.rs | 233 +++++++++++++ .../requests/videos/video_extension/mod.rs | 1 + .../requests/videos/video_generation/cost.rs | 328 ++++++++++++++++++ .../requests/videos/video_generation/mod.rs | 1 + .../grok_request_cost_calculator_trait.rs | 36 ++ ...t_source_duration_cost_calculator_trait.rs | 31 ++ .../grok_api_client/src/api/traits/mod.rs | 2 + 14 files changed, 1341 insertions(+) create mode 100644 crates/api_clients/grok_api_client/src/api/requests/images/image_edit/cost.rs create mode 100644 crates/api_clients/grok_api_client/src/api/requests/images/image_generation/cost.rs create mode 100644 crates/api_clients/grok_api_client/src/api/requests/videos/video_edit/cost.rs create mode 100644 crates/api_clients/grok_api_client/src/api/requests/videos/video_extension/cost.rs create mode 100644 crates/api_clients/grok_api_client/src/api/requests/videos/video_generation/cost.rs create mode 100644 crates/api_clients/grok_api_client/src/api/traits/grok_request_cost_calculator_trait.rs create mode 100644 crates/api_clients/grok_api_client/src/api/traits/grok_request_source_duration_cost_calculator_trait.rs create mode 100644 crates/api_clients/grok_api_client/src/api/traits/mod.rs diff --git a/crates/api_clients/grok_api_client/src/api/mod.rs b/crates/api_clients/grok_api_client/src/api/mod.rs index e5bccbbe5a..1b24f16c4d 100644 --- a/crates/api_clients/grok_api_client/src/api/mod.rs +++ b/crates/api_clients/grok_api_client/src/api/mod.rs @@ -1,2 +1,3 @@ pub mod requests; +pub mod traits; pub mod types; diff --git a/crates/api_clients/grok_api_client/src/api/requests/images/image_edit/cost.rs b/crates/api_clients/grok_api_client/src/api/requests/images/image_edit/cost.rs new file mode 100644 index 0000000000..44198ff2f6 --- /dev/null +++ b/crates/api_clients/grok_api_client/src/api/requests/images/image_edit/cost.rs @@ -0,0 +1,299 @@ +use crate::api::requests::images::image_edit::image_edit::ImageEditRequest; +use crate::api::requests::images::image_generation::cost::output_mills_per_image; +use crate::api::traits::grok_request_cost_calculator_trait::{GrokRequestCostCalculator, UsdMills}; +use crate::api::types::image_types::image_model::ImageModel; +use crate::api::types::image_types::image_resolution::ImageResolution; + +// `image_edit` bills BOTH input (per source image fed in) AND output (per +// generated image). Per : +// +// grok-imagine-image input $0.002/img → 2 mills/img +// grok-imagine-image-quality input $0.010/img → 10 mills/img +// +// Output rates match `image_generation` (shared helper `output_mills_per_image`). + +impl GrokRequestCostCalculator for ImageEditRequest { + fn calculate_cost_in_mills(&self) -> UsdMills { + let model = self.model.clone().unwrap_or(ImageModel::GrokImagineImageQuality); + let resolution = self.resolution.unwrap_or(ImageResolution::OneK); + + let input_mills_per_img = input_mills_per_image(&model); + let output_mills_per_img = output_mills_per_image(&model, resolution); + + let n = self.number_images.unwrap_or(1) as u64; + let source_count = self.source_images.len() as u64; + + (input_mills_per_img * source_count) + (output_mills_per_img * n) + } +} + +/// Input rate in mills per source image, by model. +fn input_mills_per_image(model: &ImageModel) -> UsdMills { + match model { + ImageModel::GrokImagineImage => 2, + ImageModel::GrokImagineImageQuality => 10, + // Custom: conservatively assume the more expensive `-quality` tier. + ImageModel::Custom(_) => 10, + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::api::requests::images::image_edit::image_edit::ImageSource; + use crate::api::types::image_types::image_aspect_ratio::ImageAspectRatio; + + fn make_request( + model: Option, + resolution: Option, + number_images: Option, + source_count: usize, + ) -> ImageEditRequest { + ImageEditRequest { + prompt: "test".to_string(), + source_images: (0..source_count) + .map(|i| ImageSource::Url(format!("https://example.com/{i}.png"))) + .collect(), + model, + number_images, + aspect_ratio: None, + resolution, + response_format: None, + user: None, + } + } + + // ── Per-rate components ── + + mod per_rate_components { + use super::*; + + #[test] + fn standard_input_is_2_mills_per_source() { + assert_eq!(input_mills_per_image(&ImageModel::GrokImagineImage), 2); + } + + #[test] + fn quality_input_is_10_mills_per_source() { + assert_eq!(input_mills_per_image(&ImageModel::GrokImagineImageQuality), 10); + } + + #[test] + fn custom_input_defaults_to_quality_rate() { + assert_eq!(input_mills_per_image(&ImageModel::Custom("anything".to_string())), 10); + } + } + + // ── Single source + single output, every (model, resolution) pair ── + + mod single_source_single_output { + use super::*; + + #[test] + fn standard_1k() { + // input 2 + output 20 = 22 mills, 3¢ (ceil) + let req = make_request(Some(ImageModel::GrokImagineImage), Some(ImageResolution::OneK), Some(1), 1); + assert_eq!(req.calculate_cost_in_mills(), 22); + assert_eq!(req.calculate_cost_in_cents(), 3); + } + + #[test] + fn standard_2k() { + // input 2 + output 20 = 22 mills, 3¢ + let req = make_request(Some(ImageModel::GrokImagineImage), Some(ImageResolution::TwoK), Some(1), 1); + assert_eq!(req.calculate_cost_in_mills(), 22); + assert_eq!(req.calculate_cost_in_cents(), 3); + } + + #[test] + fn quality_1k() { + // input 10 + output 50 = 60 mills, 6¢ + let req = make_request(Some(ImageModel::GrokImagineImageQuality), Some(ImageResolution::OneK), Some(1), 1); + assert_eq!(req.calculate_cost_in_mills(), 60); + assert_eq!(req.calculate_cost_in_cents(), 6); + } + + #[test] + fn quality_2k() { + // input 10 + output 70 = 80 mills, 8¢ + let req = make_request(Some(ImageModel::GrokImagineImageQuality), Some(ImageResolution::TwoK), Some(1), 1); + assert_eq!(req.calculate_cost_in_mills(), 80); + assert_eq!(req.calculate_cost_in_cents(), 8); + } + } + + // ── Multi-source edit (up to 3 per xAI's docs) ── + + mod multi_source { + use super::*; + + #[test] + fn quality_1k_three_sources_one_output() { + // input 10 × 3 = 30, output 50, total 80 mills, 8¢ + let req = make_request(Some(ImageModel::GrokImagineImageQuality), Some(ImageResolution::OneK), Some(1), 3); + assert_eq!(req.calculate_cost_in_mills(), 80); + assert_eq!(req.calculate_cost_in_cents(), 8); + } + + #[test] + fn standard_1k_three_sources_two_outputs() { + // input 2 × 3 = 6, output 20 × 2 = 40, total 46 mills, 5¢ (ceil) + let req = make_request(Some(ImageModel::GrokImagineImage), Some(ImageResolution::OneK), Some(2), 3); + assert_eq!(req.calculate_cost_in_mills(), 46); + assert_eq!(req.calculate_cost_in_cents(), 5); + } + } + + // ── Batch output scaling (n > 1) ── + + mod batch_scaling { + use super::*; + + #[test] + fn batch_scales_only_output_not_input() { + let single = make_request(Some(ImageModel::GrokImagineImageQuality), Some(ImageResolution::TwoK), Some(1), 1) + .calculate_cost_in_mills(); // 10 + 70 = 80 + let quad = make_request(Some(ImageModel::GrokImagineImageQuality), Some(ImageResolution::TwoK), Some(4), 1) + .calculate_cost_in_mills(); // 10 + 70 × 4 = 290 + assert_eq!(single, 80); + assert_eq!(quad, 290); + // Input doesn't scale with n. + assert_ne!(quad, single * 4); + } + + #[test] + fn quality_1k_scaling_1_through_8() { + for n in 1u32..=8 { + let req = make_request(Some(ImageModel::GrokImagineImageQuality), Some(ImageResolution::OneK), Some(n), 1); + assert_eq!(req.calculate_cost_in_mills(), 10 + 50 * n as u64, "n={n}"); + } + } + } + + // ── Defaults ── + + mod defaults { + use super::*; + + #[test] + fn fully_default_with_one_source() { + // model=quality, res=1k, n=1, sources=1 → 10 + 50 = 60 mills + let req = make_request(None, None, None, 1); + assert_eq!(req.calculate_cost_in_mills(), 60); + assert_eq!(req.calculate_cost_in_cents(), 6); + } + } + + // ── Independence from non-pricing fields ── + + mod independence { + use super::*; + + #[test] + fn cost_is_independent_of_aspect_ratio() { + let mut base = make_request(Some(ImageModel::GrokImagineImageQuality), Some(ImageResolution::OneK), Some(1), 1); + let base_cost = base.calculate_cost_in_mills(); + for ar in [ + ImageAspectRatio::Square, + ImageAspectRatio::Landscape16x9, + ImageAspectRatio::Portrait9x16, + ImageAspectRatio::Auto, + ] { + base.aspect_ratio = Some(ar); + assert_eq!(base.calculate_cost_in_mills(), base_cost, "{ar:?}"); + } + } + + #[test] + fn cost_is_independent_of_source_url_content() { + let mut req = make_request(Some(ImageModel::GrokImagineImageQuality), Some(ImageResolution::OneK), Some(1), 0); + req.source_images = vec![ + ImageSource::Url("https://example.com/a.png".to_string()), + ImageSource::FileId("file_abc".to_string()), + ]; + // 10 × 2 + 50 = 70 mills + assert_eq!(req.calculate_cost_in_mills(), 70); + } + } + + // ── Exhaustive matrix ── + + mod exhaustive_matrix { + use super::*; + + // (model, resolution, n, sources, expected_mills) + const CASES: &[(ImageModelKind, ImageResolution, u32, usize, u64)] = &[ + // grok-imagine-image (standard) — input 2/img, output 20/img regardless of resolution + (ImageModelKind::Standard, ImageResolution::OneK, 1, 1, 22), + (ImageModelKind::Standard, ImageResolution::OneK, 1, 2, 24), + (ImageModelKind::Standard, ImageResolution::OneK, 1, 3, 26), + (ImageModelKind::Standard, ImageResolution::OneK, 2, 1, 42), + (ImageModelKind::Standard, ImageResolution::OneK, 4, 1, 82), + (ImageModelKind::Standard, ImageResolution::TwoK, 1, 1, 22), + (ImageModelKind::Standard, ImageResolution::TwoK, 2, 2, 44), + (ImageModelKind::Standard, ImageResolution::TwoK, 3, 3, 66), + // grok-imagine-image-quality — input 10/img, output 50/img (1k) or 70/img (2k) + (ImageModelKind::Quality, ImageResolution::OneK, 1, 1, 60), + (ImageModelKind::Quality, ImageResolution::OneK, 1, 2, 70), + (ImageModelKind::Quality, ImageResolution::OneK, 1, 3, 80), + (ImageModelKind::Quality, ImageResolution::OneK, 2, 1, 110), + (ImageModelKind::Quality, ImageResolution::OneK, 4, 1, 210), + (ImageModelKind::Quality, ImageResolution::TwoK, 1, 1, 80), + (ImageModelKind::Quality, ImageResolution::TwoK, 2, 2, 160), + (ImageModelKind::Quality, ImageResolution::TwoK, 3, 3, 240), + (ImageModelKind::Quality, ImageResolution::TwoK, 4, 3, 310), + ]; + + #[derive(Copy, Clone, Debug)] + enum ImageModelKind { Standard, Quality } + + fn to_model(kind: ImageModelKind) -> ImageModel { + match kind { + ImageModelKind::Standard => ImageModel::GrokImagineImage, + ImageModelKind::Quality => ImageModel::GrokImagineImageQuality, + } + } + + #[test] + fn all_matrix_cases() { + for &(kind, res, n, sources, expected) in CASES { + let req = make_request(Some(to_model(kind)), Some(res), Some(n), sources); + assert_eq!(req.calculate_cost_in_mills(), expected, + "model={:?} res={:?} n={} sources={}", kind, res, n, sources); + } + } + } + + // ── Monotonicity ── + + mod monotonicity { + use super::*; + + #[test] + fn more_sources_cost_more() { + let one = make_request(Some(ImageModel::GrokImagineImageQuality), Some(ImageResolution::OneK), Some(1), 1) + .calculate_cost_in_mills(); + let three = make_request(Some(ImageModel::GrokImagineImageQuality), Some(ImageResolution::OneK), Some(1), 3) + .calculate_cost_in_mills(); + assert!(one < three); + } + + #[test] + fn more_outputs_cost_more() { + let one = make_request(Some(ImageModel::GrokImagineImageQuality), Some(ImageResolution::OneK), Some(1), 1) + .calculate_cost_in_mills(); + let four = make_request(Some(ImageModel::GrokImagineImageQuality), Some(ImageResolution::OneK), Some(4), 1) + .calculate_cost_in_mills(); + assert!(one < four); + } + + #[test] + fn quality_costs_more_than_standard() { + for res in [ImageResolution::OneK, ImageResolution::TwoK] { + let std = make_request(Some(ImageModel::GrokImagineImage), Some(res), Some(2), 2).calculate_cost_in_mills(); + let qua = make_request(Some(ImageModel::GrokImagineImageQuality), Some(res), Some(2), 2).calculate_cost_in_mills(); + assert!(std < qua, "standard ({std}) should be < quality ({qua}) at {res:?}"); + } + } + } +} diff --git a/crates/api_clients/grok_api_client/src/api/requests/images/image_edit/mod.rs b/crates/api_clients/grok_api_client/src/api/requests/images/image_edit/mod.rs index f8db9d920f..b01b824a27 100644 --- a/crates/api_clients/grok_api_client/src/api/requests/images/image_edit/mod.rs +++ b/crates/api_clients/grok_api_client/src/api/requests/images/image_edit/mod.rs @@ -1,2 +1,3 @@ +pub mod cost; pub mod image_edit; pub(crate) mod request_types; diff --git a/crates/api_clients/grok_api_client/src/api/requests/images/image_generation/cost.rs b/crates/api_clients/grok_api_client/src/api/requests/images/image_generation/cost.rs new file mode 100644 index 0000000000..f83f624155 --- /dev/null +++ b/crates/api_clients/grok_api_client/src/api/requests/images/image_generation/cost.rs @@ -0,0 +1,249 @@ +use crate::api::requests::images::image_generation::image_generation::ImageGenerationRequest; +use crate::api::traits::grok_request_cost_calculator_trait::{GrokRequestCostCalculator, UsdMills}; +use crate::api::types::image_types::image_model::ImageModel; +use crate::api::types::image_types::image_resolution::ImageResolution; + +// xAI published rates as of these docs: +// +// grok-imagine-image 1K $0.02/img, 2K $0.02/img (input $0.002/img) +// grok-imagine-image-quality 1K $0.05/img, 2K $0.07/img (input $0.010/img) +// +// In mills (1¢ = 10 mills): +// +// grok-imagine-image: 1K 20 mills/img, 2K 20 mills/img +// grok-imagine-image-quality: 1K 50 mills/img, 2K 70 mills/img +// +// `image_generation` has no INPUT cost (it's pure text → image) — only output. + +impl GrokRequestCostCalculator for ImageGenerationRequest { + fn calculate_cost_in_mills(&self) -> UsdMills { + let model = self.model.clone().unwrap_or(ImageModel::GrokImagineImageQuality); + let resolution = self.resolution.unwrap_or(ImageResolution::OneK); + + let mills_per_image = output_mills_per_image(&model, resolution); + + // Server default for `n` is 1. + let n = self.number_images.unwrap_or(1) as u64; + + mills_per_image * n + } +} + +/// Output rate in mills per generated image. Used by both +/// `image_generation` and `image_edit` (same output side). +pub(crate) fn output_mills_per_image(model: &ImageModel, resolution: ImageResolution) -> UsdMills { + match (model, resolution) { + (ImageModel::GrokImagineImage, ImageResolution::OneK) => 20, + (ImageModel::GrokImagineImage, ImageResolution::TwoK) => 20, + (ImageModel::GrokImagineImageQuality, ImageResolution::OneK) => 50, + (ImageModel::GrokImagineImageQuality, ImageResolution::TwoK) => 70, + // Custom: conservatively assume the more expensive `-quality` tier. + (ImageModel::Custom(_), ImageResolution::OneK) => 50, + (ImageModel::Custom(_), ImageResolution::TwoK) => 70, + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::api::types::image_types::image_aspect_ratio::ImageAspectRatio; + use crate::api::types::image_types::image_response_format::ImageResponseFormat; + + fn make_request( + model: Option, + resolution: Option, + number_images: Option, + ) -> ImageGenerationRequest { + ImageGenerationRequest { + prompt: "test".to_string(), + model, + number_images, + aspect_ratio: None, + resolution, + response_format: None, + user: None, + } + } + + // ── Per-image rate at each (model, resolution) combo ── + + mod per_image_rates { + use super::*; + + #[test] + fn standard_1k_is_20_mills() { + let req = make_request(Some(ImageModel::GrokImagineImage), Some(ImageResolution::OneK), Some(1)); + assert_eq!(req.calculate_cost_in_mills(), 20); + // 20 mills = 2¢ (ceiling) + assert_eq!(req.calculate_cost_in_cents(), 2); + } + + #[test] + fn standard_2k_is_20_mills() { + let req = make_request(Some(ImageModel::GrokImagineImage), Some(ImageResolution::TwoK), Some(1)); + assert_eq!(req.calculate_cost_in_mills(), 20); + assert_eq!(req.calculate_cost_in_cents(), 2); + } + + #[test] + fn quality_1k_is_50_mills() { + let req = make_request(Some(ImageModel::GrokImagineImageQuality), Some(ImageResolution::OneK), Some(1)); + assert_eq!(req.calculate_cost_in_mills(), 50); + assert_eq!(req.calculate_cost_in_cents(), 5); + } + + #[test] + fn quality_2k_is_70_mills() { + let req = make_request(Some(ImageModel::GrokImagineImageQuality), Some(ImageResolution::TwoK), Some(1)); + assert_eq!(req.calculate_cost_in_mills(), 70); + assert_eq!(req.calculate_cost_in_cents(), 7); + } + } + + // ── Defaults ── + + mod defaults { + use super::*; + + #[test] + fn default_model_is_quality() { + let r1 = make_request(None, Some(ImageResolution::OneK), Some(1)); + let r2 = make_request(Some(ImageModel::GrokImagineImageQuality), Some(ImageResolution::OneK), Some(1)); + assert_eq!(r1.calculate_cost_in_mills(), r2.calculate_cost_in_mills()); + } + + #[test] + fn default_resolution_is_one_k() { + let r1 = make_request(Some(ImageModel::GrokImagineImageQuality), None, Some(1)); + let r2 = make_request(Some(ImageModel::GrokImagineImageQuality), Some(ImageResolution::OneK), Some(1)); + assert_eq!(r1.calculate_cost_in_mills(), r2.calculate_cost_in_mills()); + } + + #[test] + fn default_n_is_one() { + let r1 = make_request(Some(ImageModel::GrokImagineImageQuality), Some(ImageResolution::OneK), None); + let r2 = make_request(Some(ImageModel::GrokImagineImageQuality), Some(ImageResolution::OneK), Some(1)); + assert_eq!(r1.calculate_cost_in_mills(), r2.calculate_cost_in_mills()); + } + + #[test] + fn fully_default_request_costs_50_mills() { + // All defaults = grok-imagine-image-quality, 1k, n=1 → 50 mills + let req = make_request(None, None, None); + assert_eq!(req.calculate_cost_in_mills(), 50); + assert_eq!(req.calculate_cost_in_cents(), 5); + } + } + + // ── Batch scaling ── + + mod batch_scaling { + use super::*; + + #[test] + fn n_scales_linearly_standard_1k() { + for n in 1u32..=10 { + let req = make_request(Some(ImageModel::GrokImagineImage), Some(ImageResolution::OneK), Some(n)); + assert_eq!(req.calculate_cost_in_mills(), 20 * n as u64, "n={n}"); + } + } + + #[test] + fn n_scales_linearly_quality_2k() { + for n in 1u32..=10 { + let req = make_request(Some(ImageModel::GrokImagineImageQuality), Some(ImageResolution::TwoK), Some(n)); + assert_eq!(req.calculate_cost_in_mills(), 70 * n as u64, "n={n}"); + } + } + + #[test] + fn batch_of_four_quality_2k() { + let req = make_request(Some(ImageModel::GrokImagineImageQuality), Some(ImageResolution::TwoK), Some(4)); + // 70 mills × 4 = 280 mills = 28¢ + assert_eq!(req.calculate_cost_in_mills(), 280); + assert_eq!(req.calculate_cost_in_cents(), 28); + } + } + + // ── Custom model (forward-compat) ── + + mod custom_model { + use super::*; + + #[test] + fn custom_model_uses_quality_rate() { + let custom = ImageModel::Custom("grok-imagine-image-future".to_string()); + let r_custom_1k = make_request(Some(custom.clone()), Some(ImageResolution::OneK), Some(1)); + let r_quality_1k = make_request(Some(ImageModel::GrokImagineImageQuality), Some(ImageResolution::OneK), Some(1)); + assert_eq!(r_custom_1k.calculate_cost_in_mills(), r_quality_1k.calculate_cost_in_mills()); + + let r_custom_2k = make_request(Some(custom), Some(ImageResolution::TwoK), Some(1)); + let r_quality_2k = make_request(Some(ImageModel::GrokImagineImageQuality), Some(ImageResolution::TwoK), Some(1)); + assert_eq!(r_custom_2k.calculate_cost_in_mills(), r_quality_2k.calculate_cost_in_mills()); + } + } + + // ── Cost doesn't depend on irrelevant fields ── + + mod independence { + use super::*; + + #[test] + fn cost_is_independent_of_aspect_ratio() { + let mut base = make_request(Some(ImageModel::GrokImagineImageQuality), Some(ImageResolution::OneK), Some(1)); + let base_cost = base.calculate_cost_in_mills(); + for ar in [ + ImageAspectRatio::Square, + ImageAspectRatio::Landscape16x9, + ImageAspectRatio::Portrait9x16, + ImageAspectRatio::Landscape20x9, + ImageAspectRatio::Auto, + ] { + base.aspect_ratio = Some(ar); + assert_eq!(base.calculate_cost_in_mills(), base_cost, "{ar:?}"); + } + } + + #[test] + fn cost_is_independent_of_response_format() { + let mut base = make_request(Some(ImageModel::GrokImagineImageQuality), Some(ImageResolution::OneK), Some(1)); + let base_cost = base.calculate_cost_in_mills(); + base.response_format = Some(ImageResponseFormat::Url); + assert_eq!(base.calculate_cost_in_mills(), base_cost); + base.response_format = Some(ImageResponseFormat::B64Json); + assert_eq!(base.calculate_cost_in_mills(), base_cost); + } + + #[test] + fn cost_is_independent_of_user_field() { + let mut base = make_request(Some(ImageModel::GrokImagineImageQuality), Some(ImageResolution::OneK), Some(1)); + let base_cost = base.calculate_cost_in_mills(); + base.user = Some("any-user-id".to_string()); + assert_eq!(base.calculate_cost_in_mills(), base_cost); + } + } + + // ── Monotonicity sanity checks ── + + mod monotonicity { + use super::*; + + #[test] + fn quality_is_at_least_as_expensive_as_standard() { + for res in [ImageResolution::OneK, ImageResolution::TwoK] { + let std = make_request(Some(ImageModel::GrokImagineImage), Some(res), Some(1)).calculate_cost_in_mills(); + let q = make_request(Some(ImageModel::GrokImagineImageQuality), Some(res), Some(1)).calculate_cost_in_mills(); + assert!(std <= q, "standard ({std}) should be <= quality ({q}) at {res:?}"); + } + } + + #[test] + fn two_k_is_at_least_as_expensive_as_one_k() { + for model in [ImageModel::GrokImagineImage, ImageModel::GrokImagineImageQuality] { + let lo = make_request(Some(model.clone()), Some(ImageResolution::OneK), Some(1)).calculate_cost_in_mills(); + let hi = make_request(Some(model.clone()), Some(ImageResolution::TwoK), Some(1)).calculate_cost_in_mills(); + assert!(lo <= hi, "1k ({lo}) should be <= 2k ({hi}) for {:?}", model.as_str()); + } + } + } +} diff --git a/crates/api_clients/grok_api_client/src/api/requests/images/image_generation/mod.rs b/crates/api_clients/grok_api_client/src/api/requests/images/image_generation/mod.rs index 7b81356be5..01ec9a1cf0 100644 --- a/crates/api_clients/grok_api_client/src/api/requests/images/image_generation/mod.rs +++ b/crates/api_clients/grok_api_client/src/api/requests/images/image_generation/mod.rs @@ -1,2 +1,3 @@ +pub mod cost; pub mod image_generation; pub(crate) mod request_types; 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 new file mode 100644 index 0000000000..742501efa7 --- /dev/null +++ b/crates/api_clients/grok_api_client/src/api/requests/videos/video_edit/cost.rs @@ -0,0 +1,157 @@ +use crate::api::requests::videos::video_edit::video_edit::VideoEditRequest; +use crate::api::requests::videos::video_generation::cost::{ + output_mills_per_second, + 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: +// +// 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) +// +// 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. +// +// 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: +// +// 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 + +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. + /// + /// If you DO have the source duration, use + /// [`GrokRequestSourceDurationCostCalculator::calculate_cost_in_mills_with_source_duration`] + /// instead. + fn calculate_cost_in_mills(&self) -> UsdMills { + 0 + } +} + +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: + /// + /// 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 { + let secs = source_duration_seconds as u64; + let per_second = INPUT_MILLS_PER_SECOND_OF_SOURCE_VIDEO + output_mills_per_second(VideoResolution::SevenTwentyP); + per_second * secs + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::api::requests::videos::video_edit::video_edit::{VideoEditRequest, VideoSource}; + use crate::api::types::video_types::video_model::VideoModel; + + fn make_request(source_video: VideoSource, model: Option) -> VideoEditRequest { + VideoEditRequest { + prompt: "test edit".to_string(), + source_video, + model, + user: None, + } + } + + #[test] + fn always_returns_zero() { + 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); + } + } + + #[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); + } + + #[test] + fn zero_regardless_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); + } + + // ── Source-duration-aware variant ── + + mod with_source_duration { + use super::*; + + #[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), 400); + assert_eq!(req.calculate_cost_in_cents_with_source_duration(5), 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), 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}"); + } + } + + #[test] + fn ten_second_source_is_one_dollar() { + // 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), 800); + assert_eq!(req.calculate_cost_in_cents_with_source_duration(10), 80); + } + + #[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), + ); + } + } +} diff --git a/crates/api_clients/grok_api_client/src/api/requests/videos/video_edit/mod.rs b/crates/api_clients/grok_api_client/src/api/requests/videos/video_edit/mod.rs index c7b840296b..0aa9ef93f5 100644 --- a/crates/api_clients/grok_api_client/src/api/requests/videos/video_edit/mod.rs +++ b/crates/api_clients/grok_api_client/src/api/requests/videos/video_edit/mod.rs @@ -1,2 +1,3 @@ +pub mod cost; pub mod video_edit; pub(crate) mod request_types; 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 new file mode 100644 index 0000000000..49ec5dc5f3 --- /dev/null +++ b/crates/api_clients/grok_api_client/src/api/requests/videos/video_extension/cost.rs @@ -0,0 +1,233 @@ +use crate::api::requests::videos::video_extension::video_extension::VideoExtensionRequest; +use crate::api::requests::videos::video_generation::cost::{ + output_mills_per_second, + 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_extension` charges: +// +// Output: $0.07/sec × extension_duration (70 mills/sec, since output +// inherits the source's resolution capped at 720p) +// Input: $0.01/sec × source_duration (10 mills/sec, UNKNOWN to us) +// +// `extension_duration` is the `duration` field on the request (xAI default +// is 6 seconds; range 1–10). `source_duration` is NOT in the request. +// +// Our `calculate_cost_in_mills` therefore returns ONLY the output portion. +// Callers who know the source duration should add: +// +// source_duration_seconds * INPUT_MILLS_PER_SECOND_OF_SOURCE_VIDEO +// +// to get the true total. + +/// xAI default duration for video_extension when `duration` is omitted. +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. + /// + /// If you DO have the source duration, use + /// [`GrokRequestSourceDurationCostCalculator::calculate_cost_in_mills_with_source_duration`] + /// instead — that returns the true total. + 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 + } +} + +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(); + let input_mills = INPUT_MILLS_PER_SECOND_OF_SOURCE_VIDEO * source_duration_seconds as u64; + output_mills + input_mills + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::api::requests::videos::video_extension::video_extension::{ + VideoExtensionRequest, VideoExtensionSource, + }; + use crate::api::types::video_types::video_model::VideoModel; + + fn make_request( + duration: Option, + source: VideoExtensionSource, + model: Option, + ) -> VideoExtensionRequest { + VideoExtensionRequest { + prompt: "test".to_string(), + source_video: source, + model, + duration, + } + } + + fn url_source() -> VideoExtensionSource { + VideoExtensionSource::Url("https://example.com/v.mp4".to_string()) + } + + // ── Output portion (the part we CAN compute) ── + + mod output_portion { + use super::*; + + #[test] + fn default_duration_is_6_seconds() { + // 70 × 6 = 420 mills = 42¢ + let req = make_request(None, url_source(), None); + assert_eq!(req.calculate_cost_in_mills(), 420); + assert_eq!(req.calculate_cost_in_cents(), 42); + } + + #[test] + fn one_second_extension() { + // 70 × 1 = 70 mills = 7¢ + let req = make_request(Some(1), url_source(), None); + assert_eq!(req.calculate_cost_in_mills(), 70); + assert_eq!(req.calculate_cost_in_cents(), 7); + } + + #[test] + fn five_second_extension() { + // 70 × 5 = 350 mills = 35¢ + let req = make_request(Some(5), url_source(), None); + assert_eq!(req.calculate_cost_in_mills(), 350); + assert_eq!(req.calculate_cost_in_cents(), 35); + } + + #[test] + fn ten_second_extension_is_max() { + // 70 × 10 = 700 mills = 70¢ + let req = make_request(Some(10), url_source(), None); + assert_eq!(req.calculate_cost_in_mills(), 700); + assert_eq!(req.calculate_cost_in_cents(), 70); + } + } + + // ── Scaling ── + + mod scaling { + use super::*; + + #[test] + fn duration_scales_linearly() { + for d in 1u32..=10 { + let req = make_request(Some(d), url_source(), None); + assert_eq!(req.calculate_cost_in_mills(), 70 * d as u64, "d={d}"); + } + } + } + + // ── Independence from non-pricing fields ── + + mod independence { + use super::*; + + #[test] + fn cost_is_independent_of_source_kind() { + let url = make_request(Some(5), VideoExtensionSource::Url("u".to_string()), None); + let file = make_request(Some(5), VideoExtensionSource::FileId("f".to_string()), None); + assert_eq!(url.calculate_cost_in_mills(), file.calculate_cost_in_mills()); + } + + #[test] + fn cost_is_independent_of_model_variant() { + let mut base = make_request(Some(5), url_source(), None); + let base_cost = base.calculate_cost_in_mills(); + base.model = Some(VideoModel::GrokImagineVideo); + assert_eq!(base.calculate_cost_in_mills(), base_cost); + base.model = Some(VideoModel::Custom("future".to_string())); + assert_eq!(base.calculate_cost_in_mills(), base_cost); + } + + #[test] + fn cost_is_independent_of_prompt_length() { + let mut base = make_request(Some(5), url_source(), None); + let base_cost = base.calculate_cost_in_mills(); + base.prompt = "a much longer prompt with many more words to influence pricing... or not".to_string(); + assert_eq!(base.calculate_cost_in_mills(), base_cost); + } + } + + // ── Source-duration-aware variant ── + + mod with_source_duration { + use super::*; + + #[test] + fn five_extension_ten_source() { + // output: 70 × 5 = 350 mills; input: 10 × 10 = 100 mills; total: 450 + 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}"); + } + } + + #[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), expected, "ext={ext}"); + } + } + + #[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}"); + } + } + + #[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); + } + } +} diff --git a/crates/api_clients/grok_api_client/src/api/requests/videos/video_extension/mod.rs b/crates/api_clients/grok_api_client/src/api/requests/videos/video_extension/mod.rs index 1953d77b65..3769e45251 100644 --- a/crates/api_clients/grok_api_client/src/api/requests/videos/video_extension/mod.rs +++ b/crates/api_clients/grok_api_client/src/api/requests/videos/video_extension/mod.rs @@ -1,2 +1,3 @@ +pub mod cost; pub mod video_extension; pub(crate) mod request_types; 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 new file mode 100644 index 0000000000..3eb210de63 --- /dev/null +++ b/crates/api_clients/grok_api_client/src/api/requests/videos/video_generation/cost.rs @@ -0,0 +1,328 @@ +use crate::api::requests::videos::video_generation::video_generation::VideoGenerationRequest; +use crate::api::traits::grok_request_cost_calculator_trait::{GrokRequestCostCalculator, UsdMills}; +use crate::api::types::video_types::video_resolution::VideoResolution; + +// xAI pricing per : +// +// grok-imagine-video +// Output: 480p $0.05/sec, 720p $0.07/sec +// Input: $0.01/sec (source video) + $0.002/img (source images) +// +// In mills (1¢ = 10 mills): +// +// Output: 480p 50 mills/sec, 720p 70 mills/sec +// Input: 10 mills/sec (video), 2 mills/img (per source image) +// +// video_generation has NO input video — only optional input images. xAI +// defaults: `duration` = 8 seconds, `resolution` = "480p". + +/// xAI default duration for video_generation when `duration` is omitted. +pub(crate) const DEFAULT_VIDEO_DURATION_SECONDS: u32 = 8; + +/// Per-source-image input rate for video endpoints, in mills. +pub(crate) const INPUT_MILLS_PER_IMAGE: UsdMills = 2; + +/// Per-source-second input rate (when a source video is supplied) for video +/// endpoints, in mills. Not used by `video_generation` (which has no source +/// video) but referenced by `video_edit` and `video_extension`. +pub(crate) const INPUT_MILLS_PER_SECOND_OF_SOURCE_VIDEO: UsdMills = 10; + +impl GrokRequestCostCalculator for VideoGenerationRequest { + fn calculate_cost_in_mills(&self) -> UsdMills { + let duration = self.duration.unwrap_or(DEFAULT_VIDEO_DURATION_SECONDS) as u64; + let resolution = self.resolution.unwrap_or(VideoResolution::SevenTwentyP); + + let output_mills = output_mills_per_second(resolution) * duration; + + // Source image count = `image` (image-to-video) + len(`reference_images`) + // (reference-to-video). These are mutually exclusive at call time but the + // cost math doesn't need to enforce that — it just counts. + let source_image_count: u64 = + (self.image.is_some() as u64) + + (self.reference_images.as_ref().map(|v| v.len() as u64).unwrap_or(0)); + + let input_mills = INPUT_MILLS_PER_IMAGE * source_image_count; + + output_mills + input_mills + } +} + +/// Output rate in mills per second of generated video, by resolution. +pub(crate) fn output_mills_per_second(resolution: VideoResolution) -> UsdMills { + match resolution { + VideoResolution::FourEightyP => 50, + VideoResolution::SevenTwentyP => 70, + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::api::requests::videos::video_generation::video_generation::VideoImageSource; + use crate::api::types::video_types::video_aspect_ratio::VideoAspectRatio; + use crate::api::types::video_types::video_model::VideoModel; + + fn make_request( + duration: Option, + resolution: Option, + image: Option, + reference_images: Option>, + ) -> VideoGenerationRequest { + VideoGenerationRequest { + prompt: "test".to_string(), + model: None, + image, + reference_images, + aspect_ratio: None, + duration, + resolution, + user: None, + } + } + + fn url_source() -> VideoImageSource { + VideoImageSource::Url("https://example.com/img.png".to_string()) + } + + // ── Output rate sanity ── + + mod output_rate { + use super::*; + + #[test] + fn rate_480p_is_50_mills_per_sec() { + assert_eq!(output_mills_per_second(VideoResolution::FourEightyP), 50); + } + + #[test] + fn rate_720p_is_70_mills_per_sec() { + assert_eq!(output_mills_per_second(VideoResolution::SevenTwentyP), 70); + } + } + + // ── Pure text-to-video (no input images) ── + + mod text_to_video { + use super::*; + + #[test] + fn fully_default_is_8s_480p() { + // 50 × 8 = 400 mills = 40¢ + let req = make_request(None, None, None, None); + assert_eq!(req.calculate_cost_in_mills(), 400); + assert_eq!(req.calculate_cost_in_cents(), 40); + } + + #[test] + fn five_seconds_480p() { + // 50 × 5 = 250 mills = 25¢ + let req = make_request(Some(5), Some(VideoResolution::FourEightyP), None, None); + assert_eq!(req.calculate_cost_in_mills(), 250); + assert_eq!(req.calculate_cost_in_cents(), 25); + } + + #[test] + fn five_seconds_720p() { + // 70 × 5 = 350 mills = 35¢ + let req = make_request(Some(5), Some(VideoResolution::SevenTwentyP), None, None); + assert_eq!(req.calculate_cost_in_mills(), 350); + assert_eq!(req.calculate_cost_in_cents(), 35); + } + + #[test] + fn fifteen_seconds_720p_is_max_duration() { + // 70 × 15 = 1050 mills = $1.05 + let req = make_request(Some(15), Some(VideoResolution::SevenTwentyP), None, None); + assert_eq!(req.calculate_cost_in_mills(), 1050); + assert_eq!(req.calculate_cost_in_cents(), 105); + } + } + + // ── Image-to-video (single source image) ── + + mod image_to_video { + use super::*; + + #[test] + fn five_seconds_480p_one_source_image() { + // 50 × 5 + 2 × 1 = 252 mills = 26¢ (ceil) + let req = make_request(Some(5), Some(VideoResolution::FourEightyP), Some(url_source()), None); + assert_eq!(req.calculate_cost_in_mills(), 252); + assert_eq!(req.calculate_cost_in_cents(), 26); + } + + #[test] + fn five_seconds_720p_one_source_image() { + // 70 × 5 + 2 = 352 mills = 36¢ (ceil) + let req = make_request(Some(5), Some(VideoResolution::SevenTwentyP), Some(url_source()), None); + assert_eq!(req.calculate_cost_in_mills(), 352); + assert_eq!(req.calculate_cost_in_cents(), 36); + } + } + + // ── Reference-to-video (multiple source images) ── + + mod reference_to_video { + use super::*; + + #[test] + fn five_seconds_720p_two_reference_images() { + // 70 × 5 + 2 × 2 = 354 mills = 36¢ (ceil) + let req = make_request(Some(5), Some(VideoResolution::SevenTwentyP), None, Some(vec![url_source(), url_source()])); + assert_eq!(req.calculate_cost_in_mills(), 354); + assert_eq!(req.calculate_cost_in_cents(), 36); + } + + #[test] + fn five_seconds_720p_three_reference_images() { + // 70 × 5 + 2 × 3 = 356 mills + let req = make_request( + Some(5), Some(VideoResolution::SevenTwentyP), + None, Some(vec![url_source(), url_source(), url_source()]), + ); + assert_eq!(req.calculate_cost_in_mills(), 356); + } + + #[test] + fn input_image_count_scales_linearly() { + let base = make_request(Some(5), Some(VideoResolution::SevenTwentyP), None, None).calculate_cost_in_mills(); + for n in 1usize..=5 { + let imgs: Vec<_> = (0..n).map(|_| url_source()).collect(); + let req = make_request(Some(5), Some(VideoResolution::SevenTwentyP), None, Some(imgs)); + assert_eq!(req.calculate_cost_in_mills(), base + INPUT_MILLS_PER_IMAGE * n as u64, "n={n}"); + } + } + } + + // ── Defaults ── + + mod defaults { + use super::*; + + #[test] + fn default_duration_is_8_seconds() { + let r1 = make_request(None, Some(VideoResolution::FourEightyP), None, None); + let r2 = make_request(Some(8), Some(VideoResolution::FourEightyP), None, None); + assert_eq!(r1.calculate_cost_in_mills(), r2.calculate_cost_in_mills()); + } + + #[test] + fn default_resolution_is_480p() { + let r1 = make_request(Some(5), None, None, None); + let r2 = make_request(Some(5), Some(VideoResolution::FourEightyP), None, None); + assert_eq!(r1.calculate_cost_in_mills(), r2.calculate_cost_in_mills()); + } + } + + // ── Independence from non-pricing fields ── + + mod independence { + use super::*; + + #[test] + fn cost_is_independent_of_aspect_ratio() { + let mut base = make_request(Some(5), Some(VideoResolution::FourEightyP), None, None); + let base_cost = base.calculate_cost_in_mills(); + for ar in [ + VideoAspectRatio::Square, + VideoAspectRatio::Landscape16x9, + VideoAspectRatio::Portrait9x16, + VideoAspectRatio::Portrait2x3, + ] { + base.aspect_ratio = Some(ar); + assert_eq!(base.calculate_cost_in_mills(), base_cost, "{ar:?}"); + } + } + + #[test] + fn cost_is_independent_of_model_variant() { + // All `model` enum variants map to the same pricing (only one video model). + let mut base = make_request(Some(5), Some(VideoResolution::FourEightyP), None, None); + let base_cost = base.calculate_cost_in_mills(); + for m in [ + VideoModel::GrokImagineVideo, + VideoModel::Custom("grok-imagine-video-future".to_string()), + ] { + base.model = Some(m.clone()); + assert_eq!(base.calculate_cost_in_mills(), base_cost, "{:?}", m.as_str()); + } + } + } + + // ── Exhaustive matrix ── + + mod exhaustive_matrix { + use super::*; + + // (duration_s, resolution, image_to_video_source, reference_image_count, expected_mills) + const CASES: &[(u32, VideoResolution, bool, usize, u64)] = &[ + // 480p, no input images + ( 1, VideoResolution::FourEightyP, false, 0, 50), + ( 5, VideoResolution::FourEightyP, false, 0, 250), + ( 8, VideoResolution::FourEightyP, false, 0, 400), + (15, VideoResolution::FourEightyP, false, 0, 750), + // 720p, no input images + ( 1, VideoResolution::SevenTwentyP, false, 0, 70), + ( 5, VideoResolution::SevenTwentyP, false, 0, 350), + ( 8, VideoResolution::SevenTwentyP, false, 0, 560), + (15, VideoResolution::SevenTwentyP, false, 0, 1050), + // 480p with image-to-video source (1 input image) + ( 5, VideoResolution::FourEightyP, true, 0, 252), + (10, VideoResolution::FourEightyP, true, 0, 502), + // 720p with image-to-video source (1 input image) + ( 5, VideoResolution::SevenTwentyP, true, 0, 352), + (10, VideoResolution::SevenTwentyP, true, 0, 702), + // 480p with reference_images (no image-to-video) + ( 5, VideoResolution::FourEightyP, false, 1, 252), + ( 5, VideoResolution::FourEightyP, false, 2, 254), + ( 5, VideoResolution::FourEightyP, false, 3, 256), + // 720p with reference_images + ( 5, VideoResolution::SevenTwentyP, false, 1, 352), + ( 5, VideoResolution::SevenTwentyP, false, 2, 354), + ( 5, VideoResolution::SevenTwentyP, false, 3, 356), + ]; + + #[test] + fn all_matrix_cases() { + for &(duration, res, has_image, ref_count, expected) in CASES { + let image = if has_image { Some(url_source()) } else { None }; + let refs = if ref_count > 0 { + Some((0..ref_count).map(|_| url_source()).collect()) + } else { None }; + let req = make_request(Some(duration), Some(res), image, refs); + assert_eq!( + req.calculate_cost_in_mills(), expected, + "duration={duration} res={res:?} has_image={has_image} ref_count={ref_count}", + ); + } + } + } + + // ── Monotonicity ── + + mod monotonicity { + use super::*; + + #[test] + fn longer_duration_costs_more() { + let short = make_request(Some(1), Some(VideoResolution::FourEightyP), None, None).calculate_cost_in_mills(); + let long = make_request(Some(15), Some(VideoResolution::FourEightyP), None, None).calculate_cost_in_mills(); + assert!(short < long); + } + + #[test] + fn higher_resolution_costs_more() { + let p480 = make_request(Some(8), Some(VideoResolution::FourEightyP), None, None).calculate_cost_in_mills(); + let p720 = make_request(Some(8), Some(VideoResolution::SevenTwentyP), None, None).calculate_cost_in_mills(); + assert!(p480 < p720); + } + + #[test] + fn more_reference_images_cost_more() { + let zero = make_request(Some(5), Some(VideoResolution::SevenTwentyP), None, None).calculate_cost_in_mills(); + let three = make_request(Some(5), Some(VideoResolution::SevenTwentyP), None, + Some(vec![url_source(), url_source(), url_source()])).calculate_cost_in_mills(); + assert!(zero < three); + } + } +} diff --git a/crates/api_clients/grok_api_client/src/api/requests/videos/video_generation/mod.rs b/crates/api_clients/grok_api_client/src/api/requests/videos/video_generation/mod.rs index f58fdea9f1..9509b24208 100644 --- a/crates/api_clients/grok_api_client/src/api/requests/videos/video_generation/mod.rs +++ b/crates/api_clients/grok_api_client/src/api/requests/videos/video_generation/mod.rs @@ -1,2 +1,3 @@ +pub mod cost; pub mod video_generation; pub(crate) mod request_types; diff --git a/crates/api_clients/grok_api_client/src/api/traits/grok_request_cost_calculator_trait.rs b/crates/api_clients/grok_api_client/src/api/traits/grok_request_cost_calculator_trait.rs new file mode 100644 index 0000000000..a5f2ee32aa --- /dev/null +++ b/crates/api_clients/grok_api_client/src/api/traits/grok_request_cost_calculator_trait.rs @@ -0,0 +1,36 @@ +//! Cost-estimation trait for xAI (Grok) API requests. +//! +//! Implementations live next to each Request type in +//! `api/requests///cost.rs`. Implemented on the public +//! `*Request` type (NOT the `*Args` wrapper), since the request body is what +//! drives the bill. +//! +//! All math is performed in mills (tenths of a US cent), which is the +//! natural unit for xAI's published rates — an image edit at the cheap tier +//! is $0.002/img = 2 mills, well below cent resolution. + +/// Cost in US cents (1¢ = 1 unit). +pub type UsdCents = u64; + +/// Cost in mills (1¢ = 10 mills). Use this when sub-cent precision matters +/// — many xAI rates are below 1 cent. +pub type UsdMills = u64; + +/// Implemented on `*Request` types to estimate the cost of a Grok API call +/// without actually making it. +/// +/// The estimate is derived from fields present in the request body. Some +/// endpoints (notably `video_edit` and the input-cost portion of +/// `video_extension`) depend on the source video's duration, which is NOT +/// part of the request. Those impls document their limitations. +pub trait GrokRequestCostCalculator { + /// Estimated cost rounded up to the nearest whole cent. + fn calculate_cost_in_cents(&self) -> UsdCents { + // Default impl: derive from mills via ceiling division. + self.calculate_cost_in_mills().div_ceil(10) + } + + /// Estimated cost in mills (tenths of a cent). This is the canonical + /// method — implementations should override this one. + fn calculate_cost_in_mills(&self) -> UsdMills; +} 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 new file mode 100644 index 0000000000..41e59b40e2 --- /dev/null +++ b/crates/api_clients/grok_api_client/src/api/traits/grok_request_source_duration_cost_calculator_trait.rs @@ -0,0 +1,31 @@ +//! Source-duration-aware cost calculator for video endpoints that +//! reference an existing video (whose duration is 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 +//! 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. + +use crate::api::traits::grok_request_cost_calculator_trait::{UsdCents, UsdMills}; + +/// Companion to `GrokRequestCostCalculator` for requests whose true cost +/// depends on a source video duration that isn't part of the request body. +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) + } + + /// 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; +} diff --git a/crates/api_clients/grok_api_client/src/api/traits/mod.rs b/crates/api_clients/grok_api_client/src/api/traits/mod.rs new file mode 100644 index 0000000000..a3b554f319 --- /dev/null +++ b/crates/api_clients/grok_api_client/src/api/traits/mod.rs @@ -0,0 +1,2 @@ +pub mod grok_request_cost_calculator_trait; +pub mod grok_request_source_duration_cost_calculator_trait;