From d7357028b7ffd34964355c3f8ae09a8ed6ec42db Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Tue, 26 May 2026 10:50:41 -0400 Subject: [PATCH] port flux pro on fal to pipeline v2 --- .../generate_image_request_builder.rs | 6 + .../image_generation_request.rs | 23 ++ .../providers/fal/cost_parity_tests.rs | 251 +++++++++++++++ .../providers/fal/flux_pro_1p1/build.rs | 302 ++++++++++++++++++ .../providers/fal/flux_pro_1p1/cost.rs | 86 +++++ .../providers/fal/flux_pro_1p1/mod.rs | 3 + .../providers/fal/flux_pro_1p1/request.rs | 82 +++++ .../providers/fal/flux_pro_1p1_ultra/build.rs | 281 ++++++++++++++++ .../providers/fal/flux_pro_1p1_ultra/cost.rs | 85 +++++ .../providers/fal/flux_pro_1p1_ultra/mod.rs | 3 + .../fal/flux_pro_1p1_ultra/request.rs | 82 +++++ .../generate_image_v2/providers/fal/mod.rs | 5 + 12 files changed, 1209 insertions(+) create mode 100644 crates/api_clients/artcraft/artcraft_router/src/generate/generate_image_v2/providers/fal/cost_parity_tests.rs create mode 100644 crates/api_clients/artcraft/artcraft_router/src/generate/generate_image_v2/providers/fal/flux_pro_1p1/build.rs create mode 100644 crates/api_clients/artcraft/artcraft_router/src/generate/generate_image_v2/providers/fal/flux_pro_1p1/cost.rs create mode 100644 crates/api_clients/artcraft/artcraft_router/src/generate/generate_image_v2/providers/fal/flux_pro_1p1/mod.rs create mode 100644 crates/api_clients/artcraft/artcraft_router/src/generate/generate_image_v2/providers/fal/flux_pro_1p1/request.rs create mode 100644 crates/api_clients/artcraft/artcraft_router/src/generate/generate_image_v2/providers/fal/flux_pro_1p1_ultra/build.rs create mode 100644 crates/api_clients/artcraft/artcraft_router/src/generate/generate_image_v2/providers/fal/flux_pro_1p1_ultra/cost.rs create mode 100644 crates/api_clients/artcraft/artcraft_router/src/generate/generate_image_v2/providers/fal/flux_pro_1p1_ultra/mod.rs create mode 100644 crates/api_clients/artcraft/artcraft_router/src/generate/generate_image_v2/providers/fal/flux_pro_1p1_ultra/request.rs diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/generate_image_request_builder.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/generate_image_request_builder.rs index 166f4db952..ea7a38b0d9 100644 --- a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/generate_image_request_builder.rs +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/generate_image_request_builder.rs @@ -12,6 +12,8 @@ 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::flux_pro_1p1::build::build_fal_flux_pro_1p1; +use crate::generate::generate_image_v2::providers::fal::flux_pro_1p1_ultra::build::build_fal_flux_pro_1p1_ultra; 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; @@ -102,6 +104,8 @@ impl GenerateImageRequestBuilder { match (self.provider, self.model) { (Provider::Fal, CommonImageModel::Flux1Dev) => true, (Provider::Fal, CommonImageModel::Flux1Schnell) => true, + (Provider::Fal, CommonImageModel::FluxPro11) => true, + (Provider::Fal, CommonImageModel::FluxPro11Ultra) => true, (Provider::Fal, CommonImageModel::GptImage1) => true, (Provider::Fal, CommonImageModel::GptImage1p5) => true, (Provider::Fal, CommonImageModel::GptImage2) => true, @@ -115,6 +119,8 @@ 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::FluxPro11) => build_fal_flux_pro_1p1(self), + (Provider::Fal, CommonImageModel::FluxPro11Ultra) => build_fal_flux_pro_1p1_ultra(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), diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image_v2/image_generation_request.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image_v2/image_generation_request.rs index 7c7135cbfd..68281591bb 100644 --- a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image_v2/image_generation_request.rs +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image_v2/image_generation_request.rs @@ -7,6 +7,10 @@ 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::flux_pro_1p1::cost::FalFluxPro1p1CostState; +use crate::generate::generate_image_v2::providers::fal::flux_pro_1p1::request::FalFluxPro1p1RequestState; +use crate::generate::generate_image_v2::providers::fal::flux_pro_1p1_ultra::cost::FalFluxPro1p1UltraCostState; +use crate::generate::generate_image_v2::providers::fal::flux_pro_1p1_ultra::request::FalFluxPro1p1UltraRequestState; 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; @@ -22,6 +26,8 @@ use crate::generate::generate_image_v2::providers::fal::nano_banana_pro::request pub enum ImageGenerationRequest { FalFlux1Dev(FalFlux1DevRequestState), FalFlux1Schnell(FalFlux1SchnellRequestState), + FalFluxPro1p1(FalFluxPro1p1RequestState), + FalFluxPro1p1Ultra(FalFluxPro1p1UltraRequestState), FalGptImage1(FalGptImage1RequestState), FalGptImage1p5(FalGptImage1p5RequestState), FalGptImage2(FalGptImage2RequestState), @@ -34,6 +40,8 @@ impl ImageGenerationRequest { match self { Self::FalFlux1Dev(_) => Provider::Fal, Self::FalFlux1Schnell(_) => Provider::Fal, + Self::FalFluxPro1p1(_) => Provider::Fal, + Self::FalFluxPro1p1Ultra(_) => Provider::Fal, Self::FalGptImage1(_) => Provider::Fal, Self::FalGptImage1p5(_) => Provider::Fal, Self::FalGptImage2(_) => Provider::Fal, @@ -50,6 +58,12 @@ impl ImageGenerationRequest { Self::FalFlux1Schnell(request) => { Ok(FalFlux1SchnellCostState::from_request(request).estimate_cost()) } + Self::FalFluxPro1p1(request) => { + Ok(FalFluxPro1p1CostState::from_request(request).estimate_cost()) + } + Self::FalFluxPro1p1Ultra(request) => { + Ok(FalFluxPro1p1UltraCostState::from_request(request).estimate_cost()) + } Self::FalGptImage1(request) => { Ok(FalGptImage1CostState::from_request(request).estimate_cost()) } @@ -78,6 +92,15 @@ impl ImageGenerationRequest { let fal_client = client.get_fal_webhook_optional_client_ref()?; request.send(fal_client).await } + Self::FalFluxPro1p1(request) => { + // Flux Pro 1.1 is webhook-required — uses the webhook-only RouterFalClient. + let fal_client = client.get_fal_client_ref()?; + request.send(fal_client).await + } + Self::FalFluxPro1p1Ultra(request) => { + let fal_client = client.get_fal_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 diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image_v2/providers/fal/cost_parity_tests.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image_v2/providers/fal/cost_parity_tests.rs new file mode 100644 index 0000000000..4ab007d03a --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image_v2/providers/fal/cost_parity_tests.rs @@ -0,0 +1,251 @@ +//! Combinatorial cost-parity tests between v1 (`build().estimate_costs()`) and v2 +//! (`build2().estimate_cost()`) for the Fal-hosted image models. +//! +//! Both pipelines must produce identical prices for every request shape we +//! expect routers to send. Image cost is simpler than video — it just scales +//! with `num_images` — but we still sweep aspect ratio + batch size + strategy +//! variations to catch any drift in the request plumbing. + +#![cfg(test)] + +use crate::api::common_aspect_ratio::CommonAspectRatio; +use crate::api::common_image_model::CommonImageModel; +use crate::api::common_quality::CommonQuality; +use crate::api::common_resolution::CommonResolution; +use crate::api::image_list_ref::ImageListRef; +use crate::api::provider::Provider; +use crate::client::request_mismatch_mitigation_strategy::RequestMismatchMitigationStrategy; +use crate::generate::generate_image::generate_image_request_builder::GenerateImageRequestBuilder; + +fn base_builder(model: CommonImageModel) -> GenerateImageRequestBuilder { + GenerateImageRequestBuilder { + model, + 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::PayMoreUpgrade, + generation_mode_mismatch_strategy: None, + idempotency_token: None, + } +} + +/// Returns `Some(cost_in_usd_cents)` from the v1 pipeline. If v1 errors out +/// (e.g. unsupported batch size with `ErrorOut`), returns `None` so the +/// parity test compares apples to apples. +fn v1_cost(builder: &GenerateImageRequestBuilder) -> Option { + builder + .build() + .ok() + .and_then(|plan| plan.estimate_costs().cost_in_usd_cents) +} + +/// Same as `v1_cost`, but for the v2 pipeline. +fn v2_cost(builder: GenerateImageRequestBuilder) -> Option { + builder + .build2() + .ok() + .and_then(|dor| dor.estimate_cost().ok()) + .and_then(|estimate| estimate.cost_in_usd_cents) +} + +fn v1_credits(builder: &GenerateImageRequestBuilder) -> Option { + builder + .build() + .ok() + .and_then(|plan| plan.estimate_costs().cost_in_credits) +} + +fn v2_credits(builder: GenerateImageRequestBuilder) -> Option { + builder + .build2() + .ok() + .and_then(|dor| dor.estimate_cost().ok()) + .and_then(|estimate| estimate.cost_in_credits) +} + +fn all_aspect_ratios() -> &'static [Option] { + &[ + None, + Some(CommonAspectRatio::Auto), + Some(CommonAspectRatio::Auto2k), + Some(CommonAspectRatio::Auto4k), + Some(CommonAspectRatio::Square), + Some(CommonAspectRatio::SquareHd), + Some(CommonAspectRatio::WideFourByThree), + Some(CommonAspectRatio::WideFiveByFour), + Some(CommonAspectRatio::WideThreeByTwo), + Some(CommonAspectRatio::WideSixteenByNine), + Some(CommonAspectRatio::WideTwentyOneByNine), + Some(CommonAspectRatio::Wide), + Some(CommonAspectRatio::TallThreeByFour), + Some(CommonAspectRatio::TallFourByFive), + Some(CommonAspectRatio::TallTwoByThree), + Some(CommonAspectRatio::TallNineBySixteen), + Some(CommonAspectRatio::TallNineByTwentyOne), + Some(CommonAspectRatio::Tall), + ] +} + +fn all_strategies() -> &'static [RequestMismatchMitigationStrategy] { + &[ + RequestMismatchMitigationStrategy::ErrorOut, + RequestMismatchMitigationStrategy::PayMoreUpgrade, + RequestMismatchMitigationStrategy::PayLessDowngrade, + ] +} + +// ── Flux Pro 1.1 ── + +mod flux_pro_1p1 { + use super::*; + + fn base() -> GenerateImageRequestBuilder { + base_builder(CommonImageModel::FluxPro11) + } + + #[test] + fn cost_parity_full_combinatorial() { + let batches = [None, Some(1u16), Some(2), Some(3), Some(4)]; + let mut combos = 0; + for &aspect_ratio in all_aspect_ratios() { + for &batch in &batches { + for &strategy in all_strategies() { + let mut builder = base(); + builder.aspect_ratio = aspect_ratio; + builder.image_batch_count = batch; + builder.request_mismatch_mitigation_strategy = strategy; + + let v1 = v1_cost(&builder); + let v2 = v2_cost(builder.clone()); + assert_eq!( + v1, v2, + "flux_pro_1p1 cost mismatch: ar={:?} batch={:?} strat={:?} → v1={:?} v2={:?}", + aspect_ratio, batch, strategy, v1, v2, + ); + + let v1c = v1_credits(&builder); + let v2c = v2_credits(builder.clone()); + assert_eq!( + v1c, v2c, + "flux_pro_1p1 credits mismatch: ar={:?} batch={:?} strat={:?} → v1={:?} v2={:?}", + aspect_ratio, batch, strategy, v1c, v2c, + ); + + combos += 1; + } + } + } + assert_eq!(combos, all_aspect_ratios().len() * batches.len() * all_strategies().len()); + } + + #[test] + fn cost_parity_out_of_range_batches() { + // Batches above 4 should error on ErrorOut and clamp to Four otherwise. + for &batch in &[5u16, 7, 10, 100] { + for &strategy in all_strategies() { + let mut builder = base(); + builder.image_batch_count = Some(batch); + builder.request_mismatch_mitigation_strategy = strategy; + let v1 = v1_cost(&builder); + let v2 = v2_cost(builder.clone()); + assert_eq!( + v1, v2, + "flux_pro_1p1 out-of-range cost mismatch: batch={} strat={:?} → v1={:?} v2={:?}", + batch, strategy, v1, v2, + ); + } + } + } + + #[test] + fn cost_parity_with_extra_fields_set() { + // v1 ignores resolution/quality/horizontal_angle/etc., so v2 should too. + let mut builder = base(); + builder.image_batch_count = Some(2); + builder.resolution = Some(CommonResolution::TenEightyP); + builder.quality = Some(CommonQuality::High); + builder.horizontal_angle = Some(45.0); + builder.vertical_angle = Some(15.0); + builder.zoom = Some(2.0); + assert_eq!(v1_cost(&builder), v2_cost(builder.clone())); + } +} + +// ── Flux Pro 1.1 Ultra ── + +mod flux_pro_1p1_ultra { + use super::*; + + fn base() -> GenerateImageRequestBuilder { + base_builder(CommonImageModel::FluxPro11Ultra) + } + + #[test] + fn cost_parity_full_combinatorial() { + let batches = [None, Some(1u16), Some(2), Some(3), Some(4)]; + let mut combos = 0; + for &aspect_ratio in all_aspect_ratios() { + for &batch in &batches { + for &strategy in all_strategies() { + let mut builder = base(); + builder.aspect_ratio = aspect_ratio; + builder.image_batch_count = batch; + builder.request_mismatch_mitigation_strategy = strategy; + + let v1 = v1_cost(&builder); + let v2 = v2_cost(builder.clone()); + assert_eq!( + v1, v2, + "flux_pro_1p1_ultra cost mismatch: ar={:?} batch={:?} strat={:?} → v1={:?} v2={:?}", + aspect_ratio, batch, strategy, v1, v2, + ); + + let v1c = v1_credits(&builder); + let v2c = v2_credits(builder.clone()); + assert_eq!( + v1c, v2c, + "flux_pro_1p1_ultra credits mismatch: ar={:?} batch={:?} strat={:?} → v1={:?} v2={:?}", + aspect_ratio, batch, strategy, v1c, v2c, + ); + + combos += 1; + } + } + } + assert_eq!(combos, all_aspect_ratios().len() * batches.len() * all_strategies().len()); + } + + #[test] + fn cost_parity_out_of_range_batches() { + for &batch in &[5u16, 7, 10, 100] { + for &strategy in all_strategies() { + let mut builder = base(); + builder.image_batch_count = Some(batch); + builder.request_mismatch_mitigation_strategy = strategy; + let v1 = v1_cost(&builder); + let v2 = v2_cost(builder.clone()); + assert_eq!( + v1, v2, + "flux_pro_1p1_ultra out-of-range cost mismatch: batch={} strat={:?} → v1={:?} v2={:?}", + batch, strategy, v1, v2, + ); + } + } + } + + #[test] + fn cost_parity_image_inputs_rejected_by_both() { + // v1 errors on image_inputs (text-to-image only); v2 should match. + let mut builder = base(); + builder.image_inputs = Some(ImageListRef::Urls(vec!["https://example.com/x.jpg".to_string()])); + assert_eq!(v1_cost(&builder), None); + assert_eq!(v2_cost(builder), None); + } +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image_v2/providers/fal/flux_pro_1p1/build.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image_v2/providers/fal/flux_pro_1p1/build.rs new file mode 100644 index 0000000000..145d600b01 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image_v2/providers/fal/flux_pro_1p1/build.rs @@ -0,0 +1,302 @@ +use fal_client::requests::webhook::image::text::enqueue_flux_pro_11_text_to_image_webhook::{ + FluxPro11AspectRatio, FluxPro11NumImages, FluxPro11Request, +}; + +use crate::api::common_aspect_ratio::CommonAspectRatio; +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::flux_pro_1p1::request::FalFluxPro1p1RequestState; + +pub fn build_fal_flux_pro_1p1( + builder: GenerateImageRequestBuilder, +) -> Result { + let strategy = builder.request_mismatch_mitigation_strategy; + + // Flux Pro 1.1 is text-to-image only. + if builder.image_inputs.is_some() { + return Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption { + field: "image_inputs", + value: "Flux Pro 1.1 is text-to-image only".to_string(), + })); + } + + let aspect_ratio = plan_aspect_ratio(builder.aspect_ratio, strategy)?; + let num_images = plan_num_images(builder.image_batch_count, strategy)?; + + let request = FluxPro11Request { + prompt: builder.prompt.clone().unwrap_or_default(), + aspect_ratio, + num_images, + }; + + Ok(ImageGenerationDraftOrRequest::Request( + ImageGenerationRequest::FalFluxPro1p1(FalFluxPro1p1RequestState { request }), + )) +} + +fn plan_aspect_ratio( + aspect_ratio: Option, + strategy: RequestMismatchMitigationStrategy, +) -> Result { + use FluxPro11AspectRatio as Ar; + match aspect_ratio { + None => Ok(Ar::Square), + + Some(CommonAspectRatio::Auto) + | Some(CommonAspectRatio::Auto2k) + | Some(CommonAspectRatio::Auto4k) => Ok(Ar::Square), + + Some(CommonAspectRatio::Square) => Ok(Ar::Square), + Some(CommonAspectRatio::SquareHd) => Ok(Ar::SquareHd), + Some(CommonAspectRatio::WideFourByThree) => Ok(Ar::LandscapeFourByThree), + Some(CommonAspectRatio::WideSixteenByNine) | Some(CommonAspectRatio::Wide) => Ok(Ar::LandscapeSixteenByNine), + Some(CommonAspectRatio::TallThreeByFour) => Ok(Ar::PortraitThreeByFour), + Some(CommonAspectRatio::TallNineBySixteen) | Some(CommonAspectRatio::Tall) => Ok(Ar::PortraitNineBySixteen), + + Some(CommonAspectRatio::WideFiveByFour) => Ok(Ar::LandscapeFourByThree), + Some(CommonAspectRatio::WideThreeByTwo) => Ok(Ar::LandscapeFourByThree), + Some(CommonAspectRatio::WideTwentyOneByNine) => Ok(Ar::LandscapeSixteenByNine), + Some(CommonAspectRatio::TallFourByFive) => Ok(Ar::PortraitThreeByFour), + Some(CommonAspectRatio::TallTwoByThree) => Ok(Ar::PortraitThreeByFour), + Some(CommonAspectRatio::TallNineByTwentyOne) => Ok(Ar::PortraitNineBySixteen), + + Some(unsupported) => match strategy { + RequestMismatchMitigationStrategy::ErrorOut => { + Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption { + field: "aspect_ratio", + value: format!("{:?}", unsupported), + })) + } + _ => Ok(Ar::Square), + }, + } +} + +fn plan_num_images( + image_batch_count: Option, + strategy: RequestMismatchMitigationStrategy, +) -> Result { + let count = image_batch_count.unwrap_or(1); + match count { + 0 => Err(ArtcraftRouterError::Client(ClientError::UserRequestedZeroGenerations)), + 1 => Ok(FluxPro11NumImages::One), + 2 => Ok(FluxPro11NumImages::Two), + 3 => Ok(FluxPro11NumImages::Three), + 4 => Ok(FluxPro11NumImages::Four), + _ => match strategy { + RequestMismatchMitigationStrategy::ErrorOut => { + Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption { + field: "image_batch_count", + value: format!("{}", count), + })) + } + _ => Ok(FluxPro11NumImages::Four), + }, + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::api::common_image_model::CommonImageModel; + use crate::api::image_list_ref::ImageListRef; + use crate::api::provider::Provider; + + fn base_builder() -> GenerateImageRequestBuilder { + GenerateImageRequestBuilder { + model: CommonImageModel::FluxPro11, + 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_request(result: Result) -> FluxPro11Request { + let ImageGenerationDraftOrRequest::Request( + ImageGenerationRequest::FalFluxPro1p1(state) + ) = result.expect("build should succeed") else { + panic!("expected FalFluxPro1p1 variant") + }; + state.request + } + + // ── Mode rejection ── + + #[test] + fn image_inputs_present_yields_error() { + let builder = GenerateImageRequestBuilder { + image_inputs: Some(ImageListRef::Urls(vec!["https://example.com/img.jpg".to_string()])), + ..base_builder() + }; + assert!(matches!( + build_fal_flux_pro_1p1(builder), + Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption { field: "image_inputs", .. })) + )); + } + + // ── Prompt ── + + mod prompt_tests { + use super::*; + + #[test] + fn prompt_passed_through() { + let builder = GenerateImageRequestBuilder { + prompt: Some("my custom prompt".to_string()), + ..base_builder() + }; + let req = unwrap_request(build_fal_flux_pro_1p1(builder)); + assert_eq!(req.prompt, "my custom prompt"); + } + + #[test] + fn missing_prompt_defaults_to_empty() { + let builder = GenerateImageRequestBuilder { prompt: None, ..base_builder() }; + let req = unwrap_request(build_fal_flux_pro_1p1(builder)); + assert_eq!(req.prompt, ""); + } + } + + // ── Aspect ratio ── + + mod aspect_ratio_tests { + use super::*; + + #[test] + fn default_is_square() { + let req = unwrap_request(build_fal_flux_pro_1p1(base_builder())); + assert!(matches!(req.aspect_ratio, FluxPro11AspectRatio::Square)); + } + + #[test] + fn auto_maps_to_square() { + let builder = GenerateImageRequestBuilder { + aspect_ratio: Some(CommonAspectRatio::Auto), + ..base_builder() + }; + let req = unwrap_request(build_fal_flux_pro_1p1(builder)); + assert!(matches!(req.aspect_ratio, FluxPro11AspectRatio::Square)); + } + + #[test] + fn square_hd() { + let builder = GenerateImageRequestBuilder { + aspect_ratio: Some(CommonAspectRatio::SquareHd), + ..base_builder() + }; + let req = unwrap_request(build_fal_flux_pro_1p1(builder)); + assert!(matches!(req.aspect_ratio, FluxPro11AspectRatio::SquareHd)); + } + + #[test] + fn wide_sixteen_by_nine() { + let builder = GenerateImageRequestBuilder { + aspect_ratio: Some(CommonAspectRatio::WideSixteenByNine), + ..base_builder() + }; + let req = unwrap_request(build_fal_flux_pro_1p1(builder)); + assert!(matches!(req.aspect_ratio, FluxPro11AspectRatio::LandscapeSixteenByNine)); + } + + #[test] + fn tall_nine_by_sixteen() { + let builder = GenerateImageRequestBuilder { + aspect_ratio: Some(CommonAspectRatio::TallNineBySixteen), + ..base_builder() + }; + let req = unwrap_request(build_fal_flux_pro_1p1(builder)); + assert!(matches!(req.aspect_ratio, FluxPro11AspectRatio::PortraitNineBySixteen)); + } + + #[test] + fn wide_twenty_one_by_nine_collapses_to_sixteen_by_nine() { + let builder = GenerateImageRequestBuilder { + aspect_ratio: Some(CommonAspectRatio::WideTwentyOneByNine), + ..base_builder() + }; + let req = unwrap_request(build_fal_flux_pro_1p1(builder)); + assert!(matches!(req.aspect_ratio, FluxPro11AspectRatio::LandscapeSixteenByNine)); + } + + #[test] + fn tall_four_by_five_collapses_to_three_by_four() { + let builder = GenerateImageRequestBuilder { + aspect_ratio: Some(CommonAspectRatio::TallFourByFive), + ..base_builder() + }; + let req = unwrap_request(build_fal_flux_pro_1p1(builder)); + assert!(matches!(req.aspect_ratio, FluxPro11AspectRatio::PortraitThreeByFour)); + } + } + + // ── Num images ── + + mod num_images_tests { + use super::*; + + #[test] + fn default_is_one() { + let req = unwrap_request(build_fal_flux_pro_1p1(base_builder())); + assert!(matches!(req.num_images, FluxPro11NumImages::One)); + } + + #[test] + fn explicit_three() { + let builder = GenerateImageRequestBuilder { + image_batch_count: Some(3), + ..base_builder() + }; + let req = unwrap_request(build_fal_flux_pro_1p1(builder)); + assert!(matches!(req.num_images, FluxPro11NumImages::Three)); + } + + #[test] + fn zero_is_error() { + let builder = GenerateImageRequestBuilder { + image_batch_count: Some(0), + ..base_builder() + }; + assert!(matches!( + build_fal_flux_pro_1p1(builder), + Err(ArtcraftRouterError::Client(ClientError::UserRequestedZeroGenerations)) + )); + } + + #[test] + fn over_four_error_out() { + let builder = GenerateImageRequestBuilder { + image_batch_count: Some(7), + ..base_builder() + }; + assert!(matches!( + build_fal_flux_pro_1p1(builder), + Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption { field: "image_batch_count", .. })) + )); + } + + #[test] + fn over_four_clamps_with_upgrade() { + let builder = GenerateImageRequestBuilder { + image_batch_count: Some(7), + request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::PayMoreUpgrade, + ..base_builder() + }; + let req = unwrap_request(build_fal_flux_pro_1p1(builder)); + assert!(matches!(req.num_images, FluxPro11NumImages::Four)); + } + } +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image_v2/providers/fal/flux_pro_1p1/cost.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image_v2/providers/fal/flux_pro_1p1/cost.rs new file mode 100644 index 0000000000..69b4e7e1ea --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image_v2/providers/fal/flux_pro_1p1/cost.rs @@ -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::flux_pro_1p1::request::FalFluxPro1p1RequestState; + +pub struct FalFluxPro1p1CostState { + cost_in_usd_cents: u64, +} + +impl FalFluxPro1p1CostState { + pub fn from_request(request: &FalFluxPro1p1RequestState) -> Self { + // Delegate to the fal_client cost calculator: $0.04 per image (1MP base). + Self { + cost_in_usd_cents: request.request.calculate_cost_in_cents(), + } + } + + pub fn estimate_cost(&self) -> ImageGenerationCostEstimate { + // Matches v1: credits == cents (1:1 with USD cents). + ImageGenerationCostEstimate { + cost_in_credits: Some(self.cost_in_usd_cents), + cost_in_usd_cents: Some(self.cost_in_usd_cents), + is_free: false, + is_unlimited: false, + is_rate_limited: false, + has_watermark: false, + failures_are_refunded: None, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use fal_client::requests::webhook::image::text::enqueue_flux_pro_11_text_to_image_webhook::{ + FluxPro11AspectRatio, FluxPro11NumImages, FluxPro11Request, + }; + + fn cost_for(n: FluxPro11NumImages) -> ImageGenerationCostEstimate { + let state = FalFluxPro1p1CostState::from_request(&FalFluxPro1p1RequestState { + request: FluxPro11Request { + prompt: "test".to_string(), + aspect_ratio: FluxPro11AspectRatio::Square, + num_images: n, + }, + }); + state.estimate_cost() + } + + #[test] + fn one_image_is_4_cents() { + assert_eq!(cost_for(FluxPro11NumImages::One).cost_in_usd_cents, Some(4)); + } + + #[test] + fn two_images_is_8_cents() { + assert_eq!(cost_for(FluxPro11NumImages::Two).cost_in_usd_cents, Some(8)); + } + + #[test] + fn three_images_is_12_cents() { + assert_eq!(cost_for(FluxPro11NumImages::Three).cost_in_usd_cents, Some(12)); + } + + #[test] + fn four_images_is_16_cents() { + assert_eq!(cost_for(FluxPro11NumImages::Four).cost_in_usd_cents, Some(16)); + } + + #[test] + fn credits_equal_cents() { + // v1 set cost_in_credits to the same USD cents value; preserve that. + let cost = cost_for(FluxPro11NumImages::Two); + assert_eq!(cost.cost_in_credits, cost.cost_in_usd_cents); + } + + #[test] + fn cost_flags_match_v1() { + let cost = cost_for(FluxPro11NumImages::One); + assert!(!cost.is_free); + assert!(!cost.is_unlimited); + assert!(!cost.is_rate_limited); + assert!(!cost.has_watermark); + assert!(cost.failures_are_refunded.is_none()); + } +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image_v2/providers/fal/flux_pro_1p1/mod.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image_v2/providers/fal/flux_pro_1p1/mod.rs new file mode 100644 index 0000000000..98150bd014 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image_v2/providers/fal/flux_pro_1p1/mod.rs @@ -0,0 +1,3 @@ +pub mod build; +pub mod cost; +pub mod request; diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image_v2/providers/fal/flux_pro_1p1/request.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image_v2/providers/fal/flux_pro_1p1/request.rs new file mode 100644 index 0000000000..fe68fc7156 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image_v2/providers/fal/flux_pro_1p1/request.rs @@ -0,0 +1,82 @@ +use std::fmt::Debug; +use std::sync::Arc; + +use fal_client::requests::webhook::image::text::enqueue_flux_pro_11_text_to_image_webhook::{ + enqueue_flux_pro_11_text_to_image_webhook, FluxPro11Args, FluxPro11Request, +}; + +use crate::client::router_fal_client::RouterFalClient; +use crate::errors::artcraft_router_error::ArtcraftRouterError; +use crate::errors::provider_error::ProviderError; +use crate::generate::generate_image::generate_image_response::{ + FalImageResponsePayload, GenerateImageResponse, +}; + +#[derive(Clone, Debug)] +pub struct FalFluxPro1p1RequestState { + pub request: FluxPro11Request, +} + +impl FalFluxPro1p1RequestState { + pub async fn send(&self, client: &RouterFalClient) -> Result { + let outbound: Arc = Arc::new(self.request.clone()); + let args = FluxPro11Args { + request: self.request.clone(), + webhook_url: client.webhook_url.as_str(), + api_key: &client.api_key, + }; + let webhook_response = enqueue_flux_pro_11_text_to_image_webhook(args) + .await + .map_err(|e| ArtcraftRouterError::Provider(ProviderError::Fal(e)))?; + + Ok(GenerateImageResponse::Fal(FalImageResponsePayload { + request_id: webhook_response.request_id, + gateway_request_id: webhook_response.gateway_request_id, + maybe_status_url: None, + maybe_response_url: None, + maybe_outbound_request: Some(outbound), + })) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use fal_client::creds::fal_api_key::FalApiKey; + use fal_client::requests::webhook::image::text::enqueue_flux_pro_11_text_to_image_webhook::{ + FluxPro11AspectRatio, FluxPro11NumImages, + }; + + fn read_fal_api_key() -> FalApiKey { + let secret = std::fs::read_to_string("/Users/bt/Artcraft/credentials/fal_api_key.txt") + .expect("Failed to read fal_api_key.txt"); + FalApiKey::from_str(secret.trim()) + } + + fn client_with_webhook() -> RouterFalClient { + RouterFalClient::new( + read_fal_api_key(), + "https://example.com/fal-webhook-test".to_string(), + ) + } + + fn t2i_state() -> FalFluxPro1p1RequestState { + FalFluxPro1p1RequestState { + request: FluxPro11Request { + prompt: "a corgi wearing sunglasses on a surfboard".to_string(), + aspect_ratio: FluxPro11AspectRatio::LandscapeSixteenByNine, + num_images: FluxPro11NumImages::One, + }, + } + } + + #[tokio::test] + #[ignore] // requires real API key, incurs cost + async fn send_via_webhook() { + let client = client_with_webhook(); + let response = t2i_state().send(&client).await.expect("send should succeed"); + let payload = response.get_fal_payload().expect("expected Fal payload"); + println!("Webhook t2i — request_id: {:?}, gateway_request_id: {:?}", payload.request_id, payload.gateway_request_id); + assert!(payload.request_id.is_some() || payload.gateway_request_id.is_some()); + } +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image_v2/providers/fal/flux_pro_1p1_ultra/build.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image_v2/providers/fal/flux_pro_1p1_ultra/build.rs new file mode 100644 index 0000000000..1019d730a5 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image_v2/providers/fal/flux_pro_1p1_ultra/build.rs @@ -0,0 +1,281 @@ +use fal_client::requests::webhook::image::text::enqueue_flux_pro_11_ultra_text_to_image_webhook::{ + FluxPro11UltraAspectRatio, FluxPro11UltraNumImages, FluxPro11UltraRequest, +}; + +use crate::api::common_aspect_ratio::CommonAspectRatio; +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::flux_pro_1p1_ultra::request::FalFluxPro1p1UltraRequestState; + +pub fn build_fal_flux_pro_1p1_ultra( + builder: GenerateImageRequestBuilder, +) -> Result { + let strategy = builder.request_mismatch_mitigation_strategy; + + // Flux Pro 1.1 Ultra is text-to-image only. + if builder.image_inputs.is_some() { + return Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption { + field: "image_inputs", + value: "Flux Pro 1.1 Ultra is text-to-image only".to_string(), + })); + } + + let aspect_ratio = plan_aspect_ratio(builder.aspect_ratio, strategy)?; + let num_images = plan_num_images(builder.image_batch_count, strategy)?; + + let request = FluxPro11UltraRequest { + prompt: builder.prompt.clone().unwrap_or_default(), + aspect_ratio, + num_images, + }; + + Ok(ImageGenerationDraftOrRequest::Request( + ImageGenerationRequest::FalFluxPro1p1Ultra(FalFluxPro1p1UltraRequestState { request }), + )) +} + +fn plan_aspect_ratio( + aspect_ratio: Option, + strategy: RequestMismatchMitigationStrategy, +) -> Result { + use FluxPro11UltraAspectRatio as Ar; + match aspect_ratio { + None => Ok(Ar::Square), + + Some(CommonAspectRatio::Auto) + | Some(CommonAspectRatio::Auto2k) + | Some(CommonAspectRatio::Auto4k) => Ok(Ar::Square), + + Some(CommonAspectRatio::Square) | Some(CommonAspectRatio::SquareHd) => Ok(Ar::Square), + Some(CommonAspectRatio::WideFourByThree) => Ok(Ar::LandscapeFourByThree), + Some(CommonAspectRatio::WideThreeByTwo) => Ok(Ar::LandscapeThreeByTwo), + Some(CommonAspectRatio::WideSixteenByNine) | Some(CommonAspectRatio::Wide) => Ok(Ar::LandscapeSixteenByNine), + Some(CommonAspectRatio::WideTwentyOneByNine) => Ok(Ar::LandscapeTwentyOneByNine), + Some(CommonAspectRatio::TallThreeByFour) => Ok(Ar::PortraitThreeByFour), + Some(CommonAspectRatio::TallTwoByThree) => Ok(Ar::PortraitTwoByThree), + Some(CommonAspectRatio::TallNineBySixteen) | Some(CommonAspectRatio::Tall) => Ok(Ar::PortraitNineBySixteen), + Some(CommonAspectRatio::TallNineByTwentyOne) => Ok(Ar::PortraitNineByTwentyOne), + + Some(CommonAspectRatio::WideFiveByFour) => Ok(Ar::LandscapeFourByThree), + Some(CommonAspectRatio::TallFourByFive) => Ok(Ar::PortraitThreeByFour), + + Some(unsupported) => match strategy { + RequestMismatchMitigationStrategy::ErrorOut => { + Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption { + field: "aspect_ratio", + value: format!("{:?}", unsupported), + })) + } + _ => Ok(Ar::Square), + }, + } +} + +fn plan_num_images( + image_batch_count: Option, + strategy: RequestMismatchMitigationStrategy, +) -> Result { + let count = image_batch_count.unwrap_or(1); + match count { + 0 => Err(ArtcraftRouterError::Client(ClientError::UserRequestedZeroGenerations)), + 1 => Ok(FluxPro11UltraNumImages::One), + 2 => Ok(FluxPro11UltraNumImages::Two), + 3 => Ok(FluxPro11UltraNumImages::Three), + 4 => Ok(FluxPro11UltraNumImages::Four), + _ => match strategy { + RequestMismatchMitigationStrategy::ErrorOut => { + Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption { + field: "image_batch_count", + value: format!("{}", count), + })) + } + _ => Ok(FluxPro11UltraNumImages::Four), + }, + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::api::common_image_model::CommonImageModel; + use crate::api::image_list_ref::ImageListRef; + use crate::api::provider::Provider; + + fn base_builder() -> GenerateImageRequestBuilder { + GenerateImageRequestBuilder { + model: CommonImageModel::FluxPro11Ultra, + 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_request(result: Result) -> FluxPro11UltraRequest { + let ImageGenerationDraftOrRequest::Request( + ImageGenerationRequest::FalFluxPro1p1Ultra(state) + ) = result.expect("build should succeed") else { + panic!("expected FalFluxPro1p1Ultra variant") + }; + state.request + } + + // ── Mode rejection ── + + #[test] + fn image_inputs_present_yields_error() { + let builder = GenerateImageRequestBuilder { + image_inputs: Some(ImageListRef::Urls(vec!["https://example.com/img.jpg".to_string()])), + ..base_builder() + }; + assert!(matches!( + build_fal_flux_pro_1p1_ultra(builder), + Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption { field: "image_inputs", .. })) + )); + } + + // ── Prompt ── + + mod prompt_tests { + use super::*; + + #[test] + fn prompt_passed_through() { + let builder = GenerateImageRequestBuilder { + prompt: Some("my custom prompt".to_string()), + ..base_builder() + }; + let req = unwrap_request(build_fal_flux_pro_1p1_ultra(builder)); + assert_eq!(req.prompt, "my custom prompt"); + } + + #[test] + fn missing_prompt_defaults_to_empty() { + let builder = GenerateImageRequestBuilder { prompt: None, ..base_builder() }; + let req = unwrap_request(build_fal_flux_pro_1p1_ultra(builder)); + assert_eq!(req.prompt, ""); + } + } + + // ── Aspect ratio ── + + mod aspect_ratio_tests { + use super::*; + + #[test] + fn default_is_square() { + let req = unwrap_request(build_fal_flux_pro_1p1_ultra(base_builder())); + assert!(matches!(req.aspect_ratio, FluxPro11UltraAspectRatio::Square)); + } + + #[test] + fn wide_sixteen_by_nine() { + let builder = GenerateImageRequestBuilder { + aspect_ratio: Some(CommonAspectRatio::WideSixteenByNine), + ..base_builder() + }; + let req = unwrap_request(build_fal_flux_pro_1p1_ultra(builder)); + assert!(matches!(req.aspect_ratio, FluxPro11UltraAspectRatio::LandscapeSixteenByNine)); + } + + #[test] + fn wide_three_by_two_has_direct_mapping() { + let builder = GenerateImageRequestBuilder { + aspect_ratio: Some(CommonAspectRatio::WideThreeByTwo), + ..base_builder() + }; + let req = unwrap_request(build_fal_flux_pro_1p1_ultra(builder)); + assert!(matches!(req.aspect_ratio, FluxPro11UltraAspectRatio::LandscapeThreeByTwo)); + } + + #[test] + fn tall_two_by_three_has_direct_mapping() { + let builder = GenerateImageRequestBuilder { + aspect_ratio: Some(CommonAspectRatio::TallTwoByThree), + ..base_builder() + }; + let req = unwrap_request(build_fal_flux_pro_1p1_ultra(builder)); + assert!(matches!(req.aspect_ratio, FluxPro11UltraAspectRatio::PortraitTwoByThree)); + } + + #[test] + fn wide_twenty_one_by_nine_has_direct_mapping() { + let builder = GenerateImageRequestBuilder { + aspect_ratio: Some(CommonAspectRatio::WideTwentyOneByNine), + ..base_builder() + }; + let req = unwrap_request(build_fal_flux_pro_1p1_ultra(builder)); + assert!(matches!(req.aspect_ratio, FluxPro11UltraAspectRatio::LandscapeTwentyOneByNine)); + } + + #[test] + fn square_hd_collapses_to_square() { + let builder = GenerateImageRequestBuilder { + aspect_ratio: Some(CommonAspectRatio::SquareHd), + ..base_builder() + }; + let req = unwrap_request(build_fal_flux_pro_1p1_ultra(builder)); + assert!(matches!(req.aspect_ratio, FluxPro11UltraAspectRatio::Square)); + } + } + + // ── Num images ── + + mod num_images_tests { + use super::*; + + #[test] + fn default_is_one() { + let req = unwrap_request(build_fal_flux_pro_1p1_ultra(base_builder())); + assert!(matches!(req.num_images, FluxPro11UltraNumImages::One)); + } + + #[test] + fn zero_is_error() { + let builder = GenerateImageRequestBuilder { + image_batch_count: Some(0), + ..base_builder() + }; + assert!(matches!( + build_fal_flux_pro_1p1_ultra(builder), + Err(ArtcraftRouterError::Client(ClientError::UserRequestedZeroGenerations)) + )); + } + + #[test] + fn over_four_error_out() { + let builder = GenerateImageRequestBuilder { + image_batch_count: Some(5), + ..base_builder() + }; + assert!(matches!( + build_fal_flux_pro_1p1_ultra(builder), + Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption { field: "image_batch_count", .. })) + )); + } + + #[test] + fn over_four_clamps_with_upgrade() { + let builder = GenerateImageRequestBuilder { + image_batch_count: Some(7), + request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::PayMoreUpgrade, + ..base_builder() + }; + let req = unwrap_request(build_fal_flux_pro_1p1_ultra(builder)); + assert!(matches!(req.num_images, FluxPro11UltraNumImages::Four)); + } + } +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image_v2/providers/fal/flux_pro_1p1_ultra/cost.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image_v2/providers/fal/flux_pro_1p1_ultra/cost.rs new file mode 100644 index 0000000000..6b9c8a388d --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image_v2/providers/fal/flux_pro_1p1_ultra/cost.rs @@ -0,0 +1,85 @@ +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::flux_pro_1p1_ultra::request::FalFluxPro1p1UltraRequestState; + +pub struct FalFluxPro1p1UltraCostState { + cost_in_usd_cents: u64, +} + +impl FalFluxPro1p1UltraCostState { + pub fn from_request(request: &FalFluxPro1p1UltraRequestState) -> Self { + // Delegate to the fal_client cost calculator: $0.06 per image. + Self { + cost_in_usd_cents: request.request.calculate_cost_in_cents(), + } + } + + pub fn estimate_cost(&self) -> ImageGenerationCostEstimate { + // Matches v1: credits == cents (1:1 with USD cents). + ImageGenerationCostEstimate { + cost_in_credits: Some(self.cost_in_usd_cents), + cost_in_usd_cents: Some(self.cost_in_usd_cents), + is_free: false, + is_unlimited: false, + is_rate_limited: false, + has_watermark: false, + failures_are_refunded: None, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use fal_client::requests::webhook::image::text::enqueue_flux_pro_11_ultra_text_to_image_webhook::{ + FluxPro11UltraAspectRatio, FluxPro11UltraNumImages, FluxPro11UltraRequest, + }; + + fn cost_for(n: FluxPro11UltraNumImages) -> ImageGenerationCostEstimate { + let state = FalFluxPro1p1UltraCostState::from_request(&FalFluxPro1p1UltraRequestState { + request: FluxPro11UltraRequest { + prompt: "test".to_string(), + aspect_ratio: FluxPro11UltraAspectRatio::Square, + num_images: n, + }, + }); + state.estimate_cost() + } + + #[test] + fn one_image_is_6_cents() { + assert_eq!(cost_for(FluxPro11UltraNumImages::One).cost_in_usd_cents, Some(6)); + } + + #[test] + fn two_images_is_12_cents() { + assert_eq!(cost_for(FluxPro11UltraNumImages::Two).cost_in_usd_cents, Some(12)); + } + + #[test] + fn three_images_is_18_cents() { + assert_eq!(cost_for(FluxPro11UltraNumImages::Three).cost_in_usd_cents, Some(18)); + } + + #[test] + fn four_images_is_24_cents() { + assert_eq!(cost_for(FluxPro11UltraNumImages::Four).cost_in_usd_cents, Some(24)); + } + + #[test] + fn credits_equal_cents() { + let cost = cost_for(FluxPro11UltraNumImages::Two); + assert_eq!(cost.cost_in_credits, cost.cost_in_usd_cents); + } + + #[test] + fn cost_flags_match_v1() { + let cost = cost_for(FluxPro11UltraNumImages::One); + assert!(!cost.is_free); + assert!(!cost.is_unlimited); + assert!(!cost.is_rate_limited); + assert!(!cost.has_watermark); + assert!(cost.failures_are_refunded.is_none()); + } +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image_v2/providers/fal/flux_pro_1p1_ultra/mod.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image_v2/providers/fal/flux_pro_1p1_ultra/mod.rs new file mode 100644 index 0000000000..98150bd014 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image_v2/providers/fal/flux_pro_1p1_ultra/mod.rs @@ -0,0 +1,3 @@ +pub mod build; +pub mod cost; +pub mod request; diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image_v2/providers/fal/flux_pro_1p1_ultra/request.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image_v2/providers/fal/flux_pro_1p1_ultra/request.rs new file mode 100644 index 0000000000..0d9f03b28c --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image_v2/providers/fal/flux_pro_1p1_ultra/request.rs @@ -0,0 +1,82 @@ +use std::fmt::Debug; +use std::sync::Arc; + +use fal_client::requests::webhook::image::text::enqueue_flux_pro_11_ultra_text_to_image_webhook::{ + enqueue_flux_pro_11_ultra_text_to_image_webhook, FluxPro11UltraArgs, FluxPro11UltraRequest, +}; + +use crate::client::router_fal_client::RouterFalClient; +use crate::errors::artcraft_router_error::ArtcraftRouterError; +use crate::errors::provider_error::ProviderError; +use crate::generate::generate_image::generate_image_response::{ + FalImageResponsePayload, GenerateImageResponse, +}; + +#[derive(Clone, Debug)] +pub struct FalFluxPro1p1UltraRequestState { + pub request: FluxPro11UltraRequest, +} + +impl FalFluxPro1p1UltraRequestState { + pub async fn send(&self, client: &RouterFalClient) -> Result { + let outbound: Arc = Arc::new(self.request.clone()); + let args = FluxPro11UltraArgs { + request: self.request.clone(), + webhook_url: client.webhook_url.as_str(), + api_key: &client.api_key, + }; + let webhook_response = enqueue_flux_pro_11_ultra_text_to_image_webhook(args) + .await + .map_err(|e| ArtcraftRouterError::Provider(ProviderError::Fal(e)))?; + + Ok(GenerateImageResponse::Fal(FalImageResponsePayload { + request_id: webhook_response.request_id, + gateway_request_id: webhook_response.gateway_request_id, + maybe_status_url: None, + maybe_response_url: None, + maybe_outbound_request: Some(outbound), + })) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use fal_client::creds::fal_api_key::FalApiKey; + use fal_client::requests::webhook::image::text::enqueue_flux_pro_11_ultra_text_to_image_webhook::{ + FluxPro11UltraAspectRatio, FluxPro11UltraNumImages, + }; + + fn read_fal_api_key() -> FalApiKey { + let secret = std::fs::read_to_string("/Users/bt/Artcraft/credentials/fal_api_key.txt") + .expect("Failed to read fal_api_key.txt"); + FalApiKey::from_str(secret.trim()) + } + + fn client_with_webhook() -> RouterFalClient { + RouterFalClient::new( + read_fal_api_key(), + "https://example.com/fal-webhook-test".to_string(), + ) + } + + fn t2i_state() -> FalFluxPro1p1UltraRequestState { + FalFluxPro1p1UltraRequestState { + request: FluxPro11UltraRequest { + prompt: "a corgi wearing sunglasses on a surfboard".to_string(), + aspect_ratio: FluxPro11UltraAspectRatio::LandscapeSixteenByNine, + num_images: FluxPro11UltraNumImages::One, + }, + } + } + + #[tokio::test] + #[ignore] // requires real API key, incurs cost + async fn send_via_webhook() { + let client = client_with_webhook(); + let response = t2i_state().send(&client).await.expect("send should succeed"); + let payload = response.get_fal_payload().expect("expected Fal payload"); + println!("Webhook t2i — request_id: {:?}, gateway_request_id: {:?}", payload.request_id, payload.gateway_request_id); + assert!(payload.request_id.is_some() || payload.gateway_request_id.is_some()); + } +} diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image_v2/providers/fal/mod.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image_v2/providers/fal/mod.rs index 1267b296b8..822275689a 100644 --- a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image_v2/providers/fal/mod.rs +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image_v2/providers/fal/mod.rs @@ -1,7 +1,12 @@ pub mod flux_1_dev; pub mod flux_1_schnell; +pub mod flux_pro_1p1; +pub mod flux_pro_1p1_ultra; 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; + +#[cfg(test)] +mod cost_parity_tests;