grok imagine video 1.5 preview (#1590)

This commit is contained in:
Brandon Thomas authored and GitHub committed 2026-05-31 12:21:54 -04:00
1 parent 5d4f307320
commit 0d7168c506
23 files changed
+2247 -19

No files matched your search

@@ -11,6 +11,9 @@ pub enum RouterVideoModel {
#[serde(rename = "grok_imagine_video")]
GrokImagineVideo,
#[serde(rename = "grok_imagine_video_1p5")]
GrokImagineVideo1p5,
#[serde(rename = "kling_1p6_pro")]
Kling16Pro,
@@ -10,6 +10,7 @@ use crate::api::video_list_ref::VideoListRef;
use crate::client::request_mismatch_mitigation_strategy::RequestMismatchMitigationStrategy;
use crate::errors::artcraft_router_error::ArtcraftRouterError;
use crate::generate::generate_video_v2::providers::artcraft::grok_imagine_video::build::build_artcraft_grok_imagine_video;
use crate::generate::generate_video_v2::providers::artcraft::grok_imagine_video_1p5::build::build_artcraft_grok_imagine_video_1p5;
use crate::generate::generate_video_v2::providers::artcraft::happy_horse_1p0::build::build_artcraft_happy_horse_1p0;
use crate::generate::generate_video_v2::providers::artcraft::kling_1_6_pro::build::build_artcraft_kling_1_6_pro;
use crate::generate::generate_video_v2::providers::artcraft::kling_2_1_master::build::build_artcraft_kling_2_1_master;
@@ -42,6 +43,7 @@ use crate::generate::generate_video_v2::providers::kinovi::seedance_2p0::build::
use crate::generate::generate_video_v2::providers::gmicloud::seedance_2p0_g::build::build_gmicloud_seedance_2p0_u;
use crate::generate::generate_video_v2::providers::gmicloud::seedance_2p0_fast_g::build::build_gmicloud_seedance_2p0_u_fast;
use crate::generate::generate_video_v2::providers::grok_api::grok_imagine_video::build::build_grok_api_grok_imagine_video;
use crate::generate::generate_video_v2::providers::grok_api::grok_imagine_video_1p5::build::build_grok_api_grok_imagine_video_1p5;
use crate::generate::generate_video_v2::providers::fal::kling_1_6_pro::build::build_fal_kling_1_6_pro;
use crate::generate::generate_video_v2::providers::fal::kling_2_1_master::build::build_fal_kling_2_1_master;
use crate::generate::generate_video_v2::providers::fal::kling_2_1_pro::build::build_fal_kling_2_1_pro;
@@ -151,6 +153,7 @@ impl GenerateVideoRequestBuilder {
match (self.provider, self.model) {
// Artcraft
(RouterProvider::Artcraft, RouterVideoModel::GrokImagineVideo) => build_artcraft_grok_imagine_video(self),
(RouterProvider::Artcraft, RouterVideoModel::GrokImagineVideo1p5) => build_artcraft_grok_imagine_video_1p5(self),
(RouterProvider::Artcraft, RouterVideoModel::HappyHorse1p0) => build_artcraft_happy_horse_1p0(self),
(RouterProvider::Artcraft, RouterVideoModel::Kling16Pro) => build_artcraft_kling_1_6_pro(self),
(RouterProvider::Artcraft, RouterVideoModel::Kling21Master) => build_artcraft_kling_2_1_master(self),
@@ -200,6 +203,7 @@ impl GenerateVideoRequestBuilder {
(RouterProvider::GmiCloud, RouterVideoModel::Seedance2p0UltraFast) => build_gmicloud_seedance_2p0_u_fast(self),
// Grok
(RouterProvider::GrokApi, RouterVideoModel::GrokImagineVideo) => build_grok_api_grok_imagine_video(self),
(RouterProvider::GrokApi, RouterVideoModel::GrokImagineVideo1p5) => build_grok_api_grok_imagine_video_1p5(self),
// Kinovi
(RouterProvider::Seedance2Pro, RouterVideoModel::HappyHorse1p0) => build_kinovi_happy_horse_1p0(self),
(RouterProvider::Seedance2Pro, RouterVideoModel::Seedance2p0) => build_kinovi_seedance_2p0(self),
@@ -0,0 +1,181 @@
use enums::common::generation::common_video_model::CommonVideoModel as CommonVideoModelEnum;
use crate::errors::artcraft_router_error::ArtcraftRouterError;
use crate::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder;
use crate::generate::generate_video_v2::providers::artcraft::build_common::{
build_artcraft_omni_video_request, SupportedResolutions, UltraWideSupport,
};
use crate::generate::generate_video_v2::providers::artcraft::grok_imagine_video_1p5::request::ArtcraftGrokImagineVideo1p5RequestState;
use crate::generate::generate_video_v2::video_generation_draft_or_request::VideoGenerationDraftOrRequest;
use crate::generate::generate_video_v2::video_generation_request::VideoGenerationRequest;
/// Build an ArtCraft-routed Grok Imagine Video 1.5 preview request.
///
/// Same constraints as the v1 variant — xAI v1.5 still supports only 480p
/// and 720p and no 21:9 ultra-wide. The on-wire model identifier is
/// `grok-imagine-video-1.5-preview`; the cost calculator in
/// [`super::cost`] keys off that to apply the v1.5 pricing tier (with a 5%
/// ArtCraft markup on top of the upstream rates).
pub fn build_artcraft_grok_imagine_video_1p5(
builder: GenerateVideoRequestBuilder,
) -> Result<VideoGenerationDraftOrRequest, ArtcraftRouterError> {
let request = build_artcraft_omni_video_request(
builder,
CommonVideoModelEnum::GrokImagineVideo1p5,
SupportedResolutions::Fast,
UltraWideSupport::Unsupported,
)?;
let state = ArtcraftGrokImagineVideo1p5RequestState { request };
Ok(VideoGenerationDraftOrRequest::Request(
VideoGenerationRequest::ArtcraftGrokImagineVideo1p5(state),
))
}
#[cfg(test)]
mod tests {
use enums::common::generation::common_resolution::CommonResolution as CommonResolutionEnum;
use enums::common::generation::common_video_model::CommonVideoModel as CommonVideoModelEnum;
use tokens::tokens::characters::CharacterToken;
use tokens::tokens::media_files::MediaFileToken;
use crate::api::character_list_ref::CharacterListRef;
use crate::api::router_resolution::RouterResolution;
use crate::api::image_list_ref::ImageListRef;
use crate::api::image_ref::ImageRef;
use crate::api::router_provider::RouterProvider;
use crate::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder;
use crate::generate::generate_video_v2::video_generation_draft_or_request::VideoGenerationDraftOrRequest;
use crate::generate::generate_video_v2::video_generation_request::VideoGenerationRequest;
use super::*;
mod field_conversions {
use super::*;
#[test]
fn model_is_grok_imagine_video_1p5() {
let req = unwrap_request(make_builder(|_| {}));
assert!(matches!(req.request.model, Some(CommonVideoModelEnum::GrokImagineVideo1p5)));
}
#[test]
fn prompt_passed_through() {
let req = unwrap_request(make_builder(|b| { b.prompt = Some("test".to_string()); }));
assert_eq!(req.request.prompt, Some("test".to_string()));
}
#[test]
fn duration_passed_through() {
let req = unwrap_request(make_builder(|b| { b.duration_seconds = Some(10); }));
assert_eq!(req.request.duration_seconds, Some(10));
}
#[test]
fn duration_clamped_to_max() {
let req = unwrap_request(make_builder(|b| { b.duration_seconds = Some(99); }));
assert_eq!(req.request.duration_seconds, Some(15));
}
#[test]
fn batch_count_passed_through() {
let req = unwrap_request(make_builder(|b| { b.video_batch_count = Some(4); }));
assert_eq!(req.request.video_batch_count, Some(4));
}
}
mod resolution_tests {
use super::*;
#[test]
fn res_480p() {
let req = unwrap_request(make_builder(|b| { b.resolution = Some(RouterResolution::FourEightyP); }));
assert_eq!(req.request.resolution, Some(CommonResolutionEnum::FourEightyP));
}
#[test]
fn res_720p() {
let req = unwrap_request(make_builder(|b| { b.resolution = Some(RouterResolution::SevenTwentyP); }));
assert_eq!(req.request.resolution, Some(CommonResolutionEnum::SevenTwentyP));
}
#[test]
fn res_1080p_downgrades_to_720p() {
// SupportedResolutions::Fast caps at 720p (Grok Imagine doesn't render 1080p output).
let req = unwrap_request(make_builder(|b| { b.resolution = Some(RouterResolution::TenEightyP); }));
assert_eq!(req.request.resolution, Some(CommonResolutionEnum::SevenTwentyP));
}
#[test]
fn none_stays_none() {
let req = unwrap_request(make_builder(|_| {}));
assert!(req.request.resolution.is_none());
}
}
mod media_token_tests {
use super::*;
#[test]
fn start_frame_token_passed_through() {
let token = MediaFileToken::new("mf_start".to_string());
let req = unwrap_request(make_builder(|b| {
b.start_frame = Some(ImageRef::MediaFileToken(token.clone()));
}));
assert_eq!(req.request.start_frame_image_media_token, Some(token));
}
#[test]
fn url_start_frame_rejected() {
// ArtCraft's omni endpoint requires media tokens — URLs are rejected
// (unlike the direct Grok provider, which is URL-only).
let result = build_artcraft_grok_imagine_video_1p5(GenerateVideoRequestBuilder {
start_frame: Some(ImageRef::Url("https://example.com".to_string())),
..base_builder()
});
assert!(result.is_err());
}
#[test]
fn reference_image_tokens_passed_through() {
let tokens = vec![MediaFileToken::new("mf_a".to_string()), MediaFileToken::new("mf_b".to_string())];
let req = unwrap_request(make_builder(|b| {
b.reference_images = Some(ImageListRef::MediaFileTokens(tokens.clone()));
}));
assert_eq!(req.request.reference_image_media_tokens, Some(tokens));
}
#[test]
fn character_tokens_passed_through() {
let tokens = vec![CharacterToken::new("char_a".to_string()), CharacterToken::new("char_b".to_string())];
let req = unwrap_request(make_builder(|b| {
b.reference_character_tokens = Some(CharacterListRef::CharacterTokens(tokens.clone()));
}));
assert_eq!(req.request.reference_character_tokens, Some(tokens));
}
}
fn base_builder() -> GenerateVideoRequestBuilder {
GenerateVideoRequestBuilder {
provider: RouterProvider::Artcraft,
duration_seconds: Some(5),
video_batch_count: Some(1),
..Default::default()
}
}
fn make_builder(f: impl FnOnce(&mut GenerateVideoRequestBuilder)) -> GenerateVideoRequestBuilder {
let mut builder = base_builder();
f(&mut builder);
builder
}
fn unwrap_request(builder: GenerateVideoRequestBuilder) -> ArtcraftGrokImagineVideo1p5RequestState {
let result = build_artcraft_grok_imagine_video_1p5(builder).expect("build should succeed");
match result {
VideoGenerationDraftOrRequest::Request(
VideoGenerationRequest::ArtcraftGrokImagineVideo1p5(state)
) => state,
_ => panic!("expected ArtcraftGrokImagineVideo1p5 request"),
}
}
}
@@ -0,0 +1,546 @@
use enums::common::generation::common_resolution::CommonResolution;
use grok_api_client::api::requests::videos::video_generation::video_generation::{
VideoGenerationRequest as GrokVideoGenerationRequest,
VideoImageSource as GrokVideoImageSource,
};
use grok_api_client::api::traits::grok_request_cost_calculator_trait::GrokRequestCostCalculator;
use grok_api_client::api::types::video_types::video_model::VideoModel as GrokVideoModel;
use grok_api_client::api::types::video_types::video_resolution::VideoResolution as GrokResolution;
use crate::generate::generate_video::video_generation_cost_estimate::VideoGenerationCostEstimate;
use crate::generate::generate_video_v2::providers::artcraft::grok_imagine_video_1p5::request::ArtcraftGrokImagineVideo1p5RequestState;
// ── Markup ──
//
// ArtCraft adds a modest 5% markup over the underlying xAI v1.5 pricing. The
// markup is applied at cent granularity with ceiling rounding so the final
// charge always covers the upstream cost (we never bill the user less than
// the cost of the API call we forwarded).
const MARKUP_NUMERATOR: u64 = 105;
const MARKUP_DENOMINATOR: u64 = 100;
pub struct ArtcraftGrokImagineVideo1p5CostState {
pub resolution: CommonResolution,
pub duration_seconds: u16,
pub batch_count: u16,
pub input_image_count: u64,
}
impl ArtcraftGrokImagineVideo1p5CostState {
pub fn from_request(request: &ArtcraftGrokImagineVideo1p5RequestState) -> Self {
let resolution = request.request.resolution.unwrap_or(CommonResolution::SevenTwentyP);
let duration_seconds = request.request.duration_seconds.unwrap_or(8);
let batch_count = request.request.video_batch_count.unwrap_or(1);
// Mirror grok_api_client's input-image counting: start_frame + reference_images.
let input_image_count = (request.request.start_frame_image_media_token.is_some() as u64)
+ (request.request.reference_image_media_tokens
.as_ref()
.map(|v| v.len() as u64)
.unwrap_or(0));
Self { resolution, duration_seconds, batch_count, input_image_count }
}
/// Base cost in cents for a single video at this state's resolution,
/// duration, and input-image count. Computed via grok_api_client's
/// official cost calculator (with the model pinned to the v1.5 preview),
/// so this number tracks upstream pricing automatically and never drifts
/// from what xAI bills.
///
/// Does NOT include the per-batch multiplier and does NOT include the
/// ArtCraft markup. Used by tests to assert parity with grok_api_client.
pub fn base_cost_in_cents_for_one_video(&self) -> u64 {
self.build_equivalent_grok_request().calculate_cost_in_cents()
}
/// Total base cost (across the batch) in cents, before markup.
/// Each video in the batch costs [`Self::base_cost_in_cents_for_one_video`].
pub fn base_cost_in_cents_for_batch(&self) -> u64 {
self.base_cost_in_cents_for_one_video()
.saturating_mul(self.batch_count as u64)
}
pub fn estimate_cost(&self) -> VideoGenerationCostEstimate {
let base = self.base_cost_in_cents_for_batch();
// 5% markup, ceil so the user is always charged enough to cover the
// upstream grok cost regardless of rounding.
let usd_cents = base.saturating_mul(MARKUP_NUMERATOR).div_ceil(MARKUP_DENOMINATOR);
VideoGenerationCostEstimate {
cost_in_credits: Some(usd_cents),
cost_in_usd_cents: Some(usd_cents),
is_free: false,
is_unlimited: false,
is_rate_limited: false,
has_watermark: false,
failures_are_refunded: None,
}
}
/// Build a `GrokVideoGenerationRequest` whose pricing-relevant fields
/// exactly mirror this cost state, so the grok_api_client calculator
/// produces the same number ArtCraft would owe for one video. Non-pricing
/// fields (prompt, aspect_ratio, user) are left empty — they don't affect
/// cost.
fn build_equivalent_grok_request(&self) -> GrokVideoGenerationRequest {
let resolution = Some(match self.resolution {
CommonResolution::FourEightyP => GrokResolution::FourEightyP,
// Grok Imagine caps at 720p (and our build step downgrades higher
// tiers via SupportedResolutions::Fast); price anything else as 720p.
_ => GrokResolution::SevenTwentyP,
});
// Grok's calculator only counts the TOTAL number of input images — it
// doesn't care how they're split between `image` and `reference_images`
// — so we put one in `image` (if any) and the rest in `reference_images`.
let image = (self.input_image_count >= 1)
.then(|| GrokVideoImageSource::Url(String::new()));
let reference_images = if self.input_image_count >= 2 {
Some(
(1..self.input_image_count)
.map(|_| GrokVideoImageSource::Url(String::new()))
.collect()
)
} else { None };
GrokVideoGenerationRequest {
prompt: String::new(),
model: Some(GrokVideoModel::GrokImagineVideo1p5Preview),
image,
reference_images,
aspect_ratio: None,
duration: Some(self.duration_seconds as u32),
resolution,
user: None,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use tokens::tokens::media_files::MediaFileToken;
use crate::api::router_resolution::RouterResolution;
use crate::api::router_video_model::RouterVideoModel;
use crate::api::image_list_ref::ImageListRef;
use crate::api::image_ref::ImageRef;
use crate::api::router_provider::RouterProvider;
use crate::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder;
use crate::generate::generate_video::video_generation_cost_estimate::VideoGenerationCostEstimate;
// ── Base cost parity with grok_api_client ─────────────────────────────
//
// The base cost (before ArtCraft markup, batch=1) must equal exactly what
// grok_api_client's calculator returns for an equivalent direct-Grok
// request. This protects against the artcraft and grok-api implementations
// silently drifting apart.
mod grok_api_parity {
use super::*;
// (resolution, duration, image_count)
//
// Durations are confined to [4, 15] because the ArtCraft pipeline clamps
// outside that range (see `plan_duration` in artcraft/build_common.rs).
const PARITY_CASES: &[(Option<RouterResolution>, u16, u64)] = &[
// 480p variations
(Some(RouterResolution::FourEightyP), 4, 0),
(Some(RouterResolution::FourEightyP), 5, 0),
(Some(RouterResolution::FourEightyP), 8, 0),
(Some(RouterResolution::FourEightyP), 15, 0),
(Some(RouterResolution::FourEightyP), 5, 1),
(Some(RouterResolution::FourEightyP), 5, 3),
// 720p variations
(Some(RouterResolution::SevenTwentyP), 4, 0),
(Some(RouterResolution::SevenTwentyP), 5, 0),
(Some(RouterResolution::SevenTwentyP), 8, 0),
(Some(RouterResolution::SevenTwentyP), 15, 0),
(Some(RouterResolution::SevenTwentyP), 5, 1),
(Some(RouterResolution::SevenTwentyP), 5, 3),
// None defaults to 720p in our `from_request`, which matches the
// assumption Grok's calculator makes when fed a 720p request.
(None, 5, 0),
(None, 5, 2),
];
#[test]
fn base_cost_for_one_video_matches_grok_api_client() {
for &(router_res, duration, image_count) in PARITY_CASES {
let artcraft = artcraft_state(router_res, duration, image_count, 1);
let grok_cents = grok_cents(router_res, duration, image_count);
assert_eq!(
artcraft.base_cost_in_cents_for_one_video(),
grok_cents,
"parity mismatch at res={router_res:?} dur={duration} images={image_count}: \
artcraft_base={} grok={}",
artcraft.base_cost_in_cents_for_one_video(),
grok_cents,
);
}
}
#[test]
fn batch_one_base_total_equals_one_video_base() {
let s = artcraft_state(Some(RouterResolution::SevenTwentyP), 5, 1, 1);
assert_eq!(s.base_cost_in_cents_for_batch(), s.base_cost_in_cents_for_one_video());
}
#[test]
fn batch_scales_base_linearly() {
// The ArtCraft pipeline only accepts batch counts of 1, 2, or 4 (see
// `plan_batch_count` in artcraft/build_common.rs); anything else gets
// clamped, so we can only meaningfully test those values.
let per_one = artcraft_state(Some(RouterResolution::SevenTwentyP), 5, 1, 1)
.base_cost_in_cents_for_one_video();
for batch in [1u16, 2, 4] {
let s = artcraft_state(Some(RouterResolution::SevenTwentyP), 5, 1, batch);
assert_eq!(
s.base_cost_in_cents_for_batch(),
per_one * batch as u64,
"batch={batch}",
);
}
}
/// Builder-driven parity — exercises the full pipeline (build2 →
/// estimate_cost) on both providers and compares ArtCraft's base cost
/// (final / 1.05, accounting for ceiling) against GrokApi's direct
/// cost.
///
/// We verify the markup rather than the base here because the public
/// `estimate_cost()` returns the marked-up number; the explicit
/// `base_cost_in_cents_*` API is tested above.
#[test]
fn end_to_end_markup_is_at_least_five_percent_over_grok() {
let cases: &[(Option<RouterResolution>, u16, bool, usize)] = &[
(Some(RouterResolution::FourEightyP), 5, false, 0),
(Some(RouterResolution::FourEightyP), 10, true, 0),
(Some(RouterResolution::SevenTwentyP), 5, false, 0),
(Some(RouterResolution::SevenTwentyP), 5, false, 3),
(Some(RouterResolution::SevenTwentyP), 8, true, 0),
(Some(RouterResolution::SevenTwentyP),15, false, 0),
];
for &(res, duration, has_start, refs) in cases {
let artcraft_cents = artcraft_end_to_end_cents(res, duration, 1, has_start, refs);
let grok_cents = grok_cents(res, duration, has_start as u64 + refs as u64);
let want_minimum = grok_cents.saturating_mul(MARKUP_NUMERATOR).div_ceil(MARKUP_DENOMINATOR);
assert_eq!(
artcraft_cents, want_minimum,
"res={res:?} dur={duration} start={has_start} refs={refs}: \
artcraft={artcraft_cents}¢ vs ceil(grok({grok_cents})×1.05)={want_minimum}¢",
);
}
}
}
// ── Markup correctness ─────────────────────────────────────────────────
mod markup {
use super::*;
#[test]
fn estimate_cost_equals_ceil_of_base_times_1p05() {
for &(res, dur, imgs, batch) in MARKUP_CASES {
let state = artcraft_state(res, dur, imgs, batch);
let base = state.base_cost_in_cents_for_batch();
let want = base.saturating_mul(MARKUP_NUMERATOR).div_ceil(MARKUP_DENOMINATOR);
let got = state.estimate_cost().cost_in_usd_cents.unwrap();
assert_eq!(
got, want,
"res={res:?} dur={dur} imgs={imgs} batch={batch} base={base}",
);
}
}
#[test]
fn final_cost_is_always_at_least_base() {
for &(res, dur, imgs, batch) in MARKUP_CASES {
let state = artcraft_state(res, dur, imgs, batch);
let base = state.base_cost_in_cents_for_batch();
let final_ = state.estimate_cost().cost_in_usd_cents.unwrap();
assert!(
final_ >= base,
"final {final_}¢ < base {base}¢ at res={res:?} dur={dur} imgs={imgs} batch={batch}",
);
}
}
#[test]
fn known_value_at_720p_5s_batch_1() {
// grok v1.5 720p × 5s = 700 mills = 70¢
// 70 × 1.05 = 73.5 → ceil = 74¢
let state = artcraft_state(Some(RouterResolution::SevenTwentyP), 5, 0, 1);
assert_eq!(state.base_cost_in_cents_for_one_video(), 70);
assert_eq!(state.estimate_cost().cost_in_usd_cents.unwrap(), 74);
}
#[test]
fn known_value_at_480p_5s_batch_1() {
// grok v1.5 480p × 5s = 400 mills = 40¢
// 40 × 1.05 = 42 → ceil = 42¢
let state = artcraft_state(Some(RouterResolution::FourEightyP), 5, 0, 1);
assert_eq!(state.base_cost_in_cents_for_one_video(), 40);
assert_eq!(state.estimate_cost().cost_in_usd_cents.unwrap(), 42);
}
#[test]
fn known_value_at_720p_5s_batch_2() {
// base for batch 2 = 70 × 2 = 140¢
// 140 × 1.05 = 147 → ceil 147¢
let state = artcraft_state(Some(RouterResolution::SevenTwentyP), 5, 0, 2);
assert_eq!(state.base_cost_in_cents_for_batch(), 140);
assert_eq!(state.estimate_cost().cost_in_usd_cents.unwrap(), 147);
}
// (resolution, duration, image_count, batch)
const MARKUP_CASES: &[(Option<RouterResolution>, u16, u64, u16)] = &[
(Some(RouterResolution::FourEightyP), 5, 0, 1),
(Some(RouterResolution::FourEightyP), 5, 0, 2),
(Some(RouterResolution::FourEightyP), 5, 0, 4),
(Some(RouterResolution::FourEightyP), 8, 0, 1),
(Some(RouterResolution::FourEightyP), 15, 0, 1),
(Some(RouterResolution::FourEightyP), 5, 1, 1),
(Some(RouterResolution::FourEightyP), 5, 3, 1),
(Some(RouterResolution::SevenTwentyP), 5, 0, 1),
(Some(RouterResolution::SevenTwentyP), 5, 0, 2),
(Some(RouterResolution::SevenTwentyP), 5, 0, 4),
(Some(RouterResolution::SevenTwentyP), 8, 0, 1),
(Some(RouterResolution::SevenTwentyP), 15, 0, 1),
(Some(RouterResolution::SevenTwentyP), 5, 1, 1),
(Some(RouterResolution::SevenTwentyP), 5, 3, 1),
(None, 5, 0, 1),
];
}
// ── Relative pricing / monotonicity sanity ─────────────────────────────
mod monotonicity {
use super::*;
#[test]
fn higher_resolution_costs_more() {
let p480 = artcraft_state(Some(RouterResolution::FourEightyP), 5, 0, 1)
.estimate_cost().cost_in_usd_cents.unwrap();
let p720 = artcraft_state(Some(RouterResolution::SevenTwentyP), 5, 0, 1)
.estimate_cost().cost_in_usd_cents.unwrap();
assert!(p480 < p720, "480p ({p480}¢) should be < 720p ({p720}¢)");
}
#[test]
fn longer_duration_costs_more() {
let c5 = artcraft_state(Some(RouterResolution::SevenTwentyP), 5, 0, 1)
.estimate_cost().cost_in_usd_cents.unwrap();
let c10 = artcraft_state(Some(RouterResolution::SevenTwentyP), 10, 0, 1)
.estimate_cost().cost_in_usd_cents.unwrap();
let c15 = artcraft_state(Some(RouterResolution::SevenTwentyP), 15, 0, 1)
.estimate_cost().cost_in_usd_cents.unwrap();
assert!(c5 < c10);
assert!(c10 < c15);
}
#[test]
fn more_images_cost_more_or_equal() {
// Each image is +10 mills = +1¢ before markup, so 1+ image always
// strictly increases final cost (the markup ceil pushes us up enough).
let zero = artcraft_state(Some(RouterResolution::SevenTwentyP), 5, 0, 1)
.estimate_cost().cost_in_usd_cents.unwrap();
let three = artcraft_state(Some(RouterResolution::SevenTwentyP), 5, 3, 1)
.estimate_cost().cost_in_usd_cents.unwrap();
assert!(zero < three, "zero={zero}¢ vs three={three}¢");
}
#[test]
fn more_batch_costs_more() {
let b1 = artcraft_state(Some(RouterResolution::SevenTwentyP), 5, 0, 1)
.estimate_cost().cost_in_usd_cents.unwrap();
let b4 = artcraft_state(Some(RouterResolution::SevenTwentyP), 5, 0, 4)
.estimate_cost().cost_in_usd_cents.unwrap();
assert!(b1 < b4);
}
}
// ── End-to-end through the public builder API ──────────────────────────
mod end_to_end {
use super::*;
#[test]
fn credits_equal_usd_cents() {
// ArtCraft credits == USD cents, as in the v1 module.
let resolutions = [
None,
Some(RouterResolution::FourEightyP),
Some(RouterResolution::SevenTwentyP),
Some(RouterResolution::TenEightyP), // clamps to 720p
];
for res in resolutions {
for dur in [4u16, 5, 10, 15] {
for batch in [1u16, 2, 4] {
let cost = build_cost_via_builder(res, dur, batch);
assert_eq!(
cost.cost_in_credits, cost.cost_in_usd_cents,
"credits should equal cents at res={res:?} dur={dur}s batch={batch}",
);
}
}
}
}
#[test]
fn ten_eighty_p_request_clamps_to_720p_pricing() {
let p1080 = build_cost_via_builder(Some(RouterResolution::TenEightyP), 10, 1)
.cost_in_usd_cents.unwrap();
let p720 = build_cost_via_builder(Some(RouterResolution::SevenTwentyP), 10, 1)
.cost_in_usd_cents.unwrap();
assert_eq!(p1080, p720);
}
}
// ── Helpers ────────────────────────────────────────────────────────────
fn artcraft_state(
resolution: Option<RouterResolution>,
duration_seconds: u16,
input_image_count: u64,
batch_count: u16,
) -> ArtcraftGrokImagineVideo1p5CostState {
// Build a state directly from a synthesized request — avoids the full
// build2() pipeline so we can construct arbitrary image counts without
// worrying about builder validation rules.
let state = unwrap_request_via_builder(resolution, duration_seconds, input_image_count, batch_count);
ArtcraftGrokImagineVideo1p5CostState::from_request(&state)
}
fn unwrap_request_via_builder(
resolution: Option<RouterResolution>,
duration_seconds: u16,
input_image_count: u64,
batch_count: u16,
) -> ArtcraftGrokImagineVideo1p5RequestState {
// Split input_image_count into start_frame (1 if any) + reference_images (the rest),
// mirroring how `from_request` counts source images.
let start_frame = if input_image_count >= 1 {
Some(ImageRef::MediaFileToken(MediaFileToken::new("mf_start".to_string())))
} else { None };
let reference_images = if input_image_count >= 2 {
Some(ImageListRef::MediaFileTokens(
(1..input_image_count).map(|i| MediaFileToken::new(format!("mf_ref_{i}"))).collect(),
))
} else { None };
let builder = GenerateVideoRequestBuilder {
model: RouterVideoModel::GrokImagineVideo1p5,
provider: RouterProvider::Artcraft,
resolution,
duration_seconds: Some(duration_seconds),
video_batch_count: Some(batch_count),
start_frame,
reference_images,
..Default::default()
};
let result = builder.build2().expect("build2 should succeed");
match result {
crate::generate::generate_video_v2::video_generation_draft_or_request::VideoGenerationDraftOrRequest::Request(
crate::generate::generate_video_v2::video_generation_request::VideoGenerationRequest::ArtcraftGrokImagineVideo1p5(s)
) => s,
_ => panic!("expected ArtcraftGrokImagineVideo1p5 request"),
}
}
fn artcraft_end_to_end_cents(
resolution: Option<RouterResolution>,
duration_seconds: u16,
batch_count: u16,
has_start_frame: bool,
num_reference_images: usize,
) -> u64 {
let start_frame = if has_start_frame {
Some(ImageRef::MediaFileToken(MediaFileToken::new("mf_start".to_string())))
} else { None };
let reference_images = if num_reference_images > 0 {
Some(ImageListRef::MediaFileTokens(
(0..num_reference_images)
.map(|i| MediaFileToken::new(format!("mf_ref_{i}")))
.collect(),
))
} else { None };
let builder = GenerateVideoRequestBuilder {
model: RouterVideoModel::GrokImagineVideo1p5,
provider: RouterProvider::Artcraft,
resolution,
duration_seconds: Some(duration_seconds),
video_batch_count: Some(batch_count),
start_frame,
reference_images,
..Default::default()
};
builder.build2()
.expect("build2 should succeed")
.estimate_cost()
.expect("estimate_cost should succeed")
.cost_in_usd_cents
.unwrap()
}
fn build_cost_via_builder(
resolution: Option<RouterResolution>,
duration_seconds: u16,
video_batch_count: u16,
) -> VideoGenerationCostEstimate {
let builder = GenerateVideoRequestBuilder {
model: RouterVideoModel::GrokImagineVideo1p5,
provider: RouterProvider::Artcraft,
resolution,
duration_seconds: Some(duration_seconds),
video_batch_count: Some(video_batch_count),
..Default::default()
};
builder.build2()
.expect("build2 should succeed")
.estimate_cost()
.expect("estimate_cost should succeed")
}
/// Reference value: what GrokApi's own pipeline would charge for one
/// equivalent video. Used by parity tests.
///
/// The Grok build step prefers `start_frame` over `reference_images` when
/// both are supplied and drops the latter (see grok_api/grok_imagine_video_1p5/build.rs).
/// To keep parity with the ArtCraft input-image *count*, we put all source
/// images in `reference_images` only — the cost calculator counts the total
/// either way.
fn grok_cents(
resolution: Option<RouterResolution>,
duration_seconds: u16,
image_count: u64,
) -> u64 {
let reference_images = if image_count >= 1 {
Some(ImageListRef::Urls(
(0..image_count).map(|i| format!("https://example.com/ref_{i}.png")).collect(),
))
} else { None };
let builder = GenerateVideoRequestBuilder {
model: RouterVideoModel::GrokImagineVideo1p5,
provider: RouterProvider::GrokApi,
resolution,
duration_seconds: Some(duration_seconds),
video_batch_count: Some(1),
start_frame: None,
reference_images,
..Default::default()
};
builder.build2()
.expect("build2 (grokapi) should succeed")
.estimate_cost()
.expect("estimate_cost (grokapi) should succeed")
.cost_in_usd_cents
.unwrap()
}
}
@@ -0,0 +1,3 @@
pub mod build;
pub mod cost;
pub mod request;
@@ -0,0 +1,17 @@
use artcraft_api_defs::omni_gen::cost_and_generate_requests::omni_gen_video_cost_and_generate_request::OmniGenVideoCostAndGenerateRequest;
use crate::client::router_artcraft_client::RouterArtcraftClient;
use crate::errors::artcraft_router_error::ArtcraftRouterError;
use crate::generate::generate_video::generate_video_response::GenerateVideoResponse;
use crate::generate::generate_video_v2::providers::artcraft::request_common::send_artcraft_omni_video_request;
#[derive(Clone, Debug)]
pub struct ArtcraftGrokImagineVideo1p5RequestState {
pub request: OmniGenVideoCostAndGenerateRequest,
}
impl ArtcraftGrokImagineVideo1p5RequestState {
pub async fn send(&self, client: &RouterArtcraftClient) -> Result<GenerateVideoResponse, ArtcraftRouterError> {
send_artcraft_omni_video_request(&self.request, client).await
}
}
@@ -2,6 +2,7 @@ pub(crate) mod build_common;
#[cfg(test)]
mod parity_tests;
pub mod grok_imagine_video;
pub mod grok_imagine_video_1p5;
pub mod happy_horse_1p0;
pub mod kling_1_6_pro;
pub mod kling_2_1_master;
@@ -0,0 +1,650 @@
use log::info;
use grok_api_client::api::requests::videos::video_generation::video_generation::{
VideoGenerationRequest as GrokVideoGenerationRequest,
VideoImageSource as GrokVideoImageSource,
};
use grok_api_client::api::types::video_types::video_aspect_ratio::VideoAspectRatio as GrokAspectRatio;
use grok_api_client::api::types::video_types::video_model::VideoModel as GrokVideoModel;
use grok_api_client::api::types::video_types::video_resolution::VideoResolution as GrokResolution;
use crate::api::audio_list_ref::AudioListRef;
use crate::api::router_aspect_ratio::RouterAspectRatio;
use crate::api::router_resolution::RouterResolution;
use crate::api::image_list_ref::ImageListRef;
use crate::api::image_ref::ImageRef;
use crate::api::video_list_ref::VideoListRef;
use crate::client::request_mismatch_mitigation_strategy::RequestMismatchMitigationStrategy;
use crate::errors::artcraft_router_error::ArtcraftRouterError;
use crate::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder;
use crate::generate::generate_video_v2::providers::grok_api::grok_imagine_video_1p5::request::GrokApiGrokImagineVideo1p5RequestState;
use crate::generate::generate_video_v2::video_generation_draft_or_request::VideoGenerationDraftOrRequest;
use crate::generate::generate_video_v2::video_generation_request::VideoGenerationRequest;
/// Builds a Grok Imagine Video 1.5 preview request from the generic
/// GenerateVideoRequestBuilder. Mirrors the v1 builder's capabilities and
/// graceful-drop policy (the 1.5 model accepts the same wire shape as v1);
/// the only material differences are the `model` identifier on the wire and
/// the steeper pricing in the cost calculator (see `cost.rs`).
///
/// xAI's grok-imagine-video-1.5-preview accepts:
/// - `image` (single source image, image-to-video mode) OR `reference_images`
/// (multi-image reference-to-video). These two are mutually exclusive per
/// xAI's API.
/// - Aspect ratio, resolution (480p/720p), duration (1–15s), prompt.
///
/// Fields that Grok DOESN'T accept (`end_frame`, `reference_videos`,
/// `reference_audio`, and `MediaFileToken`-style image refs) are silently
/// dropped with an info-level log.
pub fn build_grok_api_grok_imagine_video_1p5(
mut builder: GenerateVideoRequestBuilder,
) -> Result<VideoGenerationDraftOrRequest, ArtcraftRouterError> {
let strategy = builder.request_mismatch_mitigation_strategy;
// Scalar fields.
let prompt = builder.prompt.take().unwrap_or_default();
let aspect_ratio = plan_aspect_ratio(builder.aspect_ratio.take(), strategy);
let resolution = plan_resolution(builder.resolution.take(), strategy);
let duration = builder.duration_seconds.take().map(|d| (d as u32).clamp(1, 15));
log_and_drop_end_frame(builder.end_frame.take());
log_and_drop_reference_videos(builder.reference_videos.take());
log_and_drop_reference_audio(builder.reference_audio.take());
let start_frame = resolve_url_to_image_source(builder.start_frame.take());
let reference_images_supplied = builder.reference_images.take();
// xAI's v1.5 model explicitly does NOT accept `reference_images` (it only
// supports text-to-video and single-image-to-video). If the caller supplies
// reference images and no start_frame, promote the first reference image
// into `image` (image-to-video) and drop the rest.
let (image, reference_images) = match (start_frame, reference_images_supplied) {
(Some(img), Some(refs)) => {
info!(
"grok_imagine_video_1p5: both start_frame and reference_images supplied — \
preferring start_frame (image-to-video). Dropping {} reference image(s).",
count_image_list_ref(&refs),
);
(Some(img), None)
}
(Some(img), None) => (Some(img), None),
(None, Some(refs)) => promote_first_reference_to_image(refs),
(None, None) => (None, None),
};
let request = GrokVideoGenerationRequest {
prompt,
// Pin to the 1.5 preview identifier so the grok_api_client cost
// calculator picks the v1.5 pricing tier (see VideoModel::pricing_tier).
model: Some(GrokVideoModel::GrokImagineVideo1p5Preview),
image,
reference_images,
aspect_ratio,
duration,
resolution,
user: None,
};
let state = GrokApiGrokImagineVideo1p5RequestState { request };
Ok(VideoGenerationDraftOrRequest::Request(
VideoGenerationRequest::GrokApiGrokImagineVideo1p5(state),
))
}
// ── Field planners ──
fn plan_aspect_ratio(
aspect_ratio: Option<RouterAspectRatio>,
_strategy: RequestMismatchMitigationStrategy,
) -> Option<GrokAspectRatio> {
match aspect_ratio {
None
| Some(RouterAspectRatio::Auto)
| Some(RouterAspectRatio::Auto2k)
| Some(RouterAspectRatio::Auto3k)
| Some(RouterAspectRatio::Auto4k) => None,
Some(RouterAspectRatio::Square) | Some(RouterAspectRatio::SquareHd) => {
Some(GrokAspectRatio::Square)
}
Some(RouterAspectRatio::WideSixteenByNine) | Some(RouterAspectRatio::Wide) => {
Some(GrokAspectRatio::Landscape16x9)
}
Some(RouterAspectRatio::TallNineBySixteen) | Some(RouterAspectRatio::Tall) => {
Some(GrokAspectRatio::Portrait9x16)
}
Some(RouterAspectRatio::WideFourByThree) => Some(GrokAspectRatio::Landscape4x3),
Some(RouterAspectRatio::TallThreeByFour) => Some(GrokAspectRatio::Portrait3x4),
Some(RouterAspectRatio::WideThreeByTwo) => Some(GrokAspectRatio::Landscape3x2),
Some(RouterAspectRatio::TallTwoByThree) => Some(GrokAspectRatio::Portrait2x3),
Some(RouterAspectRatio::WideFiveByFour)
| Some(RouterAspectRatio::WideTwentyOneByNine) => Some(GrokAspectRatio::Landscape16x9),
Some(RouterAspectRatio::TallFourByFive)
| Some(RouterAspectRatio::TallNineByTwentyOne) => Some(GrokAspectRatio::Portrait9x16),
}
}
fn plan_resolution(
resolution: Option<RouterResolution>,
_strategy: RequestMismatchMitigationStrategy,
) -> Option<GrokResolution> {
match resolution {
None => None,
Some(RouterResolution::FourEightyP) => Some(GrokResolution::FourEightyP),
Some(RouterResolution::SevenTwentyP) => Some(GrokResolution::SevenTwentyP),
Some(RouterResolution::TenEightyP)
| Some(RouterResolution::TwoK)
| Some(RouterResolution::ThreeK)
| Some(RouterResolution::FourK) => Some(GrokResolution::SevenTwentyP),
Some(RouterResolution::HalfK) | Some(RouterResolution::OneK) => {
Some(GrokResolution::FourEightyP)
}
}
}
// ── Image source resolvers (silent drops for unsupported variants) ──
fn resolve_url_to_image_source(image_ref: Option<ImageRef>) -> Option<GrokVideoImageSource> {
match image_ref {
None => None,
Some(ImageRef::Url(url)) => Some(GrokVideoImageSource::Url(url)),
Some(ImageRef::MediaFileToken(token)) => {
info!(
"grok_imagine_video_1p5: dropping MediaFileToken {:?} for start_frame — \
Grok only accepts public URLs. Resolve the token to a URL upstream if needed.",
token,
);
None
}
}
}
/// xAI's v1.5 model rejects `reference_images` entirely. When a caller has
/// only supplied reference images (no start_frame), promote the first one
/// to `image` so they at least get image-to-video, and drop the remainder
/// with a log so it's visible in operator traces.
fn promote_first_reference_to_image(
list_ref: ImageListRef,
) -> (Option<GrokVideoImageSource>, Option<Vec<GrokVideoImageSource>>) {
match list_ref {
ImageListRef::Urls(urls) if urls.is_empty() => (None, None),
ImageListRef::Urls(mut urls) => {
let first_url = urls.remove(0);
let dropped = urls.len();
if dropped > 0 {
info!(
"grok_imagine_video_1p5: v1.5 doesn't accept reference_images; promoting \
the first reference image to start_frame (image-to-video). Dropping {} \
additional reference image(s).",
dropped,
);
} else {
info!(
"grok_imagine_video_1p5: v1.5 doesn't accept reference_images; promoting \
the single reference image to start_frame (image-to-video).",
);
}
(Some(GrokVideoImageSource::Url(first_url)), None)
}
ImageListRef::MediaFileTokens(tokens) => {
info!(
"grok_imagine_video_1p5: dropping {} MediaFileToken reference image(s) — \
Grok only accepts public URLs. Resolve tokens to URLs upstream if needed.",
tokens.len(),
);
(None, None)
}
}
}
fn count_image_list_ref(refs: &ImageListRef) -> usize {
match refs {
ImageListRef::Urls(v) => v.len(),
ImageListRef::MediaFileTokens(v) => v.len(),
}
}
// ── Drop-with-log helpers for fields Grok doesn't accept at all ──
fn log_and_drop_end_frame(end_frame: Option<ImageRef>) {
if end_frame.is_some() {
info!("grok_imagine_video_1p5: dropping end_frame — Grok doesn't support end-frame keyframes.");
}
}
fn log_and_drop_reference_videos(refs: Option<VideoListRef>) {
if let Some(refs) = refs {
let count = match refs {
VideoListRef::Urls(v) => v.len(),
VideoListRef::MediaFileTokens(v) => v.len(),
};
info!(
"grok_imagine_video_1p5: dropping {} reference video(s) — Grok doesn't accept reference videos.",
count,
);
}
}
fn log_and_drop_reference_audio(refs: Option<AudioListRef>) {
if let Some(refs) = refs {
let count = match refs {
AudioListRef::Urls(v) => v.len(),
AudioListRef::MediaFileTokens(v) => v.len(),
};
info!(
"grok_imagine_video_1p5: dropping {} reference audio clip(s) — Grok doesn't accept reference audio.",
count,
);
}
}
#[cfg(test)]
mod tests {
use super::*;
use tokens::tokens::media_files::MediaFileToken;
use crate::api::router_aspect_ratio::RouterAspectRatio;
use crate::api::router_resolution::RouterResolution;
use crate::api::router_video_model::RouterVideoModel;
use crate::api::image_list_ref::ImageListRef;
use crate::api::image_ref::ImageRef;
use crate::api::router_provider::RouterProvider;
use crate::api::video_list_ref::VideoListRef;
use crate::api::audio_list_ref::AudioListRef;
use crate::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder;
use crate::generate::generate_video_v2::video_generation_draft_or_request::VideoGenerationDraftOrRequest;
use crate::generate::generate_video_v2::video_generation_request::VideoGenerationRequest;
// ── Wire-shape: the built request always carries the 1.5 preview model id ──
mod model_id {
use super::*;
#[test]
fn built_request_pins_grok_imagine_video_1p5_preview() {
let req = unwrap_request(make_builder(|_| {}));
assert_eq!(
req.request.model,
Some(GrokVideoModel::GrokImagineVideo1p5Preview),
);
}
#[test]
fn built_request_serializes_canonical_wire_name() {
// The on-wire model string is the canonical name; the v1.5 cost
// calculator in grok_api_client keys off this string.
let req = unwrap_request(make_builder(|_| {}));
let model = req.request.model.as_ref().expect("model should be set");
assert_eq!(model.as_str(), "grok-imagine-video-1.5-preview");
}
}
// ── Field passthrough ──
mod field_conversions {
use super::*;
#[test]
fn prompt_passed_through() {
let req = unwrap_request(make_builder(|b| { b.prompt = Some("test prompt".to_string()); }));
assert_eq!(req.request.prompt, "test prompt");
}
#[test]
fn duration_passed_through() {
let req = unwrap_request(make_builder(|b| { b.duration_seconds = Some(8); }));
assert_eq!(req.request.duration, Some(8));
}
#[test]
fn duration_clamped_to_min() {
let req = unwrap_request(make_builder(|b| { b.duration_seconds = Some(0); }));
assert_eq!(req.request.duration, Some(1));
}
#[test]
fn duration_clamped_to_max() {
let req = unwrap_request(make_builder(|b| { b.duration_seconds = Some(99); }));
assert_eq!(req.request.duration, Some(15));
}
#[test]
fn duration_none_stays_none() {
let req = unwrap_request(make_builder(|b| { b.duration_seconds = None; }));
assert_eq!(req.request.duration, None);
}
}
// ── Aspect ratio ──
mod aspect_ratio_tests {
use super::*;
#[test]
fn square() {
let req = unwrap_request(make_builder(|b| { b.aspect_ratio = Some(RouterAspectRatio::Square); }));
assert_eq!(req.request.aspect_ratio, Some(GrokAspectRatio::Square));
}
#[test]
fn landscape_16x9() {
let req = unwrap_request(make_builder(|b| { b.aspect_ratio = Some(RouterAspectRatio::WideSixteenByNine); }));
assert_eq!(req.request.aspect_ratio, Some(GrokAspectRatio::Landscape16x9));
}
#[test]
fn portrait_9x16() {
let req = unwrap_request(make_builder(|b| { b.aspect_ratio = Some(RouterAspectRatio::TallNineBySixteen); }));
assert_eq!(req.request.aspect_ratio, Some(GrokAspectRatio::Portrait9x16));
}
#[test]
fn landscape_4x3() {
let req = unwrap_request(make_builder(|b| { b.aspect_ratio = Some(RouterAspectRatio::WideFourByThree); }));
assert_eq!(req.request.aspect_ratio, Some(GrokAspectRatio::Landscape4x3));
}
#[test]
fn portrait_3x4() {
let req = unwrap_request(make_builder(|b| { b.aspect_ratio = Some(RouterAspectRatio::TallThreeByFour); }));
assert_eq!(req.request.aspect_ratio, Some(GrokAspectRatio::Portrait3x4));
}
#[test]
fn landscape_3x2() {
let req = unwrap_request(make_builder(|b| { b.aspect_ratio = Some(RouterAspectRatio::WideThreeByTwo); }));
assert_eq!(req.request.aspect_ratio, Some(GrokAspectRatio::Landscape3x2));
}
#[test]
fn portrait_2x3() {
let req = unwrap_request(make_builder(|b| { b.aspect_ratio = Some(RouterAspectRatio::TallTwoByThree); }));
assert_eq!(req.request.aspect_ratio, Some(GrokAspectRatio::Portrait2x3));
}
#[test]
fn auto_maps_to_none() {
let req = unwrap_request(make_builder(|b| { b.aspect_ratio = Some(RouterAspectRatio::Auto); }));
assert_eq!(req.request.aspect_ratio, None);
}
#[test]
fn unsupported_wide_falls_back_to_16x9() {
let req = unwrap_request(make_builder(|b| {
b.aspect_ratio = Some(RouterAspectRatio::WideTwentyOneByNine);
}));
assert_eq!(req.request.aspect_ratio, Some(GrokAspectRatio::Landscape16x9));
}
#[test]
fn unsupported_tall_falls_back_to_9x16() {
let req = unwrap_request(make_builder(|b| {
b.aspect_ratio = Some(RouterAspectRatio::TallNineByTwentyOne);
}));
assert_eq!(req.request.aspect_ratio, Some(GrokAspectRatio::Portrait9x16));
}
}
// ── Resolution ──
mod resolution_tests {
use super::*;
#[test]
fn res_480p() {
let req = unwrap_request(make_builder(|b| { b.resolution = Some(RouterResolution::FourEightyP); }));
assert_eq!(req.request.resolution, Some(GrokResolution::FourEightyP));
}
#[test]
fn res_720p() {
let req = unwrap_request(make_builder(|b| { b.resolution = Some(RouterResolution::SevenTwentyP); }));
assert_eq!(req.request.resolution, Some(GrokResolution::SevenTwentyP));
}
#[test]
fn res_1080p_clamps_to_720p() {
let req = unwrap_request(make_builder(|b| { b.resolution = Some(RouterResolution::TenEightyP); }));
assert_eq!(req.request.resolution, Some(GrokResolution::SevenTwentyP));
}
#[test]
fn res_4k_clamps_to_720p() {
let req = unwrap_request(make_builder(|b| { b.resolution = Some(RouterResolution::FourK); }));
assert_eq!(req.request.resolution, Some(GrokResolution::SevenTwentyP));
}
#[test]
fn res_1k_bumps_to_480p() {
let req = unwrap_request(make_builder(|b| { b.resolution = Some(RouterResolution::OneK); }));
assert_eq!(req.request.resolution, Some(GrokResolution::FourEightyP));
}
#[test]
fn none_stays_none() {
let req = unwrap_request(make_builder(|_| {}));
assert!(req.request.resolution.is_none());
}
}
// ── Image source plumbing (graceful — never errors) ──
mod image_tests {
use super::*;
#[test]
fn start_frame_url_becomes_image() {
let req = unwrap_request(make_builder(|b| {
b.start_frame = Some(ImageRef::Url("https://example.com/start.png".to_string()));
}));
match &req.request.image {
Some(GrokVideoImageSource::Url(u)) => assert_eq!(u, "https://example.com/start.png"),
other => panic!("expected Url variant, got {:?}", other),
}
assert!(req.request.reference_images.is_none());
}
#[test]
fn start_frame_media_file_token_silently_dropped() {
let req = unwrap_request(make_builder(|b| {
b.start_frame = Some(ImageRef::MediaFileToken(MediaFileToken::new("mf_test".to_string())));
}));
assert!(req.request.image.is_none());
assert!(req.request.reference_images.is_none());
}
#[test]
fn multiple_reference_image_urls_promote_first_and_drop_rest() {
// xAI's v1.5 model rejects `reference_images`. The builder promotes the
// first reference to `image` and drops the remainder.
let urls = vec![
"https://example.com/a.png".to_string(),
"https://example.com/b.png".to_string(),
"https://example.com/c.png".to_string(),
];
let req = unwrap_request(make_builder(|b| {
b.reference_images = Some(ImageListRef::Urls(urls.clone()));
}));
assert!(
req.request.reference_images.is_none(),
"reference_images must be None (xAI v1.5 rejects them)",
);
match &req.request.image {
Some(GrokVideoImageSource::Url(u)) => assert_eq!(u, "https://example.com/a.png"),
other => panic!("expected first reference image promoted to `image`, got {:?}", other),
}
}
#[test]
fn single_reference_image_url_promotes_to_image() {
let req = unwrap_request(make_builder(|b| {
b.reference_images = Some(ImageListRef::Urls(vec![
"https://example.com/only.png".to_string(),
]));
}));
assert!(req.request.reference_images.is_none());
match &req.request.image {
Some(GrokVideoImageSource::Url(u)) => assert_eq!(u, "https://example.com/only.png"),
other => panic!("expected single reference promoted to `image`, got {:?}", other),
}
}
/// Replicates the production failure from 2026-05-31: storyteller-web
/// received `reference_image_media_tokens: [..., ...]` and routed it to
/// the v1.5 model, which rejected the request with
/// `reference_images is not supported for this model`. After the
/// promote-first fix, the resolved URLs go into `image` instead.
#[test]
fn reproduces_2026_05_31_two_ref_image_urls_no_start_frame() {
let req = unwrap_request(make_builder(|b| {
// Two URLs (the storyteller-web pipeline resolves media tokens to URLs
// before calling build2() on the GrokApi provider).
b.reference_images = Some(ImageListRef::Urls(vec![
"https://pub.example.com/media/a.png".to_string(),
"https://pub.example.com/media/b.png".to_string(),
]));
b.start_frame = None;
b.resolution = Some(RouterResolution::SevenTwentyP);
b.aspect_ratio = Some(RouterAspectRatio::WideSixteenByNine);
b.duration_seconds = Some(5);
}));
// The fix: no `reference_images` ever reach xAI for v1.5.
assert!(
req.request.reference_images.is_none(),
"v1.5 must never send reference_images to xAI",
);
// The first reference is preserved as image-to-video so the user still
// gets a sensible result.
assert!(matches!(req.request.image, Some(GrokVideoImageSource::Url(_))));
assert_eq!(req.request.resolution, Some(GrokResolution::SevenTwentyP));
assert_eq!(req.request.aspect_ratio, Some(GrokAspectRatio::Landscape16x9));
assert_eq!(req.request.duration, Some(5));
assert_eq!(req.request.model, Some(GrokVideoModel::GrokImagineVideo1p5Preview));
}
#[test]
fn reference_image_tokens_silently_dropped() {
let req = unwrap_request(make_builder(|b| {
b.reference_images = Some(ImageListRef::MediaFileTokens(vec![
MediaFileToken::new("mf_a".to_string()),
MediaFileToken::new("mf_b".to_string()),
]));
}));
assert!(req.request.reference_images.is_none());
assert!(req.request.image.is_none());
}
#[test]
fn start_frame_and_reference_images_together_prefers_start_frame() {
let req = unwrap_request(make_builder(|b| {
b.start_frame = Some(ImageRef::Url("https://example.com/start.png".to_string()));
b.reference_images = Some(ImageListRef::Urls(vec![
"https://example.com/ref1.png".to_string(),
"https://example.com/ref2.png".to_string(),
]));
}));
match &req.request.image {
Some(GrokVideoImageSource::Url(u)) => assert_eq!(u, "https://example.com/start.png"),
other => panic!("expected start_frame URL, got {:?}", other),
}
assert!(req.request.reference_images.is_none());
}
#[test]
fn empty_reference_image_url_list_becomes_none() {
let req = unwrap_request(make_builder(|b| {
b.reference_images = Some(ImageListRef::Urls(vec![]));
}));
assert!(req.request.reference_images.is_none());
}
}
// ── Unsupported fields are silently dropped, not rejected ──
mod unsupported_fields_graceful {
use super::*;
#[test]
fn end_frame_silently_dropped() {
let req = unwrap_request(make_builder(|b| {
b.end_frame = Some(ImageRef::Url("https://example.com/end.png".to_string()));
}));
assert!(req.request.image.is_none());
assert!(req.request.reference_images.is_none());
}
#[test]
fn reference_videos_silently_dropped() {
let req = unwrap_request(make_builder(|b| {
b.reference_videos = Some(VideoListRef::Urls(vec!["https://example.com/v.mp4".to_string()]));
}));
assert!(req.request.reference_images.is_none());
}
#[test]
fn reference_audio_silently_dropped() {
let req = unwrap_request(make_builder(|b| {
b.reference_audio = Some(AudioListRef::Urls(vec!["https://example.com/a.wav".to_string()]));
}));
assert!(req.request.reference_images.is_none());
}
#[test]
fn kitchen_sink_with_every_unsupported_field_still_succeeds() {
let req = unwrap_request(make_builder(|b| {
b.prompt = Some("kitchen sink".to_string());
b.start_frame = Some(ImageRef::Url("https://example.com/start.png".to_string()));
b.end_frame = Some(ImageRef::Url("https://example.com/end.png".to_string()));
b.reference_images = Some(ImageListRef::Urls(vec![
"https://example.com/ref.png".to_string(),
]));
b.reference_videos = Some(VideoListRef::Urls(vec!["https://example.com/v.mp4".to_string()]));
b.reference_audio = Some(AudioListRef::Urls(vec!["https://example.com/a.wav".to_string()]));
b.resolution = Some(RouterResolution::SevenTwentyP);
b.aspect_ratio = Some(RouterAspectRatio::WideSixteenByNine);
b.duration_seconds = Some(8);
}));
assert_eq!(req.request.prompt, "kitchen sink");
assert!(matches!(
req.request.image,
Some(GrokVideoImageSource::Url(_))
));
assert!(req.request.reference_images.is_none());
assert_eq!(req.request.resolution, Some(GrokResolution::SevenTwentyP));
assert_eq!(req.request.aspect_ratio, Some(GrokAspectRatio::Landscape16x9));
assert_eq!(req.request.duration, Some(8));
// Still pinned to the 1.5 model regardless of kitchen-sink inputs.
assert_eq!(req.request.model, Some(GrokVideoModel::GrokImagineVideo1p5Preview));
}
}
// ── Helpers ──
fn base_builder() -> GenerateVideoRequestBuilder {
GenerateVideoRequestBuilder {
model: RouterVideoModel::GrokImagineVideo1p5,
provider: RouterProvider::GrokApi,
duration_seconds: Some(5),
video_batch_count: Some(1),
..Default::default()
}
}
fn make_builder(f: impl FnOnce(&mut GenerateVideoRequestBuilder)) -> GenerateVideoRequestBuilder {
let mut b = base_builder();
f(&mut b);
b
}
fn unwrap_request(builder: GenerateVideoRequestBuilder) -> GrokApiGrokImagineVideo1p5RequestState {
let result = build_grok_api_grok_imagine_video_1p5(builder).expect("build should succeed");
match result {
VideoGenerationDraftOrRequest::Request(VideoGenerationRequest::GrokApiGrokImagineVideo1p5(s)) => s,
_ => panic!("expected GrokApiGrokImagineVideo1p5 request"),
}
}
}
@@ -0,0 +1,308 @@
use grok_api_client::api::traits::grok_request_cost_calculator_trait::GrokRequestCostCalculator;
use crate::generate::generate_video::video_generation_cost_estimate::VideoGenerationCostEstimate;
use crate::generate::generate_video_v2::providers::grok_api::grok_imagine_video_1p5::request::GrokApiGrokImagineVideo1p5RequestState;
pub struct GrokApiGrokImagineVideo1p5CostState {
request: GrokApiGrokImagineVideo1p5RequestState,
}
impl GrokApiGrokImagineVideo1p5CostState {
pub fn from_request(request: &GrokApiGrokImagineVideo1p5RequestState) -> Self {
Self { request: request.clone() }
}
pub fn estimate_cost(&self) -> VideoGenerationCostEstimate {
// grok_api_client's calculator picks the v1.5 pricing tier from the
// request's `model` field (see VideoModel::pricing_tier). The build step
// sets `model = Some(GrokImagineVideo1p5Preview)`, so this returns the
// v1.5 rates:
// Output: 80 mills/s @ 480p, 140 mills/s @ 720p
// Input: 10 mills per source image
let cost_in_usd_cents = self.request.request.calculate_cost_in_cents();
VideoGenerationCostEstimate {
cost_in_credits: None,
cost_in_usd_cents: Some(cost_in_usd_cents),
is_free: false,
is_unlimited: false,
is_rate_limited: false,
has_watermark: false,
failures_are_refunded: None,
}
}
}
#[cfg(test)]
mod tests {
use crate::api::router_aspect_ratio::RouterAspectRatio;
use crate::api::router_resolution::RouterResolution;
use crate::api::router_video_model::RouterVideoModel;
use crate::api::image_list_ref::ImageListRef;
use crate::api::image_ref::ImageRef;
use crate::api::router_provider::RouterProvider;
use crate::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder;
// v1.5 pricing reference (from grok_api_client/video_generation/cost.rs):
// Output: 480p 80 mills/s ($0.08/s), 720p 140 mills/s ($0.14/s)
// Input: 10 mills per source image ($0.01/img)
//
// Cost in cents = ceil(mills / 10).
// ── 720p pricing ──
mod pricing_720p {
use super::*;
#[test]
fn five_s_no_image_ref() {
// 140 × 5 = 700 mills → 70¢
assert_eq!(cost_cents(Some(RouterResolution::SevenTwentyP), 5, None, None), 70);
}
#[test]
fn ten_s_no_image_ref() {
// 140 × 10 = 1400 mills → 140¢
assert_eq!(cost_cents(Some(RouterResolution::SevenTwentyP), 10, None, None), 140);
}
#[test]
fn fifteen_s_no_image_ref() {
// 140 × 15 = 2100 mills → 210¢
assert_eq!(cost_cents(Some(RouterResolution::SevenTwentyP), 15, None, None), 210);
}
}
// ── 480p pricing ──
mod pricing_480p {
use super::*;
#[test]
fn five_s_no_image_ref() {
// 80 × 5 = 400 mills → 40¢
assert_eq!(cost_cents(Some(RouterResolution::FourEightyP), 5, None, None), 40);
}
#[test]
fn ten_s_no_image_ref() {
// 80 × 10 = 800 mills → 80¢
assert_eq!(cost_cents(Some(RouterResolution::FourEightyP), 10, None, None), 80);
}
#[test]
fn fifteen_s_no_image_ref() {
// 80 × 15 = 1200 mills → 120¢
assert_eq!(cost_cents(Some(RouterResolution::FourEightyP), 15, None, None), 120);
}
}
// ── Image input pricing (10 mills/image, NOT 2 mills as in v1) ──
mod image_inputs_add_to_cost {
use super::*;
#[test]
fn single_start_frame_image_adds_10_mills() {
// 5s @ 720p output 700 mills + 1 input image 10 mills = 710 mills → 71¢
let cents = cost_cents(
Some(RouterResolution::SevenTwentyP),
5,
Some(ImageRef::Url("https://example.com/a.png".to_string())),
None,
);
assert_eq!(cents, 71);
}
#[test]
fn three_reference_images_adds_30_mills() {
// 5s @ 720p output 700 mills + 3 input images 30 mills = 730 mills → 73¢
let cents = cost_cents(
Some(RouterResolution::SevenTwentyP),
5,
None,
Some(ImageListRef::Urls(vec![
"https://example.com/a.png".to_string(),
"https://example.com/b.png".to_string(),
"https://example.com/c.png".to_string(),
])),
);
assert_eq!(cents, 73);
}
#[test]
fn image_input_count_scales_linearly() {
// Fix 5s @ 720p (700 mills base); vary ref image count 1..=5.
for n in 1u64..=5 {
let images: Vec<String> = (0..n)
.map(|i| format!("https://example.com/{i}.png"))
.collect();
let cents = cost_cents(
Some(RouterResolution::SevenTwentyP),
5,
None,
Some(ImageListRef::Urls(images)),
);
let expected_mills = 700 + 10 * n;
let expected_cents = expected_mills.div_ceil(10);
assert_eq!(cents, expected_cents, "n={n}");
}
}
}
// ── Relative pricing sanity ──
mod relative_pricing {
use super::*;
#[test]
fn higher_resolution_costs_more() {
let p480 = cost_cents(Some(RouterResolution::FourEightyP), 10, None, None);
let p720 = cost_cents(Some(RouterResolution::SevenTwentyP), 10, None, None);
assert!(p480 < p720, "480p ({p480}) should be < 720p ({p720})");
}
#[test]
fn longer_duration_costs_more() {
let c5 = cost_cents(Some(RouterResolution::SevenTwentyP), 5, None, None);
let c10 = cost_cents(Some(RouterResolution::SevenTwentyP), 10, None, None);
let c15 = cost_cents(Some(RouterResolution::SevenTwentyP), 15, None, None);
assert!(c5 < c10);
assert!(c10 < c15);
}
#[test]
fn one_k_request_clamps_to_480p_pricing() {
let one_k = cost_cents(Some(RouterResolution::OneK), 10, None, None);
let p480 = cost_cents(Some(RouterResolution::FourEightyP), 10, None, None);
assert_eq!(one_k, p480);
}
#[test]
fn ten_eighty_p_request_clamps_to_720p_pricing() {
let p1080 = cost_cents(Some(RouterResolution::TenEightyP), 10, None, None);
let p720 = cost_cents(Some(RouterResolution::SevenTwentyP), 10, None, None);
assert_eq!(p1080, p720);
}
}
// ── Cross-tier sanity: 1.5 must cost more than v1 at same settings ──
mod cross_tier {
use super::*;
#[test]
fn v1p5_costs_more_than_v1_at_720p() {
let v1p5 = cost_cents(Some(RouterResolution::SevenTwentyP), 5, None, None);
let v1 = v1_cost_cents(Some(RouterResolution::SevenTwentyP), 5, None, None);
assert!(v1 < v1p5, "v1={v1} v1p5={v1p5}");
}
#[test]
fn v1p5_costs_more_than_v1_at_480p() {
let v1p5 = cost_cents(Some(RouterResolution::FourEightyP), 5, None, None);
let v1 = v1_cost_cents(Some(RouterResolution::FourEightyP), 5, None, None);
assert!(v1 < v1p5, "v1={v1} v1p5={v1p5}");
}
}
// ── Exhaustive matrix ──
mod exhaustive_matrix {
use super::*;
// (resolution, duration_s, has_image, ref_count, expected_cents)
//
// Mills math:
// 480p output: 80 mills/s 720p output: 140 mills/s
// per input image: 10 mills
// cents = ceil(mills / 10)
const CASES: &[(RouterResolution, u16, bool, usize, u64)] = &[
// 480p text-only
(RouterResolution::FourEightyP, 1, false, 0, 8), // 80
(RouterResolution::FourEightyP, 5, false, 0, 40), // 400
(RouterResolution::FourEightyP, 8, false, 0, 64), // 640
(RouterResolution::FourEightyP, 15, false, 0, 120), // 1200
// 720p text-only
(RouterResolution::SevenTwentyP, 1, false, 0, 14), // 140
(RouterResolution::SevenTwentyP, 5, false, 0, 70), // 700
(RouterResolution::SevenTwentyP, 8, false, 0, 112), // 1120
(RouterResolution::SevenTwentyP,15, false, 0, 210), // 2100
// 480p with image-to-video (1 input image, +10 mills)
(RouterResolution::FourEightyP, 5, true, 0, 41), // 410
(RouterResolution::FourEightyP, 10, true, 0, 81), // 810
// 720p with image-to-video (1 input image, +10 mills)
(RouterResolution::SevenTwentyP, 5, true, 0, 71), // 710
(RouterResolution::SevenTwentyP,10, true, 0, 141), // 1410
// 720p reference-to-video (no start frame, N reference images)
(RouterResolution::SevenTwentyP, 5, false, 1, 71), // 710
(RouterResolution::SevenTwentyP, 5, false, 2, 72), // 720
(RouterResolution::SevenTwentyP, 5, false, 3, 73), // 730
];
#[test]
fn all_matrix_cases() {
for &(res, duration, has_image, ref_count, expected_cents) in CASES {
let start_frame = if has_image {
Some(ImageRef::Url("https://example.com/start.png".to_string()))
} else { None };
let reference_images = if ref_count > 0 {
Some(ImageListRef::Urls(
(0..ref_count).map(|i| format!("https://example.com/ref_{i}.png")).collect(),
))
} else { None };
let cents = cost_cents(Some(res), duration, start_frame, reference_images);
assert_eq!(
cents, expected_cents,
"res={res:?} dur={duration} has_image={has_image} ref_count={ref_count}",
);
}
}
}
// ── Helpers ──
fn cost_cents(
resolution: Option<RouterResolution>,
duration_seconds: u16,
start_frame: Option<ImageRef>,
reference_images: Option<ImageListRef>,
) -> u64 {
build_for(RouterVideoModel::GrokImagineVideo1p5, resolution, duration_seconds, start_frame, reference_images)
}
fn v1_cost_cents(
resolution: Option<RouterResolution>,
duration_seconds: u16,
start_frame: Option<ImageRef>,
reference_images: Option<ImageListRef>,
) -> u64 {
build_for(RouterVideoModel::GrokImagineVideo, resolution, duration_seconds, start_frame, reference_images)
}
fn build_for(
model: RouterVideoModel,
resolution: Option<RouterResolution>,
duration_seconds: u16,
start_frame: Option<ImageRef>,
reference_images: Option<ImageListRef>,
) -> u64 {
let builder = GenerateVideoRequestBuilder {
model,
provider: RouterProvider::GrokApi,
aspect_ratio: Some(RouterAspectRatio::WideSixteenByNine),
resolution,
duration_seconds: Some(duration_seconds),
video_batch_count: Some(1),
start_frame,
reference_images,
..Default::default()
};
builder.build2()
.expect("build2 should succeed")
.estimate_cost()
.expect("estimate_cost should succeed")
.cost_in_usd_cents
.unwrap()
}
}
@@ -0,0 +1,3 @@
pub mod build;
pub mod cost;
pub mod request;
@@ -0,0 +1,152 @@
use std::sync::Arc;
use grok_api_client::api::requests::videos::video_generation::video_generation::{
video_generation, VideoGenerationArgs, VideoGenerationRequest as GrokVideoGenerationRequest,
};
use crate::client::router_grok_api_client::RouterGrokApiClient;
use crate::errors::artcraft_router_error::ArtcraftRouterError;
use crate::errors::provider_error::ProviderError;
use crate::generate::generate_video::generate_video_response::{
GenerateVideoResponse, GrokVideoResponsePayload,
};
#[derive(Clone, Debug)]
pub struct GrokApiGrokImagineVideo1p5RequestState {
/// The fully-resolved Grok request body, with `model` set to
/// `VideoModel::GrokImagineVideo1p5Preview`. Doesn't carry the API key —
/// that gets borrowed at send time from the router client.
pub request: GrokVideoGenerationRequest,
}
impl GrokApiGrokImagineVideo1p5RequestState {
pub async fn send(&self, client: &RouterGrokApiClient) -> Result<GenerateVideoResponse, ArtcraftRouterError> {
let outbound_request = Arc::new(self.request.clone());
let response = video_generation(VideoGenerationArgs {
api_key: &client.api_key,
request: self.request.clone(),
})
.await
.map_err(|err| ArtcraftRouterError::Provider(ProviderError::Grok(err)))?;
Ok(GenerateVideoResponse::Grok(GrokVideoResponsePayload {
request_id: response.request_id,
maybe_outbound_request: Some(outbound_request),
}))
}
}
#[cfg(test)]
mod tests {
use super::*;
use grok_api_client::api::types::video_types::video_model::VideoModel;
use grok_api_client::creds::grok_api_key::GrokApiKey;
use test_data::web::image_urls::JUNO_AT_LAKE_IMAGE_URL;
use crate::api::router_aspect_ratio::RouterAspectRatio;
use crate::api::router_resolution::RouterResolution;
use crate::api::router_video_model::RouterVideoModel;
use crate::api::image_ref::ImageRef;
use crate::api::router_provider::RouterProvider;
use crate::client::router_client::RouterClient;
use crate::client::router_grok_api_client::RouterGrokApiClient;
use crate::generate::generate_video::generate_video_request_builder::GenerateVideoRequestBuilder;
use crate::generate::generate_video::generate_video_response::GenerateVideoResponse;
use crate::generate::generate_video_v2::video_generation_draft_or_request::VideoGenerationDraftOrRequest;
// ── Wire-shape sanity: the built request always carries the 1.5 model id ──
#[test]
fn built_request_targets_grok_imagine_video_1p5_preview() {
let builder = GenerateVideoRequestBuilder {
prompt: Some("test".to_string()),
resolution: Some(RouterResolution::SevenTwentyP),
..grok_builder()
};
let request = unwrap_request(builder);
assert_eq!(request.request.model, Some(VideoModel::GrokImagineVideo1p5Preview));
}
// ── Live API tests (manual) ──
#[tokio::test]
#[ignore] // requires real API key, incurs costs
async fn test_text_to_video_720p() {
let response = run_pipeline(GenerateVideoRequestBuilder {
prompt: Some("A glowing crystal rocket launching from Mars.".to_string()),
aspect_ratio: Some(RouterAspectRatio::WideSixteenByNine),
resolution: Some(RouterResolution::SevenTwentyP),
duration_seconds: Some(5),
..grok_builder()
}).await;
assert!(matches!(response, GenerateVideoResponse::Grok(_)));
}
#[tokio::test]
#[ignore] // requires real API key, incurs costs
async fn test_image_to_video_480p() {
let response = run_pipeline(GenerateVideoRequestBuilder {
prompt: Some("The dog leaps into the lake and splashes around.".to_string()),
start_frame: Some(ImageRef::Url(JUNO_AT_LAKE_IMAGE_URL.to_string())),
resolution: Some(RouterResolution::FourEightyP),
duration_seconds: Some(5),
..grok_builder()
}).await;
assert!(matches!(response, GenerateVideoResponse::Grok(_)));
}
#[tokio::test]
#[ignore] // requires real API key, incurs costs
async fn test_image_to_video_720p() {
let response = run_pipeline(GenerateVideoRequestBuilder {
prompt: Some("The dog leaps into the lake and splashes around.".to_string()),
start_frame: Some(ImageRef::Url(JUNO_AT_LAKE_IMAGE_URL.to_string())),
resolution: Some(RouterResolution::SevenTwentyP),
duration_seconds: Some(5),
..grok_builder()
}).await;
assert!(matches!(response, GenerateVideoResponse::Grok(_)));
}
fn grok_builder() -> GenerateVideoRequestBuilder {
GenerateVideoRequestBuilder {
model: RouterVideoModel::GrokImagineVideo1p5,
provider: RouterProvider::GrokApi,
video_batch_count: Some(1),
..Default::default()
}
}
fn get_grok_client() -> RouterClient {
let secret = std::fs::read_to_string("/Users/bt/Artcraft/credentials/grok_api_key.txt")
.expect("Failed to read Grok API key");
let api_key = GrokApiKey::new(secret.trim().to_string());
RouterClient::GrokApi(RouterGrokApiClient::new(api_key))
}
fn unwrap_request(builder: GenerateVideoRequestBuilder) -> GrokApiGrokImagineVideo1p5RequestState {
let result = builder.build2().expect("build2 should succeed");
match result {
VideoGenerationDraftOrRequest::Request(
crate::generate::generate_video_v2::video_generation_request::VideoGenerationRequest::GrokApiGrokImagineVideo1p5(s)
) => s,
_ => panic!("expected GrokApiGrokImagineVideo1p5 request"),
}
}
async fn run_pipeline(builder: GenerateVideoRequestBuilder) -> GenerateVideoResponse {
let client = get_grok_client();
let draft_or_request = builder.build2().expect("build2 should succeed");
let request = match draft_or_request {
VideoGenerationDraftOrRequest::Request(r) => r,
_ => panic!("expected Request variant (Grok skips draft)"),
};
let response = request.send_request(&client).await.expect("send_request should succeed");
match &response {
GenerateVideoResponse::Grok(p) => println!("grok request_id={}", p.request_id),
other => println!("unexpected response: {:?}", other),
}
response
}
}
@@ -1 +1,2 @@
pub mod grok_imagine_video;
pub mod grok_imagine_video_1p5;
@@ -5,6 +5,8 @@ use crate::generate::generate_video::generate_video_response::GenerateVideoRespo
use crate::generate::generate_video::video_generation_cost_estimate::VideoGenerationCostEstimate;
use crate::generate::generate_video_v2::providers::artcraft::grok_imagine_video::cost::ArtcraftGrokImagineVideoCostState;
use crate::generate::generate_video_v2::providers::artcraft::grok_imagine_video::request::ArtcraftGrokImagineVideoRequestState;
use crate::generate::generate_video_v2::providers::artcraft::grok_imagine_video_1p5::cost::ArtcraftGrokImagineVideo1p5CostState;
use crate::generate::generate_video_v2::providers::artcraft::grok_imagine_video_1p5::request::ArtcraftGrokImagineVideo1p5RequestState;
use crate::generate::generate_video_v2::providers::artcraft::happy_horse_1p0::cost::ArtcraftHappyHorse1p0CostState;
use crate::generate::generate_video_v2::providers::artcraft::happy_horse_1p0::request::ArtcraftHappyHorse1p0RequestState;
use crate::generate::generate_video_v2::providers::artcraft::kling_1_6_pro::cost::ArtcraftKling16ProCostState;
@@ -97,6 +99,8 @@ use crate::generate::generate_video_v2::providers::gmicloud::seedance_2p0_fast_g
use crate::generate::generate_video_v2::providers::gmicloud::seedance_2p0_fast_g::request::GmiCloudSeedance2p0UltraFastRequestState;
use crate::generate::generate_video_v2::providers::grok_api::grok_imagine_video::cost::GrokApiGrokImagineVideoCostState;
use crate::generate::generate_video_v2::providers::grok_api::grok_imagine_video::request::GrokApiGrokImagineVideoRequestState;
use crate::generate::generate_video_v2::providers::grok_api::grok_imagine_video_1p5::cost::GrokApiGrokImagineVideo1p5CostState;
use crate::generate::generate_video_v2::providers::grok_api::grok_imagine_video_1p5::request::GrokApiGrokImagineVideo1p5RequestState;
use crate::generate::generate_video_v2::providers::kinovi::happy_horse_1p0::cost::KinoviHappyHorse1p0CostState;
use crate::generate::generate_video_v2::providers::kinovi::happy_horse_1p0::request::KinoviHappyHorse1p0RequestState;
use crate::generate::generate_video_v2::providers::kinovi::seedance_2p0::cost::KinoviSeedance2p0CostState;
@@ -107,6 +111,7 @@ use crate::generate::generate_video_v2::providers::kinovi::seedance_2p0_fast::re
#[derive(Clone, Debug)]
pub enum VideoGenerationRequest {
ArtcraftGrokImagineVideo(ArtcraftGrokImagineVideoRequestState),
ArtcraftGrokImagineVideo1p5(ArtcraftGrokImagineVideo1p5RequestState),
ArtcraftHappyHorse1p0(ArtcraftHappyHorse1p0RequestState),
ArtcraftKling16Pro(ArtcraftKling16ProRequestState),
ArtcraftKling21Master(ArtcraftKling21MasterRequestState),
@@ -153,6 +158,7 @@ pub enum VideoGenerationRequest {
GmiCloudSeedance2p0Ultra(GmiCloudSeedance2p0UltraRequestState),
GmiCloudSeedance2p0UltraFast(GmiCloudSeedance2p0UltraFastRequestState),
GrokApiGrokImagineVideo(GrokApiGrokImagineVideoRequestState),
GrokApiGrokImagineVideo1p5(GrokApiGrokImagineVideo1p5RequestState),
KinoviHappyHorse1p0(KinoviHappyHorse1p0RequestState),
KinoviSeedance2p0(KinoviSeedance2p0RequestState),
KinoviSeedance2p0Fast(KinoviSeedance2p0FastRequestState),
@@ -163,6 +169,7 @@ impl VideoGenerationRequest {
pub fn get_provider(&self) -> RouterProvider {
match self {
Self::ArtcraftGrokImagineVideo(_) => RouterProvider::Artcraft,
Self::ArtcraftGrokImagineVideo1p5(_) => RouterProvider::Artcraft,
Self::ArtcraftHappyHorse1p0(_) => RouterProvider::Artcraft,
Self::ArtcraftKling16Pro(_) => RouterProvider::Artcraft,
Self::ArtcraftKling21Master(_) => RouterProvider::Artcraft,
@@ -209,6 +216,7 @@ impl VideoGenerationRequest {
Self::GmiCloudSeedance2p0Ultra(_) => RouterProvider::GmiCloud,
Self::GmiCloudSeedance2p0UltraFast(_) => RouterProvider::GmiCloud,
Self::GrokApiGrokImagineVideo(_) => RouterProvider::GrokApi,
Self::GrokApiGrokImagineVideo1p5(_) => RouterProvider::GrokApi,
Self::KinoviHappyHorse1p0(_) => RouterProvider::Seedance2Pro,
Self::KinoviSeedance2p0(_) => RouterProvider::Seedance2Pro,
Self::KinoviSeedance2p0Fast(_) => RouterProvider::Seedance2Pro,
@@ -219,6 +227,7 @@ impl VideoGenerationRequest {
pub fn estimate_cost(&self) -> Result<VideoGenerationCostEstimate, ArtcraftRouterError> {
match self {
VideoGenerationRequest::ArtcraftGrokImagineVideo(request) => Ok(ArtcraftGrokImagineVideoCostState::from_request(request).estimate_cost()),
VideoGenerationRequest::ArtcraftGrokImagineVideo1p5(request) => Ok(ArtcraftGrokImagineVideo1p5CostState::from_request(request).estimate_cost()),
VideoGenerationRequest::ArtcraftHappyHorse1p0(request) => Ok(ArtcraftHappyHorse1p0CostState::from_request(request).estimate_cost()),
VideoGenerationRequest::ArtcraftKling16Pro(request) => Ok(ArtcraftKling16ProCostState::from_request(request).estimate_cost()),
VideoGenerationRequest::ArtcraftKling21Master(request) => Ok(ArtcraftKling21MasterCostState::from_request(request).estimate_cost()),
@@ -265,6 +274,7 @@ impl VideoGenerationRequest {
VideoGenerationRequest::GmiCloudSeedance2p0Ultra(request) => Ok(GmiCloudSeedance2p0UltraCostState::from_request(request).estimate_cost()),
VideoGenerationRequest::GmiCloudSeedance2p0UltraFast(request) => Ok(GmiCloudSeedance2p0UltraFastCostState::from_request(request).estimate_cost()),
VideoGenerationRequest::GrokApiGrokImagineVideo(request) => Ok(GrokApiGrokImagineVideoCostState::from_request(request).estimate_cost()),
VideoGenerationRequest::GrokApiGrokImagineVideo1p5(request) => Ok(GrokApiGrokImagineVideo1p5CostState::from_request(request).estimate_cost()),
VideoGenerationRequest::KinoviHappyHorse1p0(request) => Ok(KinoviHappyHorse1p0CostState::from_request(request).estimate_cost()),
VideoGenerationRequest::KinoviSeedance2p0(request) => Ok(KinoviSeedance2p0CostState::from_request(request).estimate_cost()),
VideoGenerationRequest::KinoviSeedance2p0Fast(request) => Ok(KinoviSeedance2p0FastCostState::from_request(request).estimate_cost()),
@@ -279,6 +289,10 @@ impl VideoGenerationRequest {
let client_ref = client.get_artcraft_client_ref()?;
request.send(client_ref).await
},
VideoGenerationRequest::ArtcraftGrokImagineVideo1p5(request) => {
let client_ref = client.get_artcraft_client_ref()?;
request.send(client_ref).await
},
VideoGenerationRequest::ArtcraftHappyHorse1p0(request) => {
let client_ref = client.get_artcraft_client_ref()?;
request.send(client_ref).await
@@ -463,6 +477,10 @@ impl VideoGenerationRequest {
let client_ref = client.get_grok_api_client_ref()?;
request.send(client_ref).await
},
VideoGenerationRequest::GrokApiGrokImagineVideo1p5(request) => {
let client_ref = client.get_grok_api_client_ref()?;
request.send(client_ref).await
},
VideoGenerationRequest::KinoviHappyHorse1p0(request) => {
let client_ref = client.get_seedance2pro_client_ref()?;
request.send(client_ref).await
@@ -1,27 +1,39 @@
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_model::{VideoModel, VideoModelPricingTier};
use crate::api::types::video_types::video_resolution::VideoResolution;
// xAI pricing per <https://docs.x.ai/developers/pricing>:
// xAI pricing per <https://docs.x.ai/developers/pricing> and
// <https://docs.x.ai/developers/models/grok-imagine-video-1.5-preview>:
//
// grok-imagine-video
// grok-imagine-video (v1)
// Output: 480p $0.05/sec, 720p $0.07/sec
// Input: $0.01/sec (source video) + $0.002/img (source images)
//
// grok-imagine-video-1.5-preview (v1.5)
// Output: 480p $0.08/sec, 720p $0.14/sec
// Input: $0.01/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)
// v1 output: 480p 50 mills/sec, 720p 70 mills/sec
// v1 input: 10 mills/sec (video), 2 mills/img
// v1.5 output: 480p 80 mills/sec, 720p 140 mills/sec
// v1.5 input: 10 mills/img
//
// video_generation has NO input video — only optional input images. xAI
// defaults: `duration` = 8 seconds, `resolution` = "480p".
// defaults: `duration` = 8 seconds, `resolution` = "480p" (the server side;
// our local default below is 720p — see DEFAULT comments in the impl).
/// 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.
/// Per-source-image input rate (v1 tier), in mills.
pub(crate) const INPUT_MILLS_PER_IMAGE: UsdMills = 2;
/// Per-source-image input rate (v1.5 tier), in mills.
const INPUT_MILLS_PER_IMAGE_V1P5: UsdMills = 10;
/// 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`.
@@ -31,8 +43,12 @@ 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 tier = self.model
.as_ref()
.map(VideoModel::pricing_tier)
.unwrap_or(VideoModelPricingTier::V1);
let output_mills = output_mills_per_second(resolution) * duration;
let output_mills = output_mills_per_second_for_tier(resolution, tier) * duration;
// Source image count = `image` (image-to-video) + len(`reference_images`)
// (reference-to-video). These are mutually exclusive at call time but the
@@ -41,17 +57,38 @@ impl GrokRequestCostCalculator for VideoGenerationRequest {
(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;
let input_mills = input_mills_per_image(tier) * source_image_count;
output_mills + input_mills
}
}
/// Output rate in mills per second of generated video, by resolution.
/// Output rate in mills per second of generated video at v1 rates, by
/// resolution. Kept for cross-module use by `video_edit` and
/// `video_extension`, which both assume v1 pricing and do not currently take
/// a model into account in their cost calculators.
pub(crate) fn output_mills_per_second(resolution: VideoResolution) -> UsdMills {
match resolution {
VideoResolution::FourEightyP => 50,
VideoResolution::SevenTwentyP => 70,
output_mills_per_second_for_tier(resolution, VideoModelPricingTier::V1)
}
/// Output rate in mills per second of generated video, by resolution AND
/// pricing tier. Used by `video_generation`.
fn output_mills_per_second_for_tier(
resolution: VideoResolution,
tier: VideoModelPricingTier,
) -> UsdMills {
match (tier, resolution) {
(VideoModelPricingTier::V1, VideoResolution::FourEightyP) => 50,
(VideoModelPricingTier::V1, VideoResolution::SevenTwentyP) => 70,
(VideoModelPricingTier::V1p5Preview, VideoResolution::FourEightyP) => 80,
(VideoModelPricingTier::V1p5Preview, VideoResolution::SevenTwentyP) => 140,
}
}
fn input_mills_per_image(tier: VideoModelPricingTier) -> UsdMills {
match tier {
VideoModelPricingTier::V1 => INPUT_MILLS_PER_IMAGE,
VideoModelPricingTier::V1p5Preview => INPUT_MILLS_PER_IMAGE_V1P5,
}
}
@@ -236,8 +273,9 @@ mod tests {
}
#[test]
fn cost_is_independent_of_model_variant() {
// All `model` enum variants map to the same pricing (only one video model).
fn cost_within_v1_tier_is_independent_of_model_variant() {
// Within the v1 pricing tier (default + unrecognized Custom strings),
// model variant must not affect cost.
let mut base = make_request(Some(5), Some(VideoResolution::FourEightyP), None, None);
let base_cost = base.calculate_cost_in_mills();
for m in [
@@ -250,6 +288,132 @@ mod tests {
}
}
// ── grok-imagine-video-1.5-preview pricing ──
//
// v1.5 rates per the model's docs page:
// 480p: 80 mills/sec, 720p: 140 mills/sec, 10 mills per source image.
mod v1p5_preview {
use super::*;
fn make_v1p5_request(
duration: Option<u32>,
resolution: Option<VideoResolution>,
image: Option<VideoImageSource>,
reference_images: Option<Vec<VideoImageSource>>,
) -> VideoGenerationRequest {
let mut req = make_request(duration, resolution, image, reference_images);
req.model = Some(VideoModel::GrokImagineVideo1p5Preview);
req
}
#[test]
fn five_seconds_480p_text_only() {
// 80 × 5 = 400 mills = 40¢
let req = make_v1p5_request(Some(5), Some(VideoResolution::FourEightyP), None, None);
assert_eq!(req.calculate_cost_in_mills(), 400);
assert_eq!(req.calculate_cost_in_cents(), 40);
}
#[test]
fn five_seconds_720p_text_only() {
// 140 × 5 = 700 mills = 70¢
let req = make_v1p5_request(Some(5), Some(VideoResolution::SevenTwentyP), None, None);
assert_eq!(req.calculate_cost_in_mills(), 700);
assert_eq!(req.calculate_cost_in_cents(), 70);
}
#[test]
fn five_seconds_720p_one_source_image() {
// 140 × 5 + 10 × 1 = 710 mills = 71¢
let req = make_v1p5_request(Some(5), Some(VideoResolution::SevenTwentyP), Some(url_source()), None);
assert_eq!(req.calculate_cost_in_mills(), 710);
assert_eq!(req.calculate_cost_in_cents(), 71);
}
#[test]
fn five_seconds_480p_three_reference_images() {
// 80 × 5 + 10 × 3 = 430 mills = 43¢
let req = make_v1p5_request(
Some(5), Some(VideoResolution::FourEightyP),
None, Some(vec![url_source(), url_source(), url_source()]),
);
assert_eq!(req.calculate_cost_in_mills(), 430);
assert_eq!(req.calculate_cost_in_cents(), 43);
}
#[test]
fn dated_alias_via_custom_is_billed_as_v1p5() {
// xAI accepts the dated alias as a synonym; cost must match the
// canonical 1.5 variant exactly.
let canonical = {
let mut req = make_v1p5_request(Some(5), Some(VideoResolution::SevenTwentyP), Some(url_source()), None);
req.model = Some(VideoModel::GrokImagineVideo1p5Preview);
req.calculate_cost_in_mills()
};
let via_alias = {
let mut req = make_v1p5_request(Some(5), Some(VideoResolution::SevenTwentyP), Some(url_source()), None);
req.model = Some(VideoModel::Custom("grok-imagine-video-1.5-2026-05-30".to_string()));
req.calculate_cost_in_mills()
};
assert_eq!(canonical, via_alias);
}
#[test]
fn custom_with_canonical_1p5_name_is_billed_as_v1p5() {
let via_custom = {
let mut req = make_request(Some(5), Some(VideoResolution::SevenTwentyP), None, None);
req.model = Some(VideoModel::Custom("grok-imagine-video-1.5-preview".to_string()));
req.calculate_cost_in_mills()
};
let via_enum = make_v1p5_request(Some(5), Some(VideoResolution::SevenTwentyP), None, None)
.calculate_cost_in_mills();
assert_eq!(via_custom, via_enum);
}
#[test]
fn v1p5_costs_more_than_v1_at_same_settings() {
let v1 = make_request(Some(5), Some(VideoResolution::SevenTwentyP), None, None).calculate_cost_in_mills();
let v1p5 = make_v1p5_request(Some(5), Some(VideoResolution::SevenTwentyP), None, None).calculate_cost_in_mills();
assert!(v1 < v1p5, "v1={v1} v1p5={v1p5}");
}
// (duration, resolution, has_image, ref_count, expected_mills)
const V1P5_CASES: &[(u32, VideoResolution, bool, usize, u64)] = &[
// 480p text-only
( 1, VideoResolution::FourEightyP, false, 0, 80),
( 8, VideoResolution::FourEightyP, false, 0, 640),
(15, VideoResolution::FourEightyP, false, 0, 1200),
// 720p text-only
( 1, VideoResolution::SevenTwentyP, false, 0, 140),
( 8, VideoResolution::SevenTwentyP, false, 0, 1120),
(15, VideoResolution::SevenTwentyP, false, 0, 2100),
// 480p with image-to-video
( 5, VideoResolution::FourEightyP, true, 0, 410),
(10, VideoResolution::FourEightyP, true, 0, 810),
// 720p with image-to-video
( 5, VideoResolution::SevenTwentyP, true, 0, 710),
// 720p with reference images
( 5, VideoResolution::SevenTwentyP, false, 2, 720),
( 5, VideoResolution::SevenTwentyP, false, 3, 730),
];
#[test]
fn v1p5_matrix() {
for &(duration, res, has_image, ref_count, expected) in V1P5_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_v1p5_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}",
);
}
}
}
// ── Exhaustive matrix ──
mod exhaustive_matrix {
@@ -169,6 +169,21 @@ mod tests {
assert!(result.video.is_none());
}
#[test]
fn pending_response_with_v1p5_model_passes_model_string_through() {
// The `model` field is opaque Option<String>; the v1.5 preview model
// identifier (and its dated alias) flow through unchanged.
let json = r#"{ "status": "pending", "progress": 5, "model": "grok-imagine-video-1.5-preview" }"#;
let parsed: VideoStatusResponseBody = serde_json::from_str(json).unwrap();
let result = classify_status_field(&parsed).unwrap();
assert_eq!(result.model.as_deref(), Some("grok-imagine-video-1.5-preview"));
let json_alias = r#"{ "status": "pending", "progress": 5, "model": "grok-imagine-video-1.5-2026-05-30" }"#;
let parsed_alias: VideoStatusResponseBody = serde_json::from_str(json_alias).unwrap();
let result_alias = classify_status_field(&parsed_alias).unwrap();
assert_eq!(result_alias.model.as_deref(), Some("grok-imagine-video-1.5-2026-05-30"));
}
#[test]
fn done_response_classifies_as_done_with_video() {
let json = r#"{
@@ -291,7 +306,8 @@ mod tests {
let result = video_status(VideoStatusArgs {
api_key: &api_key,
request: VideoStatusRequest {
request_id: "e397ac83-c22f-90b1-9831-900c01497345".to_string(),
//request_id: "e397ac83-c22f-90b1-9831-900c01497345".to_string(),
request_id: "ce681bd0-133d-9cf3-975c-422d292d4e8e".to_string(),
},
}).await;
@@ -13,10 +13,16 @@
/// - <https://docs.x.ai/developers/model-capabilities/video/extension>
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum VideoModel {
/// `grok-imagine-video` — the (currently sole) video model. Used by
/// `grok-imagine-video` — the original Imagine video model. Used by
/// generation, editing, and extension endpoints.
GrokImagineVideo,
/// `grok-imagine-video-1.5-preview` — the 1.5 preview model. xAI also
/// accepts the dated alias `grok-imagine-video-1.5-2026-05-30`, which
/// resolves to the same model server-side; serialize via this variant for
/// the canonical name.
GrokImagineVideo1p5Preview,
/// Escape hatch for model identifiers not yet enumerated here.
Custom(String),
}
@@ -25,10 +31,40 @@ impl VideoModel {
/// Wire representation — the exact string xAI expects in the `"model"` field.
pub fn as_str(&self) -> &str {
match self {
Self::GrokImagineVideo => "grok-imagine-video",
Self::Custom(s) => s.as_str(),
Self::GrokImagineVideo => "grok-imagine-video",
Self::GrokImagineVideo1p5Preview => "grok-imagine-video-1.5-preview",
Self::Custom(s) => s.as_str(),
}
}
/// Pricing tier this model bills under. Includes recognition of the dated
/// alias `grok-imagine-video-1.5-2026-05-30` passed via [`Self::Custom`],
/// which xAI treats as a synonym for `grok-imagine-video-1.5-preview` and
/// therefore charges at the 1.5 rates.
pub fn pricing_tier(&self) -> VideoModelPricingTier {
match self {
Self::GrokImagineVideo => VideoModelPricingTier::V1,
Self::GrokImagineVideo1p5Preview => VideoModelPricingTier::V1p5Preview,
Self::Custom(s) => match s.as_str() {
"grok-imagine-video-1.5-preview" | "grok-imagine-video-1.5-2026-05-30" => {
VideoModelPricingTier::V1p5Preview
}
_ => VideoModelPricingTier::V1,
},
}
}
}
/// Pricing tier identifier used by the per-request cost calculators. xAI
/// publishes different per-second and per-image rates for each tier — see
/// `video_generation/cost.rs`.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum VideoModelPricingTier {
/// `grok-imagine-video` and any unrecognized identifier that defaults
/// to v1 rates.
V1,
/// `grok-imagine-video-1.5-preview` (and its dated alias).
V1p5Preview,
}
// Serialize as the wire string ("grok-imagine-video" or the Custom inner
@@ -50,6 +86,18 @@ mod tests {
assert_eq!(VideoModel::GrokImagineVideo.as_str(), "grok-imagine-video");
}
#[test]
fn one_point_five_preview_serializes_canonical_name() {
assert_eq!(
VideoModel::GrokImagineVideo1p5Preview.as_str(),
"grok-imagine-video-1.5-preview",
);
assert_eq!(
serde_json::to_string(&VideoModel::GrokImagineVideo1p5Preview).unwrap(),
"\"grok-imagine-video-1.5-preview\"",
);
}
#[test]
fn custom_model_passes_through() {
let m = VideoModel::Custom("grok-imagine-video-future".to_string());
@@ -62,4 +110,48 @@ mod tests {
let m = VideoModel::Custom("grok-imagine-video-v2".to_string());
assert_eq!(serde_json::to_string(&m).unwrap(), "\"grok-imagine-video-v2\"");
}
// ── Pricing tier classification ──
mod pricing_tier {
use super::*;
#[test]
fn grok_imagine_video_is_v1() {
assert_eq!(VideoModel::GrokImagineVideo.pricing_tier(), VideoModelPricingTier::V1);
}
#[test]
fn one_point_five_preview_enum_is_v1p5() {
assert_eq!(
VideoModel::GrokImagineVideo1p5Preview.pricing_tier(),
VideoModelPricingTier::V1p5Preview,
);
}
#[test]
fn custom_with_canonical_1p5_name_is_v1p5() {
let m = VideoModel::Custom("grok-imagine-video-1.5-preview".to_string());
assert_eq!(m.pricing_tier(), VideoModelPricingTier::V1p5Preview);
}
#[test]
fn custom_with_dated_1p5_alias_is_v1p5() {
// xAI treats this alias as a synonym for the 1.5 preview model.
let m = VideoModel::Custom("grok-imagine-video-1.5-2026-05-30".to_string());
assert_eq!(m.pricing_tier(), VideoModelPricingTier::V1p5Preview);
}
#[test]
fn custom_unknown_defaults_to_v1() {
let m = VideoModel::Custom("grok-imagine-video-future".to_string());
assert_eq!(m.pricing_tier(), VideoModelPricingTier::V1);
}
#[test]
fn custom_with_v1_name_is_v1() {
let m = VideoModel::Custom("grok-imagine-video".to_string());
assert_eq!(m.pricing_tier(), VideoModelPricingTier::V1);
}
}
}
@@ -77,6 +77,7 @@ fn video_model_to_common_model_type(model: RouterVideoModel) -> Option<CommonMod
RouterVideoModel::PreviewModel => Some(CommonModelType::PreviewModel),
RouterVideoModel::PreviewModelFast => Some(CommonModelType::PreviewModelFast),
RouterVideoModel::GrokImagineVideo => Some(CommonModelType::GrokImagineVideo),
RouterVideoModel::GrokImagineVideo1p5 => Some(CommonModelType::GrokImagineVideo1p5),
}
}
@@ -149,6 +149,8 @@ pub enum InferenceModelType {
GrokVideo,
#[serde(rename = "grok_imagine_video")]
GrokImagineVideo,
#[serde(rename = "grok_imagine_video_1p5")]
GrokImagineVideo1p5,
#[serde(rename = "kling_1p6_pro")]
Kling16Pro,
#[serde(rename = "kling_2p1_pro")]
@@ -286,6 +288,7 @@ impl InferenceModelType {
// Video models
Self::GrokVideo => "grok_video",
Self::GrokImagineVideo => "grok_imagine_video",
Self::GrokImagineVideo1p5 => "grok_imagine_video_1p5",
Self::Kling16Pro => "kling_1p6_pro",
Self::Kling21Pro => "kling_2p1_pro",
Self::Kling21Master => "kling_2p1_master",
@@ -384,6 +387,7 @@ impl InferenceModelType {
// Video models
"grok_video" => Ok(Self::GrokVideo),
"grok_imagine_video" => Ok(Self::GrokImagineVideo),
"grok_imagine_video_1p5" => Ok(Self::GrokImagineVideo1p5),
"kling_1p6_pro" => Ok(Self::Kling16Pro),
"kling_2p1_pro" => Ok(Self::Kling21Pro),
"kling_2p1_master" => Ok(Self::Kling21Master),
@@ -486,6 +490,7 @@ impl InferenceModelType {
// Video models
Self::GrokVideo,
Self::GrokImagineVideo,
Self::GrokImagineVideo1p5,
Self::Kling16Pro,
Self::Kling21Pro,
Self::Kling21Master,
@@ -571,6 +576,7 @@ impl InferenceModelType {
// Video models
CommonModelType::GrokVideo => Self::GrokVideo,
CommonModelType::GrokImagineVideo => Self::GrokImagineVideo,
CommonModelType::GrokImagineVideo1p5 => Self::GrokImagineVideo1p5,
CommonModelType::Kling16Pro => Self::Kling16Pro,
CommonModelType::Kling21Pro => Self::Kling21Pro,
CommonModelType::Kling21Master => Self::Kling21Master,
@@ -679,6 +685,7 @@ mod tests {
// Video models
assert_serialization(InferenceModelType::GrokVideo, "grok_video");
assert_serialization(InferenceModelType::GrokImagineVideo, "grok_imagine_video");
assert_serialization(InferenceModelType::GrokImagineVideo1p5, "grok_imagine_video_1p5");
assert_serialization(InferenceModelType::Kling16Pro, "kling_1p6_pro");
assert_serialization(InferenceModelType::Kling21Pro, "kling_2p1_pro");
assert_serialization(InferenceModelType::Kling21Master, "kling_2p1_master");
@@ -776,6 +783,7 @@ mod tests {
// Video models
assert_eq!(InferenceModelType::GrokVideo.to_str(), "grok_video");
assert_eq!(InferenceModelType::GrokImagineVideo.to_str(), "grok_imagine_video");
assert_eq!(InferenceModelType::GrokImagineVideo1p5.to_str(), "grok_imagine_video_1p5");
assert_eq!(InferenceModelType::Kling16Pro.to_str(), "kling_1p6_pro");
assert_eq!(InferenceModelType::Kling21Pro.to_str(), "kling_2p1_pro");
assert_eq!(InferenceModelType::Kling21Master.to_str(), "kling_2p1_master");
@@ -873,6 +881,7 @@ mod tests {
// Video models
assert_eq!(InferenceModelType::from_str("grok_video").unwrap(), InferenceModelType::GrokVideo);
assert_eq!(InferenceModelType::from_str("grok_imagine_video").unwrap(), InferenceModelType::GrokImagineVideo);
assert_eq!(InferenceModelType::from_str("grok_imagine_video_1p5").unwrap(), InferenceModelType::GrokImagineVideo1p5);
assert_eq!(InferenceModelType::from_str("kling_1p6_pro").unwrap(), InferenceModelType::Kling16Pro);
assert_eq!(InferenceModelType::from_str("kling_2p1_pro").unwrap(), InferenceModelType::Kling21Pro);
assert_eq!(InferenceModelType::from_str("kling_2p1_master").unwrap(), InferenceModelType::Kling21Master);
@@ -117,6 +117,9 @@ pub enum CommonModelType {
/// No version is specified.
#[serde(rename = "grok_imagine_video")]
GrokImagineVideo,
/// xAI's Grok Imagine video 1.5 preview model (grok-imagine-video-1.5-preview).
#[serde(rename = "grok_imagine_video_1p5")]
GrokImagineVideo1p5,
#[serde(rename = "kling_1p6_pro")]
Kling16Pro,
#[serde(rename = "kling_2p1_pro")]
@@ -238,6 +241,7 @@ impl CommonModelType {
// Video models
Self::GrokVideo => "grok_video",
Self::GrokImagineVideo => "grok_imagine_video",
Self::GrokImagineVideo1p5 => "grok_imagine_video_1p5",
Self::Kling16Pro => "kling_1p6_pro",
Self::Kling21Pro => "kling_2p1_pro",
Self::Kling21Master => "kling_2p1_master",
@@ -319,6 +323,7 @@ impl CommonModelType {
// Video models
"grok_video" => Ok(Self::GrokVideo),
"grok_imagine_video" => Ok(Self::GrokImagineVideo),
"grok_imagine_video_1p5" => Ok(Self::GrokImagineVideo1p5),
"kling_1p6_pro" => Ok(Self::Kling16Pro),
"kling_2p1_pro" => Ok(Self::Kling21Pro),
"kling_2p1_master" => Ok(Self::Kling21Master),
@@ -404,6 +409,7 @@ impl CommonModelType {
// Video models
Self::GrokVideo,
Self::GrokImagineVideo,
Self::GrokImagineVideo1p5,
Self::Kling16Pro,
Self::Kling21Pro,
Self::Kling21Master,
@@ -486,6 +492,7 @@ impl CommonModelType {
// Video models
Self::GrokVideo => CommonModelClass::Video,
Self::GrokImagineVideo => CommonModelClass::Video,
Self::GrokImagineVideo1p5 => CommonModelClass::Video,
Self::Kling16Pro => CommonModelClass::Video,
Self::Kling21Pro => CommonModelClass::Video,
Self::Kling21Master => CommonModelClass::Video,
@@ -577,6 +584,7 @@ mod tests {
// Video models
assert_serialization(CommonModelType::GrokVideo, "grok_video");
assert_serialization(CommonModelType::GrokImagineVideo, "grok_imagine_video");
assert_serialization(CommonModelType::GrokImagineVideo1p5, "grok_imagine_video_1p5");
assert_serialization(CommonModelType::Kling16Pro, "kling_1p6_pro");
assert_serialization(CommonModelType::Kling21Pro, "kling_2p1_pro");
assert_serialization(CommonModelType::Kling21Master, "kling_2p1_master");
@@ -655,6 +663,7 @@ mod tests {
// Video models
assert_eq!(CommonModelType::GrokVideo.to_str(), "grok_video");
assert_eq!(CommonModelType::GrokImagineVideo.to_str(), "grok_imagine_video");
assert_eq!(CommonModelType::GrokImagineVideo1p5.to_str(), "grok_imagine_video_1p5");
assert_eq!(CommonModelType::Kling16Pro.to_str(), "kling_1p6_pro");
assert_eq!(CommonModelType::Kling21Pro.to_str(), "kling_2p1_pro");
assert_eq!(CommonModelType::Kling21Master.to_str(), "kling_2p1_master");
@@ -733,6 +742,7 @@ mod tests {
// Video models
assert_eq!(CommonModelType::from_str("grok_video").unwrap(), CommonModelType::GrokVideo);
assert_eq!(CommonModelType::from_str("grok_imagine_video").unwrap(), CommonModelType::GrokImagineVideo);
assert_eq!(CommonModelType::from_str("grok_imagine_video_1p5").unwrap(), CommonModelType::GrokImagineVideo1p5);
assert_eq!(CommonModelType::from_str("kling_1p6_pro").unwrap(), CommonModelType::Kling16Pro);
assert_eq!(CommonModelType::from_str("kling_2p1_pro").unwrap(), CommonModelType::Kling21Pro);
assert_eq!(CommonModelType::from_str("kling_2p1_master").unwrap(), CommonModelType::Kling21Master);
@@ -775,7 +785,7 @@ mod tests {
#[test]
fn all_variants() {
let mut variants = CommonModelType::all_variants();
assert_eq!(variants.len(), 69);
assert_eq!(variants.len(), 70);
// Image models
assert_eq!(variants.pop_first(), Some(CommonModelType::Flux1Dev));
assert_eq!(variants.pop_first(), Some(CommonModelType::Flux1Schnell));
@@ -813,6 +823,7 @@ mod tests {
// Video models
assert_eq!(variants.pop_first(), Some(CommonModelType::GrokVideo));
assert_eq!(variants.pop_first(), Some(CommonModelType::GrokImagineVideo));
assert_eq!(variants.pop_first(), Some(CommonModelType::GrokImagineVideo1p5));
assert_eq!(variants.pop_first(), Some(CommonModelType::Kling16Pro));
assert_eq!(variants.pop_first(), Some(CommonModelType::Kling21Pro));
assert_eq!(variants.pop_first(), Some(CommonModelType::Kling21Master));
@@ -14,6 +14,9 @@ pub enum CommonVideoModel {
#[serde(rename = "grok_imagine_video")]
GrokImagineVideo,
#[serde(rename = "grok_imagine_video_1p5")]
GrokImagineVideo1p5,
#[serde(rename = "kling_1p6_pro")]
Kling16Pro,
@@ -106,6 +109,7 @@ impl CommonVideoModel {
match self {
Self::GrokVideo => CommonModelType::GrokVideo,
Self::GrokImagineVideo => CommonModelType::GrokImagineVideo,
Self::GrokImagineVideo1p5 => CommonModelType::GrokImagineVideo1p5,
Self::Kling16Pro => CommonModelType::Kling16Pro,
Self::Kling21Pro => CommonModelType::Kling21Pro,
Self::Kling21Master => CommonModelType::Kling21Master,
@@ -147,6 +151,7 @@ mod tests {
fn test_serialization() {
assert_serialization(CommonVideoModel::GrokVideo, "grok_video");
assert_serialization(CommonVideoModel::GrokImagineVideo, "grok_imagine_video");
assert_serialization(CommonVideoModel::GrokImagineVideo1p5, "grok_imagine_video_1p5");
assert_serialization(CommonVideoModel::Kling16Pro, "kling_1p6_pro");
assert_serialization(CommonVideoModel::Kling21Pro, "kling_2p1_pro");
assert_serialization(CommonVideoModel::Kling21Master, "kling_2p1_master");
@@ -181,6 +186,7 @@ mod tests {
let cases = [
("grok_video", CommonVideoModel::GrokVideo),
("grok_imagine_video", CommonVideoModel::GrokImagineVideo),
("grok_imagine_video_1p5", CommonVideoModel::GrokImagineVideo1p5),
("kling_1p6_pro", CommonVideoModel::Kling16Pro),
("kling_2p1_pro", CommonVideoModel::Kling21Pro),
("kling_2p1_master", CommonVideoModel::Kling21Master),
@@ -222,6 +228,7 @@ mod tests {
let all = [
CommonVideoModel::GrokVideo,
CommonVideoModel::GrokImagineVideo,
CommonVideoModel::GrokImagineVideo1p5,
CommonVideoModel::Kling16Pro,
CommonVideoModel::Kling21Pro,
CommonVideoModel::Kling21Master,
@@ -262,6 +269,7 @@ mod tests {
let models = [
(CommonVideoModel::GrokVideo, CommonModelType::GrokVideo),
(CommonVideoModel::GrokImagineVideo, CommonModelType::GrokImagineVideo),
(CommonVideoModel::GrokImagineVideo1p5, CommonModelType::GrokImagineVideo1p5),
(CommonVideoModel::Kling16Pro, CommonModelType::Kling16Pro),
(CommonVideoModel::Kling21Pro, CommonModelType::Kling21Pro),
(CommonVideoModel::Kling21Master, CommonModelType::Kling21Master),
@@ -55,6 +55,44 @@ fn build_omni_gen_video_models() -> Vec<OmniGenVideoModelDetails> {
..Default::default()
});
models.push(OmniGenVideoModelDetails {
model: CommonVideoModel::GrokImagineVideo1p5,
model_creator: Some(ModelCreator::Grok),
full_name: Some("Grok Imagine 1.5 Preview".to_string()),
extra_info: Some("Fast and high quality".to_string()),
extra_info_short: Some("Fast and high quality".to_string()),
text_prompt_supported: Some(true),
starting_keyframe_supported: Some(true),
ending_keyframe_supported: Some(false),
image_references_supported: Some(false),
//image_references_max: Some(7),
//video_references_supported: Some(false),
//video_references_max: Some(3),
//video_references_max_total_duration_seconds: Some(15),
aspect_ratio_options: Some(vec![
CommonAspectRatio::WideSixteenByNine,
CommonAspectRatio::WideFourByThree,
CommonAspectRatio::WideThreeByTwo,
CommonAspectRatio::Square,
CommonAspectRatio::TallTwoByThree,
CommonAspectRatio::TallThreeByFour,
CommonAspectRatio::TallNineBySixteen,
]),
aspect_ratio_default: Some(CommonAspectRatio::WideSixteenByNine),
resolution_options: Some(vec![
CommonResolution::FourEightyP,
CommonResolution::SevenTwentyP,
]),
resolution_default: Some(CommonResolution::SevenTwentyP),
//batch_size_options: Some(vec![1, 2, 4]),
//batch_size_default: Some(1),
duration_seconds_min: Some(1),
duration_seconds_max: Some(15),
duration_seconds_max_with_image_references: Some(10),
duration_seconds_default: Some(8),
..Default::default()
});
models.push(OmniGenVideoModelDetails {
model: CommonVideoModel::HappyHorse1p0,
model_creator: Some(ModelCreator::Alibaba),
@@ -131,6 +131,7 @@ fn map_video_model(model: CommonVideoModel) -> Result<RouterVideoModel, HandlerE
let router_model = match model {
CommonVideoModel::GrokVideo => RouterVideoModel::GrokVideo,
CommonVideoModel::GrokImagineVideo => RouterVideoModel::GrokImagineVideo,
CommonVideoModel::GrokImagineVideo1p5 => RouterVideoModel::GrokImagineVideo1p5,
CommonVideoModel::Kling16Pro => RouterVideoModel::Kling16Pro,
CommonVideoModel::Kling21Pro => RouterVideoModel::Kling21Pro,
CommonVideoModel::Kling21Master => RouterVideoModel::Kling21Master,
@@ -65,6 +65,7 @@ pub async fn run_pipeline_v2(args: RunPipelineV2Args<'_>) -> Result<PipelineResu
//RouterVideoModel::Seedance2p0Ultra => RouterProvider::GmiCloud,
//RouterVideoModel::Seedance2p0UltraFast => RouterProvider::GmiCloud,
RouterVideoModel::GrokImagineVideo => RouterProvider::GrokApi,
RouterVideoModel::GrokImagineVideo1p5 => RouterProvider::GrokApi,
_ => RouterProvider::Fal,
};