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 index c864507acb..4bee3e727d 100644 --- 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 @@ -649,12 +649,6 @@ mod nano_banana_2 { ] } - /// Currently fails: v1 prices `resolution=None` at 8¢ (1K default); v2 reads - /// 15¢ from fal_client. The defaults predate the trait and need to be - /// reconciled — either fix v2 to clamp `None`→OneK before delegating, or - /// update v1 to read from fal_client too. Strict parity test kept here for - /// regression detection once the fix lands. - #[ignore] #[test] fn cost_parity_full_combinatorial() { let batches = [None, Some(1u16), Some(4)]; @@ -722,9 +716,6 @@ mod nano_banana_pro { ] } - /// Currently fails: v1 maps `ThreeK`→TwoK pricing (15¢); v2 routes - /// `ThreeK`→FourK in fal_client (30¢). Pick a side and update the other. - #[ignore] #[test] fn cost_parity_full_combinatorial() { let batches = [None, Some(1u16), Some(4)]; @@ -791,11 +782,6 @@ mod gpt_image_1 { base_builder(CommonImageModel::GptImage1) } - /// Currently fails: v1 bills 2¢ per input image (edit-mode high-fidelity - /// estimate); v2 doesn't bill input-image tokens. Decide whether to - /// preserve the input-image fee in v2 or drop it from v1 (likely the - /// former, since Fal does bill tokens for inputs in practice). - #[ignore] #[test] fn cost_parity_full_combinatorial() { let batches = [None, Some(1u16), Some(2), Some(4)]; @@ -841,10 +827,6 @@ mod gpt_image_1p5 { base_builder(CommonImageModel::GptImage1p5) } - /// Currently fails: hand-rolled v1 cost table rounds $0.133→14¢ for - /// High+Square; v2's fal_client trait returns 13¢. Off-by-one cent in - /// several quality/size buckets — reconcile rounding direction. - #[ignore] #[test] fn cost_parity_full_combinatorial() { let batches = [None, Some(1u16), Some(2), Some(4)]; @@ -886,11 +868,6 @@ mod gpt_image_2 { base_builder(CommonImageModel::GptImage2) } - /// Currently fails: v1 reads quality+size buckets explicitly and prices - /// `q=None`→High→Auto at 23¢; v2 reads fal_client which returns 16¢ for the - /// same input shape. Differences exist across most matrix cells — needs a - /// full price-table reconciliation. - #[ignore] #[test] fn cost_parity_full_combinatorial() { let batches = [None, Some(1u16), Some(2), Some(4)]; diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image_v2/providers/fal/flux_1_dev/cost.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image_v2/providers/fal/flux_1_dev/cost.rs index 6d1924659e..68cb64f1be 100644 --- a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image_v2/providers/fal/flux_1_dev/cost.rs +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image_v2/providers/fal/flux_1_dev/cost.rs @@ -18,12 +18,13 @@ impl FalFlux1DevCostState { pub fn estimate_cost(&self) -> ImageGenerationCostEstimate { ImageGenerationCostEstimate { - cost_in_credits: None, + // v1 sets cost_in_credits to the same value as USD cents (1:1). + 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, + has_watermark: false, failures_are_refunded: None, } } @@ -107,6 +108,6 @@ mod tests { assert!(!cost.is_unlimited); assert!(!cost.is_rate_limited); assert!(!cost.has_watermark); - assert!(cost.cost_in_credits.is_none()); + assert_eq!(cost.cost_in_credits, cost.cost_in_usd_cents); } } diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image_v2/providers/fal/flux_1_schnell/cost.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image_v2/providers/fal/flux_1_schnell/cost.rs index 7a718f408b..c92632595e 100644 --- a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image_v2/providers/fal/flux_1_schnell/cost.rs +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image_v2/providers/fal/flux_1_schnell/cost.rs @@ -18,7 +18,8 @@ impl FalFlux1SchnellCostState { pub fn estimate_cost(&self) -> ImageGenerationCostEstimate { ImageGenerationCostEstimate { - cost_in_credits: None, + // v1 sets cost_in_credits to the same value as USD cents (1:1). + 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, @@ -108,6 +109,6 @@ mod tests { assert!(!cost.is_unlimited); assert!(!cost.is_rate_limited); assert!(!cost.has_watermark); - assert!(cost.cost_in_credits.is_none()); + assert_eq!(cost.cost_in_credits, cost.cost_in_usd_cents); } } diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image_v2/providers/fal/gpt_image_1/cost.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image_v2/providers/fal/gpt_image_1/cost.rs index 4160c43446..57b5027935 100644 --- a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image_v2/providers/fal/gpt_image_1/cost.rs +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image_v2/providers/fal/gpt_image_1/cost.rs @@ -1,8 +1,27 @@ -use fal_client::requests::traits::fal_request_cost_calculator_trait::FalRequestCostCalculator; +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 crate::generate::generate_image::image_generation_cost_estimate::ImageGenerationCostEstimate; use crate::generate::generate_image_v2::providers::fal::gpt_image_1::request::FalGptImage1RequestState; +/// Cost state for Fal GPT Image 1. Mirrors v1 +/// (`estimate_image_cost_fal_gpt_image_1`): +/// +/// Output image cost (per output image): +/// Low: 2¢ all sizes +/// Medium: 5¢ square, 7¢ wide/tall +/// High: 17¢ square, 25¢ wide/tall +/// +/// Plus 2¢ per input image in edit mode (high-fidelity estimate). +/// Quality defaults to High when unspecified. +/// +/// The hand-rolled rates here intentionally override fal_client's trait +/// (which uses Medium as the default and omits the input-image fee). Keep +/// in sync with the v1 cost file. #[derive(Clone, Debug)] pub struct FalGptImage1CostState { request: FalGptImage1RequestState, @@ -15,12 +34,19 @@ impl FalGptImage1CostState { 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(), + FalGptImage1RequestState::TextToImage(req) => { + cost_t2i(req.quality, req.image_size, req.num_images) + } + FalGptImage1RequestState::EditImage(req) => { + let output_cost = cost_edit_output(req.quality, req.image_size, req.num_images); + let input_cost = 2 * req.image_urls.len() as u64; + output_cost + input_cost + } }; ImageGenerationCostEstimate { - cost_in_credits: None, + // v1 sets cost_in_credits to the same value as USD cents (1:1). + cost_in_credits: Some(cost_in_usd_cents), cost_in_usd_cents: Some(cost_in_usd_cents), is_free: false, is_unlimited: false, @@ -31,6 +57,61 @@ impl FalGptImage1CostState { } } +fn cost_t2i( + quality: Option, + image_size: Option, + num_images: GptImage1TextToImageNumImages, +) -> u64 { + use GptImage1TextToImageQuality as Q; + use GptImage1TextToImageSize as S; + let q = quality.unwrap_or(Q::High); + let is_square = matches!(image_size, None | Some(S::Square)); + let per_image = output_cost(matches!(q, Q::Low), matches!(q, Q::Medium), is_square); + per_image * t2i_num_images(num_images) +} + +fn cost_edit_output( + quality: Option, + image_size: Option, + num_images: GptImage1EditImageNumImages, +) -> u64 { + use GptImage1EditImageQuality as Q; + use GptImage1EditImageSize as S; + let q = quality.unwrap_or(Q::High); + let is_square = matches!(image_size, None | Some(S::Square)); + let per_image = output_cost(matches!(q, Q::Low), matches!(q, Q::Medium), is_square); + per_image * edit_num_images(num_images) +} + +fn output_cost(is_low: bool, is_medium: bool, is_square: bool) -> u64 { + if is_low { + 2 + } else if is_medium { + if is_square { 5 } else { 7 } + } else { + // High (default) + if is_square { 17 } else { 25 } + } +} + +fn t2i_num_images(n: GptImage1TextToImageNumImages) -> u64 { + match n { + GptImage1TextToImageNumImages::One => 1, + GptImage1TextToImageNumImages::Two => 2, + GptImage1TextToImageNumImages::Three => 3, + GptImage1TextToImageNumImages::Four => 4, + } +} + +fn edit_num_images(n: GptImage1EditImageNumImages) -> u64 { + match n { + GptImage1EditImageNumImages::One => 1, + GptImage1EditImageNumImages::Two => 2, + GptImage1EditImageNumImages::Three => 3, + GptImage1EditImageNumImages::Four => 4, + } +} + #[cfg(test)] mod tests { use super::*; @@ -60,39 +141,53 @@ mod tests { } } + // Tables encode v1's pricing semantics: + // - Quality defaults to High when unspecified. + // - is_square = matches!(image_size, None | Some(Square)). Auto, Horizontal, + // and Vertical are treated as non-square. + // - Low is 2¢ for every size; Medium and High differ by square vs non-square. + // Edit-mode rows additionally include a +2¢ input-image fee (one input URL + // in the make_edit helper below). fn text_to_image_price_cases() -> [(Option, Option, [u64; 4]); 13] { [ - (None, None, [5, 10, 15, 20]), + // Default quality (None → High) + default size (None → square). + (None, None, [17, 34, 51, 68]), + // Low: flat 2¢ regardless of size. (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]), + // Medium: 5¢ square, 7¢ non-square. + (Some(GptImage1TextToImageQuality::Medium), Some(GptImage1TextToImageSize::Auto), [7, 14, 21, 28]), (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]), + // High: 17¢ square, 25¢ non-square. + (Some(GptImage1TextToImageQuality::High), Some(GptImage1TextToImageSize::Auto), [25, 50, 75, 100]), (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]), ] } + // Each edit-mode row is t2i row + 2¢ (single input image — see make_edit + // below). Numbers are constant +2 because input fee scales with input count, + // not output count. fn edit_image_price_cases() -> [(Option, Option, [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]), + (None, None, [19, 36, 53, 70]), + (Some(GptImage1EditImageQuality::Low), Some(GptImage1EditImageSize::Auto), [4, 6, 8, 10]), + (Some(GptImage1EditImageQuality::Low), Some(GptImage1EditImageSize::Square), [4, 6, 8, 10]), + (Some(GptImage1EditImageQuality::Low), Some(GptImage1EditImageSize::Horizontal), [4, 6, 8, 10]), + (Some(GptImage1EditImageQuality::Low), Some(GptImage1EditImageSize::Vertical), [4, 6, 8, 10]), + (Some(GptImage1EditImageQuality::Medium), Some(GptImage1EditImageSize::Auto), [9, 16, 23, 30]), + (Some(GptImage1EditImageQuality::Medium), Some(GptImage1EditImageSize::Square), [7, 12, 17, 22]), + (Some(GptImage1EditImageQuality::Medium), Some(GptImage1EditImageSize::Horizontal), [9, 16, 23, 30]), + (Some(GptImage1EditImageQuality::Medium), Some(GptImage1EditImageSize::Vertical), [9, 16, 23, 30]), + (Some(GptImage1EditImageQuality::High), Some(GptImage1EditImageSize::Auto), [27, 52, 77, 102]), + (Some(GptImage1EditImageQuality::High), Some(GptImage1EditImageSize::Square), [19, 36, 53, 70]), + (Some(GptImage1EditImageQuality::High), Some(GptImage1EditImageSize::Horizontal), [27, 52, 77, 102]), + (Some(GptImage1EditImageQuality::High), Some(GptImage1EditImageSize::Vertical), [27, 52, 77, 102]), ] } @@ -106,7 +201,7 @@ mod tests { 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_eq!(cost.cost_in_credits, cost.cost_in_usd_cents); assert!(!cost.is_free); assert!(!cost.is_unlimited); assert!(!cost.is_rate_limited); diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image_v2/providers/fal/gpt_image_1p5/cost.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image_v2/providers/fal/gpt_image_1p5/cost.rs index 979ad06321..8d29823ffc 100644 --- a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image_v2/providers/fal/gpt_image_1p5/cost.rs +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image_v2/providers/fal/gpt_image_1p5/cost.rs @@ -1,8 +1,26 @@ -use fal_client::requests::traits::fal_request_cost_calculator_trait::FalRequestCostCalculator; +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 crate::generate::generate_image::image_generation_cost_estimate::ImageGenerationCostEstimate; use crate::generate::generate_image_v2::providers::fal::gpt_image_1p5::request::FalGptImage1p5RequestState; +/// Cost state for Fal GPT Image 1.5. Mirrors v1 +/// (`estimate_image_cost_fal_gpt_image_1p5`): +/// +/// Output image cost (per output image), rounded up to whole cents: +/// Low: 1¢ square, 2¢ wide/tall +/// Medium: 4¢ square, 5¢ wide, 6¢ tall +/// High: 14¢ square, 20¢ wide/tall +/// +/// Quality defaults to High when unspecified. +/// +/// The hand-rolled rates here intentionally override fal_client's trait, +/// which rounds $0.133→13¢ for High+Square (v1 rounds up to 14¢) and +/// flattens Medium wide/tall to a single value. Keep in sync with v1. #[derive(Clone, Debug)] pub struct FalGptImage1p5CostState { request: FalGptImage1p5RequestState, @@ -15,12 +33,17 @@ impl FalGptImage1p5CostState { 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(), + FalGptImage1p5RequestState::TextToImage(req) => { + cost_t2i(req.quality, req.image_size, req.num_images) + } + FalGptImage1p5RequestState::EditImage(req) => { + cost_edit(req.quality, req.image_size, req.num_images) + } }; ImageGenerationCostEstimate { - cost_in_credits: None, + // v1 sets cost_in_credits to the same value as USD cents (1:1). + cost_in_credits: Some(cost_in_usd_cents), cost_in_usd_cents: Some(cost_in_usd_cents), is_free: false, is_unlimited: false, @@ -31,6 +54,76 @@ impl FalGptImage1p5CostState { } } +#[derive(Copy, Clone)] +enum SizeBucket { Square, Wide, Tall } + +fn cost_t2i( + quality: Option, + image_size: Option, + num_images: GptImage1p5TextToImageNumImages, +) -> u64 { + use GptImage1p5TextToImageQuality as Q; + use GptImage1p5TextToImageSize as S; + let q = quality.unwrap_or(Q::High); + let bucket = match image_size { + None | Some(S::Square) => SizeBucket::Square, + Some(S::Wide) => SizeBucket::Wide, + Some(S::Tall) => SizeBucket::Tall, + }; + let per_image = output_cost(matches!(q, Q::Low), matches!(q, Q::Medium), bucket); + per_image * t2i_num_images(num_images) +} + +fn cost_edit( + quality: Option, + image_size: Option, + num_images: GptImage1p5EditImageNumImages, +) -> u64 { + use GptImage1p5EditImageQuality as Q; + use GptImage1p5EditImageSize as S; + let q = quality.unwrap_or(Q::High); + let bucket = match image_size { + None | Some(S::Square) => SizeBucket::Square, + Some(S::Wide) => SizeBucket::Wide, + Some(S::Tall) => SizeBucket::Tall, + }; + let per_image = output_cost(matches!(q, Q::Low), matches!(q, Q::Medium), bucket); + per_image * edit_num_images(num_images) +} + +fn output_cost(is_low: bool, is_medium: bool, bucket: SizeBucket) -> u64 { + if is_low { + match bucket { SizeBucket::Square => 1, _ => 2 } + } else if is_medium { + match bucket { + SizeBucket::Square => 4, + SizeBucket::Wide => 5, + SizeBucket::Tall => 6, + } + } else { + // High (default) + match bucket { SizeBucket::Square => 14, _ => 20 } + } +} + +fn t2i_num_images(n: GptImage1p5TextToImageNumImages) -> u64 { + match n { + GptImage1p5TextToImageNumImages::One => 1, + GptImage1p5TextToImageNumImages::Two => 2, + GptImage1p5TextToImageNumImages::Three => 3, + GptImage1p5TextToImageNumImages::Four => 4, + } +} + +fn edit_num_images(n: GptImage1p5EditImageNumImages) -> u64 { + match n { + GptImage1p5EditImageNumImages::One => 1, + GptImage1p5EditImageNumImages::Two => 2, + GptImage1p5EditImageNumImages::Three => 3, + GptImage1p5EditImageNumImages::Four => 4, + } +} + #[cfg(test)] mod tests { use super::*; @@ -60,12 +153,38 @@ mod tests { } } + // v1 rates: Low=1/2 (sq/wide-tall), Medium=4/5/6 (sq/wide/tall), + // High=14/20/20 (sq/wide/tall). Default quality is High. fn text_to_image_price_cases() -> [(Option, Option, [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])] + [ + (None, None, [14, 28, 42, 56]), + (Some(GptImage1p5TextToImageQuality::Low), Some(GptImage1p5TextToImageSize::Square), [1, 2, 3, 4]), + (Some(GptImage1p5TextToImageQuality::Low), Some(GptImage1p5TextToImageSize::Wide), [2, 4, 6, 8]), + (Some(GptImage1p5TextToImageQuality::Low), Some(GptImage1p5TextToImageSize::Tall), [2, 4, 6, 8]), + (Some(GptImage1p5TextToImageQuality::Medium), Some(GptImage1p5TextToImageSize::Square), [4, 8, 12, 16]), + (Some(GptImage1p5TextToImageQuality::Medium), Some(GptImage1p5TextToImageSize::Wide), [5, 10, 15, 20]), + (Some(GptImage1p5TextToImageQuality::Medium), Some(GptImage1p5TextToImageSize::Tall), [6, 12, 18, 24]), + (Some(GptImage1p5TextToImageQuality::High), Some(GptImage1p5TextToImageSize::Square), [14, 28, 42, 56]), + (Some(GptImage1p5TextToImageQuality::High), Some(GptImage1p5TextToImageSize::Wide), [20, 40, 60, 80]), + (Some(GptImage1p5TextToImageQuality::High), Some(GptImage1p5TextToImageSize::Tall), [20, 40, 60, 80]), + ] } + // Edit-mode pricing is identical to t2i (v1 has no input-image fee for + // gpt_image_1p5 — that's only gpt_image_1). fn edit_image_price_cases() -> [(Option, Option, [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])] + [ + (None, None, [14, 28, 42, 56]), + (Some(GptImage1p5EditImageQuality::Low), Some(GptImage1p5EditImageSize::Square), [1, 2, 3, 4]), + (Some(GptImage1p5EditImageQuality::Low), Some(GptImage1p5EditImageSize::Wide), [2, 4, 6, 8]), + (Some(GptImage1p5EditImageQuality::Low), Some(GptImage1p5EditImageSize::Tall), [2, 4, 6, 8]), + (Some(GptImage1p5EditImageQuality::Medium), Some(GptImage1p5EditImageSize::Square), [4, 8, 12, 16]), + (Some(GptImage1p5EditImageQuality::Medium), Some(GptImage1p5EditImageSize::Wide), [5, 10, 15, 20]), + (Some(GptImage1p5EditImageQuality::Medium), Some(GptImage1p5EditImageSize::Tall), [6, 12, 18, 24]), + (Some(GptImage1p5EditImageQuality::High), Some(GptImage1p5EditImageSize::Square), [14, 28, 42, 56]), + (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] { @@ -78,7 +197,7 @@ mod tests { 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_eq!(cost.cost_in_credits, cost.cost_in_usd_cents); assert!(!cost.is_free); assert!(!cost.is_unlimited); assert!(!cost.is_rate_limited); diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image_v2/providers/fal/gpt_image_2/cost.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image_v2/providers/fal/gpt_image_2/cost.rs index d9c90a54db..d60b3d84c2 100644 --- a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image_v2/providers/fal/gpt_image_2/cost.rs +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image_v2/providers/fal/gpt_image_2/cost.rs @@ -1,8 +1,28 @@ -use fal_client::requests::traits::fal_request_cost_calculator_trait::FalRequestCostCalculator; +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 crate::generate::generate_image::image_generation_cost_estimate::ImageGenerationCostEstimate; use crate::generate::generate_image_v2::providers::fal::gpt_image_2::request::FalGptImage2RequestState; +/// Cost state for Fal GPT Image 2. Mirrors v1 +/// (`estimate_image_cost_fal_gpt_image_2`): +/// +/// Output image cost (per output image), rounded up to whole cents: +/// Low: 1¢ all sizes +/// Medium: 6¢ square/squareHD/auto; 4¢ landscape/portrait (4:3 or 16:9) +/// High: 22¢ square; 23¢ squareHD/auto; +/// 15¢ landscape/portrait 4:3; 16¢ landscape/portrait 16:9 +/// +/// Quality defaults to High when unspecified. Resolution is NOT priced +/// separately — v1's table only varies by quality + size. +/// +/// The hand-rolled rates here intentionally override fal_client's trait, +/// which uses a base+per-megapixel formula that scales with the resolution +/// field. Keep in sync with v1. #[derive(Clone, Debug)] pub struct FalGptImage2CostState { request: FalGptImage2RequestState, @@ -15,12 +35,17 @@ impl FalGptImage2CostState { 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(), + FalGptImage2RequestState::TextToImage(req) => { + cost_t2i(req.quality, req.image_size, req.num_images) + } + FalGptImage2RequestState::EditImage(req) => { + cost_edit(req.quality, req.image_size, req.num_images) + } }; ImageGenerationCostEstimate { - cost_in_credits: None, + // v1 sets cost_in_credits to the same value as USD cents (1:1). + cost_in_credits: Some(cost_in_usd_cents), cost_in_usd_cents: Some(cost_in_usd_cents), is_free: false, is_unlimited: false, @@ -31,22 +56,115 @@ impl FalGptImage2CostState { } } +#[derive(Copy, Clone)] +enum SizeBucket { + Square, + SquareHd, + Landscape4x3, + Landscape16x9, + Portrait4x3, + Portrait16x9, + Auto, +} + +fn cost_t2i( + quality: Option, + image_size: Option, + num_images: GptImage2TextToImageNumImages, +) -> u64 { + use GptImage2TextToImageQuality as Q; + use GptImage2TextToImageSize as S; + let q = quality.unwrap_or(Q::High); + // T2I size enum has no Auto variant — `None` is the only auto-equivalent. + let bucket = match image_size { + None => SizeBucket::Auto, + Some(S::Square) => SizeBucket::Square, + Some(S::SquareHd) => SizeBucket::SquareHd, + Some(S::Landscape4x3) => SizeBucket::Landscape4x3, + Some(S::Landscape16x9) => SizeBucket::Landscape16x9, + Some(S::Portrait4x3) => SizeBucket::Portrait4x3, + Some(S::Portrait16x9) => SizeBucket::Portrait16x9, + }; + output_cost(matches!(q, Q::Low), matches!(q, Q::Medium), bucket) * t2i_num_images(num_images) +} + +fn cost_edit( + quality: Option, + image_size: Option, + num_images: GptImage2EditImageNumImages, +) -> u64 { + use GptImage2EditImageQuality as Q; + use GptImage2EditImageSize as S; + let q = quality.unwrap_or(Q::High); + let bucket = match image_size { + None | Some(S::Auto) => SizeBucket::Auto, + Some(S::Square) => SizeBucket::Square, + Some(S::SquareHd) => SizeBucket::SquareHd, + Some(S::Landscape4x3) => SizeBucket::Landscape4x3, + Some(S::Landscape16x9) => SizeBucket::Landscape16x9, + Some(S::Portrait4x3) => SizeBucket::Portrait4x3, + Some(S::Portrait16x9) => SizeBucket::Portrait16x9, + }; + output_cost(matches!(q, Q::Low), matches!(q, Q::Medium), bucket) * edit_num_images(num_images) +} + +fn output_cost(is_low: bool, is_medium: bool, bucket: SizeBucket) -> u64 { + if is_low { + return 1; + } + if is_medium { + return match bucket { + SizeBucket::Square | SizeBucket::SquareHd | SizeBucket::Auto => 6, + SizeBucket::Landscape4x3 | SizeBucket::Portrait4x3 + | SizeBucket::Landscape16x9 | SizeBucket::Portrait16x9 => 4, + }; + } + // High (default) + match bucket { + SizeBucket::Square => 22, + SizeBucket::SquareHd | SizeBucket::Auto => 23, + SizeBucket::Landscape4x3 | SizeBucket::Portrait4x3 => 15, + SizeBucket::Landscape16x9 | SizeBucket::Portrait16x9 => 16, + } +} + +fn t2i_num_images(n: GptImage2TextToImageNumImages) -> u64 { + match n { + GptImage2TextToImageNumImages::One => 1, + GptImage2TextToImageNumImages::Two => 2, + GptImage2TextToImageNumImages::Three => 3, + GptImage2TextToImageNumImages::Four => 4, + } +} + +fn edit_num_images(n: GptImage2EditImageNumImages) -> u64 { + match n { + GptImage2EditImageNumImages::One => 1, + GptImage2EditImageNumImages::Two => 2, + GptImage2EditImageNumImages::Three => 3, + GptImage2EditImageNumImages::Four => 4, + } +} + #[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, Option, Option, [u64; 4]); - type EditPriceCase = (Option, Option, Option, [u64; 4]); + type TextPriceCase = (Option, Option, [u64; 4]); + type EditPriceCase = (Option, Option, [u64; 4]); + + // v1 doesn't price by resolution — only quality + size. The Fal request's + // resolution field is sent to Fal for actual generation but doesn't enter + // the cost calculation. #[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() { + fn text_to_image_cost_table_covers_quality_and_size() { + 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 = FalGptImage2RequestState::TextToImage(GptImage2TextToImageRequest { prompt: "test".to_string(), num_images, image_size, resolution, quality, output_format: None }); + let request = FalGptImage2RequestState::TextToImage(GptImage2TextToImageRequest { prompt: "test".to_string(), num_images, image_size, resolution: None, quality, output_format: None }); let cost = FalGptImage2CostState::from_request(&request).estimate_cost(); assert_standard_cost_estimate(cost, expected); } @@ -54,146 +172,80 @@ mod tests { } #[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() { + fn edit_image_cost_table_covers_quality_and_size() { + 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 = 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 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: None, quality, output_format: None }); let cost = FalGptImage2CostState::from_request(&request).estimate_cost(); assert_standard_cost_estimate(cost, expected); } } } + // v1 rates (independent of resolution): + // Low: 1¢ all sizes + // Medium: 6¢ Square/SquareHd/Auto; 4¢ Landscape/Portrait (4:3 or 16:9) + // High: 22¢ Square; 23¢ SquareHd/Auto; 15¢ 4:3 (L/P); 16¢ 16:9 (L/P) + // Default quality is High. fn text_to_image_price_cases() -> Vec { - let mut cases = vec![ - // Default: High quality, Square - (None, None, None, [16, 32, 48, 64]), - // Presets (no resolution) - (Some(GptImage2TextToImageQuality::Low), Some(GptImage2TextToImageSize::Landscape4x3), None, [1, 2, 3, 4]), - (Some(GptImage2TextToImageQuality::Low), Some(GptImage2TextToImageSize::Portrait4x3), None, [1, 2, 3, 4]), - (Some(GptImage2TextToImageQuality::Low), Some(GptImage2TextToImageSize::Square), None, [1, 2, 3, 4]), - (Some(GptImage2TextToImageQuality::Low), Some(GptImage2TextToImageSize::Landscape16x9), None, [2, 4, 6, 8]), - (Some(GptImage2TextToImageQuality::Low), Some(GptImage2TextToImageSize::Portrait16x9), None, [2, 4, 6, 8]), - (Some(GptImage2TextToImageQuality::Low), Some(GptImage2TextToImageSize::SquareHd), None, [2, 4, 6, 8]), - (Some(GptImage2TextToImageQuality::Medium), Some(GptImage2TextToImageSize::Landscape4x3), None, [5, 10, 15, 20]), - (Some(GptImage2TextToImageQuality::Medium), Some(GptImage2TextToImageSize::Portrait4x3), None, [5, 10, 15, 20]), - (Some(GptImage2TextToImageQuality::Medium), Some(GptImage2TextToImageSize::Square), None, [5, 10, 15, 20]), - (Some(GptImage2TextToImageQuality::Medium), Some(GptImage2TextToImageSize::Landscape16x9), None, [6, 12, 18, 24]), - (Some(GptImage2TextToImageQuality::Medium), Some(GptImage2TextToImageSize::Portrait16x9), None, [6, 12, 18, 24]), - (Some(GptImage2TextToImageQuality::Medium), Some(GptImage2TextToImageSize::SquareHd), None, [8, 16, 24, 32]), - (Some(GptImage2TextToImageQuality::High), Some(GptImage2TextToImageSize::Landscape4x3), None, [15, 30, 45, 60]), - (Some(GptImage2TextToImageQuality::High), Some(GptImage2TextToImageSize::Portrait4x3), None, [15, 30, 45, 60]), - (Some(GptImage2TextToImageQuality::High), Some(GptImage2TextToImageSize::Square), None, [16, 32, 48, 64]), - (Some(GptImage2TextToImageQuality::High), Some(GptImage2TextToImageSize::Landscape16x9), None, [20, 40, 60, 80]), - (Some(GptImage2TextToImageQuality::High), Some(GptImage2TextToImageSize::Portrait16x9), None, [20, 40, 60, 80]), - (Some(GptImage2TextToImageQuality::High), Some(GptImage2TextToImageSize::SquareHd), None, [27, 54, 81, 108]), - ]; - - 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 { - let mut cases = vec![ - // Default: High quality, Square - (None, None, None, [15, 30, 45, 60]), - // Auto (with and without resolution — same cost, conservative max) - (Some(GptImage2EditImageQuality::Low), Some(GptImage2EditImageSize::Auto), None, [2, 4, 6, 8]), - (Some(GptImage2EditImageQuality::Medium), Some(GptImage2EditImageSize::Auto), None, [10, 20, 30, 40]), - (Some(GptImage2EditImageQuality::High), Some(GptImage2EditImageSize::Auto), None, [41, 82, 123, 164]), - (Some(GptImage2EditImageQuality::Low), Some(GptImage2EditImageSize::Auto), Some(GptImage2Resolution::OneK), [2, 4, 6, 8]), - (Some(GptImage2EditImageQuality::Medium), Some(GptImage2EditImageSize::Auto), Some(GptImage2Resolution::OneK), [10, 20, 30, 40]), - (Some(GptImage2EditImageQuality::High), Some(GptImage2EditImageSize::Auto), Some(GptImage2Resolution::OneK), [41, 82, 123, 164]), - // Presets (no resolution) - (Some(GptImage2EditImageQuality::Low), Some(GptImage2EditImageSize::Landscape4x3), None, [1, 2, 3, 4]), - (Some(GptImage2EditImageQuality::Low), Some(GptImage2EditImageSize::Portrait4x3), None, [1, 2, 3, 4]), - (Some(GptImage2EditImageQuality::Low), Some(GptImage2EditImageSize::Square), None, [1, 2, 3, 4]), - (Some(GptImage2EditImageQuality::Low), Some(GptImage2EditImageSize::Landscape16x9), None, [1, 2, 3, 4]), - (Some(GptImage2EditImageQuality::Low), Some(GptImage2EditImageSize::Portrait16x9), None, [1, 2, 3, 4]), - (Some(GptImage2EditImageQuality::Low), Some(GptImage2EditImageSize::SquareHd), None, [1, 2, 3, 4]), - (Some(GptImage2EditImageQuality::Medium), Some(GptImage2EditImageSize::Landscape4x3), None, [4, 8, 12, 16]), - (Some(GptImage2EditImageQuality::Medium), Some(GptImage2EditImageSize::Portrait4x3), None, [4, 8, 12, 16]), - (Some(GptImage2EditImageQuality::Medium), Some(GptImage2EditImageSize::Square), None, [4, 8, 12, 16]), - (Some(GptImage2EditImageQuality::Medium), Some(GptImage2EditImageSize::Landscape16x9), None, [5, 10, 15, 20]), - (Some(GptImage2EditImageQuality::Medium), Some(GptImage2EditImageSize::Portrait16x9), None, [5, 10, 15, 20]), - (Some(GptImage2EditImageQuality::Medium), Some(GptImage2EditImageSize::SquareHd), None, [7, 14, 21, 28]), - (Some(GptImage2EditImageQuality::High), Some(GptImage2EditImageSize::Landscape4x3), None, [14, 28, 42, 56]), - (Some(GptImage2EditImageQuality::High), Some(GptImage2EditImageSize::Portrait4x3), None, [14, 28, 42, 56]), - (Some(GptImage2EditImageQuality::High), Some(GptImage2EditImageSize::Square), None, [15, 30, 45, 60]), - (Some(GptImage2EditImageQuality::High), Some(GptImage2EditImageSize::Landscape16x9), None, [19, 38, 57, 76]), - (Some(GptImage2EditImageQuality::High), Some(GptImage2EditImageSize::Portrait16x9), None, [19, 38, 57, 76]), - (Some(GptImage2EditImageQuality::High), Some(GptImage2EditImageSize::SquareHd), None, [26, 52, 78, 104]), - ]; - - 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, [1, 2, 3, 4], [5, 10, 15, 20], [15, 30, 45, 60]), - (GptImage2TextToImageSize::Landscape4x3, GptImage2Resolution::TwoK, [2, 4, 6, 8], [7, 14, 21, 28], [24, 48, 72, 96]), - (GptImage2TextToImageSize::Landscape4x3, GptImage2Resolution::ThreeK, [2, 4, 6, 8], [11, 22, 33, 44], [37, 74, 111, 148]), - (GptImage2TextToImageSize::Landscape4x3, GptImage2Resolution::FourK, [3, 6, 9, 12], [12, 24, 36, 48], [41, 82, 123, 164]), - (GptImage2TextToImageSize::Landscape16x9, GptImage2Resolution::OneK, [1, 2, 3, 4], [5, 10, 15, 20], [15, 30, 45, 60]), - (GptImage2TextToImageSize::Landscape16x9, GptImage2Resolution::TwoK, [2, 4, 6, 8], [6, 12, 18, 24], [21, 42, 63, 84]), - (GptImage2TextToImageSize::Landscape16x9, GptImage2Resolution::ThreeK, [2, 4, 6, 8], [9, 18, 27, 36], [31, 62, 93, 124]), - (GptImage2TextToImageSize::Landscape16x9, GptImage2Resolution::FourK, [3, 6, 9, 12], [12, 24, 36, 48], [42, 84, 126, 168]), - (GptImage2TextToImageSize::Portrait4x3, GptImage2Resolution::OneK, [1, 2, 3, 4], [5, 10, 15, 20], [15, 30, 45, 60]), - (GptImage2TextToImageSize::Portrait4x3, GptImage2Resolution::TwoK, [2, 4, 6, 8], [7, 14, 21, 28], [24, 48, 72, 96]), - (GptImage2TextToImageSize::Portrait4x3, GptImage2Resolution::ThreeK, [2, 4, 6, 8], [11, 22, 33, 44], [37, 74, 111, 148]), - (GptImage2TextToImageSize::Portrait4x3, GptImage2Resolution::FourK, [3, 6, 9, 12], [12, 24, 36, 48], [41, 82, 123, 164]), - (GptImage2TextToImageSize::Portrait16x9, GptImage2Resolution::OneK, [1, 2, 3, 4], [5, 10, 15, 20], [15, 30, 45, 60]), - (GptImage2TextToImageSize::Portrait16x9, GptImage2Resolution::TwoK, [2, 4, 6, 8], [6, 12, 18, 24], [21, 42, 63, 84]), - (GptImage2TextToImageSize::Portrait16x9, GptImage2Resolution::ThreeK, [2, 4, 6, 8], [9, 18, 27, 36], [31, 62, 93, 124]), - (GptImage2TextToImageSize::Portrait16x9, GptImage2Resolution::FourK, [3, 6, 9, 12], [12, 24, 36, 48], [42, 84, 126, 168]), - (GptImage2TextToImageSize::Square, GptImage2Resolution::OneK, [1, 2, 3, 4], [5, 10, 15, 20], [16, 32, 48, 64]), - (GptImage2TextToImageSize::Square, GptImage2Resolution::TwoK, [2, 4, 6, 8], [8, 16, 24, 32], [27, 54, 81, 108]), - (GptImage2TextToImageSize::Square, GptImage2Resolution::ThreeK, [3, 6, 9, 12], [12, 24, 36, 48], [42, 84, 126, 168]), - (GptImage2TextToImageSize::Square, GptImage2Resolution::FourK, [3, 6, 9, 12], [12, 24, 36, 48], [42, 84, 126, 168]), - (GptImage2TextToImageSize::SquareHd, GptImage2Resolution::OneK, [1, 2, 3, 4], [5, 10, 15, 20], [16, 32, 48, 64]), - (GptImage2TextToImageSize::SquareHd, GptImage2Resolution::TwoK, [2, 4, 6, 8], [8, 16, 24, 32], [27, 54, 81, 108]), - (GptImage2TextToImageSize::SquareHd, GptImage2Resolution::ThreeK, [3, 6, 9, 12], [12, 24, 36, 48], [42, 84, 126, 168]), - (GptImage2TextToImageSize::SquareHd, GptImage2Resolution::FourK, [3, 6, 9, 12], [12, 24, 36, 48], [42, 84, 126, 168]), + (None, None, [23, 46, 69, 92]), + // Low + // No Auto variant on t2i — None is the only auto-equivalent (tested above). + (Some(GptImage2TextToImageQuality::Low), Some(GptImage2TextToImageSize::Square), [1, 2, 3, 4]), + (Some(GptImage2TextToImageQuality::Low), Some(GptImage2TextToImageSize::SquareHd), [1, 2, 3, 4]), + (Some(GptImage2TextToImageQuality::Low), Some(GptImage2TextToImageSize::Landscape4x3), [1, 2, 3, 4]), + (Some(GptImage2TextToImageQuality::Low), Some(GptImage2TextToImageSize::Landscape16x9), [1, 2, 3, 4]), + (Some(GptImage2TextToImageQuality::Low), Some(GptImage2TextToImageSize::Portrait4x3), [1, 2, 3, 4]), + (Some(GptImage2TextToImageQuality::Low), Some(GptImage2TextToImageSize::Portrait16x9), [1, 2, 3, 4]), + // Medium + // No Auto variant on t2i. + (Some(GptImage2TextToImageQuality::Medium), Some(GptImage2TextToImageSize::Square), [6, 12, 18, 24]), + (Some(GptImage2TextToImageQuality::Medium), Some(GptImage2TextToImageSize::SquareHd), [6, 12, 18, 24]), + (Some(GptImage2TextToImageQuality::Medium), Some(GptImage2TextToImageSize::Landscape4x3), [4, 8, 12, 16]), + (Some(GptImage2TextToImageQuality::Medium), Some(GptImage2TextToImageSize::Landscape16x9), [4, 8, 12, 16]), + (Some(GptImage2TextToImageQuality::Medium), Some(GptImage2TextToImageSize::Portrait4x3), [4, 8, 12, 16]), + (Some(GptImage2TextToImageQuality::Medium), Some(GptImage2TextToImageSize::Portrait16x9), [4, 8, 12, 16]), + // High + // No Auto variant on t2i. + (Some(GptImage2TextToImageQuality::High), Some(GptImage2TextToImageSize::Square), [22, 44, 66, 88]), + (Some(GptImage2TextToImageQuality::High), Some(GptImage2TextToImageSize::SquareHd), [23, 46, 69, 92]), + (Some(GptImage2TextToImageQuality::High), Some(GptImage2TextToImageSize::Landscape4x3), [15, 30, 45, 60]), + (Some(GptImage2TextToImageQuality::High), Some(GptImage2TextToImageSize::Landscape16x9), [16, 32, 48, 64]), + (Some(GptImage2TextToImageQuality::High), Some(GptImage2TextToImageSize::Portrait4x3), [15, 30, 45, 60]), + (Some(GptImage2TextToImageQuality::High), Some(GptImage2TextToImageSize::Portrait16x9), [16, 32, 48, 64]), ] } - fn edit_image_resolution_price_cases() -> Vec<(GptImage2EditImageSize, GptImage2Resolution, [u64; 4], [u64; 4], [u64; 4])> { + // Edit-mode pricing is identical to t2i (v1 has no input-image fee for + // gpt_image_2 — only gpt_image_1 has that fee). + fn edit_image_price_cases() -> Vec { vec![ - (GptImage2EditImageSize::Landscape4x3, GptImage2Resolution::OneK, [1, 2, 3, 4], [4, 8, 12, 16], [14, 28, 42, 56]), - (GptImage2EditImageSize::Landscape4x3, GptImage2Resolution::TwoK, [1, 2, 3, 4], [6, 12, 18, 24], [23, 46, 69, 92]), - (GptImage2EditImageSize::Landscape4x3, GptImage2Resolution::ThreeK, [2, 4, 6, 8], [9, 18, 27, 36], [36, 72, 108, 144]), - (GptImage2EditImageSize::Landscape4x3, GptImage2Resolution::FourK, [2, 4, 6, 8], [10, 20, 30, 40], [40, 80, 120, 160]), - (GptImage2EditImageSize::Landscape16x9, GptImage2Resolution::OneK, [1, 2, 3, 4], [4, 8, 12, 16], [14, 28, 42, 56]), - (GptImage2EditImageSize::Landscape16x9, GptImage2Resolution::TwoK, [1, 2, 3, 4], [5, 10, 15, 20], [20, 40, 60, 80]), - (GptImage2EditImageSize::Landscape16x9, GptImage2Resolution::ThreeK, [1, 2, 3, 4], [8, 16, 24, 32], [30, 60, 90, 120]), - (GptImage2EditImageSize::Landscape16x9, GptImage2Resolution::FourK, [2, 4, 6, 8], [10, 20, 30, 40], [41, 82, 123, 164]), - (GptImage2EditImageSize::Portrait4x3, GptImage2Resolution::OneK, [1, 2, 3, 4], [4, 8, 12, 16], [14, 28, 42, 56]), - (GptImage2EditImageSize::Portrait4x3, GptImage2Resolution::TwoK, [1, 2, 3, 4], [6, 12, 18, 24], [23, 46, 69, 92]), - (GptImage2EditImageSize::Portrait4x3, GptImage2Resolution::ThreeK, [2, 4, 6, 8], [9, 18, 27, 36], [36, 72, 108, 144]), - (GptImage2EditImageSize::Portrait4x3, GptImage2Resolution::FourK, [2, 4, 6, 8], [10, 20, 30, 40], [40, 80, 120, 160]), - (GptImage2EditImageSize::Portrait16x9, GptImage2Resolution::OneK, [1, 2, 3, 4], [4, 8, 12, 16], [14, 28, 42, 56]), - (GptImage2EditImageSize::Portrait16x9, GptImage2Resolution::TwoK, [1, 2, 3, 4], [5, 10, 15, 20], [20, 40, 60, 80]), - (GptImage2EditImageSize::Portrait16x9, GptImage2Resolution::ThreeK, [1, 2, 3, 4], [8, 16, 24, 32], [30, 60, 90, 120]), - (GptImage2EditImageSize::Portrait16x9, GptImage2Resolution::FourK, [2, 4, 6, 8], [10, 20, 30, 40], [41, 82, 123, 164]), - (GptImage2EditImageSize::Square, GptImage2Resolution::OneK, [1, 2, 3, 4], [4, 8, 12, 16], [15, 30, 45, 60]), - (GptImage2EditImageSize::Square, GptImage2Resolution::TwoK, [1, 2, 3, 4], [7, 14, 21, 28], [26, 52, 78, 104]), - (GptImage2EditImageSize::Square, GptImage2Resolution::ThreeK, [2, 4, 6, 8], [10, 20, 30, 40], [41, 82, 123, 164]), - (GptImage2EditImageSize::Square, GptImage2Resolution::FourK, [2, 4, 6, 8], [10, 20, 30, 40], [41, 82, 123, 164]), - (GptImage2EditImageSize::SquareHd, GptImage2Resolution::OneK, [1, 2, 3, 4], [4, 8, 12, 16], [15, 30, 45, 60]), - (GptImage2EditImageSize::SquareHd, GptImage2Resolution::TwoK, [1, 2, 3, 4], [7, 14, 21, 28], [26, 52, 78, 104]), - (GptImage2EditImageSize::SquareHd, GptImage2Resolution::ThreeK, [2, 4, 6, 8], [10, 20, 30, 40], [41, 82, 123, 164]), - (GptImage2EditImageSize::SquareHd, GptImage2Resolution::FourK, [2, 4, 6, 8], [10, 20, 30, 40], [41, 82, 123, 164]), + (None, None, [23, 46, 69, 92]), + // Low + (Some(GptImage2EditImageQuality::Low), Some(GptImage2EditImageSize::Auto), [1, 2, 3, 4]), + (Some(GptImage2EditImageQuality::Low), Some(GptImage2EditImageSize::Square), [1, 2, 3, 4]), + (Some(GptImage2EditImageQuality::Low), Some(GptImage2EditImageSize::SquareHd), [1, 2, 3, 4]), + (Some(GptImage2EditImageQuality::Low), Some(GptImage2EditImageSize::Landscape4x3), [1, 2, 3, 4]), + (Some(GptImage2EditImageQuality::Low), Some(GptImage2EditImageSize::Landscape16x9), [1, 2, 3, 4]), + (Some(GptImage2EditImageQuality::Low), Some(GptImage2EditImageSize::Portrait4x3), [1, 2, 3, 4]), + (Some(GptImage2EditImageQuality::Low), Some(GptImage2EditImageSize::Portrait16x9), [1, 2, 3, 4]), + // Medium + (Some(GptImage2EditImageQuality::Medium), Some(GptImage2EditImageSize::Auto), [6, 12, 18, 24]), + (Some(GptImage2EditImageQuality::Medium), Some(GptImage2EditImageSize::Square), [6, 12, 18, 24]), + (Some(GptImage2EditImageQuality::Medium), Some(GptImage2EditImageSize::SquareHd), [6, 12, 18, 24]), + (Some(GptImage2EditImageQuality::Medium), Some(GptImage2EditImageSize::Landscape4x3), [4, 8, 12, 16]), + (Some(GptImage2EditImageQuality::Medium), Some(GptImage2EditImageSize::Landscape16x9), [4, 8, 12, 16]), + (Some(GptImage2EditImageQuality::Medium), Some(GptImage2EditImageSize::Portrait4x3), [4, 8, 12, 16]), + (Some(GptImage2EditImageQuality::Medium), Some(GptImage2EditImageSize::Portrait16x9), [4, 8, 12, 16]), + // High + (Some(GptImage2EditImageQuality::High), Some(GptImage2EditImageSize::Auto), [23, 46, 69, 92]), + (Some(GptImage2EditImageQuality::High), Some(GptImage2EditImageSize::Square), [22, 44, 66, 88]), + (Some(GptImage2EditImageQuality::High), Some(GptImage2EditImageSize::SquareHd), [23, 46, 69, 92]), + (Some(GptImage2EditImageQuality::High), Some(GptImage2EditImageSize::Landscape4x3), [15, 30, 45, 60]), + (Some(GptImage2EditImageQuality::High), Some(GptImage2EditImageSize::Landscape16x9), [16, 32, 48, 64]), + (Some(GptImage2EditImageQuality::High), Some(GptImage2EditImageSize::Portrait4x3), [15, 30, 45, 60]), + (Some(GptImage2EditImageQuality::High), Some(GptImage2EditImageSize::Portrait16x9), [16, 32, 48, 64]), ] } @@ -207,7 +259,7 @@ mod tests { 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_eq!(cost.cost_in_credits, cost.cost_in_usd_cents); assert!(!cost.is_free); assert!(!cost.is_unlimited); assert!(!cost.is_rate_limited); diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image_v2/providers/fal/nano_banana_2/build.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image_v2/providers/fal/nano_banana_2/build.rs index c898022b74..5f1ebc621c 100644 --- a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image_v2/providers/fal/nano_banana_2/build.rs +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image_v2/providers/fal/nano_banana_2/build.rs @@ -122,7 +122,10 @@ fn plan_resolution(resolution: Option) -> Option PlannedResolution::HalfK, CommonResolution::OneK | CommonResolution::SevenTwentyP | CommonResolution::TenEightyP => PlannedResolution::OneK, CommonResolution::TwoK => PlannedResolution::TwoK, - CommonResolution::ThreeK | CommonResolution::FourK => PlannedResolution::FourK, + // 3K isn't natively supported on Fal nano_banana_2; v1 downgrades to 2K + // pricing (and TwoK resolution). Match v1 here so cost+behavior agree. + CommonResolution::ThreeK => PlannedResolution::TwoK, + CommonResolution::FourK => PlannedResolution::FourK, }) } @@ -416,13 +419,14 @@ mod tests { } #[test] - fn three_k_rounds_up_to_four_k() { + fn three_k_rounds_down_to_two_k() { + // Match v1's downgrade behavior: 3K → 2K (cheaper, no 3K native support). let builder = GenerateImageRequestBuilder { resolution: Some(CommonResolution::ThreeK), ..base_builder() }; let req = unwrap_t2i(build_fal_nano_banana_2(builder)); - assert!(matches!(req.resolution, Some(NanoBanana2TextToImageResolution::FourK))); + assert!(matches!(req.resolution, Some(NanoBanana2TextToImageResolution::TwoK))); } #[test] diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image_v2/providers/fal/nano_banana_2/cost.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image_v2/providers/fal/nano_banana_2/cost.rs index 86380af135..2bf011c781 100644 --- a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image_v2/providers/fal/nano_banana_2/cost.rs +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image_v2/providers/fal/nano_banana_2/cost.rs @@ -1,24 +1,44 @@ -use fal_client::requests::traits::fal_request_cost_calculator_trait::FalRequestCostCalculator; +use fal_client::requests::api::image::edit::nano_banana_2_edit_image::api::{ + NanoBanana2EditImageNumImages, NanoBanana2EditImageResolution, +}; +use fal_client::requests::api::image::text::nano_banana_2_text_to_image::api::{ + NanoBanana2TextToImageNumImages, NanoBanana2TextToImageResolution, +}; use crate::generate::generate_image::image_generation_cost_estimate::ImageGenerationCostEstimate; use crate::generate::generate_image_v2::providers::fal::nano_banana_2::request::FalNanoBanana2RequestState; +/// Cost state for Fal Nano Banana 2. Mirrors v1 +/// (`estimate_image_cost_fal_nano_banana_2`): +/// +/// $0.08/image base (1K). Multipliers: 0.5K = 0.75x, 2K = 1.5x, 4K = 2x. +/// 0.5K → 6¢, 1K → 8¢ (also default), 2K → 12¢, 4K → 16¢ +/// +/// The hand-rolled rates here intentionally override fal_client's trait, +/// whose rates are placeholder values from a `TODO(bt): Verify pricing` block +/// (8/15/15/30 instead of v1's 6/8/12/16). Keep this in sync with the v1 +/// cost file. pub struct FalNanoBanana2CostState { cost_in_usd_cents: u64, } impl FalNanoBanana2CostState { pub fn from_request(request: &FalNanoBanana2RequestState) -> Self { - let cost_in_usd_cents = match request { - FalNanoBanana2RequestState::TextToImage(req) => req.calculate_cost_in_cents(), - FalNanoBanana2RequestState::EditImage(req) => req.calculate_cost_in_cents(), + let (cost_per_image, num_images) = match request { + FalNanoBanana2RequestState::TextToImage(req) => { + (cost_per_image_for_t2i_resolution(req.resolution), t2i_num_images(req.num_images)) + } + FalNanoBanana2RequestState::EditImage(req) => { + (cost_per_image_for_edit_resolution(req.resolution), edit_num_images(req.num_images)) + } }; - Self { cost_in_usd_cents } + Self { cost_in_usd_cents: cost_per_image * num_images } } pub fn estimate_cost(&self) -> ImageGenerationCostEstimate { ImageGenerationCostEstimate { - cost_in_credits: None, + // v1 sets cost_in_credits to the same value as USD cents (1:1). + 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, @@ -29,6 +49,42 @@ impl FalNanoBanana2CostState { } } +fn cost_per_image_for_t2i_resolution(r: Option) -> u64 { + match r { + Some(NanoBanana2TextToImageResolution::HalfK) => 6, + Some(NanoBanana2TextToImageResolution::OneK) | None => 8, + Some(NanoBanana2TextToImageResolution::TwoK) => 12, + Some(NanoBanana2TextToImageResolution::FourK) => 16, + } +} + +fn cost_per_image_for_edit_resolution(r: Option) -> u64 { + match r { + Some(NanoBanana2EditImageResolution::HalfK) => 6, + Some(NanoBanana2EditImageResolution::OneK) | None => 8, + Some(NanoBanana2EditImageResolution::TwoK) => 12, + Some(NanoBanana2EditImageResolution::FourK) => 16, + } +} + +fn t2i_num_images(n: NanoBanana2TextToImageNumImages) -> u64 { + match n { + NanoBanana2TextToImageNumImages::One => 1, + NanoBanana2TextToImageNumImages::Two => 2, + NanoBanana2TextToImageNumImages::Three => 3, + NanoBanana2TextToImageNumImages::Four => 4, + } +} + +fn edit_num_images(n: NanoBanana2EditImageNumImages) -> u64 { + match n { + NanoBanana2EditImageNumImages::One => 1, + NanoBanana2EditImageNumImages::Two => 2, + NanoBanana2EditImageNumImages::Three => 3, + NanoBanana2EditImageNumImages::Four => 4, + } +} + #[cfg(test)] mod tests { use super::*; @@ -61,7 +117,8 @@ mod tests { let state = FalNanoBanana2CostState::from_request( &make_t2i(NanoBanana2TextToImageNumImages::One, None), ); - assert_eq!(state.estimate_cost().cost_in_usd_cents, Some(15)); + // None defaults to 1K = 8¢ per v1's rate table. + assert_eq!(state.estimate_cost().cost_in_usd_cents, Some(8)); } #[test] @@ -69,7 +126,8 @@ mod tests { let state = FalNanoBanana2CostState::from_request( &make_t2i(NanoBanana2TextToImageNumImages::One, Some(NanoBanana2TextToImageResolution::HalfK)), ); - assert_eq!(state.estimate_cost().cost_in_usd_cents, Some(8)); + // HalfK = 0.75x base = 6¢ per v1's rate table. + assert_eq!(state.estimate_cost().cost_in_usd_cents, Some(6)); } #[test] @@ -77,7 +135,7 @@ mod tests { let state = FalNanoBanana2CostState::from_request( &make_t2i(NanoBanana2TextToImageNumImages::Four, Some(NanoBanana2TextToImageResolution::OneK)), ); - assert_eq!(state.estimate_cost().cost_in_usd_cents, Some(60)); + assert_eq!(state.estimate_cost().cost_in_usd_cents, Some(32)); } #[test] @@ -85,7 +143,7 @@ mod tests { let state = FalNanoBanana2CostState::from_request( &make_t2i(NanoBanana2TextToImageNumImages::One, Some(NanoBanana2TextToImageResolution::FourK)), ); - assert_eq!(state.estimate_cost().cost_in_usd_cents, Some(30)); + assert_eq!(state.estimate_cost().cost_in_usd_cents, Some(16)); } #[test] @@ -93,7 +151,7 @@ mod tests { let state = FalNanoBanana2CostState::from_request( &make_t2i(NanoBanana2TextToImageNumImages::Two, Some(NanoBanana2TextToImageResolution::FourK)), ); - assert_eq!(state.estimate_cost().cost_in_usd_cents, Some(60)); + assert_eq!(state.estimate_cost().cost_in_usd_cents, Some(32)); } } @@ -118,7 +176,7 @@ mod tests { let state = FalNanoBanana2CostState::from_request( &make_edit(NanoBanana2EditImageNumImages::One, None), ); - assert_eq!(state.estimate_cost().cost_in_usd_cents, Some(15)); + assert_eq!(state.estimate_cost().cost_in_usd_cents, Some(8)); } #[test] @@ -126,7 +184,7 @@ mod tests { let state = FalNanoBanana2CostState::from_request( &make_edit(NanoBanana2EditImageNumImages::One, Some(NanoBanana2EditImageResolution::HalfK)), ); - assert_eq!(state.estimate_cost().cost_in_usd_cents, Some(8)); + assert_eq!(state.estimate_cost().cost_in_usd_cents, Some(6)); } #[test] @@ -134,7 +192,7 @@ mod tests { let state = FalNanoBanana2CostState::from_request( &make_edit(NanoBanana2EditImageNumImages::Three, Some(NanoBanana2EditImageResolution::TwoK)), ); - assert_eq!(state.estimate_cost().cost_in_usd_cents, Some(45)); + assert_eq!(state.estimate_cost().cost_in_usd_cents, Some(36)); } #[test] @@ -142,7 +200,7 @@ mod tests { let state = FalNanoBanana2CostState::from_request( &make_edit(NanoBanana2EditImageNumImages::One, Some(NanoBanana2EditImageResolution::FourK)), ); - assert_eq!(state.estimate_cost().cost_in_usd_cents, Some(30)); + assert_eq!(state.estimate_cost().cost_in_usd_cents, Some(16)); } } @@ -161,6 +219,6 @@ mod tests { assert!(!cost.is_unlimited); assert!(!cost.is_rate_limited); assert!(!cost.has_watermark); - assert!(cost.cost_in_credits.is_none()); + assert_eq!(cost.cost_in_credits, cost.cost_in_usd_cents); } } diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image_v2/providers/fal/nano_banana_pro/build.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image_v2/providers/fal/nano_banana_pro/build.rs index e506be5301..17b4a4f810 100644 --- a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image_v2/providers/fal/nano_banana_pro/build.rs +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image_v2/providers/fal/nano_banana_pro/build.rs @@ -121,7 +121,10 @@ fn plan_resolution(resolution: Option) -> Option PlannedResolution::OneK, CommonResolution::TwoK => PlannedResolution::TwoK, - CommonResolution::ThreeK | CommonResolution::FourK => PlannedResolution::FourK, + // 3K isn't natively supported on Fal nano_banana_pro; v1 downgrades to 2K + // pricing (and TwoK resolution). Match v1 here so cost+behavior agree. + CommonResolution::ThreeK => PlannedResolution::TwoK, + CommonResolution::FourK => PlannedResolution::FourK, }) } @@ -393,13 +396,14 @@ mod tests { } #[test] - fn three_k_rounds_up_to_four_k() { + fn three_k_rounds_down_to_two_k() { + // Match v1's downgrade behavior: 3K → 2K (cheaper, no 3K native support). let builder = GenerateImageRequestBuilder { resolution: Some(CommonResolution::ThreeK), ..base_builder() }; let req = unwrap_t2i(build_fal_nano_banana_pro(builder)); - assert!(matches!(req.resolution, Some(NanoBananaProTextToImageResolution::FourK))); + assert!(matches!(req.resolution, Some(NanoBananaProTextToImageResolution::TwoK))); } #[test] diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image_v2/providers/fal/nano_banana_pro/cost.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image_v2/providers/fal/nano_banana_pro/cost.rs index c8c8c44dd3..f1bf3afc7b 100644 --- a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image_v2/providers/fal/nano_banana_pro/cost.rs +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image_v2/providers/fal/nano_banana_pro/cost.rs @@ -18,7 +18,8 @@ impl FalNanoBananaProCostState { pub fn estimate_cost(&self) -> ImageGenerationCostEstimate { ImageGenerationCostEstimate { - cost_in_credits: None, + // v1 sets cost_in_credits to the same value as USD cents (1:1). + 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, @@ -145,6 +146,6 @@ mod tests { assert!(!cost.is_unlimited); assert!(!cost.is_rate_limited); assert!(!cost.has_watermark); - assert!(cost.cost_in_credits.is_none()); + assert_eq!(cost.cost_in_credits, cost.cost_in_usd_cents); } }