This commit is contained in:
Brandon Thomas
2026-05-26 16:03:26 -04:00
parent 276cd472c1
commit 555cafd030
10 changed files with 530 additions and 218 deletions
@@ -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)];
@@ -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);
}
}
@@ -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);
}
}
@@ -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<GptImage1TextToImageQuality>,
image_size: Option<GptImage1TextToImageSize>,
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<GptImage1EditImageQuality>,
image_size: Option<GptImage1EditImageSize>,
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<GptImage1TextToImageQuality>, Option<GptImage1TextToImageSize>, [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<GptImage1EditImageQuality>, Option<GptImage1EditImageSize>, [u64; 4]); 13] {
[
(None, None, [5, 10, 15, 20]),
(Some(GptImage1EditImageQuality::Low), Some(GptImage1EditImageSize::Auto), [2, 4, 6, 8]),
(Some(GptImage1EditImageQuality::Low), Some(GptImage1EditImageSize::Square), [2, 4, 6, 8]),
(Some(GptImage1EditImageQuality::Low), Some(GptImage1EditImageSize::Horizontal), [2, 4, 6, 8]),
(Some(GptImage1EditImageQuality::Low), Some(GptImage1EditImageSize::Vertical), [2, 4, 6, 8]),
(Some(GptImage1EditImageQuality::Medium), Some(GptImage1EditImageSize::Auto), [5, 10, 15, 20]),
(Some(GptImage1EditImageQuality::Medium), Some(GptImage1EditImageSize::Square), [5, 10, 15, 20]),
(Some(GptImage1EditImageQuality::Medium), Some(GptImage1EditImageSize::Horizontal), [7, 14, 21, 28]),
(Some(GptImage1EditImageQuality::Medium), Some(GptImage1EditImageSize::Vertical), [7, 14, 21, 28]),
(Some(GptImage1EditImageQuality::High), Some(GptImage1EditImageSize::Auto), [17, 34, 51, 68]),
(Some(GptImage1EditImageQuality::High), Some(GptImage1EditImageSize::Square), [17, 34, 51, 68]),
(Some(GptImage1EditImageQuality::High), Some(GptImage1EditImageSize::Horizontal), [25, 50, 75, 100]),
(Some(GptImage1EditImageQuality::High), Some(GptImage1EditImageSize::Vertical), [25, 50, 75, 100]),
(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);
@@ -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<GptImage1p5TextToImageQuality>,
image_size: Option<GptImage1p5TextToImageSize>,
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<GptImage1p5EditImageQuality>,
image_size: Option<GptImage1p5EditImageSize>,
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<GptImage1p5TextToImageQuality>, Option<GptImage1p5TextToImageSize>, [u64; 4]); 10] {
[(None, None, [3, 6, 9, 12]), (Some(GptImage1p5TextToImageQuality::Low), Some(GptImage1p5TextToImageSize::Square), [1, 2, 3, 4]), (Some(GptImage1p5TextToImageQuality::Low), Some(GptImage1p5TextToImageSize::Wide), [1, 2, 3, 4]), (Some(GptImage1p5TextToImageQuality::Low), Some(GptImage1p5TextToImageSize::Tall), [1, 2, 3, 4]), (Some(GptImage1p5TextToImageQuality::Medium), Some(GptImage1p5TextToImageSize::Square), [3, 6, 9, 12]), (Some(GptImage1p5TextToImageQuality::Medium), Some(GptImage1p5TextToImageSize::Wide), [5, 10, 15, 20]), (Some(GptImage1p5TextToImageQuality::Medium), Some(GptImage1p5TextToImageSize::Tall), [5, 10, 15, 20]), (Some(GptImage1p5TextToImageQuality::High), Some(GptImage1p5TextToImageSize::Square), [13, 26, 39, 52]), (Some(GptImage1p5TextToImageQuality::High), Some(GptImage1p5TextToImageSize::Wide), [20, 40, 60, 80]), (Some(GptImage1p5TextToImageQuality::High), Some(GptImage1p5TextToImageSize::Tall), [20, 40, 60, 80])]
[
(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<GptImage1p5EditImageQuality>, Option<GptImage1p5EditImageSize>, [u64; 4]); 10] {
[(None, None, [3, 6, 9, 12]), (Some(GptImage1p5EditImageQuality::Low), Some(GptImage1p5EditImageSize::Square), [1, 2, 3, 4]), (Some(GptImage1p5EditImageQuality::Low), Some(GptImage1p5EditImageSize::Wide), [1, 2, 3, 4]), (Some(GptImage1p5EditImageQuality::Low), Some(GptImage1p5EditImageSize::Tall), [1, 2, 3, 4]), (Some(GptImage1p5EditImageQuality::Medium), Some(GptImage1p5EditImageSize::Square), [3, 6, 9, 12]), (Some(GptImage1p5EditImageQuality::Medium), Some(GptImage1p5EditImageSize::Wide), [5, 10, 15, 20]), (Some(GptImage1p5EditImageQuality::Medium), Some(GptImage1p5EditImageSize::Tall), [5, 10, 15, 20]), (Some(GptImage1p5EditImageQuality::High), Some(GptImage1p5EditImageSize::Square), [13, 26, 39, 52]), (Some(GptImage1p5EditImageQuality::High), Some(GptImage1p5EditImageSize::Wide), [20, 40, 60, 80]), (Some(GptImage1p5EditImageQuality::High), Some(GptImage1p5EditImageSize::Tall), [20, 40, 60, 80])]
[
(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);
@@ -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<GptImage2TextToImageQuality>,
image_size: Option<GptImage2TextToImageSize>,
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<GptImage2EditImageQuality>,
image_size: Option<GptImage2EditImageSize>,
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<GptImage2TextToImageQuality>, Option<GptImage2TextToImageSize>, Option<GptImage2Resolution>, [u64; 4]);
type EditPriceCase = (Option<GptImage2EditImageQuality>, Option<GptImage2EditImageSize>, Option<GptImage2Resolution>, [u64; 4]);
type TextPriceCase = (Option<GptImage2TextToImageQuality>, Option<GptImage2TextToImageSize>, [u64; 4]);
type EditPriceCase = (Option<GptImage2EditImageQuality>, Option<GptImage2EditImageSize>, [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<TextPriceCase> {
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<EditPriceCase> {
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<EditPriceCase> {
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);
@@ -122,7 +122,10 @@ fn plan_resolution(resolution: Option<CommonResolution>) -> Option<PlannedResolu
CommonResolution::HalfK | CommonResolution::FourEightyP => 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]
@@ -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<NanoBanana2TextToImageResolution>) -> 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<NanoBanana2EditImageResolution>) -> 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);
}
}
@@ -121,7 +121,10 @@ fn plan_resolution(resolution: Option<CommonResolution>) -> Option<PlannedResolu
CommonResolution::HalfK | CommonResolution::FourEightyP | CommonResolution::SevenTwentyP
| CommonResolution::OneK | CommonResolution::TenEightyP => 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]
@@ -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);
}
}