mirror of
https://github.com/storytold/artcraft.git
synced 2026-10-29 21:12:52 -04:00
Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
48fa864615 | ||
|
|
ddaa88fe91 | ||
|
|
2c58a7c6ba |
+20
@@ -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
|
||||
}
|
||||
|
||||
|
||||
+103
-68
@@ -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()
|
||||
|
||||
+12
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
+68
-19
@@ -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
|
||||
}
|
||||
|
||||
|
||||
+124
-47
@@ -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,
|
||||
|
||||
+12
@@ -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);
|
||||
|
||||
+2
-2
@@ -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),
|
||||
|
||||
+3
@@ -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;
|
||||
|
||||
+1
@@ -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;
|
||||
|
||||
+156
@@ -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());
|
||||
}
|
||||
}
|
||||
+5
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user