cost edits

This commit is contained in:
Brandon Thomas committed 2026-05-23 21:51:42 -04:00
1 parent 4ab13bb11a
commit a59561cddf
6 files changed
+640 -150

No files matched your search

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