Compare commits

...
Author SHA1 Message Date
Brandon Thomas e2ff06dfcb tests 2026-05-14 21:38:09 -04:00
Brandon Thomas 74f0fb1731 tests 2026-05-14 21:24:01 -04:00
Brandon Thomas 479d27cd49 artcraft_router impl 2026-05-14 21:14:58 -04:00
15 changed files with 2088 additions and 0 deletions
@@ -12,6 +12,9 @@ use crate::generate::generate_image::image_generation_plan::ImageGenerationPlan;
use crate::generate::generate_image_v2::image_generation_draft_or_request::ImageGenerationDraftOrRequest;
use crate::generate::generate_image_v2::providers::fal::flux_1_dev::build::build_fal_flux_1_dev;
use crate::generate::generate_image_v2::providers::fal::flux_1_schnell::build::build_fal_flux_1_schnell;
use crate::generate::generate_image_v2::providers::fal::gpt_image_1::build::build_fal_gpt_image_1;
use crate::generate::generate_image_v2::providers::fal::gpt_image_1p5::build::build_fal_gpt_image_1p5;
use crate::generate::generate_image_v2::providers::fal::gpt_image_2::build::build_fal_gpt_image_2;
use crate::generate::generate_image_v2::providers::fal::nano_banana_2::build::build_fal_nano_banana_2;
use crate::generate::generate_image_v2::providers::fal::nano_banana_pro::build::build_fal_nano_banana_pro;
use crate::generate::generate_image::plan::artcraft::plan_generate_image_artcraft_flux_1_dev::plan_generate_image_artcraft_flux_1_dev;
@@ -99,6 +102,9 @@ impl GenerateImageRequestBuilder {
match (self.provider, self.model) {
(Provider::Fal, CommonImageModel::Flux1Dev) => true,
(Provider::Fal, CommonImageModel::Flux1Schnell) => true,
(Provider::Fal, CommonImageModel::GptImage1) => true,
(Provider::Fal, CommonImageModel::GptImage1p5) => true,
(Provider::Fal, CommonImageModel::GptImage2) => true,
(Provider::Fal, CommonImageModel::NanoBanana2) => true,
(Provider::Fal, CommonImageModel::NanoBananaPro) => true,
_ => false,
@@ -109,6 +115,9 @@ impl GenerateImageRequestBuilder {
match (self.provider, self.model) {
(Provider::Fal, CommonImageModel::Flux1Dev) => build_fal_flux_1_dev(self),
(Provider::Fal, CommonImageModel::Flux1Schnell) => build_fal_flux_1_schnell(self),
(Provider::Fal, CommonImageModel::GptImage1) => build_fal_gpt_image_1(self),
(Provider::Fal, CommonImageModel::GptImage1p5) => build_fal_gpt_image_1p5(self),
(Provider::Fal, CommonImageModel::GptImage2) => build_fal_gpt_image_2(self),
(Provider::Fal, CommonImageModel::NanoBanana2) => build_fal_nano_banana_2(self),
(Provider::Fal, CommonImageModel::NanoBananaPro) => build_fal_nano_banana_pro(self),
_ => self.unsupported_provider_and_model(),
@@ -7,6 +7,12 @@ use crate::generate::generate_image_v2::providers::fal::flux_1_dev::cost::FalFlu
use crate::generate::generate_image_v2::providers::fal::flux_1_dev::request::FalFlux1DevRequestState;
use crate::generate::generate_image_v2::providers::fal::flux_1_schnell::cost::FalFlux1SchnellCostState;
use crate::generate::generate_image_v2::providers::fal::flux_1_schnell::request::FalFlux1SchnellRequestState;
use crate::generate::generate_image_v2::providers::fal::gpt_image_1::cost::FalGptImage1CostState;
use crate::generate::generate_image_v2::providers::fal::gpt_image_1::request::FalGptImage1RequestState;
use crate::generate::generate_image_v2::providers::fal::gpt_image_1p5::cost::FalGptImage1p5CostState;
use crate::generate::generate_image_v2::providers::fal::gpt_image_1p5::request::FalGptImage1p5RequestState;
use crate::generate::generate_image_v2::providers::fal::gpt_image_2::cost::FalGptImage2CostState;
use crate::generate::generate_image_v2::providers::fal::gpt_image_2::request::FalGptImage2RequestState;
use crate::generate::generate_image_v2::providers::fal::nano_banana_2::cost::FalNanoBanana2CostState;
use crate::generate::generate_image_v2::providers::fal::nano_banana_2::request::FalNanoBanana2RequestState;
use crate::generate::generate_image_v2::providers::fal::nano_banana_pro::cost::FalNanoBananaProCostState;
@@ -16,6 +22,9 @@ use crate::generate::generate_image_v2::providers::fal::nano_banana_pro::request
pub enum ImageGenerationRequest {
FalFlux1Dev(FalFlux1DevRequestState),
FalFlux1Schnell(FalFlux1SchnellRequestState),
FalGptImage1(FalGptImage1RequestState),
FalGptImage1p5(FalGptImage1p5RequestState),
FalGptImage2(FalGptImage2RequestState),
FalNanoBanana2(FalNanoBanana2RequestState),
FalNanoBananaPro(FalNanoBananaProRequestState),
}
@@ -25,6 +34,9 @@ impl ImageGenerationRequest {
match self {
Self::FalFlux1Dev(_) => Provider::Fal,
Self::FalFlux1Schnell(_) => Provider::Fal,
Self::FalGptImage1(_) => Provider::Fal,
Self::FalGptImage1p5(_) => Provider::Fal,
Self::FalGptImage2(_) => Provider::Fal,
Self::FalNanoBanana2(_) => Provider::Fal,
Self::FalNanoBananaPro(_) => Provider::Fal,
}
@@ -38,6 +50,15 @@ impl ImageGenerationRequest {
Self::FalFlux1Schnell(request) => {
Ok(FalFlux1SchnellCostState::from_request(request).estimate_cost())
}
Self::FalGptImage1(request) => {
Ok(FalGptImage1CostState::from_request(request).estimate_cost())
}
Self::FalGptImage1p5(request) => {
Ok(FalGptImage1p5CostState::from_request(request).estimate_cost())
}
Self::FalGptImage2(request) => {
Ok(FalGptImage2CostState::from_request(request).estimate_cost())
}
Self::FalNanoBanana2(request) => {
Ok(FalNanoBanana2CostState::from_request(request).estimate_cost())
}
@@ -57,6 +78,18 @@ impl ImageGenerationRequest {
let fal_client = client.get_fal_webhook_optional_client_ref()?;
request.send(fal_client).await
}
Self::FalGptImage1(request) => {
let fal_client = client.get_fal_webhook_optional_client_ref()?;
request.send(fal_client).await
}
Self::FalGptImage1p5(request) => {
let fal_client = client.get_fal_webhook_optional_client_ref()?;
request.send(fal_client).await
}
Self::FalGptImage2(request) => {
let fal_client = client.get_fal_webhook_optional_client_ref()?;
request.send(fal_client).await
}
Self::FalNanoBanana2(request) => {
let fal_client = client.get_fal_webhook_optional_client_ref()?;
request.send(fal_client).await
@@ -0,0 +1,401 @@
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,
};
use crate::api::common_aspect_ratio::CommonAspectRatio;
use crate::api::common_quality::CommonQuality;
use crate::api::image_list_ref::ImageListRef;
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_image::generate_image_request_builder::GenerateImageRequestBuilder;
use crate::generate::generate_image_v2::image_generation_draft_or_request::ImageGenerationDraftOrRequest;
use crate::generate::generate_image_v2::image_generation_request::ImageGenerationRequest;
use crate::generate::generate_image_v2::providers::fal::gpt_image_1::request::FalGptImage1RequestState;
pub fn build_fal_gpt_image_1(
builder: GenerateImageRequestBuilder,
) -> Result<ImageGenerationDraftOrRequest, ArtcraftRouterError> {
let prompt = builder.prompt.clone().unwrap_or_default();
let image_urls = resolve_image_urls(builder.image_inputs.clone())?;
let num_images = plan_num_images(builder.image_batch_count, builder.request_mismatch_mitigation_strategy)?;
let image_size = plan_image_size(builder.aspect_ratio);
let quality = plan_quality(builder.quality);
let state = if image_urls.is_empty() {
FalGptImage1RequestState::TextToImage(GptImage1TextToImageRequest {
prompt,
num_images: to_t2i_num_images(num_images),
image_size: image_size.map(to_t2i_image_size),
quality: Some(to_t2i_quality(quality)),
background: None,
output_format: None,
})
} else {
FalGptImage1RequestState::EditImage(GptImage1EditImageRequest {
prompt,
image_urls,
num_images: to_edit_num_images(num_images),
mask_image_url: None,
image_size: image_size.map(to_edit_image_size),
quality: Some(to_edit_quality(quality)),
input_fidelity: None,
background: None,
output_format: None,
})
};
Ok(ImageGenerationDraftOrRequest::Request(
ImageGenerationRequest::FalGptImage1(state),
))
}
#[derive(Copy, Clone, Debug)]
enum PlannedNumImages {
One,
Two,
Three,
Four,
}
#[derive(Copy, Clone, Debug)]
enum PlannedQuality {
Low,
Medium,
High,
}
#[derive(Copy, Clone, Debug)]
enum PlannedImageSize {
Square,
Horizontal,
Vertical,
}
fn plan_num_images(
count: Option<u16>,
strategy: RequestMismatchMitigationStrategy,
) -> Result<PlannedNumImages, ArtcraftRouterError> {
let count = count.unwrap_or(1);
match count {
0 => Err(ArtcraftRouterError::Client(ClientError::UserRequestedZeroGenerations)),
1 => Ok(PlannedNumImages::One),
2 => Ok(PlannedNumImages::Two),
3 => Ok(PlannedNumImages::Three),
4 => Ok(PlannedNumImages::Four),
_ => match strategy {
RequestMismatchMitigationStrategy::ErrorOut => {
Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption {
field: "image_batch_count",
value: format!("{}", count),
}))
}
_ => Ok(PlannedNumImages::Four),
},
}
}
fn plan_quality(quality: Option<CommonQuality>) -> PlannedQuality {
match quality {
Some(CommonQuality::Low) => PlannedQuality::Low,
Some(CommonQuality::Medium) => PlannedQuality::Medium,
Some(CommonQuality::High) | None => PlannedQuality::High,
}
}
fn plan_image_size(aspect_ratio: Option<CommonAspectRatio>) -> Option<PlannedImageSize> {
match aspect_ratio {
None
| Some(CommonAspectRatio::Auto)
| Some(CommonAspectRatio::Auto2k)
| Some(CommonAspectRatio::Auto3k)
| Some(CommonAspectRatio::Auto4k) => None,
Some(CommonAspectRatio::Square) | Some(CommonAspectRatio::SquareHd) => {
Some(PlannedImageSize::Square)
}
Some(CommonAspectRatio::WideThreeByTwo)
| Some(CommonAspectRatio::WideFourByThree)
| Some(CommonAspectRatio::WideFiveByFour)
| Some(CommonAspectRatio::WideSixteenByNine)
| Some(CommonAspectRatio::WideTwentyOneByNine)
| Some(CommonAspectRatio::Wide) => Some(PlannedImageSize::Horizontal),
Some(CommonAspectRatio::TallTwoByThree)
| Some(CommonAspectRatio::TallThreeByFour)
| Some(CommonAspectRatio::TallFourByFive)
| Some(CommonAspectRatio::TallNineBySixteen)
| Some(CommonAspectRatio::TallNineByTwentyOne)
| Some(CommonAspectRatio::Tall) => Some(PlannedImageSize::Vertical),
}
}
fn to_t2i_num_images(n: PlannedNumImages) -> GptImage1TextToImageNumImages {
match n {
PlannedNumImages::One => GptImage1TextToImageNumImages::One,
PlannedNumImages::Two => GptImage1TextToImageNumImages::Two,
PlannedNumImages::Three => GptImage1TextToImageNumImages::Three,
PlannedNumImages::Four => GptImage1TextToImageNumImages::Four,
}
}
fn to_edit_num_images(n: PlannedNumImages) -> GptImage1EditImageNumImages {
match n {
PlannedNumImages::One => GptImage1EditImageNumImages::One,
PlannedNumImages::Two => GptImage1EditImageNumImages::Two,
PlannedNumImages::Three => GptImage1EditImageNumImages::Three,
PlannedNumImages::Four => GptImage1EditImageNumImages::Four,
}
}
fn to_t2i_quality(q: PlannedQuality) -> GptImage1TextToImageQuality {
match q {
PlannedQuality::Low => GptImage1TextToImageQuality::Low,
PlannedQuality::Medium => GptImage1TextToImageQuality::Medium,
PlannedQuality::High => GptImage1TextToImageQuality::High,
}
}
fn to_edit_quality(q: PlannedQuality) -> GptImage1EditImageQuality {
match q {
PlannedQuality::Low => GptImage1EditImageQuality::Low,
PlannedQuality::Medium => GptImage1EditImageQuality::Medium,
PlannedQuality::High => GptImage1EditImageQuality::High,
}
}
fn to_t2i_image_size(s: PlannedImageSize) -> GptImage1TextToImageSize {
match s {
PlannedImageSize::Square => GptImage1TextToImageSize::Square,
PlannedImageSize::Horizontal => GptImage1TextToImageSize::Horizontal,
PlannedImageSize::Vertical => GptImage1TextToImageSize::Vertical,
}
}
fn to_edit_image_size(s: PlannedImageSize) -> GptImage1EditImageSize {
match s {
PlannedImageSize::Square => GptImage1EditImageSize::Square,
PlannedImageSize::Horizontal => GptImage1EditImageSize::Horizontal,
PlannedImageSize::Vertical => GptImage1EditImageSize::Vertical,
}
}
fn resolve_image_urls(
image_inputs: Option<ImageListRef>,
) -> Result<Vec<String>, ArtcraftRouterError> {
match image_inputs {
None => Ok(vec![]),
Some(ImageListRef::Urls(urls)) => Ok(urls),
Some(ImageListRef::MediaFileTokens(_)) => {
Err(ArtcraftRouterError::Client(ClientError::FalOnlySupportsUrls))
}
}
}
#[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<ImageGenerationDraftOrRequest, ArtcraftRouterError>) -> 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<ImageGenerationDraftOrRequest, ArtcraftRouterError>) -> 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<T: 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);
}
}
}
@@ -0,0 +1,114 @@
use fal_client::requests::traits::fal_request_cost_calculator_trait::FalRequestCostCalculator;
use crate::generate::generate_image::image_generation_cost_estimate::ImageGenerationCostEstimate;
use crate::generate::generate_image_v2::providers::fal::gpt_image_1::request::FalGptImage1RequestState;
#[derive(Clone, Debug)]
pub struct FalGptImage1CostState {
request: FalGptImage1RequestState,
}
impl FalGptImage1CostState {
pub fn from_request(request: &FalGptImage1RequestState) -> Self {
Self { request: request.clone() }
}
pub fn estimate_cost(&self) -> ImageGenerationCostEstimate {
let cost_in_usd_cents = match &self.request {
FalGptImage1RequestState::TextToImage(req) => req.calculate_cost_in_cents(),
FalGptImage1RequestState::EditImage(req) => req.calculate_cost_in_cents(),
};
ImageGenerationCostEstimate {
cost_in_credits: None,
cost_in_usd_cents: Some(cost_in_usd_cents),
is_free: false,
is_unlimited: false,
is_rate_limited: false,
has_watermark: false,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::generate::generate_image::image_generation_cost_estimate::ImageGenerationCostEstimate;
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_table_covers_quality_size_and_count() {
for (quality, image_size, expected_by_count) in text_to_image_price_cases() {
for (num_images, expected) in text_to_image_num_image_cases(expected_by_count) {
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_standard_cost_estimate(cost, expected);
}
}
}
#[test]
fn edit_image_cost_table_covers_quality_size_and_count() {
for (quality, image_size, expected_by_count) in edit_image_price_cases() {
for (num_images, expected) in edit_image_num_image_cases(expected_by_count) {
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_standard_cost_estimate(cost, expected);
}
}
}
fn text_to_image_price_cases() -> [(Option<GptImage1TextToImageQuality>, Option<GptImage1TextToImageSize>, [u64; 4]); 13] {
[
(None, None, [5, 10, 15, 20]),
(Some(GptImage1TextToImageQuality::Low), Some(GptImage1TextToImageSize::Auto), [2, 4, 6, 8]),
(Some(GptImage1TextToImageQuality::Low), Some(GptImage1TextToImageSize::Square), [2, 4, 6, 8]),
(Some(GptImage1TextToImageQuality::Low), Some(GptImage1TextToImageSize::Horizontal), [2, 4, 6, 8]),
(Some(GptImage1TextToImageQuality::Low), Some(GptImage1TextToImageSize::Vertical), [2, 4, 6, 8]),
(Some(GptImage1TextToImageQuality::Medium), Some(GptImage1TextToImageSize::Auto), [5, 10, 15, 20]),
(Some(GptImage1TextToImageQuality::Medium), Some(GptImage1TextToImageSize::Square), [5, 10, 15, 20]),
(Some(GptImage1TextToImageQuality::Medium), Some(GptImage1TextToImageSize::Horizontal), [7, 14, 21, 28]),
(Some(GptImage1TextToImageQuality::Medium), Some(GptImage1TextToImageSize::Vertical), [7, 14, 21, 28]),
(Some(GptImage1TextToImageQuality::High), Some(GptImage1TextToImageSize::Auto), [17, 34, 51, 68]),
(Some(GptImage1TextToImageQuality::High), Some(GptImage1TextToImageSize::Square), [17, 34, 51, 68]),
(Some(GptImage1TextToImageQuality::High), Some(GptImage1TextToImageSize::Horizontal), [25, 50, 75, 100]),
(Some(GptImage1TextToImageQuality::High), Some(GptImage1TextToImageSize::Vertical), [25, 50, 75, 100]),
]
}
fn edit_image_price_cases() -> [(Option<GptImage1EditImageQuality>, Option<GptImage1EditImageSize>, [u64; 4]); 13] {
[
(None, None, [5, 10, 15, 20]),
(Some(GptImage1EditImageQuality::Low), Some(GptImage1EditImageSize::Auto), [2, 4, 6, 8]),
(Some(GptImage1EditImageQuality::Low), Some(GptImage1EditImageSize::Square), [2, 4, 6, 8]),
(Some(GptImage1EditImageQuality::Low), Some(GptImage1EditImageSize::Horizontal), [2, 4, 6, 8]),
(Some(GptImage1EditImageQuality::Low), Some(GptImage1EditImageSize::Vertical), [2, 4, 6, 8]),
(Some(GptImage1EditImageQuality::Medium), Some(GptImage1EditImageSize::Auto), [5, 10, 15, 20]),
(Some(GptImage1EditImageQuality::Medium), Some(GptImage1EditImageSize::Square), [5, 10, 15, 20]),
(Some(GptImage1EditImageQuality::Medium), Some(GptImage1EditImageSize::Horizontal), [7, 14, 21, 28]),
(Some(GptImage1EditImageQuality::Medium), Some(GptImage1EditImageSize::Vertical), [7, 14, 21, 28]),
(Some(GptImage1EditImageQuality::High), Some(GptImage1EditImageSize::Auto), [17, 34, 51, 68]),
(Some(GptImage1EditImageQuality::High), Some(GptImage1EditImageSize::Square), [17, 34, 51, 68]),
(Some(GptImage1EditImageQuality::High), Some(GptImage1EditImageSize::Horizontal), [25, 50, 75, 100]),
(Some(GptImage1EditImageQuality::High), Some(GptImage1EditImageSize::Vertical), [25, 50, 75, 100]),
]
}
fn text_to_image_num_image_cases(expected_by_count: [u64; 4]) -> [(GptImage1TextToImageNumImages, u64); 4] {
[(GptImage1TextToImageNumImages::One, expected_by_count[0]), (GptImage1TextToImageNumImages::Two, expected_by_count[1]), (GptImage1TextToImageNumImages::Three, expected_by_count[2]), (GptImage1TextToImageNumImages::Four, expected_by_count[3])]
}
fn edit_image_num_image_cases(expected_by_count: [u64; 4]) -> [(GptImage1EditImageNumImages, u64); 4] {
[(GptImage1EditImageNumImages::One, expected_by_count[0]), (GptImage1EditImageNumImages::Two, expected_by_count[1]), (GptImage1EditImageNumImages::Three, expected_by_count[2]), (GptImage1EditImageNumImages::Four, expected_by_count[3])]
}
fn assert_standard_cost_estimate(cost: ImageGenerationCostEstimate, expected_usd_cents: u64) {
assert_eq!(cost.cost_in_usd_cents, Some(expected_usd_cents));
assert_eq!(cost.cost_in_credits, None);
assert!(!cost.is_free);
assert!(!cost.is_unlimited);
assert!(!cost.is_rate_limited);
assert!(!cost.has_watermark);
}
}
@@ -0,0 +1,3 @@
pub mod build;
pub mod cost;
pub mod request;
@@ -0,0 +1,125 @@
use std::fmt::Debug;
use std::sync::Arc;
use fal_client::requests::api::image::edit::gpt_image_1_edit_image::api::GptImage1EditImageRequest;
use fal_client::requests::api::image::text::gpt_image_1_text_to_image::api::GptImage1TextToImageRequest;
use fal_client::requests::traits::fal_endpoint_trait::FalEndpoint;
use crate::client::router_fal_webhook_optional_client::RouterFalWebhookOptionalClient;
use crate::errors::artcraft_router_error::ArtcraftRouterError;
use crate::generate::generate_image::generate_image_response::{FalImageResponsePayload, GenerateImageResponse};
#[derive(Clone, Debug)]
pub enum FalGptImage1RequestState {
TextToImage(GptImage1TextToImageRequest),
EditImage(GptImage1EditImageRequest),
}
impl FalGptImage1RequestState {
pub async fn send(&self, client: &RouterFalWebhookOptionalClient) -> Result<GenerateImageResponse, ArtcraftRouterError> {
match self {
Self::TextToImage(request) => send_request(request, client).await,
Self::EditImage(request) => send_request(request, client).await,
}
}
}
struct FalResponseIds {
request_id: Option<String>,
gateway_request_id: Option<String>,
status_url: Option<String>,
response_url: Option<String>,
}
async fn send_request<T>(request: &T, client: &RouterFalWebhookOptionalClient) -> Result<GenerateImageResponse, ArtcraftRouterError>
where
T: FalEndpoint + Clone + Debug + Send + Sync + 'static,
{
let outbound: Arc<dyn Debug + Send + Sync> = Arc::new(request.clone());
let payload = send_fal_request(request, client).await?;
Ok(GenerateImageResponse::Fal(FalImageResponsePayload {
request_id: payload.request_id,
gateway_request_id: payload.gateway_request_id,
maybe_status_url: payload.status_url,
maybe_response_url: payload.response_url,
maybe_outbound_request: Some(outbound),
}))
}
async fn send_fal_request<T: FalEndpoint>(
request: &T,
client: &RouterFalWebhookOptionalClient,
) -> Result<FalResponseIds, ArtcraftRouterError> {
if let Some(webhook_url) = &client.webhook_url {
let response = request.send_webhook_request(&client.api_key, webhook_url).await?;
Ok(FalResponseIds {
request_id: response.request_id,
gateway_request_id: response.gateway_request_id,
status_url: None,
response_url: None,
})
} else {
let response = request.send_queue_request(&client.api_key).await?;
Ok(FalResponseIds {
request_id: Some(response.request_id),
gateway_request_id: None,
status_url: Some(response.status_url),
response_url: Some(response.response_url),
})
}
}
#[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());
}
}
@@ -0,0 +1,401 @@
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,
};
use crate::api::common_aspect_ratio::CommonAspectRatio;
use crate::api::common_quality::CommonQuality;
use crate::api::image_list_ref::ImageListRef;
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_image::generate_image_request_builder::GenerateImageRequestBuilder;
use crate::generate::generate_image_v2::image_generation_draft_or_request::ImageGenerationDraftOrRequest;
use crate::generate::generate_image_v2::image_generation_request::ImageGenerationRequest;
use crate::generate::generate_image_v2::providers::fal::gpt_image_1p5::request::FalGptImage1p5RequestState;
pub fn build_fal_gpt_image_1p5(
builder: GenerateImageRequestBuilder,
) -> Result<ImageGenerationDraftOrRequest, ArtcraftRouterError> {
let prompt = builder.prompt.clone().unwrap_or_default();
let image_urls = resolve_image_urls(builder.image_inputs.clone())?;
let num_images = plan_num_images(builder.image_batch_count, builder.request_mismatch_mitigation_strategy)?;
let image_size = plan_image_size(builder.aspect_ratio);
let quality = plan_quality(builder.quality);
let state = if image_urls.is_empty() {
FalGptImage1p5RequestState::TextToImage(GptImage1p5TextToImageRequest {
prompt,
num_images: to_t2i_num_images(num_images),
image_size: image_size.map(to_t2i_image_size),
background: None,
quality: Some(to_t2i_quality(quality)),
output_format: None,
})
} else {
FalGptImage1p5RequestState::EditImage(GptImage1p5EditImageRequest {
prompt,
image_urls,
num_images: to_edit_num_images(num_images),
mask_image_url: None,
image_size: image_size.map(to_edit_image_size),
background: None,
quality: Some(to_edit_quality(quality)),
input_fidelity: None,
output_format: None,
})
};
Ok(ImageGenerationDraftOrRequest::Request(
ImageGenerationRequest::FalGptImage1p5(state),
))
}
#[derive(Copy, Clone, Debug)]
enum PlannedNumImages {
One,
Two,
Three,
Four,
}
#[derive(Copy, Clone, Debug)]
enum PlannedQuality {
Low,
Medium,
High,
}
#[derive(Copy, Clone, Debug)]
enum PlannedImageSize {
Square,
Wide,
Tall,
}
fn plan_num_images(
count: Option<u16>,
strategy: RequestMismatchMitigationStrategy,
) -> Result<PlannedNumImages, ArtcraftRouterError> {
let count = count.unwrap_or(1);
match count {
0 => Err(ArtcraftRouterError::Client(ClientError::UserRequestedZeroGenerations)),
1 => Ok(PlannedNumImages::One),
2 => Ok(PlannedNumImages::Two),
3 => Ok(PlannedNumImages::Three),
4 => Ok(PlannedNumImages::Four),
_ => match strategy {
RequestMismatchMitigationStrategy::ErrorOut => {
Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption {
field: "image_batch_count",
value: format!("{}", count),
}))
}
_ => Ok(PlannedNumImages::Four),
},
}
}
fn plan_quality(quality: Option<CommonQuality>) -> PlannedQuality {
match quality {
Some(CommonQuality::Low) => PlannedQuality::Low,
Some(CommonQuality::Medium) => PlannedQuality::Medium,
Some(CommonQuality::High) | None => PlannedQuality::High,
}
}
fn plan_image_size(aspect_ratio: Option<CommonAspectRatio>) -> Option<PlannedImageSize> {
match aspect_ratio {
None
| Some(CommonAspectRatio::Auto)
| Some(CommonAspectRatio::Auto2k)
| Some(CommonAspectRatio::Auto3k)
| Some(CommonAspectRatio::Auto4k) => None,
Some(CommonAspectRatio::Square) | Some(CommonAspectRatio::SquareHd) => {
Some(PlannedImageSize::Square)
}
Some(CommonAspectRatio::WideThreeByTwo)
| Some(CommonAspectRatio::WideFourByThree)
| Some(CommonAspectRatio::WideFiveByFour)
| Some(CommonAspectRatio::WideSixteenByNine)
| Some(CommonAspectRatio::WideTwentyOneByNine)
| Some(CommonAspectRatio::Wide) => Some(PlannedImageSize::Wide),
Some(CommonAspectRatio::TallTwoByThree)
| Some(CommonAspectRatio::TallThreeByFour)
| Some(CommonAspectRatio::TallFourByFive)
| Some(CommonAspectRatio::TallNineBySixteen)
| Some(CommonAspectRatio::TallNineByTwentyOne)
| Some(CommonAspectRatio::Tall) => Some(PlannedImageSize::Tall),
}
}
fn to_t2i_num_images(n: PlannedNumImages) -> GptImage1p5TextToImageNumImages {
match n {
PlannedNumImages::One => GptImage1p5TextToImageNumImages::One,
PlannedNumImages::Two => GptImage1p5TextToImageNumImages::Two,
PlannedNumImages::Three => GptImage1p5TextToImageNumImages::Three,
PlannedNumImages::Four => GptImage1p5TextToImageNumImages::Four,
}
}
fn to_edit_num_images(n: PlannedNumImages) -> GptImage1p5EditImageNumImages {
match n {
PlannedNumImages::One => GptImage1p5EditImageNumImages::One,
PlannedNumImages::Two => GptImage1p5EditImageNumImages::Two,
PlannedNumImages::Three => GptImage1p5EditImageNumImages::Three,
PlannedNumImages::Four => GptImage1p5EditImageNumImages::Four,
}
}
fn to_t2i_quality(q: PlannedQuality) -> GptImage1p5TextToImageQuality {
match q {
PlannedQuality::Low => GptImage1p5TextToImageQuality::Low,
PlannedQuality::Medium => GptImage1p5TextToImageQuality::Medium,
PlannedQuality::High => GptImage1p5TextToImageQuality::High,
}
}
fn to_edit_quality(q: PlannedQuality) -> GptImage1p5EditImageQuality {
match q {
PlannedQuality::Low => GptImage1p5EditImageQuality::Low,
PlannedQuality::Medium => GptImage1p5EditImageQuality::Medium,
PlannedQuality::High => GptImage1p5EditImageQuality::High,
}
}
fn to_t2i_image_size(s: PlannedImageSize) -> GptImage1p5TextToImageSize {
match s {
PlannedImageSize::Square => GptImage1p5TextToImageSize::Square,
PlannedImageSize::Wide => GptImage1p5TextToImageSize::Wide,
PlannedImageSize::Tall => GptImage1p5TextToImageSize::Tall,
}
}
fn to_edit_image_size(s: PlannedImageSize) -> GptImage1p5EditImageSize {
match s {
PlannedImageSize::Square => GptImage1p5EditImageSize::Square,
PlannedImageSize::Wide => GptImage1p5EditImageSize::Wide,
PlannedImageSize::Tall => GptImage1p5EditImageSize::Tall,
}
}
fn resolve_image_urls(
image_inputs: Option<ImageListRef>,
) -> Result<Vec<String>, ArtcraftRouterError> {
match image_inputs {
None => Ok(vec![]),
Some(ImageListRef::Urls(urls)) => Ok(urls),
Some(ImageListRef::MediaFileTokens(_)) => {
Err(ArtcraftRouterError::Client(ClientError::FalOnlySupportsUrls))
}
}
}
#[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<ImageGenerationDraftOrRequest, ArtcraftRouterError>) -> 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<ImageGenerationDraftOrRequest, ArtcraftRouterError>) -> 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<T: 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);
}
}
}
@@ -0,0 +1,86 @@
use fal_client::requests::traits::fal_request_cost_calculator_trait::FalRequestCostCalculator;
use crate::generate::generate_image::image_generation_cost_estimate::ImageGenerationCostEstimate;
use crate::generate::generate_image_v2::providers::fal::gpt_image_1p5::request::FalGptImage1p5RequestState;
#[derive(Clone, Debug)]
pub struct FalGptImage1p5CostState {
request: FalGptImage1p5RequestState,
}
impl FalGptImage1p5CostState {
pub fn from_request(request: &FalGptImage1p5RequestState) -> Self {
Self { request: request.clone() }
}
pub fn estimate_cost(&self) -> ImageGenerationCostEstimate {
let cost_in_usd_cents = match &self.request {
FalGptImage1p5RequestState::TextToImage(req) => req.calculate_cost_in_cents(),
FalGptImage1p5RequestState::EditImage(req) => req.calculate_cost_in_cents(),
};
ImageGenerationCostEstimate {
cost_in_credits: None,
cost_in_usd_cents: Some(cost_in_usd_cents),
is_free: false,
is_unlimited: false,
is_rate_limited: false,
has_watermark: false,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::generate::generate_image::image_generation_cost_estimate::ImageGenerationCostEstimate;
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_table_covers_quality_size_and_count() {
for (quality, image_size, expected_by_count) in text_to_image_price_cases() {
for (num_images, expected) in text_to_image_num_image_cases(expected_by_count) {
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_standard_cost_estimate(cost, expected);
}
}
}
#[test]
fn edit_image_cost_table_covers_quality_size_and_count() {
for (quality, image_size, expected_by_count) in edit_image_price_cases() {
for (num_images, expected) in edit_image_num_image_cases(expected_by_count) {
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_standard_cost_estimate(cost, expected);
}
}
}
fn text_to_image_price_cases() -> [(Option<GptImage1p5TextToImageQuality>, Option<GptImage1p5TextToImageSize>, [u64; 4]); 10] {
[(None, None, [3, 6, 9, 12]), (Some(GptImage1p5TextToImageQuality::Low), Some(GptImage1p5TextToImageSize::Square), [1, 2, 3, 4]), (Some(GptImage1p5TextToImageQuality::Low), Some(GptImage1p5TextToImageSize::Wide), [1, 2, 3, 4]), (Some(GptImage1p5TextToImageQuality::Low), Some(GptImage1p5TextToImageSize::Tall), [1, 2, 3, 4]), (Some(GptImage1p5TextToImageQuality::Medium), Some(GptImage1p5TextToImageSize::Square), [3, 6, 9, 12]), (Some(GptImage1p5TextToImageQuality::Medium), Some(GptImage1p5TextToImageSize::Wide), [5, 10, 15, 20]), (Some(GptImage1p5TextToImageQuality::Medium), Some(GptImage1p5TextToImageSize::Tall), [5, 10, 15, 20]), (Some(GptImage1p5TextToImageQuality::High), Some(GptImage1p5TextToImageSize::Square), [13, 26, 39, 52]), (Some(GptImage1p5TextToImageQuality::High), Some(GptImage1p5TextToImageSize::Wide), [20, 40, 60, 80]), (Some(GptImage1p5TextToImageQuality::High), Some(GptImage1p5TextToImageSize::Tall), [20, 40, 60, 80])]
}
fn edit_image_price_cases() -> [(Option<GptImage1p5EditImageQuality>, Option<GptImage1p5EditImageSize>, [u64; 4]); 10] {
[(None, None, [3, 6, 9, 12]), (Some(GptImage1p5EditImageQuality::Low), Some(GptImage1p5EditImageSize::Square), [1, 2, 3, 4]), (Some(GptImage1p5EditImageQuality::Low), Some(GptImage1p5EditImageSize::Wide), [1, 2, 3, 4]), (Some(GptImage1p5EditImageQuality::Low), Some(GptImage1p5EditImageSize::Tall), [1, 2, 3, 4]), (Some(GptImage1p5EditImageQuality::Medium), Some(GptImage1p5EditImageSize::Square), [3, 6, 9, 12]), (Some(GptImage1p5EditImageQuality::Medium), Some(GptImage1p5EditImageSize::Wide), [5, 10, 15, 20]), (Some(GptImage1p5EditImageQuality::Medium), Some(GptImage1p5EditImageSize::Tall), [5, 10, 15, 20]), (Some(GptImage1p5EditImageQuality::High), Some(GptImage1p5EditImageSize::Square), [13, 26, 39, 52]), (Some(GptImage1p5EditImageQuality::High), Some(GptImage1p5EditImageSize::Wide), [20, 40, 60, 80]), (Some(GptImage1p5EditImageQuality::High), Some(GptImage1p5EditImageSize::Tall), [20, 40, 60, 80])]
}
fn text_to_image_num_image_cases(expected_by_count: [u64; 4]) -> [(GptImage1p5TextToImageNumImages, u64); 4] {
[(GptImage1p5TextToImageNumImages::One, expected_by_count[0]), (GptImage1p5TextToImageNumImages::Two, expected_by_count[1]), (GptImage1p5TextToImageNumImages::Three, expected_by_count[2]), (GptImage1p5TextToImageNumImages::Four, expected_by_count[3])]
}
fn edit_image_num_image_cases(expected_by_count: [u64; 4]) -> [(GptImage1p5EditImageNumImages, u64); 4] {
[(GptImage1p5EditImageNumImages::One, expected_by_count[0]), (GptImage1p5EditImageNumImages::Two, expected_by_count[1]), (GptImage1p5EditImageNumImages::Three, expected_by_count[2]), (GptImage1p5EditImageNumImages::Four, expected_by_count[3])]
}
fn assert_standard_cost_estimate(cost: ImageGenerationCostEstimate, expected_usd_cents: u64) {
assert_eq!(cost.cost_in_usd_cents, Some(expected_usd_cents));
assert_eq!(cost.cost_in_credits, None);
assert!(!cost.is_free);
assert!(!cost.is_unlimited);
assert!(!cost.is_rate_limited);
assert!(!cost.has_watermark);
}
}
@@ -0,0 +1,3 @@
pub mod build;
pub mod cost;
pub mod request;
@@ -0,0 +1,126 @@
use std::fmt::Debug;
use std::sync::Arc;
use fal_client::requests::api::image::edit::gpt_image_1p5_edit_image::api::GptImage1p5EditImageRequest;
use fal_client::requests::api::image::text::gpt_image_1p5_text_to_image::api::GptImage1p5TextToImageRequest;
use fal_client::requests::traits::fal_endpoint_trait::FalEndpoint;
use crate::client::router_fal_webhook_optional_client::RouterFalWebhookOptionalClient;
use crate::errors::artcraft_router_error::ArtcraftRouterError;
use crate::generate::generate_image::generate_image_response::{FalImageResponsePayload, GenerateImageResponse};
#[derive(Clone, Debug)]
pub enum FalGptImage1p5RequestState {
TextToImage(GptImage1p5TextToImageRequest),
EditImage(GptImage1p5EditImageRequest),
}
impl FalGptImage1p5RequestState {
pub async fn send(&self, client: &RouterFalWebhookOptionalClient) -> Result<GenerateImageResponse, ArtcraftRouterError> {
match self {
Self::TextToImage(request) => send_request(request, client).await,
Self::EditImage(request) => send_request(request, client).await,
}
}
}
struct FalResponseIds {
request_id: Option<String>,
gateway_request_id: Option<String>,
status_url: Option<String>,
response_url: Option<String>,
}
async fn send_request<T>(request: &T, client: &RouterFalWebhookOptionalClient) -> Result<GenerateImageResponse, ArtcraftRouterError>
where
T: FalEndpoint + Clone + Debug + Send + Sync + 'static,
{
let outbound: Arc<dyn Debug + Send + Sync> = Arc::new(request.clone());
let payload = send_fal_request(request, client).await?;
Ok(GenerateImageResponse::Fal(FalImageResponsePayload {
request_id: payload.request_id,
gateway_request_id: payload.gateway_request_id,
maybe_status_url: payload.status_url,
maybe_response_url: payload.response_url,
maybe_outbound_request: Some(outbound),
}))
}
async fn send_fal_request<T: FalEndpoint>(
request: &T,
client: &RouterFalWebhookOptionalClient,
) -> Result<FalResponseIds, ArtcraftRouterError> {
if let Some(webhook_url) = &client.webhook_url {
let response = request.send_webhook_request(&client.api_key, webhook_url).await?;
Ok(FalResponseIds {
request_id: response.request_id,
gateway_request_id: response.gateway_request_id,
status_url: None,
response_url: None,
})
} else {
let response = request.send_queue_request(&client.api_key).await?;
Ok(FalResponseIds {
request_id: Some(response.request_id),
gateway_request_id: None,
status_url: Some(response.status_url),
response_url: Some(response.response_url),
})
}
}
#[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());
}
}
@@ -0,0 +1,458 @@
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,
};
use crate::api::common_aspect_ratio::CommonAspectRatio;
use crate::api::common_quality::CommonQuality;
use crate::api::common_resolution::CommonResolution;
use crate::api::image_list_ref::ImageListRef;
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_image::generate_image_request_builder::GenerateImageRequestBuilder;
use crate::generate::generate_image_v2::image_generation_draft_or_request::ImageGenerationDraftOrRequest;
use crate::generate::generate_image_v2::image_generation_request::ImageGenerationRequest;
use crate::generate::generate_image_v2::providers::fal::gpt_image_2::request::FalGptImage2RequestState;
pub fn build_fal_gpt_image_2(
builder: GenerateImageRequestBuilder,
) -> Result<ImageGenerationDraftOrRequest, ArtcraftRouterError> {
let prompt = builder.prompt.clone().unwrap_or_default();
let image_urls = resolve_image_urls(builder.image_inputs.clone())?;
let num_images = plan_num_images(builder.image_batch_count, builder.request_mismatch_mitigation_strategy)?;
let image_size = plan_image_size(builder.aspect_ratio);
let resolution = plan_resolution(builder.resolution);
let quality = plan_quality(builder.quality);
let state = if image_urls.is_empty() {
FalGptImage2RequestState::TextToImage(GptImage2TextToImageRequest {
prompt,
num_images: to_t2i_num_images(num_images),
image_size: image_size.and_then(to_t2i_image_size),
resolution,
quality: Some(to_t2i_quality(quality)),
output_format: None,
})
} else {
FalGptImage2RequestState::EditImage(GptImage2EditImageRequest {
prompt,
image_urls,
num_images: to_edit_num_images(num_images),
mask_url: None,
image_size: image_size.map(to_edit_image_size),
resolution,
quality: Some(to_edit_quality(quality)),
output_format: None,
})
};
Ok(ImageGenerationDraftOrRequest::Request(
ImageGenerationRequest::FalGptImage2(state),
))
}
#[derive(Copy, Clone, Debug)]
enum PlannedNumImages {
One,
Two,
Three,
Four,
}
#[derive(Copy, Clone, Debug)]
enum PlannedQuality {
Low,
Medium,
High,
}
#[derive(Copy, Clone, Debug)]
enum PlannedImageSize {
SquareHd,
Square,
Portrait4x3,
Portrait16x9,
Landscape4x3,
Landscape16x9,
Auto,
}
fn plan_num_images(
count: Option<u16>,
strategy: RequestMismatchMitigationStrategy,
) -> Result<PlannedNumImages, ArtcraftRouterError> {
let count = count.unwrap_or(1);
match count {
0 => Err(ArtcraftRouterError::Client(ClientError::UserRequestedZeroGenerations)),
1 => Ok(PlannedNumImages::One),
2 => Ok(PlannedNumImages::Two),
3 => Ok(PlannedNumImages::Three),
4 => Ok(PlannedNumImages::Four),
_ => match strategy {
RequestMismatchMitigationStrategy::ErrorOut => {
Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption {
field: "image_batch_count",
value: format!("{}", count),
}))
}
_ => Ok(PlannedNumImages::Four),
},
}
}
fn plan_quality(quality: Option<CommonQuality>) -> PlannedQuality {
match quality {
Some(CommonQuality::Low) => PlannedQuality::Low,
Some(CommonQuality::Medium) => PlannedQuality::Medium,
Some(CommonQuality::High) | None => PlannedQuality::High,
}
}
fn plan_resolution(resolution: Option<CommonResolution>) -> Option<GptImage2Resolution> {
resolution.map(|r| match r {
CommonResolution::HalfK
| CommonResolution::FourEightyP
| CommonResolution::SevenTwentyP
| CommonResolution::OneK => GptImage2Resolution::OneK,
CommonResolution::TenEightyP | CommonResolution::TwoK => GptImage2Resolution::TwoK,
CommonResolution::ThreeK => GptImage2Resolution::ThreeK,
CommonResolution::FourK => GptImage2Resolution::FourK,
})
}
fn plan_image_size(aspect_ratio: Option<CommonAspectRatio>) -> Option<PlannedImageSize> {
match aspect_ratio {
None => None,
Some(CommonAspectRatio::Auto)
| Some(CommonAspectRatio::Auto2k)
| Some(CommonAspectRatio::Auto3k)
| Some(CommonAspectRatio::Auto4k) => Some(PlannedImageSize::Auto),
Some(CommonAspectRatio::Square) => Some(PlannedImageSize::Square),
Some(CommonAspectRatio::SquareHd) => Some(PlannedImageSize::SquareHd),
Some(CommonAspectRatio::WideFourByThree)
| Some(CommonAspectRatio::WideFiveByFour) => Some(PlannedImageSize::Landscape4x3),
Some(CommonAspectRatio::WideThreeByTwo)
| Some(CommonAspectRatio::WideSixteenByNine)
| Some(CommonAspectRatio::WideTwentyOneByNine)
| Some(CommonAspectRatio::Wide) => Some(PlannedImageSize::Landscape16x9),
Some(CommonAspectRatio::TallThreeByFour)
| Some(CommonAspectRatio::TallFourByFive) => Some(PlannedImageSize::Portrait4x3),
Some(CommonAspectRatio::TallTwoByThree)
| Some(CommonAspectRatio::TallNineBySixteen)
| Some(CommonAspectRatio::TallNineByTwentyOne)
| Some(CommonAspectRatio::Tall) => Some(PlannedImageSize::Portrait16x9),
}
}
fn to_t2i_num_images(n: PlannedNumImages) -> GptImage2TextToImageNumImages {
match n {
PlannedNumImages::One => GptImage2TextToImageNumImages::One,
PlannedNumImages::Two => GptImage2TextToImageNumImages::Two,
PlannedNumImages::Three => GptImage2TextToImageNumImages::Three,
PlannedNumImages::Four => GptImage2TextToImageNumImages::Four,
}
}
fn to_edit_num_images(n: PlannedNumImages) -> GptImage2EditImageNumImages {
match n {
PlannedNumImages::One => GptImage2EditImageNumImages::One,
PlannedNumImages::Two => GptImage2EditImageNumImages::Two,
PlannedNumImages::Three => GptImage2EditImageNumImages::Three,
PlannedNumImages::Four => GptImage2EditImageNumImages::Four,
}
}
fn to_t2i_quality(q: PlannedQuality) -> GptImage2TextToImageQuality {
match q {
PlannedQuality::Low => GptImage2TextToImageQuality::Low,
PlannedQuality::Medium => GptImage2TextToImageQuality::Medium,
PlannedQuality::High => GptImage2TextToImageQuality::High,
}
}
fn to_edit_quality(q: PlannedQuality) -> GptImage2EditImageQuality {
match q {
PlannedQuality::Low => GptImage2EditImageQuality::Low,
PlannedQuality::Medium => GptImage2EditImageQuality::Medium,
PlannedQuality::High => GptImage2EditImageQuality::High,
}
}
fn to_t2i_image_size(s: PlannedImageSize) -> Option<GptImage2TextToImageSize> {
match s {
PlannedImageSize::SquareHd => Some(GptImage2TextToImageSize::SquareHd),
PlannedImageSize::Square => Some(GptImage2TextToImageSize::Square),
PlannedImageSize::Portrait4x3 => Some(GptImage2TextToImageSize::Portrait4x3),
PlannedImageSize::Portrait16x9 => Some(GptImage2TextToImageSize::Portrait16x9),
PlannedImageSize::Landscape4x3 => Some(GptImage2TextToImageSize::Landscape4x3),
PlannedImageSize::Landscape16x9 => Some(GptImage2TextToImageSize::Landscape16x9),
PlannedImageSize::Auto => None,
}
}
fn to_edit_image_size(s: PlannedImageSize) -> GptImage2EditImageSize {
match s {
PlannedImageSize::SquareHd => GptImage2EditImageSize::SquareHd,
PlannedImageSize::Square => GptImage2EditImageSize::Square,
PlannedImageSize::Portrait4x3 => GptImage2EditImageSize::Portrait4x3,
PlannedImageSize::Portrait16x9 => GptImage2EditImageSize::Portrait16x9,
PlannedImageSize::Landscape4x3 => GptImage2EditImageSize::Landscape4x3,
PlannedImageSize::Landscape16x9 => GptImage2EditImageSize::Landscape16x9,
PlannedImageSize::Auto => GptImage2EditImageSize::Auto,
}
}
fn resolve_image_urls(
image_inputs: Option<ImageListRef>,
) -> Result<Vec<String>, ArtcraftRouterError> {
match image_inputs {
None => Ok(vec![]),
Some(ImageListRef::Urls(urls)) => Ok(urls),
Some(ImageListRef::MediaFileTokens(_)) => {
Err(ArtcraftRouterError::Client(ClientError::FalOnlySupportsUrls))
}
}
}
#[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<ImageGenerationDraftOrRequest, ArtcraftRouterError>) -> 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<ImageGenerationDraftOrRequest, ArtcraftRouterError>) -> 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<T: 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);
}
}
}
@@ -0,0 +1,198 @@
use fal_client::requests::traits::fal_request_cost_calculator_trait::FalRequestCostCalculator;
use crate::generate::generate_image::image_generation_cost_estimate::ImageGenerationCostEstimate;
use crate::generate::generate_image_v2::providers::fal::gpt_image_2::request::FalGptImage2RequestState;
#[derive(Clone, Debug)]
pub struct FalGptImage2CostState {
request: FalGptImage2RequestState,
}
impl FalGptImage2CostState {
pub fn from_request(request: &FalGptImage2RequestState) -> Self {
Self { request: request.clone() }
}
pub fn estimate_cost(&self) -> ImageGenerationCostEstimate {
let cost_in_usd_cents = match &self.request {
FalGptImage2RequestState::TextToImage(req) => req.calculate_cost_in_cents(),
FalGptImage2RequestState::EditImage(req) => req.calculate_cost_in_cents(),
};
ImageGenerationCostEstimate {
cost_in_credits: None,
cost_in_usd_cents: Some(cost_in_usd_cents),
is_free: false,
is_unlimited: false,
is_rate_limited: false,
has_watermark: false,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::generate::generate_image::image_generation_cost_estimate::ImageGenerationCostEstimate;
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};
type TextPriceCase = (Option<GptImage2TextToImageQuality>, Option<GptImage2TextToImageSize>, Option<GptImage2Resolution>, [u64; 4]);
type EditPriceCase = (Option<GptImage2EditImageQuality>, Option<GptImage2EditImageSize>, Option<GptImage2Resolution>, [u64; 4]);
#[test]
fn text_to_image_cost_table_covers_quality_size_resolution_and_count() {
for (quality, image_size, resolution, expected_by_count) in text_to_image_price_cases() {
for (num_images, expected) in text_to_image_num_image_cases(expected_by_count) {
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_standard_cost_estimate(cost, expected);
}
}
}
#[test]
fn edit_image_cost_table_covers_quality_size_resolution_and_count() {
for (quality, image_size, resolution, expected_by_count) in edit_image_price_cases() {
for (num_images, expected) in edit_image_num_image_cases(expected_by_count) {
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_standard_cost_estimate(cost, expected);
}
}
}
fn text_to_image_price_cases() -> Vec<TextPriceCase> {
let mut cases = vec![
(None, None, None, [22, 44, 66, 88]),
(Some(GptImage2TextToImageQuality::Low), Some(GptImage2TextToImageSize::Landscape4x3), None, [2, 3, 4, 5]),
(Some(GptImage2TextToImageQuality::Low), Some(GptImage2TextToImageSize::Portrait4x3), None, [2, 3, 4, 5]),
(Some(GptImage2TextToImageQuality::Low), Some(GptImage2TextToImageSize::Square), None, [2, 3, 5, 6]),
(Some(GptImage2TextToImageQuality::Low), Some(GptImage2TextToImageSize::Landscape16x9), None, [2, 4, 6, 7]),
(Some(GptImage2TextToImageQuality::Low), Some(GptImage2TextToImageSize::Portrait16x9), None, [2, 4, 6, 7]),
(Some(GptImage2TextToImageQuality::Low), Some(GptImage2TextToImageSize::SquareHd), None, [2, 4, 6, 8]),
(Some(GptImage2TextToImageQuality::Medium), Some(GptImage2TextToImageSize::Landscape4x3), None, [5, 9, 13, 18]),
(Some(GptImage2TextToImageQuality::Medium), Some(GptImage2TextToImageSize::Portrait4x3), None, [5, 9, 13, 18]),
(Some(GptImage2TextToImageQuality::Medium), Some(GptImage2TextToImageSize::Square), None, [7, 13, 19, 25]),
(Some(GptImage2TextToImageQuality::Medium), Some(GptImage2TextToImageSize::Landscape16x9), None, [6, 11, 16, 22]),
(Some(GptImage2TextToImageQuality::Medium), Some(GptImage2TextToImageSize::Portrait16x9), None, [6, 11, 16, 22]),
(Some(GptImage2TextToImageQuality::Medium), Some(GptImage2TextToImageSize::SquareHd), None, [7, 14, 21, 28]),
(Some(GptImage2TextToImageQuality::High), Some(GptImage2TextToImageSize::Landscape4x3), None, [16, 31, 46, 61]),
(Some(GptImage2TextToImageQuality::High), Some(GptImage2TextToImageSize::Portrait4x3), None, [16, 31, 46, 61]),
(Some(GptImage2TextToImageQuality::High), Some(GptImage2TextToImageSize::Square), None, [22, 44, 66, 88]),
(Some(GptImage2TextToImageQuality::High), Some(GptImage2TextToImageSize::Landscape16x9), None, [16, 32, 48, 64]),
(Some(GptImage2TextToImageQuality::High), Some(GptImage2TextToImageSize::Portrait16x9), None, [16, 32, 48, 64]),
(Some(GptImage2TextToImageQuality::High), Some(GptImage2TextToImageSize::SquareHd), None, [24, 47, 71, 94]),
];
for (size, resolution, low, medium, high) in text_to_image_resolution_price_cases() {
cases.push((Some(GptImage2TextToImageQuality::Low), Some(size), Some(resolution), low));
cases.push((Some(GptImage2TextToImageQuality::Medium), Some(size), Some(resolution), medium));
cases.push((Some(GptImage2TextToImageQuality::High), Some(size), Some(resolution), high));
}
cases
}
fn edit_image_price_cases() -> Vec<EditPriceCase> {
let mut cases = vec![
(None, None, None, [22, 44, 66, 88]),
(Some(GptImage2EditImageQuality::Low), Some(GptImage2EditImageSize::Auto), None, [3, 5, 8, 10]),
(Some(GptImage2EditImageQuality::Medium), Some(GptImage2EditImageSize::Auto), None, [12, 23, 34, 46]),
(Some(GptImage2EditImageQuality::High), Some(GptImage2EditImageSize::Auto), None, [42, 83, 124, 166]),
(Some(GptImage2EditImageQuality::Low), Some(GptImage2EditImageSize::Auto), Some(GptImage2Resolution::OneK), [3, 5, 8, 10]),
(Some(GptImage2EditImageQuality::Medium), Some(GptImage2EditImageSize::Auto), Some(GptImage2Resolution::OneK), [12, 23, 34, 46]),
(Some(GptImage2EditImageQuality::High), Some(GptImage2EditImageSize::Auto), Some(GptImage2Resolution::OneK), [42, 83, 124, 166]),
(Some(GptImage2EditImageQuality::Low), Some(GptImage2EditImageSize::Landscape4x3), None, [2, 3, 4, 5]),
(Some(GptImage2EditImageQuality::Low), Some(GptImage2EditImageSize::Portrait4x3), None, [2, 3, 4, 5]),
(Some(GptImage2EditImageQuality::Low), Some(GptImage2EditImageSize::Square), None, [2, 3, 5, 6]),
(Some(GptImage2EditImageQuality::Low), Some(GptImage2EditImageSize::Landscape16x9), None, [2, 4, 6, 7]),
(Some(GptImage2EditImageQuality::Low), Some(GptImage2EditImageSize::Portrait16x9), None, [2, 4, 6, 7]),
(Some(GptImage2EditImageQuality::Low), Some(GptImage2EditImageSize::SquareHd), None, [2, 4, 6, 8]),
(Some(GptImage2EditImageQuality::Medium), Some(GptImage2EditImageSize::Landscape4x3), None, [5, 9, 13, 18]),
(Some(GptImage2EditImageQuality::Medium), Some(GptImage2EditImageSize::Portrait4x3), None, [5, 9, 13, 18]),
(Some(GptImage2EditImageQuality::Medium), Some(GptImage2EditImageSize::Square), None, [7, 13, 19, 25]),
(Some(GptImage2EditImageQuality::Medium), Some(GptImage2EditImageSize::Landscape16x9), None, [6, 11, 16, 22]),
(Some(GptImage2EditImageQuality::Medium), Some(GptImage2EditImageSize::Portrait16x9), None, [6, 11, 16, 22]),
(Some(GptImage2EditImageQuality::Medium), Some(GptImage2EditImageSize::SquareHd), None, [7, 14, 21, 28]),
(Some(GptImage2EditImageQuality::High), Some(GptImage2EditImageSize::Landscape4x3), None, [16, 31, 46, 61]),
(Some(GptImage2EditImageQuality::High), Some(GptImage2EditImageSize::Portrait4x3), None, [16, 31, 46, 61]),
(Some(GptImage2EditImageQuality::High), Some(GptImage2EditImageSize::Square), None, [22, 44, 66, 88]),
(Some(GptImage2EditImageQuality::High), Some(GptImage2EditImageSize::Landscape16x9), None, [16, 32, 48, 64]),
(Some(GptImage2EditImageQuality::High), Some(GptImage2EditImageSize::Portrait16x9), None, [16, 32, 48, 64]),
(Some(GptImage2EditImageQuality::High), Some(GptImage2EditImageSize::SquareHd), None, [24, 47, 71, 94]),
];
for (size, resolution, low, medium, high) in edit_image_resolution_price_cases() {
cases.push((Some(GptImage2EditImageQuality::Low), Some(size), Some(resolution), low));
cases.push((Some(GptImage2EditImageQuality::Medium), Some(size), Some(resolution), medium));
cases.push((Some(GptImage2EditImageQuality::High), Some(size), Some(resolution), high));
}
cases
}
fn text_to_image_resolution_price_cases() -> Vec<(GptImage2TextToImageSize, GptImage2Resolution, [u64; 4], [u64; 4], [u64; 4])> {
vec![
(GptImage2TextToImageSize::Landscape4x3, GptImage2Resolution::OneK, [2, 3, 4, 5], [5, 9, 13, 18], [16, 31, 46, 61]),
(GptImage2TextToImageSize::Landscape4x3, GptImage2Resolution::TwoK, [2, 4, 6, 8], [7, 14, 21, 28], [24, 47, 71, 94]),
(GptImage2TextToImageSize::Landscape4x3, GptImage2Resolution::ThreeK, [3, 5, 8, 10], [12, 23, 34, 46], [42, 83, 124, 166]),
(GptImage2TextToImageSize::Landscape4x3, GptImage2Resolution::FourK, [3, 5, 8, 10], [12, 23, 34, 46], [42, 83, 124, 166]),
(GptImage2TextToImageSize::Landscape16x9, GptImage2Resolution::OneK, [2, 3, 4, 5], [5, 9, 13, 18], [16, 31, 46, 61]),
(GptImage2TextToImageSize::Landscape16x9, GptImage2Resolution::TwoK, [2, 4, 6, 7], [6, 11, 16, 22], [16, 32, 48, 64]),
(GptImage2TextToImageSize::Landscape16x9, GptImage2Resolution::ThreeK, [2, 4, 6, 8], [7, 14, 21, 28], [24, 47, 71, 94]),
(GptImage2TextToImageSize::Landscape16x9, GptImage2Resolution::FourK, [3, 5, 8, 10], [12, 23, 34, 46], [42, 83, 124, 166]),
(GptImage2TextToImageSize::Portrait4x3, GptImage2Resolution::OneK, [2, 3, 4, 5], [5, 9, 13, 18], [16, 31, 46, 61]),
(GptImage2TextToImageSize::Portrait4x3, GptImage2Resolution::TwoK, [2, 4, 6, 8], [7, 14, 21, 28], [24, 47, 71, 94]),
(GptImage2TextToImageSize::Portrait4x3, GptImage2Resolution::ThreeK, [3, 5, 8, 10], [12, 23, 34, 46], [42, 83, 124, 166]),
(GptImage2TextToImageSize::Portrait4x3, GptImage2Resolution::FourK, [3, 5, 8, 10], [12, 23, 34, 46], [42, 83, 124, 166]),
(GptImage2TextToImageSize::Portrait16x9, GptImage2Resolution::OneK, [2, 3, 4, 5], [5, 9, 13, 18], [16, 31, 46, 61]),
(GptImage2TextToImageSize::Portrait16x9, GptImage2Resolution::TwoK, [2, 4, 6, 7], [6, 11, 16, 22], [16, 32, 48, 64]),
(GptImage2TextToImageSize::Portrait16x9, GptImage2Resolution::ThreeK, [2, 4, 6, 8], [7, 14, 21, 28], [24, 47, 71, 94]),
(GptImage2TextToImageSize::Portrait16x9, GptImage2Resolution::FourK, [3, 5, 8, 10], [12, 23, 34, 46], [42, 83, 124, 166]),
(GptImage2TextToImageSize::Square, GptImage2Resolution::OneK, [2, 3, 5, 6], [7, 13, 19, 25], [22, 44, 66, 88]),
(GptImage2TextToImageSize::Square, GptImage2Resolution::TwoK, [2, 4, 6, 8], [7, 14, 21, 28], [24, 47, 71, 94]),
(GptImage2TextToImageSize::Square, GptImage2Resolution::ThreeK, [3, 5, 8, 10], [12, 23, 34, 46], [42, 83, 124, 166]),
(GptImage2TextToImageSize::Square, GptImage2Resolution::FourK, [3, 5, 8, 10], [12, 23, 34, 46], [42, 83, 124, 166]),
(GptImage2TextToImageSize::SquareHd, GptImage2Resolution::OneK, [2, 3, 5, 6], [7, 13, 19, 25], [22, 44, 66, 88]),
(GptImage2TextToImageSize::SquareHd, GptImage2Resolution::TwoK, [2, 4, 6, 8], [7, 14, 21, 28], [24, 47, 71, 94]),
(GptImage2TextToImageSize::SquareHd, GptImage2Resolution::ThreeK, [3, 5, 8, 10], [12, 23, 34, 46], [42, 83, 124, 166]),
(GptImage2TextToImageSize::SquareHd, GptImage2Resolution::FourK, [3, 5, 8, 10], [12, 23, 34, 46], [42, 83, 124, 166]),
]
}
fn edit_image_resolution_price_cases() -> Vec<(GptImage2EditImageSize, GptImage2Resolution, [u64; 4], [u64; 4], [u64; 4])> {
text_to_image_resolution_price_cases()
.into_iter()
.map(|(size, resolution, low, medium, high)| {
let edit_size = match size {
GptImage2TextToImageSize::SquareHd => GptImage2EditImageSize::SquareHd,
GptImage2TextToImageSize::Square => GptImage2EditImageSize::Square,
GptImage2TextToImageSize::Portrait4x3 => GptImage2EditImageSize::Portrait4x3,
GptImage2TextToImageSize::Portrait16x9 => GptImage2EditImageSize::Portrait16x9,
GptImage2TextToImageSize::Landscape4x3 => GptImage2EditImageSize::Landscape4x3,
GptImage2TextToImageSize::Landscape16x9 => GptImage2EditImageSize::Landscape16x9,
};
(edit_size, resolution, low, medium, high)
})
.collect()
}
fn text_to_image_num_image_cases(expected_by_count: [u64; 4]) -> [(GptImage2TextToImageNumImages, u64); 4] {
[(GptImage2TextToImageNumImages::One, expected_by_count[0]), (GptImage2TextToImageNumImages::Two, expected_by_count[1]), (GptImage2TextToImageNumImages::Three, expected_by_count[2]), (GptImage2TextToImageNumImages::Four, expected_by_count[3])]
}
fn edit_image_num_image_cases(expected_by_count: [u64; 4]) -> [(GptImage2EditImageNumImages, u64); 4] {
[(GptImage2EditImageNumImages::One, expected_by_count[0]), (GptImage2EditImageNumImages::Two, expected_by_count[1]), (GptImage2EditImageNumImages::Three, expected_by_count[2]), (GptImage2EditImageNumImages::Four, expected_by_count[3])]
}
fn assert_standard_cost_estimate(cost: ImageGenerationCostEstimate, expected_usd_cents: u64) {
assert_eq!(cost.cost_in_usd_cents, Some(expected_usd_cents));
assert_eq!(cost.cost_in_credits, None);
assert!(!cost.is_free);
assert!(!cost.is_unlimited);
assert!(!cost.is_rate_limited);
assert!(!cost.has_watermark);
}
}
@@ -0,0 +1,3 @@
pub mod build;
pub mod cost;
pub mod request;
@@ -0,0 +1,125 @@
use std::fmt::Debug;
use std::sync::Arc;
use fal_client::requests::api::image::edit::gpt_image_2_edit_image::api::GptImage2EditImageRequest;
use fal_client::requests::api::image::text::gpt_image_2_text_to_image::api::GptImage2TextToImageRequest;
use fal_client::requests::traits::fal_endpoint_trait::FalEndpoint;
use crate::client::router_fal_webhook_optional_client::RouterFalWebhookOptionalClient;
use crate::errors::artcraft_router_error::ArtcraftRouterError;
use crate::generate::generate_image::generate_image_response::{FalImageResponsePayload, GenerateImageResponse};
#[derive(Clone, Debug)]
pub enum FalGptImage2RequestState {
TextToImage(GptImage2TextToImageRequest),
EditImage(GptImage2EditImageRequest),
}
impl FalGptImage2RequestState {
pub async fn send(&self, client: &RouterFalWebhookOptionalClient) -> Result<GenerateImageResponse, ArtcraftRouterError> {
match self {
Self::TextToImage(request) => send_request(request, client).await,
Self::EditImage(request) => send_request(request, client).await,
}
}
}
struct FalResponseIds {
request_id: Option<String>,
gateway_request_id: Option<String>,
status_url: Option<String>,
response_url: Option<String>,
}
async fn send_request<T>(request: &T, client: &RouterFalWebhookOptionalClient) -> Result<GenerateImageResponse, ArtcraftRouterError>
where
T: FalEndpoint + Clone + Debug + Send + Sync + 'static,
{
let outbound: Arc<dyn Debug + Send + Sync> = Arc::new(request.clone());
let payload = send_fal_request(request, client).await?;
Ok(GenerateImageResponse::Fal(FalImageResponsePayload {
request_id: payload.request_id,
gateway_request_id: payload.gateway_request_id,
maybe_status_url: payload.status_url,
maybe_response_url: payload.response_url,
maybe_outbound_request: Some(outbound),
}))
}
async fn send_fal_request<T: FalEndpoint>(
request: &T,
client: &RouterFalWebhookOptionalClient,
) -> Result<FalResponseIds, ArtcraftRouterError> {
if let Some(webhook_url) = &client.webhook_url {
let response = request.send_webhook_request(&client.api_key, webhook_url).await?;
Ok(FalResponseIds {
request_id: response.request_id,
gateway_request_id: response.gateway_request_id,
status_url: None,
response_url: None,
})
} else {
let response = request.send_queue_request(&client.api_key).await?;
Ok(FalResponseIds {
request_id: Some(response.request_id),
gateway_request_id: None,
status_url: Some(response.status_url),
response_url: Some(response.response_url),
})
}
}
#[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());
}
}
@@ -1,4 +1,7 @@
pub mod flux_1_dev;
pub mod flux_1_schnell;
pub mod gpt_image_1;
pub mod gpt_image_1p5;
pub mod gpt_image_2;
pub mod nano_banana_2;
pub mod nano_banana_pro;