diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/artcraft/grok_imagine_video_1p5/cost.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/artcraft/grok_imagine_video_1p5/cost.rs index 98ba9d4c49..61db9b0bbe 100644 --- a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/artcraft/grok_imagine_video_1p5/cost.rs +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/artcraft/grok_imagine_video_1p5/cost.rs @@ -318,6 +318,28 @@ mod tests { .cost_in_usd_cents.unwrap(); assert!(p1080 > p720, "1080p ({p1080}) should cost more than 720p ({p720})"); } + + /// Regression: an image-less v1.5 request must get a quote through the + /// full public builder path (build2 → estimate_cost). The cost UI polls + /// for a price before the user has attached an image; the image + /// requirement is enforced at send time and by the generate endpoints, + /// NOT here. + #[test] + fn image_less_request_gets_a_quote_through_the_builder() { + let builder = GenerateVideoRequestBuilder { + model: RouterVideoModel::GrokImagineVideo1p5, + provider: RouterProvider::Artcraft, + duration_seconds: Some(5), + video_batch_count: Some(1), + ..Default::default() + }; + let cost = builder.build2() + .expect("build2 should succeed without an image") + .estimate_cost() + .expect("estimate_cost should succeed without an image"); + assert!(cost.cost_in_usd_cents.unwrap() > 0); + assert_eq!(cost.cost_in_credits, cost.cost_in_usd_cents); + } } // ── Helpers ──────────────────────────────────────────────────────────── @@ -382,8 +404,7 @@ 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. + // These cases price the image-to-video tier (one input image). start_frame: Some(ImageRef::MediaFileToken(MediaFileToken::new("mf_default".to_string()))), ..Default::default() }; diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/artcraft/grok_imagine_video_1p5/request.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/artcraft/grok_imagine_video_1p5/request.rs index 8ada1fddb0..1072d50040 100644 --- a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/artcraft/grok_imagine_video_1p5/request.rs +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_video/providers/artcraft/grok_imagine_video_1p5/request.rs @@ -13,8 +13,11 @@ pub struct ArtcraftGrokImagineVideo1p5RequestState { impl ArtcraftGrokImagineVideo1p5RequestState { pub async fn send(&self, client: &RouterArtcraftClient) -> Result { - // Defense in depth: `build()` already enforces this. Bouncing here costs - // nothing and avoids an upstream call we know will fail. + // xAI's v1.5 model rejects text-to-video at the server. This is the + // GENERATION-time guard — `build()` and `estimate_cost()` deliberately + // allow image-less states so the cost path can quote a request the user + // is still composing. 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()) { @@ -27,3 +30,55 @@ impl ArtcraftGrokImagineVideo1p5RequestState { send_artcraft_omni_video_request(&self.request, client).await } } + +#[cfg(test)] +mod tests { + use artcraft_client::credentials::storyteller_credential_set::StorytellerCredentialSet; + use artcraft_client::utils::api_host::ApiHost; + use enums::common::generation::common_video_model::CommonVideoModel as CommonVideoModelEnum; + + use super::*; + + /// Generation must never reach xAI with an image-less v1.5 request. The + /// build/cost layers allow that state (the cost path quotes it), so the + /// send-time guard is the router's last line of defense. + #[tokio::test] + async fn send_rejects_image_less_request_before_any_network_call() { + let state = ArtcraftGrokImagineVideo1p5RequestState { + request: OmniGenVideoCostAndGenerateRequest { + idempotency_token: Some("a1b2c3d4-e5f6-7890-abcd-ef1234567890".to_string()), + model: Some(CommonVideoModelEnum::GrokImagineVideo1p5), + prompt: Some("a corgi running through a field".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, + bitrate: None, + quality: None, + duration_seconds: Some(5), + video_batch_count: Some(1), + generate_audio: None, + }, + }; + + // The guard fires before the client is used, so an unroutable localhost + // client with empty credentials proves no network call happens. + let client = RouterArtcraftClient::new( + ApiHost::Localhost { port: 1 }, + StorytellerCredentialSet::empty(), + ); + + let err = state.send(&client).await.expect_err("image-less send should be rejected"); + match err { + ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption { field, .. }) => { + assert_eq!(field, "image_inputs"); + } + other => panic!("expected Client(ModelDoesNotSupportOption), got {:?}", other), + } + } +}