Compare commits

...
Author SHA1 Message Date
Brandon Thomas 48fa864615 block bad requests 2026-05-31 14:16:18 -04:00
Brandon Thomas ddaa88fe91 block at artcraft_router 2026-05-31 14:16:18 -04:00
Brandon Thomas 2c58a7c6ba fix config 2026-05-31 14:16:18 -04:00
12 changed files with 507 additions and 137 deletions
@@ -1,6 +1,7 @@
use enums::common::generation::common_video_model::CommonVideoModel as CommonVideoModelEnum;
use crate::errors::artcraft_router_error::ArtcraftRouterError;
use crate::errors::client_error::ClientError;
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,
@@ -25,7 +26,20 @@ pub fn build_artcraft_grok_imagine_video_1p5(
SupportedResolutions::Fast,
UltraWideSupport::Unsupported,
)?;
// xAI's v1.5 model rejects text-to-video at the server. Bounce here rather
// than spending an upstream call to learn the same thing.
if request.start_frame_image_media_token.is_none()
&& request.reference_image_media_tokens.as_ref().map_or(true, |v| v.is_empty())
{
return Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption {
field: "image_inputs",
value: "text-to-video isn't supported by grok-imagine-video-1.5-preview; supply a start_frame or at least one reference image".to_string(),
}));
}
let state = ArtcraftGrokImagineVideo1p5RequestState { request };
Ok(VideoGenerationDraftOrRequest::Request(
VideoGenerationRequest::ArtcraftGrokImagineVideo1p5(state),
))
@@ -166,6 +180,12 @@ mod tests {
fn make_builder(f: impl FnOnce(&mut GenerateVideoRequestBuilder)) -> GenerateVideoRequestBuilder {
let mut builder = base_builder();
f(&mut builder);
// v1.5 requires an input image; tests that don't explicitly exercise the
// image-handling paths get a default MediaFileToken start_frame so they
// sail past the no-image guard in `build()`.
if builder.start_frame.is_none() && builder.reference_images.is_none() {
builder.start_frame = Some(ImageRef::MediaFileToken(MediaFileToken::new("mf_default".to_string())));
}
builder
}
@@ -8,6 +8,8 @@ use grok_api_client::api::traits::grok_request_cost_calculator_trait::GrokReques
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::errors::artcraft_router_error::ArtcraftRouterError;
use crate::errors::client_error::ClientError;
use crate::generate::generate_video::video_generation_cost_estimate::VideoGenerationCostEstimate;
use crate::generate::generate_video_v2::providers::artcraft::grok_imagine_video_1p5::request::ArtcraftGrokImagineVideo1p5RequestState;
@@ -62,13 +64,23 @@ impl ArtcraftGrokImagineVideo1p5CostState {
.saturating_mul(self.batch_count as u64)
}
pub fn estimate_cost(&self) -> VideoGenerationCostEstimate {
pub fn estimate_cost(&self) -> Result<VideoGenerationCostEstimate, ArtcraftRouterError> {
// Defense in depth: `build()` already enforces this, but a state
// constructed by hand must not get a quote for an operation xAI will
// reject.
if self.input_image_count == 0 {
return Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption {
field: "image_inputs",
value: "text-to-video isn't supported by grok-imagine-video-1.5-preview; supply a start_frame or at least one reference image".to_string(),
}));
}
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 {
Ok(VideoGenerationCostEstimate {
cost_in_credits: Some(usd_cents),
cost_in_usd_cents: Some(usd_cents),
is_free: false,
@@ -76,7 +88,7 @@ impl ArtcraftGrokImagineVideo1p5CostState {
is_rate_limited: false,
has_watermark: false,
failures_are_refunded: None,
}
})
}
/// Build a `GrokVideoGenerationRequest` whose pricing-relevant fields
@@ -146,25 +158,25 @@ mod tests {
//
// Durations are confined to [4, 15] because the ArtCraft pipeline clamps
// outside that range (see `plan_duration` in artcraft/build_common.rs).
// Every case carries exactly one image. v1.5 rejects text-to-video, AND
// the GrokApi build step collapses multi-image reference-to-video
// requests down to one `image` (promote-first; see grok_api side
// build.rs). So the only request shape where both sides agree on the
// input image count is the single-image case.
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), 4, 1),
(Some(RouterResolution::FourEightyP), 5, 1),
(Some(RouterResolution::FourEightyP), 5, 3),
(Some(RouterResolution::FourEightyP), 8, 1),
(Some(RouterResolution::FourEightyP), 15, 1),
// 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), 4, 1),
(Some(RouterResolution::SevenTwentyP), 5, 1),
(Some(RouterResolution::SevenTwentyP), 5, 3),
(Some(RouterResolution::SevenTwentyP), 8, 1),
(Some(RouterResolution::SevenTwentyP), 15, 1),
// 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),
(None, 5, 1),
];
#[test]
@@ -217,13 +229,14 @@ mod tests {
/// `base_cost_in_cents_*` API is tested above.
#[test]
fn end_to_end_markup_is_at_least_five_percent_over_grok() {
// Single-image cases only — see PARITY_CASES comment above for why
// multi-image rows can't be apples-to-apples between the two sides.
let cases: &[(Option<RouterResolution>, u16, bool, usize)] = &[
(Some(RouterResolution::FourEightyP), 5, false, 0),
(Some(RouterResolution::FourEightyP), 5, true, 0),
(Some(RouterResolution::FourEightyP), 10, true, 0),
(Some(RouterResolution::SevenTwentyP), 5, false, 0),
(Some(RouterResolution::SevenTwentyP), 5, false, 3),
(Some(RouterResolution::SevenTwentyP), 5, true, 0),
(Some(RouterResolution::SevenTwentyP), 8, true, 0),
(Some(RouterResolution::SevenTwentyP),15, false, 0),
(Some(RouterResolution::SevenTwentyP),15, true, 0),
];
for &(res, duration, has_start, refs) in cases {
let artcraft_cents = artcraft_end_to_end_cents(res, duration, 1, has_start, refs);
@@ -249,7 +262,7 @@ mod tests {
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();
let got = state.estimate_cost().expect("estimate_cost").cost_in_usd_cents.unwrap();
assert_eq!(
got, want,
"res={res:?} dur={dur} imgs={imgs} batch={batch} base={base}",
@@ -262,7 +275,7 @@ mod tests {
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();
let final_ = state.estimate_cost().expect("estimate_cost").cost_in_usd_cents.unwrap();
assert!(
final_ >= base,
"final {final_}¢ < base {base}¢ at res={res:?} dur={dur} imgs={imgs} batch={batch}",
@@ -272,48 +285,67 @@ mod tests {
#[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);
// grok v1.5 720p × 5s + 1 image = 700 + 10 = 710 mills = 71¢
// 71 × 1.05 = 74.55 → ceil = 75¢
let state = artcraft_state(Some(RouterResolution::SevenTwentyP), 5, 1, 1);
assert_eq!(state.base_cost_in_cents_for_one_video(), 71);
assert_eq!(state.estimate_cost().expect("estimate_cost").cost_in_usd_cents.unwrap(), 75);
}
#[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);
// grok v1.5 480p × 5s + 1 image = 400 + 10 = 410 mills = 41¢
// 41 × 1.05 = 43.05 → ceil = 44¢
let state = artcraft_state(Some(RouterResolution::FourEightyP), 5, 1, 1);
assert_eq!(state.base_cost_in_cents_for_one_video(), 41);
assert_eq!(state.estimate_cost().expect("estimate_cost").cost_in_usd_cents.unwrap(), 44);
}
#[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);
// base per video at 720p 5s + 1 image = 71¢. Batch 2 = 142¢.
// 142 × 1.05 = 149.1 → ceil 150¢.
let state = artcraft_state(Some(RouterResolution::SevenTwentyP), 5, 1, 2);
assert_eq!(state.base_cost_in_cents_for_batch(), 142);
assert_eq!(state.estimate_cost().expect("estimate_cost").cost_in_usd_cents.unwrap(), 150);
}
// (resolution, duration, image_count, batch)
/// v1.5 doesn't support text-to-video. The cost calculator must refuse
/// to quote an image-less request even if a state is constructed
/// directly.
#[test]
fn estimate_cost_rejects_text_only_request() {
let state = ArtcraftGrokImagineVideo1p5CostState {
resolution: CommonResolution::SevenTwentyP,
duration_seconds: 5,
batch_count: 1,
input_image_count: 0,
};
let err = state.estimate_cost().expect_err("text-only should be rejected");
match err {
ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption { field, .. }) => {
assert_eq!(field, "image_inputs");
}
other => panic!("expected Client(ModelDoesNotSupportOption), got {:?}", other),
}
}
// (resolution, duration, image_count, batch) — every case has at least
// one image since v1.5 rejects text-to-video.
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, 1, 2),
(Some(RouterResolution::FourEightyP), 5, 1, 4),
(Some(RouterResolution::FourEightyP), 8, 1, 1),
(Some(RouterResolution::FourEightyP), 15, 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, 1, 2),
(Some(RouterResolution::SevenTwentyP), 5, 1, 4),
(Some(RouterResolution::SevenTwentyP), 8, 1, 1),
(Some(RouterResolution::SevenTwentyP), 15, 1, 1),
(Some(RouterResolution::SevenTwentyP), 5, 3, 1),
(None, 5, 0, 1),
(None, 5, 1, 1),
];
}
@@ -324,42 +356,42 @@ mod tests {
#[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();
let p480 = artcraft_state(Some(RouterResolution::FourEightyP), 5, 1, 1)
.estimate_cost().expect("estimate_cost").cost_in_usd_cents.unwrap();
let p720 = artcraft_state(Some(RouterResolution::SevenTwentyP), 5, 1, 1)
.estimate_cost().expect("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();
let c5 = artcraft_state(Some(RouterResolution::SevenTwentyP), 5, 1, 1)
.estimate_cost().expect("estimate_cost").cost_in_usd_cents.unwrap();
let c10 = artcraft_state(Some(RouterResolution::SevenTwentyP), 10, 1, 1)
.estimate_cost().expect("estimate_cost").cost_in_usd_cents.unwrap();
let c15 = artcraft_state(Some(RouterResolution::SevenTwentyP), 15, 1, 1)
.estimate_cost().expect("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();
// Each image is +10 mills = +1¢ before markup, so additional images
// monotonically increase cost.
let one = artcraft_state(Some(RouterResolution::SevenTwentyP), 5, 1, 1)
.estimate_cost().expect("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}¢");
.estimate_cost().expect("estimate_cost").cost_in_usd_cents.unwrap();
assert!(one < three, "one={one}¢ 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();
let b1 = artcraft_state(Some(RouterResolution::SevenTwentyP), 5, 1, 1)
.estimate_cost().expect("estimate_cost").cost_in_usd_cents.unwrap();
let b4 = artcraft_state(Some(RouterResolution::SevenTwentyP), 5, 1, 4)
.estimate_cost().expect("estimate_cost").cost_in_usd_cents.unwrap();
assert!(b1 < b4);
}
}
@@ -499,6 +531,9 @@ mod tests {
resolution,
duration_seconds: Some(duration_seconds),
video_batch_count: Some(video_batch_count),
// v1.5 requires an input image — the no-image guard in build()
// rejects T2V.
start_frame: Some(ImageRef::MediaFileToken(MediaFileToken::new("mf_default".to_string()))),
..Default::default()
};
builder.build2()
@@ -2,6 +2,7 @@ use artcraft_api_defs::omni_gen::cost_and_generate_requests::omni_gen_video_cost
use crate::client::router_artcraft_client::RouterArtcraftClient;
use crate::errors::artcraft_router_error::ArtcraftRouterError;
use crate::errors::client_error::ClientError;
use crate::generate::generate_video::generate_video_response::GenerateVideoResponse;
use crate::generate::generate_video_v2::providers::artcraft::request_common::send_artcraft_omni_video_request;
@@ -12,6 +13,17 @@ pub struct ArtcraftGrokImagineVideo1p5RequestState {
impl ArtcraftGrokImagineVideo1p5RequestState {
pub async fn send(&self, client: &RouterArtcraftClient) -> Result<GenerateVideoResponse, ArtcraftRouterError> {
// Defense in depth: `build()` already enforces this. Bouncing here costs
// nothing and avoids an upstream call we know will fail.
if self.request.start_frame_image_media_token.is_none()
&& self.request.reference_image_media_tokens.as_ref().map_or(true, |v| v.is_empty())
{
return Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption {
field: "image_inputs",
value: "text-to-video isn't supported by grok-imagine-video-1.5-preview; supply a start_frame or at least one reference image".to_string(),
}));
}
send_artcraft_omni_video_request(&self.request, client).await
}
}
@@ -16,6 +16,7 @@ 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::errors::client_error::ClientError;
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;
@@ -72,6 +73,15 @@ pub fn build_grok_api_grok_imagine_video_1p5(
(None, None) => (None, None),
};
// xAI's v1.5 model rejects text-to-video at the server. Bounce the request
// here rather than spending an API round-trip to learn the same thing.
if image.is_none() && reference_images.is_none() {
return Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption {
field: "image_inputs",
value: "text-to-video isn't supported by grok-imagine-video-1.5-preview; supply a start_frame or at least one reference image".to_string(),
}));
}
let request = GrokVideoGenerationRequest {
prompt,
// Pin to the 1.5 preview identifier so the grok_api_client cost
@@ -449,12 +459,21 @@ mod tests {
}
#[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());
fn start_frame_media_file_token_dropped_then_rejected_as_text_only() {
// MediaFileTokens get silently dropped (Grok only accepts URLs), but
// with no usable image left the no-image guard rejects the build.
let result = build_grok_api_grok_imagine_video_1p5(GenerateVideoRequestBuilder {
model: RouterVideoModel::GrokImagineVideo1p5,
provider: RouterProvider::GrokApi,
start_frame: Some(ImageRef::MediaFileToken(MediaFileToken::new("mf_test".to_string()))),
..Default::default()
});
assert!(matches!(
result,
Err(crate::errors::artcraft_router_error::ArtcraftRouterError::Client(
crate::errors::client_error::ClientError::ModelDoesNotSupportOption { field: "image_inputs", .. }
)),
));
}
#[test]
@@ -527,15 +546,24 @@ mod tests {
}
#[test]
fn reference_image_tokens_silently_dropped() {
let req = unwrap_request(make_builder(|b| {
b.reference_images = Some(ImageListRef::MediaFileTokens(vec![
fn reference_image_tokens_dropped_then_rejected_as_text_only() {
// MediaFileTokens get silently dropped; with no usable image left the
// no-image guard rejects the build.
let result = build_grok_api_grok_imagine_video_1p5(GenerateVideoRequestBuilder {
model: RouterVideoModel::GrokImagineVideo1p5,
provider: RouterProvider::GrokApi,
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());
])),
..Default::default()
});
assert!(matches!(
result,
Err(crate::errors::artcraft_router_error::ArtcraftRouterError::Client(
crate::errors::client_error::ClientError::ModelDoesNotSupportOption { field: "image_inputs", .. }
)),
));
}
#[test]
@@ -555,11 +583,21 @@ mod tests {
}
#[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());
fn empty_reference_image_url_list_rejected_as_text_only() {
// Empty list resolves to None for both image and reference_images;
// the no-image guard then rejects the build.
let result = build_grok_api_grok_imagine_video_1p5(GenerateVideoRequestBuilder {
model: RouterVideoModel::GrokImagineVideo1p5,
provider: RouterProvider::GrokApi,
reference_images: Some(ImageListRef::Urls(vec![])),
..Default::default()
});
assert!(matches!(
result,
Err(crate::errors::artcraft_router_error::ArtcraftRouterError::Client(
crate::errors::client_error::ClientError::ModelDoesNotSupportOption { field: "image_inputs", .. }
)),
));
}
}
@@ -570,11 +608,15 @@ mod tests {
#[test]
fn end_frame_silently_dropped() {
// end_frame isn't on Grok's wire body at all, so the only thing to
// verify is that supplying it doesn't break the build. `make_builder`
// injects a default start_frame to satisfy the v1.5 no-image guard.
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());
// image comes from the default start_frame; reference_images stays None.
assert!(req.request.reference_images.is_none());
assert!(matches!(req.request.image, Some(GrokVideoImageSource::Url(_))));
}
#[test]
@@ -637,6 +679,13 @@ mod tests {
fn make_builder(f: impl FnOnce(&mut GenerateVideoRequestBuilder)) -> GenerateVideoRequestBuilder {
let mut b = base_builder();
f(&mut b);
// v1.5 requires an input image; tests that don't explicitly exercise the
// image-handling paths get a default start_frame so they sail past the
// no-image guard in `build()`. Tests that set start_frame or
// reference_images themselves keep their override.
if b.start_frame.is_none() && b.reference_images.is_none() {
b.start_frame = Some(ImageRef::Url("https://example.com/default.png".to_string()));
}
b
}
@@ -1,5 +1,7 @@
use grok_api_client::api::traits::grok_request_cost_calculator_trait::GrokRequestCostCalculator;
use crate::errors::artcraft_router_error::ArtcraftRouterError;
use crate::errors::client_error::ClientError;
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;
@@ -12,7 +14,17 @@ impl GrokApiGrokImagineVideo1p5CostState {
Self { request: request.clone() }
}
pub fn estimate_cost(&self) -> VideoGenerationCostEstimate {
pub fn estimate_cost(&self) -> Result<VideoGenerationCostEstimate, ArtcraftRouterError> {
// Defense in depth: `build()` already enforces this, but a request state
// constructed by hand must not get a cost quote for an operation the
// model will reject.
if self.request.request.image.is_none() && self.request.request.reference_images.is_none() {
return Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption {
field: "image_inputs",
value: "text-to-video isn't supported by grok-imagine-video-1.5-preview; supply a start_frame or at least one reference image".to_string(),
}));
}
// 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
@@ -21,7 +33,7 @@ impl GrokApiGrokImagineVideo1p5CostState {
// Input: 10 mills per source image
let cost_in_usd_cents = self.request.request.calculate_cost_in_cents();
VideoGenerationCostEstimate {
Ok(VideoGenerationCostEstimate {
cost_in_credits: None,
cost_in_usd_cents: Some(cost_in_usd_cents),
is_free: false,
@@ -29,7 +41,7 @@ impl GrokApiGrokImagineVideo1p5CostState {
is_rate_limited: false,
has_watermark: false,
failures_are_refunded: None,
}
})
}
}
@@ -54,22 +66,26 @@ mod tests {
mod pricing_720p {
use super::*;
// Note: build_for() injects a default start_frame when no image source
// is given (v1.5 requires an image), so every "no explicit image" case
// here actually includes 1 image worth of input surcharge (+10 mills = +1¢).
#[test]
fn five_s_no_image_ref() {
// 140 × 5 = 700 mills → 70¢
assert_eq!(cost_cents(Some(RouterResolution::SevenTwentyP), 5, None, None), 70);
fn five_s_with_default_image() {
// 140 × 5 + 10 = 710 mills → 71¢
assert_eq!(cost_cents(Some(RouterResolution::SevenTwentyP), 5, None, None), 71);
}
#[test]
fn ten_s_no_image_ref() {
// 140 × 10 = 1400 mills → 140¢
assert_eq!(cost_cents(Some(RouterResolution::SevenTwentyP), 10, None, None), 140);
fn ten_s_with_default_image() {
// 140 × 10 + 10 = 1410 mills → 141¢
assert_eq!(cost_cents(Some(RouterResolution::SevenTwentyP), 10, None, None), 141);
}
#[test]
fn fifteen_s_no_image_ref() {
// 140 × 15 = 2100 mills → 210¢
assert_eq!(cost_cents(Some(RouterResolution::SevenTwentyP), 15, None, None), 210);
fn fifteen_s_with_default_image() {
// 140 × 15 + 10 = 2110 mills → 211¢
assert_eq!(cost_cents(Some(RouterResolution::SevenTwentyP), 15, None, None), 211);
}
}
@@ -79,21 +95,21 @@ mod tests {
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);
fn five_s_with_default_image() {
// 80 × 5 + 10 = 410 mills → 41¢
assert_eq!(cost_cents(Some(RouterResolution::FourEightyP), 5, None, None), 41);
}
#[test]
fn ten_s_no_image_ref() {
// 80 × 10 = 800 mills → 80¢
assert_eq!(cost_cents(Some(RouterResolution::FourEightyP), 10, None, None), 80);
fn ten_s_with_default_image() {
// 80 × 10 + 10 = 810 mills → 81¢
assert_eq!(cost_cents(Some(RouterResolution::FourEightyP), 10, None, None), 81);
}
#[test]
fn fifteen_s_no_image_ref() {
// 80 × 15 = 1200 mills → 120¢
assert_eq!(cost_cents(Some(RouterResolution::FourEightyP), 15, None, None), 120);
fn fifteen_s_with_default_image() {
// 80 × 15 + 10 = 1210 mills → 121¢
assert_eq!(cost_cents(Some(RouterResolution::FourEightyP), 15, None, None), 121);
}
}
@@ -115,8 +131,13 @@ mod tests {
}
#[test]
fn three_reference_images_adds_30_mills() {
// 5s @ 720p output 700 mills + 3 input images 30 mills = 730 mills → 73¢
fn three_reference_images_collapse_to_one_image() {
// v1.5 rejects reference_images, so the build step promotes the first
// reference to `image` and drops the rest (see promote_first_reference_to_image
// in build.rs). Effective input image count = 1 regardless of how
// many were submitted as references.
//
// 5s @ 720p output 700 mills + 1 effective input image 10 mills = 710 mills → 71¢
let cents = cost_cents(
Some(RouterResolution::SevenTwentyP),
5,
@@ -127,12 +148,19 @@ mod tests {
"https://example.com/c.png".to_string(),
])),
);
assert_eq!(cents, 73);
assert_eq!(cents, 71);
}
#[test]
fn image_input_count_scales_linearly() {
// Fix 5s @ 720p (700 mills base); vary ref image count 1..=5.
fn reference_image_count_does_not_scale_cost() {
// Promote-first collapses any number of reference_images down to one
// `image` on the wire, so the cost stays flat at the 1-image surcharge.
let baseline = cost_cents(
Some(RouterResolution::SevenTwentyP),
5,
None,
Some(ImageListRef::Urls(vec!["https://example.com/0.png".to_string()])),
);
for n in 1u64..=5 {
let images: Vec<String> = (0..n)
.map(|i| format!("https://example.com/{i}.png"))
@@ -143,9 +171,7 @@ mod tests {
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}");
assert_eq!(cents, baseline, "n={n} should still cost the same after promote-first");
}
}
}
@@ -217,27 +243,28 @@ mod tests {
// 480p output: 80 mills/s 720p output: 140 mills/s
// per input image: 10 mills
// cents = ceil(mills / 10)
//
// v1.5 requires at least one image, so every "has_image=false,
// ref_count=0" row would be rejected. Those rows are gone; everything
// else uses an explicit image source.
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
(RouterResolution::FourEightyP, 1, true, 0, 9), // 80 + 10
(RouterResolution::FourEightyP, 5, true, 0, 41), // 400 + 10
(RouterResolution::FourEightyP, 8, true, 0, 65), // 640 + 10
(RouterResolution::FourEightyP, 15, true, 0, 121), // 1200 + 10
// 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
(RouterResolution::SevenTwentyP, 1, true, 0, 15), // 140 + 10
(RouterResolution::SevenTwentyP, 5, true, 0, 71), // 700 + 10
(RouterResolution::SevenTwentyP, 8, true, 0, 113), // 1120 + 10
(RouterResolution::SevenTwentyP,15, true, 0, 211), // 2100 + 10
(RouterResolution::SevenTwentyP,10, true, 0, 141), // 1400 + 10
// 720p reference-to-video. Promote-first collapses any non-empty refs
// list down to ONE effective image on the wire, so all of these cost
// the same regardless of ref_count.
(RouterResolution::SevenTwentyP, 5, false, 1, 71), // 700 + 10
(RouterResolution::SevenTwentyP, 5, false, 2, 71), // 700 + 10 (promote-first)
(RouterResolution::SevenTwentyP, 5, false, 3, 71), // 700 + 10 (promote-first)
];
#[test]
@@ -260,6 +287,45 @@ mod tests {
}
}
// ── No-image rejection (defense in depth) ─────────────────────────────
mod rejects_text_only {
use super::*;
use crate::errors::artcraft_router_error::ArtcraftRouterError;
use crate::errors::client_error::ClientError;
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 grok_api_client::api::requests::videos::video_generation::video_generation::VideoGenerationRequest as GrokVideoGenerationRequest;
use grok_api_client::api::types::video_types::video_model::VideoModel as GrokVideoModel;
/// A cost-state hand-built around a text-only Grok request must return
/// Err when estimate_cost is called. This guards against a state
/// constructed outside of `build()` (e.g. in a misuse).
#[test]
fn estimate_cost_returns_err_when_no_image_sources() {
let request = GrokVideoGenerationRequest {
prompt: "text only".to_string(),
model: Some(GrokVideoModel::GrokImagineVideo1p5Preview),
image: None,
reference_images: None,
aspect_ratio: None,
duration: Some(5),
resolution: None,
user: None,
};
let state = GrokApiGrokImagineVideo1p5CostState::from_request(
&GrokApiGrokImagineVideo1p5RequestState { request },
);
let err = state.estimate_cost().expect_err("text-only must be rejected");
match err {
ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption { field, .. }) => {
assert_eq!(field, "image_inputs");
}
other => panic!("expected Client(ModelDoesNotSupportOption), got {:?}", other),
}
}
}
// ── Helpers ──
fn cost_cents(
@@ -287,6 +353,17 @@ mod tests {
start_frame: Option<ImageRef>,
reference_images: Option<ImageListRef>,
) -> u64 {
// v1.5 rejects text-to-video at build time. To keep pricing tests
// focused on duration/resolution/batch scaling (rather than constantly
// duplicating an image input in every call site), the helper injects a
// single default start_frame URL when neither image source is supplied.
// That adds a 10-mill / 1¢ input-image surcharge to every "no-image"
// case — all the expected values below account for it.
let start_frame = match (start_frame, &reference_images) {
(Some(sf), _) => Some(sf),
(None, Some(_)) => None,
(None, None) => Some(ImageRef::Url("https://example.com/default.png".to_string())),
};
let builder = GenerateVideoRequestBuilder {
model,
provider: RouterProvider::GrokApi,
@@ -6,6 +6,7 @@ use grok_api_client::api::requests::videos::video_generation::video_generation::
use crate::client::router_grok_api_client::RouterGrokApiClient;
use crate::errors::artcraft_router_error::ArtcraftRouterError;
use crate::errors::client_error::ClientError;
use crate::errors::provider_error::ProviderError;
use crate::generate::generate_video::generate_video_response::{
GenerateVideoResponse, GrokVideoResponsePayload,
@@ -21,6 +22,15 @@ pub struct GrokApiGrokImagineVideo1p5RequestState {
impl GrokApiGrokImagineVideo1p5RequestState {
pub async fn send(&self, client: &RouterGrokApiClient) -> Result<GenerateVideoResponse, ArtcraftRouterError> {
// Defense in depth: `build()` already enforces this. Bouncing the
// request here costs nothing and avoids an HTTP call we know will fail.
if self.request.image.is_none() && self.request.reference_images.is_none() {
return Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption {
field: "image_inputs",
value: "text-to-video isn't supported by grok-imagine-video-1.5-preview; supply a start_frame or at least one reference image".to_string(),
}));
}
let outbound_request = Arc::new(self.request.clone());
let response = video_generation(VideoGenerationArgs {
@@ -62,6 +72,8 @@ mod tests {
let builder = GenerateVideoRequestBuilder {
prompt: Some("test".to_string()),
resolution: Some(RouterResolution::SevenTwentyP),
// v1.5 requires an input image (no T2V).
start_frame: Some(ImageRef::Url("https://example.com/start.png".to_string())),
..grok_builder()
};
let request = unwrap_request(builder);
@@ -227,7 +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::ArtcraftGrokImagineVideo1p5(request) => 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()),
@@ -274,7 +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::GrokApiGrokImagineVideo1p5(request) => 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()),
@@ -23,7 +23,6 @@ fn build_omni_gen_video_models() -> Vec<OmniGenVideoModelDetails> {
model: CommonVideoModel::GrokImagineVideo,
model_creator: Some(ModelCreator::Grok),
full_name: Some("Grok Imagine".to_string()),
text_to_video_supported: Some(false),
text_prompt_supported: Some(true),
starting_keyframe_supported: Some(true),
ending_keyframe_supported: Some(false),
@@ -62,6 +61,7 @@ fn build_omni_gen_video_models() -> Vec<OmniGenVideoModelDetails> {
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_to_video_supported: Some(false), // NB: This might be temporary
text_prompt_supported: Some(true),
starting_keyframe_supported: Some(true),
ending_keyframe_supported: Some(false),
@@ -8,6 +8,7 @@ use artcraft_router::api::router_provider::RouterProvider;
use log::warn;
use crate::http_server::common_responses::common_web_error::CommonWebError;
use crate::http_server::endpoints::omni_gen::generate::video::helpers::hydrate_router_request::hydrate_to_router_request;
use crate::http_server::endpoints::omni_gen::generate::video::helpers::validate_image_required::validate_image_required;
use crate::state::server_state::ServerState;
/// Estimate the cost of a video generation.
@@ -27,6 +28,8 @@ pub async fn omni_gen_video_cost_handler(
request: Json<OmniGenVideoCostAndGenerateRequest>,
_server_state: web::Data<Arc<ServerState>>,
) -> Result<Json<OmniGenVideoCostResponse>, CommonWebError> {
validate_image_required(&request)?;
let mut builder = hydrate_to_router_request(&request)?;
builder.provider = RouterProvider::Artcraft;
@@ -4,3 +4,4 @@ pub(crate) mod hydrate_router_request;
pub mod pipeline_result;
pub mod resolve_kinovi_character_ids;
pub mod resolve_media_tokens_to_urls;
pub mod validate_image_required;
@@ -0,0 +1,156 @@
use artcraft_api_defs::omni_gen::cost_and_generate_requests::omni_gen_video_cost_and_generate_request::OmniGenVideoCostAndGenerateRequest;
use enums::common::generation::common_video_model::CommonVideoModel;
use crate::http_server::common_responses::common_web_error::CommonWebError;
/// xAI's `grok-imagine-video-1.5-preview` model rejects text-to-video at
/// the upstream API. Reject the request at the handler layer so callers see
/// a clean 400 with an actionable message and we don't charge their wallet
/// for an inference job we know will fail.
pub fn validate_image_required(
request: &OmniGenVideoCostAndGenerateRequest,
) -> Result<(), CommonWebError> {
match request.model {
Some(CommonVideoModel::GrokImagineVideo1p5) => {}, // Fall-through
_ => return Ok(()),
}
let requires_image = matches!(request.model, Some(CommonVideoModel::GrokImagineVideo1p5));
if !requires_image {
return Ok(());
}
let has_start_frame = request.start_frame_image_media_token.is_some();
let has_reference_images = request
.reference_image_media_tokens
.as_ref()
.is_some_and(|v| !v.is_empty());
if has_start_frame || has_reference_images {
return Ok(());
}
Err(CommonWebError::BadInputWithSimpleMessage(
"Image is required".to_string(),
))
}
#[cfg(test)]
mod tests {
use super::*;
use tokens::tokens::media_files::MediaFileToken;
fn base_request() -> OmniGenVideoCostAndGenerateRequest {
OmniGenVideoCostAndGenerateRequest {
idempotency_token: Some("a1b2c3d4-e5f6-7890-abcd-ef1234567890".to_string()),
model: None,
prompt: Some("test".to_string()),
negative_prompt: None,
start_frame_image_media_token: None,
end_frame_image_media_token: None,
reference_image_media_tokens: None,
reference_video_media_tokens: None,
reference_audio_media_tokens: None,
reference_character_tokens: None,
resolution: None,
aspect_ratio: None,
quality: None,
duration_seconds: Some(5),
video_batch_count: Some(1),
generate_audio: None,
}
}
fn token(id: &str) -> MediaFileToken {
MediaFileToken::new(id.to_string())
}
#[test]
fn passes_when_model_is_none() {
let req = base_request();
assert!(validate_image_required(&req).is_ok());
}
#[test]
fn passes_for_other_models_without_image() {
// Only v1.5 needs the gate; other models can do text-to-video.
let req = OmniGenVideoCostAndGenerateRequest {
model: Some(CommonVideoModel::Seedance2p0),
..base_request()
};
assert!(validate_image_required(&req).is_ok());
}
#[test]
fn rejects_v1p5_with_no_image_inputs() {
let req = OmniGenVideoCostAndGenerateRequest {
model: Some(CommonVideoModel::GrokImagineVideo1p5),
..base_request()
};
let err = validate_image_required(&req).expect_err("should reject");
match err {
CommonWebError::BadInputWithSimpleMessage(msg) => {
assert_eq!(msg, "Image is required");
}
other => panic!("expected BadInputWithSimpleMessage, got {:?}", other),
}
}
#[test]
fn rejects_v1p5_with_empty_reference_list_and_no_start_frame() {
let req = OmniGenVideoCostAndGenerateRequest {
model: Some(CommonVideoModel::GrokImagineVideo1p5),
reference_image_media_tokens: Some(vec![]),
..base_request()
};
assert!(matches!(
validate_image_required(&req),
Err(CommonWebError::BadInputWithSimpleMessage(_))
));
}
#[test]
fn accepts_v1p5_with_start_frame_only() {
let req = OmniGenVideoCostAndGenerateRequest {
model: Some(CommonVideoModel::GrokImagineVideo1p5),
start_frame_image_media_token: Some(token("mf_start")),
..base_request()
};
assert!(validate_image_required(&req).is_ok());
}
#[test]
fn accepts_v1p5_with_reference_images_only() {
let req = OmniGenVideoCostAndGenerateRequest {
model: Some(CommonVideoModel::GrokImagineVideo1p5),
reference_image_media_tokens: Some(vec![token("mf_a"), token("mf_b")]),
..base_request()
};
assert!(validate_image_required(&req).is_ok());
}
#[test]
fn accepts_v1p5_with_both_start_frame_and_reference_images() {
let req = OmniGenVideoCostAndGenerateRequest {
model: Some(CommonVideoModel::GrokImagineVideo1p5),
start_frame_image_media_token: Some(token("mf_start")),
reference_image_media_tokens: Some(vec![token("mf_a")]),
..base_request()
};
assert!(validate_image_required(&req).is_ok());
}
#[test]
fn end_frame_alone_does_not_count_as_image_input() {
// end_frame_image_media_token is technically an image, but xAI's v1.5
// wire shape only uses start_frame / reference_images. end_frame
// shouldn't satisfy the gate.
let req = OmniGenVideoCostAndGenerateRequest {
model: Some(CommonVideoModel::GrokImagineVideo1p5),
end_frame_image_media_token: Some(token("mf_end")),
..base_request()
};
assert!(validate_image_required(&req).is_err());
}
}
@@ -32,6 +32,7 @@ use tokens::tokens::non_unique::debug_logs_event_token::DebugLogEventToken;
use crate::http_server::common_responses::common_web_error::CommonWebError;
use crate::http_server::endpoints::generate::common::payments_error_test::payments_error_test;
use crate::http_server::endpoints::omni_gen::generate::video::helpers::hydrate_router_request::hydrate_to_router_request;
use crate::http_server::endpoints::omni_gen::generate::video::helpers::validate_image_required::validate_image_required;
use crate::http_server::endpoints::omni_gen::generate::video::insert_db_job::insert_fal_job::{insert_fal_job, InsertFalJobArgs};
use crate::http_server::endpoints::omni_gen::generate::video::insert_db_job::insert_gmicloud_job::{insert_gmicloud_job, InsertGmiCloudJobArgs};
use crate::http_server::endpoints::omni_gen::generate::video::insert_db_job::insert_grok_api_job::{insert_grok_api_job, InsertGrokApiJobArgs};
@@ -67,6 +68,10 @@ pub async fn omni_gen_video_generate_handler(
info!("request: {:?}", request);
// Reject doomed combos (e.g. grok_imagine_video_1p5 without an image)
// before any billable or DB-mutating work — see helper for the rules.
validate_image_required(&request)?;
payments_error_test(&request.prompt.as_deref().unwrap_or(""))?;
let debug_log_event_token = DebugLogEventToken::generate();