mirror of
https://github.com/storytold/artcraft.git
synced 2026-11-07 10:31:46 +00:00
cost calculators
This commit is contained in:
1 parent
bdfa8bc729
commit
4ab13bb11a
14 files changed
+1341
No files matched your search
@@ -1,2 +1,3 @@
|
||||
pub mod requests;
|
||||
pub mod traits;
|
||||
pub mod types;
|
||||
@@ -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 <https://docs.x.ai/developers/pricing>:
|
||||
//
|
||||
// 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<ImageModel>,
|
||||
resolution: Option<ImageResolution>,
|
||||
number_images: Option<u32>,
|
||||
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:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,2 +1,3 @@
|
||||
pub mod cost;
|
||||
pub mod image_edit;
|
||||
pub(crate) mod request_types;
|
||||
@@ -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: <https://docs.x.ai/developers/pricing>
|
||||
//
|
||||
// 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<ImageModel>,
|
||||
resolution: Option<ImageResolution>,
|
||||
number_images: Option<u32>,
|
||||
) -> 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());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,2 +1,3 @@
|
||||
pub mod cost;
|
||||
pub mod image_generation;
|
||||
pub(crate) mod request_types;
|
||||
@@ -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<VideoModel>) -> 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),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,2 +1,3 @@
|
||||
pub mod cost;
|
||||
pub mod video_edit;
|
||||
pub(crate) mod request_types;
|
||||
@@ -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<u32>,
|
||||
source: VideoExtensionSource,
|
||||
model: Option<VideoModel>,
|
||||
) -> 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,2 +1,3 @@
|
||||
pub mod cost;
|
||||
pub mod video_extension;
|
||||
pub(crate) mod request_types;
|
||||
@@ -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 <https://docs.x.ai/developers/pricing>:
|
||||
//
|
||||
// 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<u32>,
|
||||
resolution: Option<VideoResolution>,
|
||||
image: Option<VideoImageSource>,
|
||||
reference_images: Option<Vec<VideoImageSource>>,
|
||||
) -> 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,2 +1,3 @@
|
||||
pub mod cost;
|
||||
pub mod video_generation;
|
||||
pub(crate) mod request_types;
|
||||
+36
@@ -0,0 +1,36 @@
|
||||
//! Cost-estimation trait for xAI (Grok) API requests.
|
||||
//!
|
||||
//! Implementations live next to each Request type in
|
||||
//! `api/requests/<category>/<endpoint>/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;
|
||||
}
|
||||
+31
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
pub mod grok_request_cost_calculator_trait;
|
||||
pub mod grok_request_source_duration_cost_calculator_trait;
|
||||
Reference in new issue
Block a user