diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image_v2/providers/fal/gpt_image_1/build.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image_v2/providers/fal/gpt_image_1/build.rs index d49c67e6cb..14f897dea8 100644 --- a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image_v2/providers/fal/gpt_image_1/build.rs +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image_v2/providers/fal/gpt_image_1/build.rs @@ -194,3 +194,208 @@ fn resolve_image_urls( } } } + +#[cfg(test)] +mod tests { + use super::*; + use std::fmt::Debug; + + use crate::api::common_image_model::CommonImageModel; + use crate::api::provider::Provider; + + fn base_builder() -> GenerateImageRequestBuilder { + GenerateImageRequestBuilder { + model: CommonImageModel::GptImage1, + provider: Provider::Fal, + prompt: Some("a cat in space".to_string()), + image_inputs: None, + resolution: None, + aspect_ratio: None, + quality: None, + image_batch_count: None, + horizontal_angle: None, + vertical_angle: None, + zoom: None, + request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::ErrorOut, + generation_mode_mismatch_strategy: None, + idempotency_token: None, + } + } + + fn unwrap_t2i(result: Result) -> GptImage1TextToImageRequest { + let ImageGenerationDraftOrRequest::Request( + ImageGenerationRequest::FalGptImage1( + FalGptImage1RequestState::TextToImage(req) + ) + ) = result.expect("build should succeed") else { + panic!("expected GPT Image 1 text-to-image request") + }; + req + } + + fn unwrap_edit(result: Result) -> GptImage1EditImageRequest { + let ImageGenerationDraftOrRequest::Request( + ImageGenerationRequest::FalGptImage1( + FalGptImage1RequestState::EditImage(req) + ) + ) = result.expect("build should succeed") else { + panic!("expected GPT Image 1 edit-image request") + }; + req + } + + fn assert_debug(actual: T, expected: &str) { + assert_eq!(format!("{:?}", actual), expected); + } + + #[test] + fn build2_routes_to_gpt_image_1_request() { + let req = unwrap_t2i(base_builder().build2()); + assert_eq!(req.prompt, "a cat in space"); + } + + #[test] + fn mode_detection_is_based_on_image_urls() { + let text_req = unwrap_t2i(build_fal_gpt_image_1(base_builder())); + assert_eq!(text_req.prompt, "a cat in space"); + + let edit_req = unwrap_edit(build_fal_gpt_image_1(GenerateImageRequestBuilder { + image_inputs: Some(ImageListRef::Urls(vec!["https://example.com/img.jpg".to_string()])), + ..base_builder() + })); + assert_eq!(edit_req.image_urls, vec!["https://example.com/img.jpg"]); + } + + #[test] + fn media_file_tokens_are_rejected() { + let result = build_fal_gpt_image_1(GenerateImageRequestBuilder { + image_inputs: Some(ImageListRef::MediaFileTokens(vec![])), + ..base_builder() + }); + assert!(matches!( + result, + Err(ArtcraftRouterError::Client(ClientError::FalOnlySupportsUrls)) + )); + } + + #[test] + fn num_images_maps_exhaustively_for_text_and_edit() { + let text_cases = [ + (1, "One"), + (2, "Two"), + (3, "Three"), + (4, "Four"), + ]; + + for (count, expected) in text_cases { + let req = unwrap_t2i(build_fal_gpt_image_1(GenerateImageRequestBuilder { + image_batch_count: Some(count), + ..base_builder() + })); + assert_debug(req.num_images, expected); + } + + for (count, expected) in text_cases { + let req = unwrap_edit(build_fal_gpt_image_1(GenerateImageRequestBuilder { + image_inputs: Some(ImageListRef::Urls(vec!["https://example.com/img.jpg".to_string()])), + image_batch_count: Some(count), + ..base_builder() + })); + assert_debug(req.num_images, expected); + } + } + + #[test] + fn num_images_rejects_zero_and_handles_overflow_by_strategy() { + assert!(matches!( + build_fal_gpt_image_1(GenerateImageRequestBuilder { + image_batch_count: Some(0), + ..base_builder() + }), + Err(ArtcraftRouterError::Client(ClientError::UserRequestedZeroGenerations)) + )); + assert!(matches!( + build_fal_gpt_image_1(GenerateImageRequestBuilder { + image_batch_count: Some(5), + ..base_builder() + }), + Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption { .. })) + )); + + let req = unwrap_t2i(build_fal_gpt_image_1(GenerateImageRequestBuilder { + image_batch_count: Some(5), + request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::PayMoreUpgrade, + ..base_builder() + })); + assert_debug(req.num_images, "Four"); + } + + #[test] + fn quality_maps_exhaustively_for_text_and_edit() { + let cases = [ + (None, "Some(High)"), + (Some(CommonQuality::Low), "Some(Low)"), + (Some(CommonQuality::Medium), "Some(Medium)"), + (Some(CommonQuality::High), "Some(High)"), + ]; + + for (quality, expected) in cases { + let req = unwrap_t2i(build_fal_gpt_image_1(GenerateImageRequestBuilder { + quality, + ..base_builder() + })); + assert_debug(req.quality, expected); + } + + for (quality, expected) in cases { + let req = unwrap_edit(build_fal_gpt_image_1(GenerateImageRequestBuilder { + image_inputs: Some(ImageListRef::Urls(vec!["https://example.com/img.jpg".to_string()])), + quality, + ..base_builder() + })); + assert_debug(req.quality, expected); + } + } + + #[test] + fn aspect_ratio_maps_exhaustively_for_text_and_edit() { + let cases = [ + (None, "None"), + (Some(CommonAspectRatio::Auto), "None"), + (Some(CommonAspectRatio::Auto2k), "None"), + (Some(CommonAspectRatio::Auto3k), "None"), + (Some(CommonAspectRatio::Auto4k), "None"), + (Some(CommonAspectRatio::Square), "Some(Square)"), + (Some(CommonAspectRatio::SquareHd), "Some(Square)"), + (Some(CommonAspectRatio::WideThreeByTwo), "Some(Horizontal)"), + (Some(CommonAspectRatio::WideFourByThree), "Some(Horizontal)"), + (Some(CommonAspectRatio::WideFiveByFour), "Some(Horizontal)"), + (Some(CommonAspectRatio::WideSixteenByNine), "Some(Horizontal)"), + (Some(CommonAspectRatio::WideTwentyOneByNine), "Some(Horizontal)"), + (Some(CommonAspectRatio::Wide), "Some(Horizontal)"), + (Some(CommonAspectRatio::TallTwoByThree), "Some(Vertical)"), + (Some(CommonAspectRatio::TallThreeByFour), "Some(Vertical)"), + (Some(CommonAspectRatio::TallFourByFive), "Some(Vertical)"), + (Some(CommonAspectRatio::TallNineBySixteen), "Some(Vertical)"), + (Some(CommonAspectRatio::TallNineByTwentyOne), "Some(Vertical)"), + (Some(CommonAspectRatio::Tall), "Some(Vertical)"), + ]; + + for (aspect_ratio, expected) in cases { + let req = unwrap_t2i(build_fal_gpt_image_1(GenerateImageRequestBuilder { + aspect_ratio, + ..base_builder() + })); + assert_debug(req.image_size, expected); + } + + for (aspect_ratio, expected) in cases { + let req = unwrap_edit(build_fal_gpt_image_1(GenerateImageRequestBuilder { + image_inputs: Some(ImageListRef::Urls(vec!["https://example.com/img.jpg".to_string()])), + aspect_ratio, + ..base_builder() + })); + assert_debug(req.image_size, expected); + } + } +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image_v2/providers/fal/gpt_image_1/cost.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image_v2/providers/fal/gpt_image_1/cost.rs index bcf09b34b1..afb92f8d3b 100644 --- a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image_v2/providers/fal/gpt_image_1/cost.rs +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image_v2/providers/fal/gpt_image_1/cost.rs @@ -29,3 +29,93 @@ impl FalGptImage1CostState { } } } + +#[cfg(test)] +mod tests { + use super::*; + use fal_client::requests::api::image::edit::gpt_image_1_edit_image::api::{ + GptImage1EditImageNumImages, GptImage1EditImageQuality, GptImage1EditImageRequest, + GptImage1EditImageSize, + }; + use fal_client::requests::api::image::text::gpt_image_1_text_to_image::api::{ + GptImage1TextToImageNumImages, GptImage1TextToImageQuality, + GptImage1TextToImageRequest, GptImage1TextToImageSize, + }; + + #[test] + fn text_to_image_cost_spots() { + let cases = [ + ( + GptImage1TextToImageNumImages::One, + Some(GptImage1TextToImageQuality::High), + Some(GptImage1TextToImageSize::Square), + 17, + ), + ( + GptImage1TextToImageNumImages::Three, + Some(GptImage1TextToImageQuality::Medium), + Some(GptImage1TextToImageSize::Horizontal), + 21, + ), + ( + GptImage1TextToImageNumImages::Four, + Some(GptImage1TextToImageQuality::Low), + Some(GptImage1TextToImageSize::Vertical), + 8, + ), + ]; + + for (num_images, quality, image_size, expected) in cases { + let request = FalGptImage1RequestState::TextToImage(GptImage1TextToImageRequest { + prompt: "test".to_string(), + num_images, + image_size, + quality, + background: None, + output_format: None, + }); + let cost = FalGptImage1CostState::from_request(&request).estimate_cost(); + assert_eq!(cost.cost_in_usd_cents, Some(expected)); + } + } + + #[test] + fn edit_image_cost_spots() { + let cases = [ + ( + GptImage1EditImageNumImages::One, + Some(GptImage1EditImageQuality::High), + Some(GptImage1EditImageSize::Horizontal), + 25, + ), + ( + GptImage1EditImageNumImages::Two, + Some(GptImage1EditImageQuality::Medium), + Some(GptImage1EditImageSize::Square), + 10, + ), + ( + GptImage1EditImageNumImages::Four, + Some(GptImage1EditImageQuality::Low), + Some(GptImage1EditImageSize::Auto), + 8, + ), + ]; + + for (num_images, quality, image_size, expected) in cases { + let request = FalGptImage1RequestState::EditImage(GptImage1EditImageRequest { + prompt: "test".to_string(), + image_urls: vec!["https://example.com/image.png".to_string()], + num_images, + mask_image_url: None, + image_size, + quality, + input_fidelity: None, + background: None, + output_format: None, + }); + let cost = FalGptImage1CostState::from_request(&request).estimate_cost(); + assert_eq!(cost.cost_in_usd_cents, Some(expected)); + } + } +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image_v2/providers/fal/gpt_image_1/request.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image_v2/providers/fal/gpt_image_1/request.rs index 8f5ee9d7f9..d0bbee8ff2 100644 --- a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image_v2/providers/fal/gpt_image_1/request.rs +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image_v2/providers/fal/gpt_image_1/request.rs @@ -68,3 +68,58 @@ async fn send_fal_request( }) } } + +#[cfg(test)] +mod tests { + use super::*; + use fal_client::creds::fal_api_key::FalApiKey; + use fal_client::requests::api::image::edit::gpt_image_1_edit_image::api::{ + GptImage1EditImageNumImages, GptImage1EditImageQuality, GptImage1EditImageSize, + }; + use fal_client::requests::api::image::text::gpt_image_1_text_to_image::api::{ + GptImage1TextToImageNumImages, GptImage1TextToImageQuality, + GptImage1TextToImageSize, + }; + use test_data::web::image_urls::JUNO_AT_LAKE_IMAGE_URL; + + fn client_without_webhook() -> RouterFalWebhookOptionalClient { + let secret = std::fs::read_to_string("/Users/bt/Artcraft/credentials/fal_api_key.txt") + .expect("Failed to read fal_api_key.txt"); + RouterFalWebhookOptionalClient::new(FalApiKey::from_str(secret.trim())) + } + + #[tokio::test] + #[ignore] // requires real API key, incurs cost + async fn send_text_to_image_queue() { + let request = FalGptImage1RequestState::TextToImage(GptImage1TextToImageRequest { + prompt: "a small ceramic robot reading by a window".to_string(), + num_images: GptImage1TextToImageNumImages::One, + image_size: Some(GptImage1TextToImageSize::Square), + quality: Some(GptImage1TextToImageQuality::Low), + background: None, + output_format: None, + }); + let response = request.send(&client_without_webhook()).await.expect("send should succeed"); + let payload = response.get_fal_payload().expect("expected Fal payload"); + assert!(payload.request_id.is_some()); + } + + #[tokio::test] + #[ignore] // requires real API key, incurs cost + async fn send_edit_image_queue() { + let request = FalGptImage1RequestState::EditImage(GptImage1EditImageRequest { + prompt: "make the image feel like a quiet watercolor study".to_string(), + image_urls: vec![JUNO_AT_LAKE_IMAGE_URL.to_string()], + num_images: GptImage1EditImageNumImages::One, + mask_image_url: None, + image_size: Some(GptImage1EditImageSize::Square), + quality: Some(GptImage1EditImageQuality::Low), + input_fidelity: None, + background: None, + output_format: None, + }); + let response = request.send(&client_without_webhook()).await.expect("send should succeed"); + let payload = response.get_fal_payload().expect("expected Fal payload"); + assert!(payload.request_id.is_some()); + } +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image_v2/providers/fal/gpt_image_1p5/build.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image_v2/providers/fal/gpt_image_1p5/build.rs index 9aea4e8daf..3ab3c2cc59 100644 --- a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image_v2/providers/fal/gpt_image_1p5/build.rs +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image_v2/providers/fal/gpt_image_1p5/build.rs @@ -194,3 +194,208 @@ fn resolve_image_urls( } } } + +#[cfg(test)] +mod tests { + use super::*; + use std::fmt::Debug; + + use crate::api::common_image_model::CommonImageModel; + use crate::api::provider::Provider; + + fn base_builder() -> GenerateImageRequestBuilder { + GenerateImageRequestBuilder { + model: CommonImageModel::GptImage1p5, + provider: Provider::Fal, + prompt: Some("a cat in space".to_string()), + image_inputs: None, + resolution: None, + aspect_ratio: None, + quality: None, + image_batch_count: None, + horizontal_angle: None, + vertical_angle: None, + zoom: None, + request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::ErrorOut, + generation_mode_mismatch_strategy: None, + idempotency_token: None, + } + } + + fn unwrap_t2i(result: Result) -> GptImage1p5TextToImageRequest { + let ImageGenerationDraftOrRequest::Request( + ImageGenerationRequest::FalGptImage1p5( + FalGptImage1p5RequestState::TextToImage(req) + ) + ) = result.expect("build should succeed") else { + panic!("expected GPT Image 1.5 text-to-image request") + }; + req + } + + fn unwrap_edit(result: Result) -> GptImage1p5EditImageRequest { + let ImageGenerationDraftOrRequest::Request( + ImageGenerationRequest::FalGptImage1p5( + FalGptImage1p5RequestState::EditImage(req) + ) + ) = result.expect("build should succeed") else { + panic!("expected GPT Image 1.5 edit-image request") + }; + req + } + + fn assert_debug(actual: T, expected: &str) { + assert_eq!(format!("{:?}", actual), expected); + } + + #[test] + fn build2_routes_to_gpt_image_1p5_request() { + let req = unwrap_t2i(base_builder().build2()); + assert_eq!(req.prompt, "a cat in space"); + } + + #[test] + fn mode_detection_is_based_on_image_urls() { + let text_req = unwrap_t2i(build_fal_gpt_image_1p5(base_builder())); + assert_eq!(text_req.prompt, "a cat in space"); + + let edit_req = unwrap_edit(build_fal_gpt_image_1p5(GenerateImageRequestBuilder { + image_inputs: Some(ImageListRef::Urls(vec!["https://example.com/img.jpg".to_string()])), + ..base_builder() + })); + assert_eq!(edit_req.image_urls, vec!["https://example.com/img.jpg"]); + } + + #[test] + fn media_file_tokens_are_rejected() { + let result = build_fal_gpt_image_1p5(GenerateImageRequestBuilder { + image_inputs: Some(ImageListRef::MediaFileTokens(vec![])), + ..base_builder() + }); + assert!(matches!( + result, + Err(ArtcraftRouterError::Client(ClientError::FalOnlySupportsUrls)) + )); + } + + #[test] + fn num_images_maps_exhaustively_for_text_and_edit() { + let cases = [ + (1, "One"), + (2, "Two"), + (3, "Three"), + (4, "Four"), + ]; + + for (count, expected) in cases { + let req = unwrap_t2i(build_fal_gpt_image_1p5(GenerateImageRequestBuilder { + image_batch_count: Some(count), + ..base_builder() + })); + assert_debug(req.num_images, expected); + } + + for (count, expected) in cases { + let req = unwrap_edit(build_fal_gpt_image_1p5(GenerateImageRequestBuilder { + image_inputs: Some(ImageListRef::Urls(vec!["https://example.com/img.jpg".to_string()])), + image_batch_count: Some(count), + ..base_builder() + })); + assert_debug(req.num_images, expected); + } + } + + #[test] + fn num_images_rejects_zero_and_handles_overflow_by_strategy() { + assert!(matches!( + build_fal_gpt_image_1p5(GenerateImageRequestBuilder { + image_batch_count: Some(0), + ..base_builder() + }), + Err(ArtcraftRouterError::Client(ClientError::UserRequestedZeroGenerations)) + )); + assert!(matches!( + build_fal_gpt_image_1p5(GenerateImageRequestBuilder { + image_batch_count: Some(5), + ..base_builder() + }), + Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption { .. })) + )); + + let req = unwrap_t2i(build_fal_gpt_image_1p5(GenerateImageRequestBuilder { + image_batch_count: Some(5), + request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::PayMoreUpgrade, + ..base_builder() + })); + assert_debug(req.num_images, "Four"); + } + + #[test] + fn quality_maps_exhaustively_for_text_and_edit() { + let cases = [ + (None, "Some(High)"), + (Some(CommonQuality::Low), "Some(Low)"), + (Some(CommonQuality::Medium), "Some(Medium)"), + (Some(CommonQuality::High), "Some(High)"), + ]; + + for (quality, expected) in cases { + let req = unwrap_t2i(build_fal_gpt_image_1p5(GenerateImageRequestBuilder { + quality, + ..base_builder() + })); + assert_debug(req.quality, expected); + } + + for (quality, expected) in cases { + let req = unwrap_edit(build_fal_gpt_image_1p5(GenerateImageRequestBuilder { + image_inputs: Some(ImageListRef::Urls(vec!["https://example.com/img.jpg".to_string()])), + quality, + ..base_builder() + })); + assert_debug(req.quality, expected); + } + } + + #[test] + fn aspect_ratio_maps_exhaustively_for_text_and_edit() { + let cases = [ + (None, "None"), + (Some(CommonAspectRatio::Auto), "None"), + (Some(CommonAspectRatio::Auto2k), "None"), + (Some(CommonAspectRatio::Auto3k), "None"), + (Some(CommonAspectRatio::Auto4k), "None"), + (Some(CommonAspectRatio::Square), "Some(Square)"), + (Some(CommonAspectRatio::SquareHd), "Some(Square)"), + (Some(CommonAspectRatio::WideThreeByTwo), "Some(Wide)"), + (Some(CommonAspectRatio::WideFourByThree), "Some(Wide)"), + (Some(CommonAspectRatio::WideFiveByFour), "Some(Wide)"), + (Some(CommonAspectRatio::WideSixteenByNine), "Some(Wide)"), + (Some(CommonAspectRatio::WideTwentyOneByNine), "Some(Wide)"), + (Some(CommonAspectRatio::Wide), "Some(Wide)"), + (Some(CommonAspectRatio::TallTwoByThree), "Some(Tall)"), + (Some(CommonAspectRatio::TallThreeByFour), "Some(Tall)"), + (Some(CommonAspectRatio::TallFourByFive), "Some(Tall)"), + (Some(CommonAspectRatio::TallNineBySixteen), "Some(Tall)"), + (Some(CommonAspectRatio::TallNineByTwentyOne), "Some(Tall)"), + (Some(CommonAspectRatio::Tall), "Some(Tall)"), + ]; + + for (aspect_ratio, expected) in cases { + let req = unwrap_t2i(build_fal_gpt_image_1p5(GenerateImageRequestBuilder { + aspect_ratio, + ..base_builder() + })); + assert_debug(req.image_size, expected); + } + + for (aspect_ratio, expected) in cases { + let req = unwrap_edit(build_fal_gpt_image_1p5(GenerateImageRequestBuilder { + image_inputs: Some(ImageListRef::Urls(vec!["https://example.com/img.jpg".to_string()])), + aspect_ratio, + ..base_builder() + })); + assert_debug(req.image_size, expected); + } + } +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image_v2/providers/fal/gpt_image_1p5/cost.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image_v2/providers/fal/gpt_image_1p5/cost.rs index a5d9584dd9..cb7cd8c6bc 100644 --- a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image_v2/providers/fal/gpt_image_1p5/cost.rs +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image_v2/providers/fal/gpt_image_1p5/cost.rs @@ -29,3 +29,93 @@ impl FalGptImage1p5CostState { } } } + +#[cfg(test)] +mod tests { + use super::*; + use fal_client::requests::api::image::edit::gpt_image_1p5_edit_image::api::{ + GptImage1p5EditImageNumImages, GptImage1p5EditImageQuality, + GptImage1p5EditImageRequest, GptImage1p5EditImageSize, + }; + use fal_client::requests::api::image::text::gpt_image_1p5_text_to_image::api::{ + GptImage1p5TextToImageNumImages, GptImage1p5TextToImageQuality, + GptImage1p5TextToImageRequest, GptImage1p5TextToImageSize, + }; + + #[test] + fn text_to_image_cost_spots() { + let cases = [ + ( + GptImage1p5TextToImageNumImages::One, + Some(GptImage1p5TextToImageQuality::High), + Some(GptImage1p5TextToImageSize::Square), + 13, + ), + ( + GptImage1p5TextToImageNumImages::Three, + Some(GptImage1p5TextToImageQuality::Medium), + Some(GptImage1p5TextToImageSize::Wide), + 15, + ), + ( + GptImage1p5TextToImageNumImages::Four, + Some(GptImage1p5TextToImageQuality::Low), + Some(GptImage1p5TextToImageSize::Tall), + 4, + ), + ]; + + for (num_images, quality, image_size, expected) in cases { + let request = FalGptImage1p5RequestState::TextToImage(GptImage1p5TextToImageRequest { + prompt: "test".to_string(), + num_images, + image_size, + background: None, + quality, + output_format: None, + }); + let cost = FalGptImage1p5CostState::from_request(&request).estimate_cost(); + assert_eq!(cost.cost_in_usd_cents, Some(expected)); + } + } + + #[test] + fn edit_image_cost_spots() { + let cases = [ + ( + GptImage1p5EditImageNumImages::One, + Some(GptImage1p5EditImageQuality::High), + Some(GptImage1p5EditImageSize::Wide), + 20, + ), + ( + GptImage1p5EditImageNumImages::Two, + Some(GptImage1p5EditImageQuality::Medium), + Some(GptImage1p5EditImageSize::Square), + 6, + ), + ( + GptImage1p5EditImageNumImages::Four, + Some(GptImage1p5EditImageQuality::Low), + Some(GptImage1p5EditImageSize::Tall), + 4, + ), + ]; + + for (num_images, quality, image_size, expected) in cases { + let request = FalGptImage1p5RequestState::EditImage(GptImage1p5EditImageRequest { + prompt: "test".to_string(), + image_urls: vec!["https://example.com/image.png".to_string()], + num_images, + mask_image_url: None, + image_size, + background: None, + quality, + input_fidelity: None, + output_format: None, + }); + let cost = FalGptImage1p5CostState::from_request(&request).estimate_cost(); + assert_eq!(cost.cost_in_usd_cents, Some(expected)); + } + } +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image_v2/providers/fal/gpt_image_1p5/request.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image_v2/providers/fal/gpt_image_1p5/request.rs index 86b232ef20..e9080b6a26 100644 --- a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image_v2/providers/fal/gpt_image_1p5/request.rs +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image_v2/providers/fal/gpt_image_1p5/request.rs @@ -68,3 +68,59 @@ async fn send_fal_request( }) } } + +#[cfg(test)] +mod tests { + use super::*; + use fal_client::creds::fal_api_key::FalApiKey; + use fal_client::requests::api::image::edit::gpt_image_1p5_edit_image::api::{ + GptImage1p5EditImageNumImages, GptImage1p5EditImageQuality, + GptImage1p5EditImageSize, + }; + use fal_client::requests::api::image::text::gpt_image_1p5_text_to_image::api::{ + GptImage1p5TextToImageNumImages, GptImage1p5TextToImageQuality, + GptImage1p5TextToImageSize, + }; + use test_data::web::image_urls::JUNO_AT_LAKE_IMAGE_URL; + + fn client_without_webhook() -> RouterFalWebhookOptionalClient { + let secret = std::fs::read_to_string("/Users/bt/Artcraft/credentials/fal_api_key.txt") + .expect("Failed to read fal_api_key.txt"); + RouterFalWebhookOptionalClient::new(FalApiKey::from_str(secret.trim())) + } + + #[tokio::test] + #[ignore] // requires real API key, incurs cost + async fn send_text_to_image_queue() { + let request = FalGptImage1p5RequestState::TextToImage(GptImage1p5TextToImageRequest { + prompt: "a tiny brass observatory in a snowy field".to_string(), + num_images: GptImage1p5TextToImageNumImages::One, + image_size: Some(GptImage1p5TextToImageSize::Square), + background: None, + quality: Some(GptImage1p5TextToImageQuality::Low), + output_format: None, + }); + let response = request.send(&client_without_webhook()).await.expect("send should succeed"); + let payload = response.get_fal_payload().expect("expected Fal payload"); + assert!(payload.request_id.is_some()); + } + + #[tokio::test] + #[ignore] // requires real API key, incurs cost + async fn send_edit_image_queue() { + let request = FalGptImage1p5RequestState::EditImage(GptImage1p5EditImageRequest { + prompt: "turn this into a softly lit editorial photograph".to_string(), + image_urls: vec![JUNO_AT_LAKE_IMAGE_URL.to_string()], + num_images: GptImage1p5EditImageNumImages::One, + mask_image_url: None, + image_size: Some(GptImage1p5EditImageSize::Square), + background: None, + quality: Some(GptImage1p5EditImageQuality::Low), + input_fidelity: None, + output_format: None, + }); + let response = request.send(&client_without_webhook()).await.expect("send should succeed"); + let payload = response.get_fal_payload().expect("expected Fal payload"); + assert!(payload.request_id.is_some()); + } +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image_v2/providers/fal/gpt_image_2/build.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image_v2/providers/fal/gpt_image_2/build.rs index 2df3fd1ae5..64524a1735 100644 --- a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image_v2/providers/fal/gpt_image_2/build.rs +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image_v2/providers/fal/gpt_image_2/build.rs @@ -219,3 +219,240 @@ fn resolve_image_urls( } } } + +#[cfg(test)] +mod tests { + use super::*; + use std::fmt::Debug; + + use crate::api::common_image_model::CommonImageModel; + use crate::api::provider::Provider; + + fn base_builder() -> GenerateImageRequestBuilder { + GenerateImageRequestBuilder { + model: CommonImageModel::GptImage2, + provider: Provider::Fal, + prompt: Some("a cat in space".to_string()), + image_inputs: None, + resolution: None, + aspect_ratio: None, + quality: None, + image_batch_count: None, + horizontal_angle: None, + vertical_angle: None, + zoom: None, + request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::ErrorOut, + generation_mode_mismatch_strategy: None, + idempotency_token: None, + } + } + + fn unwrap_t2i(result: Result) -> GptImage2TextToImageRequest { + let ImageGenerationDraftOrRequest::Request( + ImageGenerationRequest::FalGptImage2( + FalGptImage2RequestState::TextToImage(req) + ) + ) = result.expect("build should succeed") else { + panic!("expected GPT Image 2 text-to-image request") + }; + req + } + + fn unwrap_edit(result: Result) -> GptImage2EditImageRequest { + let ImageGenerationDraftOrRequest::Request( + ImageGenerationRequest::FalGptImage2( + FalGptImage2RequestState::EditImage(req) + ) + ) = result.expect("build should succeed") else { + panic!("expected GPT Image 2 edit-image request") + }; + req + } + + fn assert_debug(actual: T, expected: &str) { + assert_eq!(format!("{:?}", actual), expected); + } + + #[test] + fn build2_routes_to_gpt_image_2_request() { + let req = unwrap_t2i(base_builder().build2()); + assert_eq!(req.prompt, "a cat in space"); + } + + #[test] + fn mode_detection_is_based_on_image_urls() { + let text_req = unwrap_t2i(build_fal_gpt_image_2(base_builder())); + assert_eq!(text_req.prompt, "a cat in space"); + + let edit_req = unwrap_edit(build_fal_gpt_image_2(GenerateImageRequestBuilder { + image_inputs: Some(ImageListRef::Urls(vec!["https://example.com/img.jpg".to_string()])), + ..base_builder() + })); + assert_eq!(edit_req.image_urls, vec!["https://example.com/img.jpg"]); + } + + #[test] + fn media_file_tokens_are_rejected() { + let result = build_fal_gpt_image_2(GenerateImageRequestBuilder { + image_inputs: Some(ImageListRef::MediaFileTokens(vec![])), + ..base_builder() + }); + assert!(matches!( + result, + Err(ArtcraftRouterError::Client(ClientError::FalOnlySupportsUrls)) + )); + } + + #[test] + fn num_images_maps_exhaustively_for_text_and_edit() { + let cases = [ + (1, "One"), + (2, "Two"), + (3, "Three"), + (4, "Four"), + ]; + + for (count, expected) in cases { + let req = unwrap_t2i(build_fal_gpt_image_2(GenerateImageRequestBuilder { + image_batch_count: Some(count), + ..base_builder() + })); + assert_debug(req.num_images, expected); + } + + for (count, expected) in cases { + let req = unwrap_edit(build_fal_gpt_image_2(GenerateImageRequestBuilder { + image_inputs: Some(ImageListRef::Urls(vec!["https://example.com/img.jpg".to_string()])), + image_batch_count: Some(count), + ..base_builder() + })); + assert_debug(req.num_images, expected); + } + } + + #[test] + fn num_images_rejects_zero_and_handles_overflow_by_strategy() { + assert!(matches!( + build_fal_gpt_image_2(GenerateImageRequestBuilder { + image_batch_count: Some(0), + ..base_builder() + }), + Err(ArtcraftRouterError::Client(ClientError::UserRequestedZeroGenerations)) + )); + assert!(matches!( + build_fal_gpt_image_2(GenerateImageRequestBuilder { + image_batch_count: Some(5), + ..base_builder() + }), + Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption { .. })) + )); + + let req = unwrap_t2i(build_fal_gpt_image_2(GenerateImageRequestBuilder { + image_batch_count: Some(5), + request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::PayMoreUpgrade, + ..base_builder() + })); + assert_debug(req.num_images, "Four"); + } + + #[test] + fn quality_maps_exhaustively_for_text_and_edit() { + let cases = [ + (None, "Some(High)"), + (Some(CommonQuality::Low), "Some(Low)"), + (Some(CommonQuality::Medium), "Some(Medium)"), + (Some(CommonQuality::High), "Some(High)"), + ]; + + for (quality, expected) in cases { + let req = unwrap_t2i(build_fal_gpt_image_2(GenerateImageRequestBuilder { + quality, + ..base_builder() + })); + assert_debug(req.quality, expected); + } + + for (quality, expected) in cases { + let req = unwrap_edit(build_fal_gpt_image_2(GenerateImageRequestBuilder { + image_inputs: Some(ImageListRef::Urls(vec!["https://example.com/img.jpg".to_string()])), + quality, + ..base_builder() + })); + assert_debug(req.quality, expected); + } + } + + #[test] + fn resolution_maps_exhaustively_for_text_and_edit() { + let cases = [ + (None, "None"), + (Some(CommonResolution::HalfK), "Some(OneK)"), + (Some(CommonResolution::FourEightyP), "Some(OneK)"), + (Some(CommonResolution::SevenTwentyP), "Some(OneK)"), + (Some(CommonResolution::OneK), "Some(OneK)"), + (Some(CommonResolution::TenEightyP), "Some(TwoK)"), + (Some(CommonResolution::TwoK), "Some(TwoK)"), + (Some(CommonResolution::ThreeK), "Some(ThreeK)"), + (Some(CommonResolution::FourK), "Some(FourK)"), + ]; + + for (resolution, expected) in cases { + let req = unwrap_t2i(build_fal_gpt_image_2(GenerateImageRequestBuilder { + resolution, + ..base_builder() + })); + assert_debug(req.resolution, expected); + } + + for (resolution, expected) in cases { + let req = unwrap_edit(build_fal_gpt_image_2(GenerateImageRequestBuilder { + image_inputs: Some(ImageListRef::Urls(vec!["https://example.com/img.jpg".to_string()])), + resolution, + ..base_builder() + })); + assert_debug(req.resolution, expected); + } + } + + #[test] + fn aspect_ratio_maps_exhaustively_for_text_and_edit() { + let cases = [ + (None, "None", "None"), + (Some(CommonAspectRatio::Auto), "None", "Some(Auto)"), + (Some(CommonAspectRatio::Auto2k), "None", "Some(Auto)"), + (Some(CommonAspectRatio::Auto3k), "None", "Some(Auto)"), + (Some(CommonAspectRatio::Auto4k), "None", "Some(Auto)"), + (Some(CommonAspectRatio::Square), "Some(Square)", "Some(Square)"), + (Some(CommonAspectRatio::SquareHd), "Some(SquareHd)", "Some(SquareHd)"), + (Some(CommonAspectRatio::WideThreeByTwo), "Some(Landscape16x9)", "Some(Landscape16x9)"), + (Some(CommonAspectRatio::WideFourByThree), "Some(Landscape4x3)", "Some(Landscape4x3)"), + (Some(CommonAspectRatio::WideFiveByFour), "Some(Landscape4x3)", "Some(Landscape4x3)"), + (Some(CommonAspectRatio::WideSixteenByNine), "Some(Landscape16x9)", "Some(Landscape16x9)"), + (Some(CommonAspectRatio::WideTwentyOneByNine), "Some(Landscape16x9)", "Some(Landscape16x9)"), + (Some(CommonAspectRatio::Wide), "Some(Landscape16x9)", "Some(Landscape16x9)"), + (Some(CommonAspectRatio::TallTwoByThree), "Some(Portrait16x9)", "Some(Portrait16x9)"), + (Some(CommonAspectRatio::TallThreeByFour), "Some(Portrait4x3)", "Some(Portrait4x3)"), + (Some(CommonAspectRatio::TallFourByFive), "Some(Portrait4x3)", "Some(Portrait4x3)"), + (Some(CommonAspectRatio::TallNineBySixteen), "Some(Portrait16x9)", "Some(Portrait16x9)"), + (Some(CommonAspectRatio::TallNineByTwentyOne), "Some(Portrait16x9)", "Some(Portrait16x9)"), + (Some(CommonAspectRatio::Tall), "Some(Portrait16x9)", "Some(Portrait16x9)"), + ]; + + for (aspect_ratio, expected_t2i, _) in cases { + let req = unwrap_t2i(build_fal_gpt_image_2(GenerateImageRequestBuilder { + aspect_ratio, + ..base_builder() + })); + assert_debug(req.image_size, expected_t2i); + } + + for (aspect_ratio, _, expected_edit) in cases { + let req = unwrap_edit(build_fal_gpt_image_2(GenerateImageRequestBuilder { + image_inputs: Some(ImageListRef::Urls(vec!["https://example.com/img.jpg".to_string()])), + aspect_ratio, + ..base_builder() + })); + assert_debug(req.image_size, expected_edit); + } + } +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image_v2/providers/fal/gpt_image_2/cost.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image_v2/providers/fal/gpt_image_2/cost.rs index f943791474..72be681dda 100644 --- a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image_v2/providers/fal/gpt_image_2/cost.rs +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image_v2/providers/fal/gpt_image_2/cost.rs @@ -29,3 +29,99 @@ impl FalGptImage2CostState { } } } + +#[cfg(test)] +mod tests { + use super::*; + use fal_client::requests::api::image::common::gpt_image_2_resolution::GptImage2Resolution; + use fal_client::requests::api::image::edit::gpt_image_2_edit_image::api::{ + GptImage2EditImageNumImages, GptImage2EditImageQuality, GptImage2EditImageRequest, + GptImage2EditImageSize, + }; + use fal_client::requests::api::image::text::gpt_image_2_text_to_image::api::{ + GptImage2TextToImageNumImages, GptImage2TextToImageQuality, + GptImage2TextToImageRequest, GptImage2TextToImageSize, + }; + + #[test] + fn text_to_image_cost_spots() { + let cases = [ + ( + GptImage2TextToImageNumImages::One, + Some(GptImage2TextToImageQuality::High), + Some(GptImage2TextToImageSize::Landscape4x3), + Some(GptImage2Resolution::OneK), + 16, + ), + ( + GptImage2TextToImageNumImages::Two, + Some(GptImage2TextToImageQuality::Medium), + Some(GptImage2TextToImageSize::Landscape16x9), + Some(GptImage2Resolution::TwoK), + 11, + ), + ( + GptImage2TextToImageNumImages::Four, + Some(GptImage2TextToImageQuality::Low), + Some(GptImage2TextToImageSize::Square), + Some(GptImage2Resolution::FourK), + 10, + ), + ]; + + for (num_images, quality, image_size, resolution, expected) in cases { + let request = FalGptImage2RequestState::TextToImage(GptImage2TextToImageRequest { + prompt: "test".to_string(), + num_images, + image_size, + resolution, + quality, + output_format: None, + }); + let cost = FalGptImage2CostState::from_request(&request).estimate_cost(); + assert_eq!(cost.cost_in_usd_cents, Some(expected)); + } + } + + #[test] + fn edit_image_cost_spots() { + let cases = [ + ( + GptImage2EditImageNumImages::One, + Some(GptImage2EditImageQuality::High), + Some(GptImage2EditImageSize::Portrait4x3), + Some(GptImage2Resolution::TwoK), + 24, + ), + ( + GptImage2EditImageNumImages::Three, + Some(GptImage2EditImageQuality::Medium), + Some(GptImage2EditImageSize::SquareHd), + Some(GptImage2Resolution::TwoK), + 21, + ), + ( + GptImage2EditImageNumImages::Four, + Some(GptImage2EditImageQuality::Low), + Some(GptImage2EditImageSize::Auto), + Some(GptImage2Resolution::OneK), + 10, + ), + ]; + + for (num_images, quality, image_size, resolution, expected) in cases { + let request = FalGptImage2RequestState::EditImage(GptImage2EditImageRequest { + prompt: "test".to_string(), + image_urls: vec!["https://example.com/image.png".to_string()], + num_images, + mask_url: None, + image_size, + resolution, + quality, + output_format: None, + }); + let cost = FalGptImage2CostState::from_request(&request).estimate_cost(); + assert_eq!(cost.cost_in_usd_cents, Some(expected)); + } + } +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image_v2/providers/fal/gpt_image_2/request.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image_v2/providers/fal/gpt_image_2/request.rs index 923b91b665..9331ffc3aa 100644 --- a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image_v2/providers/fal/gpt_image_2/request.rs +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image_v2/providers/fal/gpt_image_2/request.rs @@ -68,3 +68,58 @@ async fn send_fal_request( }) } } + +#[cfg(test)] +mod tests { + use super::*; + use fal_client::creds::fal_api_key::FalApiKey; + use fal_client::requests::api::image::common::gpt_image_2_resolution::GptImage2Resolution; + use fal_client::requests::api::image::edit::gpt_image_2_edit_image::api::{ + GptImage2EditImageNumImages, GptImage2EditImageQuality, GptImage2EditImageSize, + }; + use fal_client::requests::api::image::text::gpt_image_2_text_to_image::api::{ + GptImage2TextToImageNumImages, GptImage2TextToImageQuality, + GptImage2TextToImageSize, + }; + use test_data::web::image_urls::JUNO_AT_LAKE_IMAGE_URL; + + fn client_without_webhook() -> RouterFalWebhookOptionalClient { + let secret = std::fs::read_to_string("/Users/bt/Artcraft/credentials/fal_api_key.txt") + .expect("Failed to read fal_api_key.txt"); + RouterFalWebhookOptionalClient::new(FalApiKey::from_str(secret.trim())) + } + + #[tokio::test] + #[ignore] // requires real API key, incurs cost + async fn send_text_to_image_queue() { + let request = FalGptImage2RequestState::TextToImage(GptImage2TextToImageRequest { + prompt: "a polished product photo of a glass compass".to_string(), + num_images: GptImage2TextToImageNumImages::One, + image_size: Some(GptImage2TextToImageSize::Square), + resolution: Some(GptImage2Resolution::OneK), + quality: Some(GptImage2TextToImageQuality::Low), + output_format: None, + }); + let response = request.send(&client_without_webhook()).await.expect("send should succeed"); + let payload = response.get_fal_payload().expect("expected Fal payload"); + assert!(payload.request_id.is_some()); + } + + #[tokio::test] + #[ignore] // requires real API key, incurs cost + async fn send_edit_image_queue() { + let request = FalGptImage2RequestState::EditImage(GptImage2EditImageRequest { + prompt: "make this look like an elegant magazine cover image".to_string(), + image_urls: vec![JUNO_AT_LAKE_IMAGE_URL.to_string()], + num_images: GptImage2EditImageNumImages::One, + mask_url: None, + image_size: Some(GptImage2EditImageSize::Square), + resolution: Some(GptImage2Resolution::OneK), + quality: Some(GptImage2EditImageQuality::Low), + output_format: None, + }); + let response = request.send(&client_without_webhook()).await.expect("send should succeed"); + let payload = response.get_fal_payload().expect("expected Fal payload"); + assert!(payload.request_id.is_some()); + } +}