Compare commits

...
Author SHA1 Message Date
Brandon Thomas 6b9bf50f2f remove frontend changes 2026-04-10 01:16:29 -04:00
Brandon Thomas 11f564596e formatting 2026-04-10 01:16:03 -04:00
Brandon Thomas ef8bff3e7a auto3k 2026-04-10 01:08:23 -04:00
Brandon Thomas 0aa0db22a8 auto3k 2026-04-10 01:05:08 -04:00
Brandon Thomas 7769e6585c seedream 4.5 2026-04-10 00:53:41 -04:00
Brandon Thomas 7b8139df14 seedream 4.5 2026-04-09 23:25:40 -04:00
Brandon Thomas d2fc71cfe9 seedream test 2026-04-09 21:23:13 -04:00
Brandon Thomas 2452e065f5 pricing 2026-04-09 20:45:51 -04:00
Brandon Thomas 8f3e2deb38 quality 2026-04-09 18:42:51 -04:00
Brandon Thomas 4dd1a4f2be gpt-image-1.5 + quality 2026-04-09 18:23:23 -04:00
Brandon Thomas 2313ef48c2 cleanup 2026-04-09 18:05:07 -04:00
Brandon Thomas c90b2390f7 gpt-image-1 + quality 2026-04-09 17:01:36 -04:00
Brandon Thomas 8c34b87cd9 gpt-image-1 tests 2026-04-09 03:55:13 -04:00
Brandon Thomas c159c47129 tests 2026-04-09 02:29:52 -04:00
Brandon Thomas 6b63ec7cf1 tests 2026-04-09 02:17:35 -04:00
Brandon Thomas 4dc903c17a gpt image 1 2026-04-09 02:08:25 -04:00
Brandon Thomas 66340bd990 add gpt-image-1 model 2026-04-09 01:20:06 -04:00
Brandon Thomas 1613386012 tests 2026-04-09 00:59:57 -04:00
Brandon Thomas 861f90355d pricing changes 2026-04-09 00:10:07 -04:00
Brandon Thomas cf7bea0527 nano banana 2 (pricing is wrong) 2026-04-08 23:58:36 -04:00
Brandon Thomas c6a8df34a9 nano banana tests 2026-04-08 22:33:38 -04:00
Brandon Thomas 09b051ed12 fal 1.1 pro ultra tests 2026-04-08 21:12:52 -04:00
Brandon Thomas 0e7b59eb75 flux pro 1.1 tests 2026-04-08 17:35:58 -04:00
Brandon Thomas 0c1ff630ef flux schnell tests 2026-04-08 16:55:22 -04:00
Brandon Thomas 0a4112ecaa refactor 2026-04-08 10:41:56 -04:00
Brandon Thomas dd1e206d52 test 2026-04-08 10:29:39 -04:00
Brandon Thomas 7dfaa3f095 tests for flux1 dev 2026-04-08 10:29:39 -04:00
Brandon Thomas 1622b68c64 reenable frontend 2026-04-08 10:29:39 -04:00
Brandon Thomas e2fe661bb7 distill module 2026-04-08 10:29:39 -04:00
Brandon Thomas 043396607a rename 2026-04-08 10:29:39 -04:00
Brandon Thomas c4f9b6c435 test modules 2026-04-08 10:29:39 -04:00
111 changed files with 12322 additions and 415 deletions
@@ -103,6 +103,7 @@ pub struct OmniGenImageModelDetails {
#[serde(skip_serializing_if = "Option::is_none")]
pub resolution_default: Option<CommonResolution>,
#[serde(skip_serializing_if = "Option::is_none")]
pub quality_options: Option<Vec<CommonQuality>>,
#[serde(skip_serializing_if = "Option::is_none")]
@@ -36,7 +36,11 @@ pub enum CommonAspectRatio {
// Auto values that bake in resolution
// These are from the Seedream models
#[serde(rename = "auto_2k")]
Auto2k,
#[serde(rename = "auto_3k")]
Auto3k,
#[serde(rename = "auto_4k")]
Auto4k,
// Defined aspect ratios that bake in resolution
@@ -13,6 +13,8 @@ pub enum CommonImageModel {
FluxPro11,
#[serde(rename = "flux_pro_1p1_ultra")]
FluxPro11Ultra,
#[serde(rename = "gpt_image_1")]
GptImage1,
#[serde(rename = "gpt_image_1p5")]
GptImage1p5,
#[serde(rename = "nano_banana")]
@@ -0,0 +1,11 @@
use serde_derive::{Deserialize, Serialize};
/// Common quality levels you can specify when enqueuing a generation.
/// Not every model will use this; models that don't simply ignore it.
#[derive(Copy, Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum CommonQuality {
High,
Medium,
Low,
}
@@ -2,6 +2,7 @@ pub mod audio_list_ref;
pub mod character_list_ref;
pub mod common_aspect_ratio;
pub mod common_image_model;
pub mod common_quality;
pub mod common_resolution;
pub mod common_splat_model;
pub mod common_video_model;
@@ -43,6 +43,7 @@ mod tests {
image_inputs: None,
resolution: None,
aspect_ratio: None,
quality: None,
image_batch_count: Some(image_batch_count),
request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::ErrorOut,
generation_mode_mismatch_strategy: None,
@@ -31,6 +31,7 @@ mod tests {
image_inputs: None,
resolution: None,
aspect_ratio: None,
quality: None,
image_batch_count: Some(1),
request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::ErrorOut,
generation_mode_mismatch_strategy: None,
@@ -46,6 +46,7 @@ mod tests {
image_inputs: Some(ImageListRef::MediaFileTokens(&tokens)),
resolution: None,
aspect_ratio: None,
quality: None,
image_batch_count: Some(image_batch_count),
horizontal_angle: None,
vertical_angle: None,
@@ -6,8 +6,11 @@ use crate::generate::generate_image::plan::artcraft::plan_generate_image_artcraf
pub(crate) fn estimate_image_cost_artcraft_flux_pro_1p1(
plan: &PlanArtcraftFluxPro11<'_>,
) -> ImageGenerationCostEstimate {
// NB(bt): Slightly more than 3 cents, weird megapixel maths. We can eat that cost for now.
let cost_per_image: u64 = 3;
// Fal pricing: $0.04 per megapixel, billed by rounding up to the nearest
// megapixel. The default image_size values are ~1MP, so 4 cents per image.
// (The legacy `generate_flux_pro_11_text_to_image_handler` charges this same
// amount via `FluxPro11Args::calculate_cost_in_cents`.)
let cost_per_image: u64 = 4;
let num_images: u64 = match plan.num_images {
GenerateFluxPro11TextToImageNumImages::One => 1,
@@ -43,6 +46,7 @@ mod tests {
image_inputs: None,
resolution: None,
aspect_ratio: None,
quality: None,
image_batch_count: Some(image_batch_count),
request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::ErrorOut,
generation_mode_mismatch_strategy: None,
@@ -60,10 +64,10 @@ mod tests {
#[test]
fn test_estimate_cost_usd_cents() {
// ~$0.03/image = 3 cents each (slightly more than 3¢ actual, rounded up)
assert_eq!(estimate_usd_cents(1), 3);
assert_eq!(estimate_usd_cents(2), 6);
assert_eq!(estimate_usd_cents(3), 9);
assert_eq!(estimate_usd_cents(4), 12);
// $0.04/image = 4 cents each (Fal's $0.04/MP at the default ~1MP).
assert_eq!(estimate_usd_cents(1), 4);
assert_eq!(estimate_usd_cents(2), 8);
assert_eq!(estimate_usd_cents(3), 12);
assert_eq!(estimate_usd_cents(4), 16);
}
}
@@ -43,6 +43,7 @@ mod tests {
image_inputs: None,
resolution: None,
aspect_ratio: None,
quality: None,
image_batch_count: Some(image_batch_count),
request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::ErrorOut,
generation_mode_mismatch_strategy: None,
@@ -0,0 +1,239 @@
use crate::generate::generate_image::image_generation_cost_estimate::ImageGenerationCostEstimate;
use crate::generate::generate_image::plan::artcraft::plan_generate_image_artcraft_gpt_image_1::{
ArtcraftGptImage1Quality, ArtcraftGptImage1Size, PlanArtcraftGptImage1,
};
pub(crate) fn estimate_image_cost_artcraft_gpt_image_1(
plan: &PlanArtcraftGptImage1<'_>,
) -> ImageGenerationCostEstimate {
// Pricing per the fal-ai GPT Image 1 docs, adapted for our artcraft billing:
//
// Output image cost (per output image):
// Low – $0.011 (1024×1024) / $0.016 (other) → 2¢ / 2¢
// Medium – $0.042 (1024×1024) / $0.063 (other) → 5¢ / 7¢
// High – $0.167 (1024×1024) / $0.250 (other) → 17¢ / 25¢
//
// Input image cost (edit mode only, per input image, high-fidelity assumed):
// 3,050 tokens × $0.005/1K tokens ≈ $0.01525 → 2¢ per input image
//
// Input text tokens: free (we don't charge for these).
//
let is_square = matches!(
plan.image_size,
None | Some(ArtcraftGptImage1Size::Square)
);
let output_cost_per_image: u64 = match (plan.quality, is_square) {
(ArtcraftGptImage1Quality::Auto, _) => 17, // Auto treated as High square
(ArtcraftGptImage1Quality::Low, true) => 2,
(ArtcraftGptImage1Quality::Low, false) => 2,
(ArtcraftGptImage1Quality::Medium, true) => 5,
(ArtcraftGptImage1Quality::Medium, false) => 7,
(ArtcraftGptImage1Quality::High, true) => 17,
(ArtcraftGptImage1Quality::High, false) => 25,
};
let num_output_images = plan.num_images.as_u64();
let output_cost = output_cost_per_image * num_output_images;
// Input image token cost: 2¢ per input image (high-fidelity estimate).
let input_image_cost: u64 = 2 * plan.num_input_images;
let cost_in_usd_cents = output_cost + input_image_cost;
ImageGenerationCostEstimate {
cost_in_credits: Some(cost_in_usd_cents),
cost_in_usd_cents: Some(cost_in_usd_cents),
is_free: false,
is_unlimited: false,
is_rate_limited: false,
has_watermark: false,
}
}
#[cfg(test)]
mod tests {
use crate::api::common_aspect_ratio::CommonAspectRatio;
use crate::api::common_image_model::CommonImageModel;
use crate::api::common_quality::CommonQuality;
use crate::api::image_list_ref::ImageListRef;
use crate::api::provider::Provider;
use crate::client::request_mismatch_mitigation_strategy::RequestMismatchMitigationStrategy;
use crate::generate::generate_image::generate_image_request::GenerateImageRequest;
use tokens::tokens::media_files::MediaFileToken;
fn base() -> GenerateImageRequest<'static> {
GenerateImageRequest {
model: CommonImageModel::GptImage1,
provider: Provider::Artcraft,
prompt: None,
image_inputs: None,
resolution: None,
aspect_ratio: None,
quality: None,
image_batch_count: Some(1),
request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::ErrorOut,
generation_mode_mismatch_strategy: None,
idempotency_token: None,
horizontal_angle: None,
vertical_angle: None,
zoom: None,
}
}
fn estimate_text(
quality: Option<CommonQuality>,
aspect_ratio: Option<CommonAspectRatio>,
batch: u16,
) -> u64 {
let request = GenerateImageRequest {
quality,
aspect_ratio,
image_batch_count: Some(batch),
..base()
};
request.build().unwrap().estimate_costs()
.cost_in_usd_cents.unwrap()
}
fn estimate_edit(
quality: Option<CommonQuality>,
aspect_ratio: Option<CommonAspectRatio>,
batch: u16,
num_input_images: usize,
) -> u64 {
let tokens: Vec<MediaFileToken> = (0..num_input_images)
.map(|i| MediaFileToken::new_from_str(&format!("mf_test{:028}", i)))
.collect();
let request = GenerateImageRequest {
quality,
aspect_ratio,
image_batch_count: Some(batch),
image_inputs: Some(ImageListRef::MediaFileTokens(&tokens)),
..base()
};
request.build().unwrap().estimate_costs()
.cost_in_usd_cents.unwrap()
}
// ── Default quality (None → High) ───────────────────────────────────────
#[test]
fn default_quality_square_one_image_costs_17_cents() {
assert_eq!(estimate_text(None, None, 1), 17);
}
#[test]
fn default_quality_square_four_images_costs_68_cents() {
assert_eq!(estimate_text(None, None, 4), 68);
}
// ── Low quality (2¢/image regardless of size) ───────────────────────────
#[test]
fn low_square_one_image() {
assert_eq!(estimate_text(Some(CommonQuality::Low), Some(CommonAspectRatio::Square), 1), 2);
}
#[test]
fn low_wide_one_image() {
assert_eq!(estimate_text(Some(CommonQuality::Low), Some(CommonAspectRatio::WideSixteenByNine), 1), 2);
}
#[test]
fn low_tall_one_image() {
assert_eq!(estimate_text(Some(CommonQuality::Low), Some(CommonAspectRatio::TallNineBySixteen), 1), 2);
}
#[test]
fn low_four_images() {
assert_eq!(estimate_text(Some(CommonQuality::Low), None, 4), 8);
}
// ── Medium quality (5¢ square, 7¢ wide/tall) ───────────────────────────
#[test]
fn medium_square_one_image() {
assert_eq!(estimate_text(Some(CommonQuality::Medium), Some(CommonAspectRatio::Square), 1), 5);
}
#[test]
fn medium_wide_one_image() {
assert_eq!(estimate_text(Some(CommonQuality::Medium), Some(CommonAspectRatio::WideSixteenByNine), 1), 7);
}
#[test]
fn medium_tall_one_image() {
assert_eq!(estimate_text(Some(CommonQuality::Medium), Some(CommonAspectRatio::TallNineBySixteen), 1), 7);
}
#[test]
fn medium_square_four_images() {
assert_eq!(estimate_text(Some(CommonQuality::Medium), Some(CommonAspectRatio::Square), 4), 20);
}
#[test]
fn medium_wide_four_images() {
assert_eq!(estimate_text(Some(CommonQuality::Medium), Some(CommonAspectRatio::WideSixteenByNine), 4), 28);
}
// ── High quality (17¢ square, 25¢ wide/tall) ───────────────────────────
#[test]
fn high_square_one_image() {
assert_eq!(estimate_text(Some(CommonQuality::High), Some(CommonAspectRatio::Square), 1), 17);
}
#[test]
fn high_wide_one_image() {
assert_eq!(estimate_text(Some(CommonQuality::High), Some(CommonAspectRatio::WideSixteenByNine), 1), 25);
}
#[test]
fn high_tall_one_image() {
assert_eq!(estimate_text(Some(CommonQuality::High), Some(CommonAspectRatio::TallNineBySixteen), 1), 25);
}
#[test]
fn high_square_four_images() {
assert_eq!(estimate_text(Some(CommonQuality::High), Some(CommonAspectRatio::Square), 4), 68);
}
#[test]
fn high_wide_four_images() {
assert_eq!(estimate_text(Some(CommonQuality::High), Some(CommonAspectRatio::WideSixteenByNine), 4), 100);
}
// ── Edit mode: adds 2¢ per input image ─────────────────────────────────
#[test]
fn edit_one_input_adds_2_cents() {
// High square 1 output = 17¢, plus 1 input × 2¢ = 19¢
assert_eq!(estimate_edit(Some(CommonQuality::High), Some(CommonAspectRatio::Square), 1, 1), 19);
}
#[test]
fn edit_three_inputs_adds_6_cents() {
// High square 1 output = 17¢, plus 3 inputs × 2¢ = 23¢
assert_eq!(estimate_edit(Some(CommonQuality::High), Some(CommonAspectRatio::Square), 1, 3), 23);
}
#[test]
fn edit_five_inputs_adds_10_cents() {
// Medium wide 2 outputs = 7×2 = 14¢, plus 5 inputs × 2¢ = 24¢
assert_eq!(estimate_edit(Some(CommonQuality::Medium), Some(CommonAspectRatio::WideSixteenByNine), 2, 5), 24);
}
#[test]
fn edit_low_quality_with_inputs() {
// Low square 1 output = 2¢, plus 2 inputs × 2¢ = 6¢
assert_eq!(estimate_edit(Some(CommonQuality::Low), None, 1, 2), 6);
}
// ── Text mode (no input images) has no input cost ──────────────────────
#[test]
fn text_mode_no_input_cost() {
assert_eq!(estimate_text(Some(CommonQuality::High), Some(CommonAspectRatio::Square), 1), 17);
}
}
@@ -9,42 +9,33 @@ use crate::generate::generate_image::plan::artcraft::plan_generate_image_artcraf
pub(crate) fn estimate_image_cost_artcraft_gpt_image_1p5(
plan: &PlanArtcraftGptImage1p5<'_>,
) -> ImageGenerationCostEstimate {
// Pricing varies by quality and image size. 1 credit = 1 USD cent.
// Low: $0.01/image (any size)
// Medium: $0.03/image (square/unset), $0.05/image (wide or tall)
// High: $0.13/image (square/unset), $0.20/image (wide or tall)
// Per fal docs (fal-ai/gpt-image-1.5 and fal-ai/gpt-image-1.5/edit):
//
// From Fal (text to image) -
// Output image cost (per output image), rounded up to whole cents:
// Low: $0.009 (1024×1024) / $0.013 (other) → 1¢ / 2¢
// Medium: $0.034 (1024×1024) / $0.050 (1536×1024) / $0.051 (1024×1536) → 4¢ / 5¢ / 6¢
// High: $0.133 (1024×1024) / $0.199 (1536×1024) / $0.200 (1024×1536) → 14¢ / 20¢ / 20¢
//
// Your request will cost different amounts based on the number of images, quality, and size.
// Input text tokens: free (we don't charge for these).
//
// You will be charged $0.005 per 1,000 input text tokens. One word is roughly 4 tokens.
// You will be charged $0.010 per 1,000 output text tokens. The model will consume tokens reasoning about your prompt based on it's complexity.
// For low quality, you will be charged $0.009 for 1024x1024 or $0.013 for any other size per image.
// For medium quality, you will be charged $0.034 for 1024x1024, $0.051 for 1024x1536 and $0.050 for 1536x1024 per image.
// For high quality, you will be charged $0.133 for 1024x1024, $0.200 for 1024x1536 or $0.199 for 1536x1024 per image.
// Default quality: High (when request.quality is None).
//
// From Fal (image to image) -
//
// Your request will cost different amounts based on the number of images, quality, and size.
//
// You will be charged $0.005 per 1,000 input text tokens. One word is roughly 4 tokens.
// You will be charged $0.008 per 1,000 input image tokens. One 1024x1024 image is roughly 135 tokens in low fidelity mode, or 3,050 tokens in high fidelity mode.
// You will be charged $0.010 per 1,000 output text tokens. The model will consume tokens reasoning about your prompt based on it's complexity.
// For low quality, you will be charged $0.009 for 1024x1024 or $0.013 for any other size per image.
// For medium quality, you will be charged $0.034 for 1024x1024, $0.051 for 1024x1536 and $0.050 for 1536x1024 per image.
// For high quality, you will be charged $0.133 for 1024x1024, $0.200 for 1024x1536 or $0.199 for 1536x1024 per image.
//
let cost_per_image: u64 = match plan.quality {
GptImage1p5MultiFunctionImageGenQuality::Low => 1,
GptImage1p5MultiFunctionImageGenQuality::Medium => match plan.image_size {
Some(GptImage1p5MultiFunctionImageGenSize::Square) | None => 3,
Some(GptImage1p5MultiFunctionImageGenSize::Wide) | Some(GptImage1p5MultiFunctionImageGenSize::Tall) => 5,
},
GptImage1p5MultiFunctionImageGenQuality::High => match plan.image_size {
Some(GptImage1p5MultiFunctionImageGenSize::Square) | None => 13,
Some(GptImage1p5MultiFunctionImageGenSize::Wide) | Some(GptImage1p5MultiFunctionImageGenSize::Tall) => 20,
},
use GptImage1p5MultiFunctionImageGenQuality as Q;
use GptImage1p5MultiFunctionImageGenSize as S;
let cost_per_image: u64 = match (plan.quality, plan.image_size) {
// Low quality
(Q::Low, None | Some(S::Square)) => 1,
(Q::Low, Some(S::Wide) | Some(S::Tall)) => 2,
// Medium quality
(Q::Medium, None | Some(S::Square)) => 4,
(Q::Medium, Some(S::Wide)) => 5,
(Q::Medium, Some(S::Tall)) => 6,
// High quality
(Q::High, None | Some(S::Square)) => 14,
(Q::High, Some(S::Wide) | Some(S::Tall)) => 20,
};
let num_images: u64 = match plan.num_images {
@@ -70,11 +61,16 @@ pub(crate) fn estimate_image_cost_artcraft_gpt_image_1p5(
mod tests {
use crate::api::common_aspect_ratio::CommonAspectRatio;
use crate::api::common_image_model::CommonImageModel;
use crate::api::common_quality::CommonQuality;
use crate::api::provider::Provider;
use crate::client::request_mismatch_mitigation_strategy::RequestMismatchMitigationStrategy;
use crate::generate::generate_image::generate_image_request::GenerateImageRequest;
fn estimate_usd_cents(image_batch_count: u16, aspect_ratio: Option<CommonAspectRatio>) -> u64 {
fn estimate(
quality: Option<CommonQuality>,
aspect_ratio: Option<CommonAspectRatio>,
batch: u16,
) -> u64 {
let request = GenerateImageRequest {
model: CommonImageModel::GptImage1p5,
provider: Provider::Artcraft,
@@ -82,7 +78,8 @@ mod tests {
image_inputs: None,
resolution: None,
aspect_ratio,
image_batch_count: Some(image_batch_count),
quality,
image_batch_count: Some(batch),
request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::ErrorOut,
generation_mode_mismatch_strategy: None,
idempotency_token: None,
@@ -90,34 +87,140 @@ mod tests {
vertical_angle: None,
zoom: None,
};
request.build()
.expect("build should succeed")
.estimate_costs()
.cost_in_usd_cents
.expect("cost_in_usd_cents should be present")
request.build().unwrap().estimate_costs()
.cost_in_usd_cents.unwrap()
}
// ── Default quality (None → High) ─────────────────────────────────────
#[test]
fn default_quality_square_one_image() {
assert_eq!(estimate(None, Some(CommonAspectRatio::Square), 1), 14);
}
#[test]
fn test_estimate_cost_medium_square() {
// Default quality (Medium) + square/unset size = 3 cents each
assert_eq!(estimate_usd_cents(1, None), 3);
assert_eq!(estimate_usd_cents(1, Some(CommonAspectRatio::Square)), 3);
assert_eq!(estimate_usd_cents(2, Some(CommonAspectRatio::Square)), 6);
assert_eq!(estimate_usd_cents(3, Some(CommonAspectRatio::Square)), 9);
assert_eq!(estimate_usd_cents(4, Some(CommonAspectRatio::Square)), 12);
fn default_quality_unset_one_image() {
assert_eq!(estimate(None, None, 1), 14);
}
#[test]
fn test_estimate_cost_medium_wide() {
// Medium + wide = 5 cents each
assert_eq!(estimate_usd_cents(1, Some(CommonAspectRatio::WideSixteenByNine)), 5);
assert_eq!(estimate_usd_cents(4, Some(CommonAspectRatio::WideSixteenByNine)), 20);
fn default_quality_wide_one_image() {
assert_eq!(estimate(None, Some(CommonAspectRatio::WideSixteenByNine), 1), 20);
}
#[test]
fn test_estimate_cost_medium_tall() {
// Medium + tall = 5 cents each
assert_eq!(estimate_usd_cents(1, Some(CommonAspectRatio::TallNineBySixteen)), 5);
assert_eq!(estimate_usd_cents(4, Some(CommonAspectRatio::TallNineBySixteen)), 20);
fn default_quality_tall_one_image() {
assert_eq!(estimate(None, Some(CommonAspectRatio::TallNineBySixteen), 1), 20);
}
#[test]
fn default_quality_square_four_images() {
assert_eq!(estimate(None, Some(CommonAspectRatio::Square), 4), 56);
}
// ── Low quality (1¢ square, 2¢ wide/tall) ─────────────────────────────
#[test]
fn low_square_one() {
assert_eq!(estimate(Some(CommonQuality::Low), Some(CommonAspectRatio::Square), 1), 1);
}
#[test]
fn low_unset_one() {
assert_eq!(estimate(Some(CommonQuality::Low), None, 1), 1);
}
#[test]
fn low_wide_one() {
assert_eq!(estimate(Some(CommonQuality::Low), Some(CommonAspectRatio::WideSixteenByNine), 1), 2);
}
#[test]
fn low_tall_one() {
assert_eq!(estimate(Some(CommonQuality::Low), Some(CommonAspectRatio::TallNineBySixteen), 1), 2);
}
#[test]
fn low_square_four() {
assert_eq!(estimate(Some(CommonQuality::Low), None, 4), 4);
}
#[test]
fn low_wide_four() {
assert_eq!(estimate(Some(CommonQuality::Low), Some(CommonAspectRatio::WideSixteenByNine), 4), 8);
}
// ── Medium quality (4¢ square, 5¢ wide, 6¢ tall) ─────────────────────
#[test]
fn medium_square_one() {
assert_eq!(estimate(Some(CommonQuality::Medium), Some(CommonAspectRatio::Square), 1), 4);
}
#[test]
fn medium_unset_one() {
assert_eq!(estimate(Some(CommonQuality::Medium), None, 1), 4);
}
#[test]
fn medium_wide_one() {
assert_eq!(estimate(Some(CommonQuality::Medium), Some(CommonAspectRatio::WideSixteenByNine), 1), 5);
}
#[test]
fn medium_tall_one() {
assert_eq!(estimate(Some(CommonQuality::Medium), Some(CommonAspectRatio::TallNineBySixteen), 1), 6);
}
#[test]
fn medium_square_four() {
assert_eq!(estimate(Some(CommonQuality::Medium), Some(CommonAspectRatio::Square), 4), 16);
}
#[test]
fn medium_wide_four() {
assert_eq!(estimate(Some(CommonQuality::Medium), Some(CommonAspectRatio::WideSixteenByNine), 4), 20);
}
#[test]
fn medium_tall_four() {
assert_eq!(estimate(Some(CommonQuality::Medium), Some(CommonAspectRatio::TallNineBySixteen), 4), 24);
}
// ── High quality (14¢ square, 20¢ wide/tall) ─────────────────────────
#[test]
fn high_square_one() {
assert_eq!(estimate(Some(CommonQuality::High), Some(CommonAspectRatio::Square), 1), 14);
}
#[test]
fn high_unset_one() {
assert_eq!(estimate(Some(CommonQuality::High), None, 1), 14);
}
#[test]
fn high_wide_one() {
assert_eq!(estimate(Some(CommonQuality::High), Some(CommonAspectRatio::WideSixteenByNine), 1), 20);
}
#[test]
fn high_tall_one() {
assert_eq!(estimate(Some(CommonQuality::High), Some(CommonAspectRatio::TallNineBySixteen), 1), 20);
}
#[test]
fn high_square_four() {
assert_eq!(estimate(Some(CommonQuality::High), Some(CommonAspectRatio::Square), 4), 56);
}
#[test]
fn high_wide_four() {
assert_eq!(estimate(Some(CommonQuality::High), Some(CommonAspectRatio::WideSixteenByNine), 4), 80);
}
#[test]
fn high_tall_four() {
assert_eq!(estimate(Some(CommonQuality::High), Some(CommonAspectRatio::TallNineBySixteen), 4), 80);
}
}
@@ -43,6 +43,7 @@ mod tests {
image_inputs: None,
resolution: None,
aspect_ratio: None,
quality: None,
image_batch_count: Some(image_batch_count),
request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::ErrorOut,
generation_mode_mismatch_strategy: None,
@@ -8,12 +8,17 @@ use crate::generate::generate_image::plan::artcraft::plan_generate_image_artcraf
pub(crate) fn estimate_image_cost_artcraft_nano_banana_2(
plan: &PlanArtcraftNanaBanana2<'_>,
) -> ImageGenerationCostEstimate {
// Pricing: $0.08/image at 0.5K, $0.15/image at 1K or 2K, $0.30/image at 4K.
// (Stored as USD cents.)
// Pricing (USD cents per image), scaled off the 1K base cost of $0.08:
// 0.5K → $0.06 (0.75×)
// 1K → $0.08 (1×, default)
// 2K → $0.12 (1.5×)
// 4K → $0.16 (2×)
let cost_per_image: u64 = match plan.resolution {
Some(NanaBanana2MultiFunctionImageGenImageResolution::HalfK) => 8,
Some(NanaBanana2MultiFunctionImageGenImageResolution::FourK) => 30,
_ => 15, // OneK, TwoK, or None (default 1K)
Some(NanaBanana2MultiFunctionImageGenImageResolution::HalfK) => 6,
Some(NanaBanana2MultiFunctionImageGenImageResolution::OneK) => 8,
Some(NanaBanana2MultiFunctionImageGenImageResolution::TwoK) => 12,
Some(NanaBanana2MultiFunctionImageGenImageResolution::FourK) => 16,
None => 8, // default 1K
};
let num_images: u64 = match plan.num_images {
@@ -51,6 +56,7 @@ mod tests {
image_inputs: None,
resolution,
aspect_ratio: None,
quality: None,
image_batch_count: Some(image_batch_count),
request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::ErrorOut,
generation_mode_mismatch_strategy: None,
@@ -66,17 +72,125 @@ mod tests {
.expect("cost_in_usd_cents should be present")
}
#[test]
fn test_estimate_cost_usd_cents() {
// 1K/2K: $0.15/image = 15 cents
assert_eq!(estimate_usd_cents(None, 1), 15);
assert_eq!(estimate_usd_cents(Some(CommonResolution::OneK), 1), 15);
assert_eq!(estimate_usd_cents(Some(CommonResolution::TwoK), 1), 15);
assert_eq!(estimate_usd_cents(Some(CommonResolution::OneK), 2), 30);
assert_eq!(estimate_usd_cents(Some(CommonResolution::OneK), 4), 60);
// ── Default (None → 1K → 8¢/image) ─────────────────────────────────────────
// 4K: $0.30/image = 30 cents
assert_eq!(estimate_usd_cents(Some(CommonResolution::FourK), 1), 30);
assert_eq!(estimate_usd_cents(Some(CommonResolution::FourK), 2), 60);
#[test]
fn default_resolution_one_image_costs_8_cents() {
assert_eq!(estimate_usd_cents(None, 1), 8);
}
#[test]
fn default_resolution_two_images_costs_16_cents() {
assert_eq!(estimate_usd_cents(None, 2), 16);
}
#[test]
fn default_resolution_three_images_costs_24_cents() {
assert_eq!(estimate_usd_cents(None, 3), 24);
}
#[test]
fn default_resolution_four_images_costs_32_cents() {
assert_eq!(estimate_usd_cents(None, 4), 32);
}
// ── 0.5K → 6¢/image (0.75× base) ───────────────────────────────────────────
#[test]
fn half_k_one_image_costs_6_cents() {
assert_eq!(estimate_usd_cents(Some(CommonResolution::HalfK), 1), 6);
}
#[test]
fn half_k_two_images_costs_12_cents() {
assert_eq!(estimate_usd_cents(Some(CommonResolution::HalfK), 2), 12);
}
#[test]
fn half_k_three_images_costs_18_cents() {
assert_eq!(estimate_usd_cents(Some(CommonResolution::HalfK), 3), 18);
}
#[test]
fn half_k_four_images_costs_24_cents() {
assert_eq!(estimate_usd_cents(Some(CommonResolution::HalfK), 4), 24);
}
// ── 1K → 8¢/image (base) ───────────────────────────────────────────────────
#[test]
fn one_k_one_image_costs_8_cents() {
assert_eq!(estimate_usd_cents(Some(CommonResolution::OneK), 1), 8);
}
#[test]
fn one_k_two_images_costs_16_cents() {
assert_eq!(estimate_usd_cents(Some(CommonResolution::OneK), 2), 16);
}
#[test]
fn one_k_three_images_costs_24_cents() {
assert_eq!(estimate_usd_cents(Some(CommonResolution::OneK), 3), 24);
}
#[test]
fn one_k_four_images_costs_32_cents() {
assert_eq!(estimate_usd_cents(Some(CommonResolution::OneK), 4), 32);
}
// ── 2K → 12¢/image (1.5× base) ─────────────────────────────────────────────
#[test]
fn two_k_one_image_costs_12_cents() {
assert_eq!(estimate_usd_cents(Some(CommonResolution::TwoK), 1), 12);
}
#[test]
fn two_k_two_images_costs_24_cents() {
assert_eq!(estimate_usd_cents(Some(CommonResolution::TwoK), 2), 24);
}
#[test]
fn two_k_three_images_costs_36_cents() {
assert_eq!(estimate_usd_cents(Some(CommonResolution::TwoK), 3), 36);
}
#[test]
fn two_k_four_images_costs_48_cents() {
assert_eq!(estimate_usd_cents(Some(CommonResolution::TwoK), 4), 48);
}
// ── 4K → 16¢/image (2× base) ───────────────────────────────────────────────
#[test]
fn four_k_one_image_costs_16_cents() {
assert_eq!(estimate_usd_cents(Some(CommonResolution::FourK), 1), 16);
}
#[test]
fn four_k_two_images_costs_32_cents() {
assert_eq!(estimate_usd_cents(Some(CommonResolution::FourK), 2), 32);
}
#[test]
fn four_k_three_images_costs_48_cents() {
assert_eq!(estimate_usd_cents(Some(CommonResolution::FourK), 3), 48);
}
#[test]
fn four_k_four_images_costs_64_cents() {
assert_eq!(estimate_usd_cents(Some(CommonResolution::FourK), 4), 64);
}
// ── 3K (unsupported) falls back to 2K pricing ──────────────────────────────
#[test]
fn three_k_one_image_falls_back_to_two_k_pricing() {
assert_eq!(estimate_usd_cents(Some(CommonResolution::ThreeK), 1), 12);
}
#[test]
fn three_k_four_images_falls_back_to_two_k_pricing() {
assert_eq!(estimate_usd_cents(Some(CommonResolution::ThreeK), 4), 48);
}
}
@@ -50,6 +50,7 @@ mod tests {
image_inputs: None,
resolution,
aspect_ratio: None,
quality: None,
image_batch_count: Some(image_batch_count),
request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::ErrorOut,
generation_mode_mismatch_strategy: None,
@@ -46,6 +46,7 @@ mod tests {
image_inputs: Some(ImageListRef::MediaFileTokens(&tokens)),
resolution: None,
aspect_ratio: None,
quality: None,
image_batch_count: Some(image_batch_count),
request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::ErrorOut,
generation_mode_mismatch_strategy: None,
@@ -43,6 +43,7 @@ mod tests {
image_inputs: None,
resolution: None,
aspect_ratio: None,
quality: None,
image_batch_count: Some(image_batch_count),
request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::ErrorOut,
generation_mode_mismatch_strategy: None,
@@ -43,6 +43,7 @@ mod tests {
image_inputs: None,
resolution: None,
aspect_ratio: None,
quality: None,
image_batch_count: Some(image_batch_count),
request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::ErrorOut,
generation_mode_mismatch_strategy: None,
@@ -43,6 +43,7 @@ mod tests {
image_inputs: None,
resolution: None,
aspect_ratio: None,
quality: None,
image_batch_count: Some(image_batch_count),
request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::ErrorOut,
generation_mode_mismatch_strategy: None,
@@ -2,6 +2,7 @@ pub mod estimate_image_cost_artcraft_flux_1_dev;
pub mod estimate_image_cost_artcraft_flux_1_schnell;
pub mod estimate_image_cost_artcraft_flux_pro_1p1;
pub mod estimate_image_cost_artcraft_flux_pro_1p1_ultra;
pub mod estimate_image_cost_artcraft_gpt_image_1;
pub mod estimate_image_cost_artcraft_gpt_image_1p5;
pub mod estimate_image_cost_artcraft_nano_banana;
pub mod estimate_image_cost_artcraft_nano_banana_2;
@@ -0,0 +1,245 @@
use crate::generate::generate_image::image_generation_cost_estimate::ImageGenerationCostEstimate;
use crate::generate::generate_image::plan::fal::plan_generate_image_fal_gpt_image_1::{
FalGptImage1ImageSize, FalGptImage1Quality, PlanFalGptImage1,
};
pub(crate) fn estimate_image_cost_fal_gpt_image_1(
plan: &PlanFalGptImage1<'_>,
) -> ImageGenerationCostEstimate {
// Per fal docs (fal-ai/gpt-image-1/{text-to-image,edit-image}):
//
// Output image cost (per output image):
// Low: $0.011 (1024×1024) / $0.016 (other) → 2¢ / 2¢
// Medium: $0.042 (1024×1024) / $0.063 (other) → 5¢ / 7¢
// High: $0.167 (1024×1024) / $0.250 (other) → 17¢ / 25¢
//
// Input image tokens (edit mode, per input image, high-fidelity assumed):
// 3,050 tokens × $0.005/1K tokens ≈ $0.01525 → 2¢ per input image
//
// Input text tokens: free (we don't charge for these).
//
let is_square = matches!(plan.image_size, None | Some(FalGptImage1ImageSize::Square));
let output_cost_per_image: u64 = match (plan.quality, is_square) {
(FalGptImage1Quality::Low, true) => 2,
(FalGptImage1Quality::Low, false) => 2,
(FalGptImage1Quality::Medium, true) => 5,
(FalGptImage1Quality::Medium, false) => 7,
(FalGptImage1Quality::High, true) => 17,
(FalGptImage1Quality::High, false) => 25,
};
let num_output_images = plan.num_images.as_u64();
let output_cost = output_cost_per_image * num_output_images;
// Input image token cost: 2¢ per input image (high-fidelity estimate).
let input_image_cost: u64 = if plan.image_urls.is_empty() {
0
} else {
2 * plan.image_urls.len() as u64
};
let cost_in_usd_cents = output_cost + input_image_cost;
ImageGenerationCostEstimate {
cost_in_credits: Some(cost_in_usd_cents),
cost_in_usd_cents: Some(cost_in_usd_cents),
is_free: false,
is_unlimited: false,
is_rate_limited: false,
has_watermark: false,
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::generate::generate_image::plan::fal::plan_generate_image_fal_gpt_image_1::FalGptImage1NumImages;
fn text_plan(
quality: FalGptImage1Quality,
image_size: Option<FalGptImage1ImageSize>,
num_images: FalGptImage1NumImages,
) -> PlanFalGptImage1<'static> {
PlanFalGptImage1 {
prompt: Some("p"),
image_urls: vec![],
image_size,
quality,
num_images,
}
}
fn edit_plan(
quality: FalGptImage1Quality,
image_size: Option<FalGptImage1ImageSize>,
num_images: FalGptImage1NumImages,
num_input_images: usize,
) -> PlanFalGptImage1<'static> {
let image_urls = (0..num_input_images)
.map(|i| format!("https://fake.example.com/img{}.png", i))
.collect();
PlanFalGptImage1 {
prompt: Some("p"),
image_urls,
image_size,
quality,
num_images,
}
}
fn cents_text(
quality: FalGptImage1Quality,
image_size: Option<FalGptImage1ImageSize>,
num_images: FalGptImage1NumImages,
) -> u64 {
estimate_image_cost_fal_gpt_image_1(&text_plan(quality, image_size, num_images))
.cost_in_usd_cents
.unwrap()
}
fn cents_edit(
quality: FalGptImage1Quality,
image_size: Option<FalGptImage1ImageSize>,
num_images: FalGptImage1NumImages,
num_input_images: usize,
) -> u64 {
estimate_image_cost_fal_gpt_image_1(&edit_plan(quality, image_size, num_images, num_input_images))
.cost_in_usd_cents
.unwrap()
}
// ── Low quality text-to-image (2¢/image, both sizes) ──────────────────
#[test]
fn low_square_one_image() {
assert_eq!(cents_text(FalGptImage1Quality::Low, Some(FalGptImage1ImageSize::Square), FalGptImage1NumImages::One), 2);
}
#[test]
fn low_horizontal_one_image() {
assert_eq!(cents_text(FalGptImage1Quality::Low, Some(FalGptImage1ImageSize::Horizontal), FalGptImage1NumImages::One), 2);
}
#[test]
fn low_vertical_one_image() {
assert_eq!(cents_text(FalGptImage1Quality::Low, Some(FalGptImage1ImageSize::Vertical), FalGptImage1NumImages::One), 2);
}
#[test]
fn low_unset_size_one_image() {
assert_eq!(cents_text(FalGptImage1Quality::Low, None, FalGptImage1NumImages::One), 2);
}
#[test]
fn low_four_images() {
assert_eq!(cents_text(FalGptImage1Quality::Low, None, FalGptImage1NumImages::Four), 8);
}
// ── Medium quality text-to-image (5¢ square, 7¢ wide/tall) ────────────
#[test]
fn medium_square_one_image() {
assert_eq!(cents_text(FalGptImage1Quality::Medium, Some(FalGptImage1ImageSize::Square), FalGptImage1NumImages::One), 5);
}
#[test]
fn medium_horizontal_one_image() {
assert_eq!(cents_text(FalGptImage1Quality::Medium, Some(FalGptImage1ImageSize::Horizontal), FalGptImage1NumImages::One), 7);
}
#[test]
fn medium_vertical_one_image() {
assert_eq!(cents_text(FalGptImage1Quality::Medium, Some(FalGptImage1ImageSize::Vertical), FalGptImage1NumImages::One), 7);
}
#[test]
fn medium_square_four_images() {
assert_eq!(cents_text(FalGptImage1Quality::Medium, Some(FalGptImage1ImageSize::Square), FalGptImage1NumImages::Four), 20);
}
#[test]
fn medium_horizontal_four_images() {
assert_eq!(cents_text(FalGptImage1Quality::Medium, Some(FalGptImage1ImageSize::Horizontal), FalGptImage1NumImages::Four), 28);
}
// ── High quality text-to-image (17¢ square, 25¢ wide/tall) ────────────
#[test]
fn high_square_one_image() {
assert_eq!(cents_text(FalGptImage1Quality::High, Some(FalGptImage1ImageSize::Square), FalGptImage1NumImages::One), 17);
}
#[test]
fn high_horizontal_one_image() {
assert_eq!(cents_text(FalGptImage1Quality::High, Some(FalGptImage1ImageSize::Horizontal), FalGptImage1NumImages::One), 25);
}
#[test]
fn high_vertical_one_image() {
assert_eq!(cents_text(FalGptImage1Quality::High, Some(FalGptImage1ImageSize::Vertical), FalGptImage1NumImages::One), 25);
}
#[test]
fn high_square_two_images() {
assert_eq!(cents_text(FalGptImage1Quality::High, Some(FalGptImage1ImageSize::Square), FalGptImage1NumImages::Two), 34);
}
#[test]
fn high_horizontal_four_images() {
assert_eq!(cents_text(FalGptImage1Quality::High, Some(FalGptImage1ImageSize::Horizontal), FalGptImage1NumImages::Four), 100);
}
// ── Edit mode adds 2¢ per input image ─────────────────────────────────
#[test]
fn edit_high_square_one_output_one_input() {
// 17¢ output + 2¢ input = 19¢
assert_eq!(cents_edit(FalGptImage1Quality::High, Some(FalGptImage1ImageSize::Square), FalGptImage1NumImages::One, 1), 19);
}
#[test]
fn edit_high_square_one_output_three_inputs() {
// 17¢ output + 3×2¢ input = 23¢
assert_eq!(cents_edit(FalGptImage1Quality::High, Some(FalGptImage1ImageSize::Square), FalGptImage1NumImages::One, 3), 23);
}
#[test]
fn edit_medium_wide_two_outputs_five_inputs() {
// 2×7¢ output + 5×2¢ input = 24¢
assert_eq!(cents_edit(FalGptImage1Quality::Medium, Some(FalGptImage1ImageSize::Horizontal), FalGptImage1NumImages::Two, 5), 24);
}
#[test]
fn edit_low_square_one_output_two_inputs() {
// 2¢ output + 2×2¢ input = 6¢
assert_eq!(cents_edit(FalGptImage1Quality::Low, Some(FalGptImage1ImageSize::Square), FalGptImage1NumImages::One, 2), 6);
}
#[test]
fn edit_high_vertical_four_outputs_one_input() {
// 4×25¢ output + 1×2¢ input = 102¢
assert_eq!(cents_edit(FalGptImage1Quality::High, Some(FalGptImage1ImageSize::Vertical), FalGptImage1NumImages::Four, 1), 102);
}
// ── Text mode has no input image cost ─────────────────────────────────
#[test]
fn text_mode_no_input_cost() {
assert_eq!(cents_text(FalGptImage1Quality::High, Some(FalGptImage1ImageSize::Square), FalGptImage1NumImages::One), 17);
}
// ── Metadata flags ────────────────────────────────────────────────────
#[test]
fn metadata_flags_are_default() {
let estimate = estimate_image_cost_fal_gpt_image_1(&text_plan(
FalGptImage1Quality::Medium,
None,
FalGptImage1NumImages::One,
));
assert!(!estimate.is_free);
assert!(!estimate.is_unlimited);
assert!(!estimate.is_rate_limited);
assert!(!estimate.has_watermark);
}
}
@@ -6,19 +6,28 @@ use crate::generate::generate_image::plan::fal::plan_generate_image_fal_gpt_imag
pub(crate) fn estimate_image_cost_fal_gpt_image_1p5(
plan: &PlanFalGptImage1p5<'_>,
) -> ImageGenerationCostEstimate {
// Per fal docs:
// Low: $0.009 (1024x1024) / $0.013 (other)
// Medium: $0.034 (1024x1024) / $0.050–0.051 (other)
// High: $0.133 (1024x1024) / $0.199–0.200 (other)
// Round up to whole cents.
let is_square = matches!(plan.image_size, None | Some(FalGptImage1p5ImageSize::Square));
let cost_per_image: u64 = match (plan.quality, is_square) {
(FalGptImage1p5Quality::Low, true) => 1,
(FalGptImage1p5Quality::Low, false) => 2,
(FalGptImage1p5Quality::Medium, true) => 4,
(FalGptImage1p5Quality::Medium, false) => 6,
(FalGptImage1p5Quality::High, true) => 14,
(FalGptImage1p5Quality::High, false) => 20,
// Per fal docs (fal-ai/gpt-image-1.5 and fal-ai/gpt-image-1.5/edit):
//
// Output image cost (per output image), rounded up to whole cents:
// Low: $0.009 (1024×1024) / $0.013 (other) → 1¢ / 2¢
// Medium: $0.034 (1024×1024) / $0.050 (1536×1024) / $0.051 (1024×1536) → 4¢ / 5¢ / 6¢
// High: $0.133 (1024×1024) / $0.199 (1536×1024) / $0.200 (1024×1536) → 14¢ / 20¢ / 20¢
//
use FalGptImage1p5ImageSize as S;
let cost_per_image: u64 = match (plan.quality, plan.image_size) {
// Low quality
(FalGptImage1p5Quality::Low, None | Some(S::Square)) => 1,
(FalGptImage1p5Quality::Low, Some(S::Wide) | Some(S::Tall)) => 2,
// Medium quality
(FalGptImage1p5Quality::Medium, None | Some(S::Square)) => 4,
(FalGptImage1p5Quality::Medium, Some(S::Wide)) => 5,
(FalGptImage1p5Quality::Medium, Some(S::Tall)) => 6,
// High quality
(FalGptImage1p5Quality::High, None | Some(S::Square)) => 14,
(FalGptImage1p5Quality::High, Some(S::Wide) | Some(S::Tall)) => 20,
};
let cost_in_usd_cents = cost_per_image * plan.num_images.as_u64();
@@ -31,3 +40,141 @@ pub(crate) fn estimate_image_cost_fal_gpt_image_1p5(
has_watermark: false,
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::generate::generate_image::plan::fal::plan_generate_image_fal_gpt_image_1p5::FalGptImage1p5NumImages;
fn plan(
quality: FalGptImage1p5Quality,
image_size: Option<FalGptImage1p5ImageSize>,
num_images: FalGptImage1p5NumImages,
) -> PlanFalGptImage1p5<'static> {
PlanFalGptImage1p5 {
prompt: Some("p"),
image_urls: vec![],
image_size,
quality,
num_images,
}
}
fn cents(
quality: FalGptImage1p5Quality,
image_size: Option<FalGptImage1p5ImageSize>,
num_images: FalGptImage1p5NumImages,
) -> u64 {
estimate_image_cost_fal_gpt_image_1p5(&plan(quality, image_size, num_images))
.cost_in_usd_cents.unwrap()
}
// ── Low quality (1¢ square, 2¢ wide/tall) ─────────────────────────────
#[test]
fn low_square() {
assert_eq!(cents(FalGptImage1p5Quality::Low, Some(FalGptImage1p5ImageSize::Square), FalGptImage1p5NumImages::One), 1);
}
#[test]
fn low_unset() {
assert_eq!(cents(FalGptImage1p5Quality::Low, None, FalGptImage1p5NumImages::One), 1);
}
#[test]
fn low_wide() {
assert_eq!(cents(FalGptImage1p5Quality::Low, Some(FalGptImage1p5ImageSize::Wide), FalGptImage1p5NumImages::One), 2);
}
#[test]
fn low_tall() {
assert_eq!(cents(FalGptImage1p5Quality::Low, Some(FalGptImage1p5ImageSize::Tall), FalGptImage1p5NumImages::One), 2);
}
#[test]
fn low_four() {
assert_eq!(cents(FalGptImage1p5Quality::Low, None, FalGptImage1p5NumImages::Four), 4);
}
// ── Medium quality (4¢ square, 5¢ wide, 6¢ tall) ─────────────────────
#[test]
fn medium_square() {
assert_eq!(cents(FalGptImage1p5Quality::Medium, Some(FalGptImage1p5ImageSize::Square), FalGptImage1p5NumImages::One), 4);
}
#[test]
fn medium_unset() {
assert_eq!(cents(FalGptImage1p5Quality::Medium, None, FalGptImage1p5NumImages::One), 4);
}
#[test]
fn medium_wide() {
assert_eq!(cents(FalGptImage1p5Quality::Medium, Some(FalGptImage1p5ImageSize::Wide), FalGptImage1p5NumImages::One), 5);
}
#[test]
fn medium_tall() {
assert_eq!(cents(FalGptImage1p5Quality::Medium, Some(FalGptImage1p5ImageSize::Tall), FalGptImage1p5NumImages::One), 6);
}
#[test]
fn medium_square_four() {
assert_eq!(cents(FalGptImage1p5Quality::Medium, Some(FalGptImage1p5ImageSize::Square), FalGptImage1p5NumImages::Four), 16);
}
#[test]
fn medium_wide_four() {
assert_eq!(cents(FalGptImage1p5Quality::Medium, Some(FalGptImage1p5ImageSize::Wide), FalGptImage1p5NumImages::Four), 20);
}
#[test]
fn medium_tall_four() {
assert_eq!(cents(FalGptImage1p5Quality::Medium, Some(FalGptImage1p5ImageSize::Tall), FalGptImage1p5NumImages::Four), 24);
}
// ── High quality (14¢ square, 20¢ wide/tall) ─────────────────────────
#[test]
fn high_square() {
assert_eq!(cents(FalGptImage1p5Quality::High, Some(FalGptImage1p5ImageSize::Square), FalGptImage1p5NumImages::One), 14);
}
#[test]
fn high_unset() {
assert_eq!(cents(FalGptImage1p5Quality::High, None, FalGptImage1p5NumImages::One), 14);
}
#[test]
fn high_wide() {
assert_eq!(cents(FalGptImage1p5Quality::High, Some(FalGptImage1p5ImageSize::Wide), FalGptImage1p5NumImages::One), 20);
}
#[test]
fn high_tall() {
assert_eq!(cents(FalGptImage1p5Quality::High, Some(FalGptImage1p5ImageSize::Tall), FalGptImage1p5NumImages::One), 20);
}
#[test]
fn high_square_four() {
assert_eq!(cents(FalGptImage1p5Quality::High, Some(FalGptImage1p5ImageSize::Square), FalGptImage1p5NumImages::Four), 56);
}
#[test]
fn high_wide_four() {
assert_eq!(cents(FalGptImage1p5Quality::High, Some(FalGptImage1p5ImageSize::Wide), FalGptImage1p5NumImages::Four), 80);
}
// ── Metadata flags ────────────────────────────────────────────────────
#[test]
fn metadata_flags_are_default() {
let estimate = estimate_image_cost_fal_gpt_image_1p5(&plan(
FalGptImage1p5Quality::Medium, None, FalGptImage1p5NumImages::One,
));
assert!(!estimate.is_free);
assert!(!estimate.is_unlimited);
assert!(!estimate.is_rate_limited);
assert!(!estimate.has_watermark);
}
}
@@ -48,6 +48,7 @@ mod tests {
image_inputs: None,
resolution,
aspect_ratio: None,
quality: None,
image_batch_count: Some(image_batch_count),
request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::ErrorOut,
generation_mode_mismatch_strategy: None,
@@ -2,6 +2,7 @@ pub(crate) mod estimate_image_cost_fal_flux_1_dev;
pub(crate) mod estimate_image_cost_fal_flux_1_schnell;
pub(crate) mod estimate_image_cost_fal_flux_pro_1p1;
pub(crate) mod estimate_image_cost_fal_flux_pro_1p1_ultra;
pub(crate) mod estimate_image_cost_fal_gpt_image_1;
pub(crate) mod estimate_image_cost_fal_gpt_image_1p5;
pub(crate) mod estimate_image_cost_fal_seedream_4;
pub(crate) mod estimate_image_cost_fal_seedream_4p5;
@@ -47,6 +47,7 @@ mod tests {
let client = get_artcraft_client();
let request = GenerateImageRequest {
aspect_ratio: Some(CommonAspectRatio::WideSixteenByNine),
quality: None,
image_batch_count: Some(1),
prompt: Some("a cat walking through a cyberpunk city at night"),
..base_flux_1_dev_image_request()
@@ -69,6 +70,7 @@ mod tests {
let client = get_artcraft_client();
let request = GenerateImageRequest {
aspect_ratio: Some(CommonAspectRatio::Square),
quality: None,
image_batch_count: Some(4),
prompt: Some("a dog surfing a wave, cinematic"),
..base_flux_1_dev_image_request()
@@ -94,6 +96,7 @@ mod tests {
println!("--- text-to-image aspect ratio: {:?} ---", ar);
let request = GenerateImageRequest {
aspect_ratio: Some(ar),
quality: None,
image_batch_count: Some(1),
prompt: Some("a bear walking through an abandoned desert city at night"),
request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::PayMoreUpgrade,
@@ -37,7 +37,6 @@ mod tests {
use crate::api::common_aspect_ratio::CommonAspectRatio;
use crate::client::request_mismatch_mitigation_strategy::RequestMismatchMitigationStrategy;
use crate::generate::generate_image::generate_image_request::GenerateImageRequest;
use crate::generate::generate_image::image_generation_plan::ImageGenerationPlan;
use crate::test_helpers::{base_flux_1_schnell_image_request, get_artcraft_client};
use strum::IntoEnumIterator;
@@ -47,6 +46,7 @@ mod tests {
let client = get_artcraft_client();
let request = GenerateImageRequest {
aspect_ratio: Some(CommonAspectRatio::WideSixteenByNine),
quality: None,
image_batch_count: Some(1),
prompt: Some("a man and woman walking through a cyberpunk city at night"),
..base_flux_1_schnell_image_request()
@@ -69,6 +69,7 @@ mod tests {
let client = get_artcraft_client();
let request = GenerateImageRequest {
aspect_ratio: Some(CommonAspectRatio::Square),
quality: None,
image_batch_count: Some(4),
prompt: Some("a dog surfing a wave, cinematic"),
..base_flux_1_schnell_image_request()
@@ -94,6 +95,7 @@ mod tests {
println!("--- text-to-image aspect ratio: {:?} ---", ar);
let request = GenerateImageRequest {
aspect_ratio: Some(ar),
quality: None,
image_batch_count: Some(1),
prompt: Some("a man and woman walking through a futuristic city at evening"),
request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::PayMoreUpgrade,
@@ -51,6 +51,7 @@ mod tests {
let tokens = vec![MediaFileToken::new_from_str("m_r45apt2swza6wp8j2z10237t92kkr8")];
let request = GenerateImageRequest {
aspect_ratio: Some(CommonAspectRatio::SquareHd),
quality: None,
image_batch_count: Some(1),
image_inputs: Some(ImageListRef::MediaFileTokens(&tokens)),
horizontal_angle: Some(30.0),
@@ -47,6 +47,7 @@ mod tests {
let client = get_artcraft_client();
let request = GenerateImageRequest {
aspect_ratio: Some(CommonAspectRatio::WideSixteenByNine),
quality: None,
image_batch_count: Some(1),
prompt: Some("a cat walking through a cyberpunk city at night"),
..base_flux_pro_1p1_image_request()
@@ -69,6 +70,7 @@ mod tests {
let client = get_artcraft_client();
let request = GenerateImageRequest {
aspect_ratio: Some(CommonAspectRatio::Square),
quality: None,
image_batch_count: Some(4),
prompt: Some("a dog surfing a wave, cinematic"),
..base_flux_pro_1p1_image_request()
@@ -94,6 +96,7 @@ mod tests {
println!("--- text-to-image aspect ratio: {:?} ---", ar);
let request = GenerateImageRequest {
aspect_ratio: Some(ar),
quality: None,
image_batch_count: Some(1),
prompt: Some("a bear walking through an abandoned desert city at night"),
request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::PayMoreUpgrade,
@@ -47,6 +47,7 @@ mod tests {
let client = get_artcraft_client();
let request = GenerateImageRequest {
aspect_ratio: Some(CommonAspectRatio::WideSixteenByNine),
quality: None,
image_batch_count: Some(1),
prompt: Some("a cat walking through a cyberpunk city at night"),
..base_flux_pro_1p1_ultra_image_request()
@@ -69,6 +70,7 @@ mod tests {
let client = get_artcraft_client();
let request = GenerateImageRequest {
aspect_ratio: Some(CommonAspectRatio::Square),
quality: None,
image_batch_count: Some(4),
prompt: Some("a dog surfing a wave, cinematic"),
..base_flux_pro_1p1_ultra_image_request()
@@ -94,6 +96,7 @@ mod tests {
println!("--- text-to-image aspect ratio: {:?} ---", ar);
let request = GenerateImageRequest {
aspect_ratio: Some(ar),
quality: None,
image_batch_count: Some(1),
prompt: Some("a t-rex walking through an abandoned desert city at night"),
request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::PayMoreUpgrade,
@@ -0,0 +1,147 @@
use crate::client::router_artcraft_client::RouterArtcraftClient;
use crate::errors::artcraft_router_error::ArtcraftRouterError;
use crate::errors::provider_error::ProviderError;
use crate::generate::generate_image::generate_image_response::{
ArtcraftImageResponsePayload, GenerateImageResponse,
};
use crate::generate::generate_image::plan::artcraft::plan_generate_image_artcraft_gpt_image_1::PlanArtcraftGptImage1;
use artcraft_api_defs::generate::image::edit::gpt_image_1_edit_image::GptImage1EditImageRequest;
use artcraft_api_defs::generate::image::text::generate_gpt_image_1_text_to_image::GenerateGptImage1TextToImageRequest;
use artcraft_client::endpoints::generate::image::edit::gpt_image_1_edit_image::gpt_image_1_edit_image;
use artcraft_client::endpoints::generate::image::text::generate_gpt_image_1_text_to_image::generate_gpt_image_1_text_to_image;
/// GPT Image 1 has two distinct legacy storyteller-web endpoints (one for
/// text-to-image, one for image-edit), so we dispatch on whether image refs
/// are present rather than calling a single multi-function endpoint.
pub async fn execute_artcraft_gpt_image_1(
plan: &PlanArtcraftGptImage1<'_>,
artcraft_client: &RouterArtcraftClient,
) -> Result<GenerateImageResponse, ArtcraftRouterError> {
let inference_job_token = match plan.image_inputs {
Some(image_inputs) => {
let request = GptImage1EditImageRequest {
uuid_idempotency_token: plan.idempotency_token.clone(),
prompt: plan.prompt.map(|p| p.to_string()),
image_media_tokens: Some(image_inputs.to_owned()),
image_size: plan.image_size.map(|s| s.to_edit()),
num_images: Some(plan.num_images.to_edit()),
image_quality: Some(plan.quality.to_edit()),
};
let response = gpt_image_1_edit_image(
&artcraft_client.api_host,
Some(&artcraft_client.credentials),
request,
)
.await
.map_err(|err| ArtcraftRouterError::Provider(ProviderError::Storyteller(err)))?;
response.inference_job_token
}
None => {
let request = GenerateGptImage1TextToImageRequest {
uuid_idempotency_token: plan.idempotency_token.clone(),
prompt: plan.prompt.map(|p| p.to_string()),
image_size: plan.image_size.map(|s| s.to_t2i()),
num_images: Some(plan.num_images.to_t2i()),
image_quality: Some(plan.quality.to_t2i()),
};
let response = generate_gpt_image_1_text_to_image(
&artcraft_client.api_host,
Some(&artcraft_client.credentials),
request,
)
.await
.map_err(|err| ArtcraftRouterError::Provider(ProviderError::Storyteller(err)))?;
response.inference_job_token
}
};
Ok(GenerateImageResponse::Artcraft(ArtcraftImageResponsePayload {
inference_job_token,
}))
}
#[cfg(test)]
mod tests {
use crate::api::common_aspect_ratio::CommonAspectRatio;
use crate::api::image_list_ref::ImageListRef;
use crate::generate::generate_image::generate_image_request::GenerateImageRequest;
use crate::generate::generate_image::image_generation_plan::ImageGenerationPlan;
use crate::test_helpers::{base_gpt_image_1_image_request, get_artcraft_client};
use tokens::tokens::media_files::MediaFileToken;
// Build-only smoke test that exercises the full text-to-image plan path
// (no network I/O — we never call generate_image).
#[test]
fn build_text_to_image_plan_smoke() {
let request = GenerateImageRequest {
aspect_ratio: Some(CommonAspectRatio::Square),
image_batch_count: Some(1),
..base_gpt_image_1_image_request()
};
let plan = request.build().expect("plan should build");
assert!(matches!(plan, ImageGenerationPlan::ArtcraftGptImage1(_)));
}
// Build-only smoke test for the edit path. We can't actually fire the
// request without an authenticated client, but we want to make sure the
// dispatcher recognizes media-token inputs as edit-mode.
#[test]
fn build_edit_image_plan_smoke() {
let tokens = vec![MediaFileToken::new_from_str("mf_test00000000000000000000000000")];
let request = GenerateImageRequest {
image_inputs: Some(ImageListRef::MediaFileTokens(&tokens)),
..base_gpt_image_1_image_request()
};
let plan = request.build().expect("plan should build");
let ImageGenerationPlan::ArtcraftGptImage1(plan) = plan else {
panic!("expected ArtcraftGptImage1")
};
assert!(plan.image_inputs.is_some(), "edit mode must carry image_inputs");
}
#[tokio::test]
#[ignore] // manually run — fires a real API request and incurs cost
async fn test_text_to_image_gpt_image_1() {
let client = get_artcraft_client();
let request = GenerateImageRequest {
aspect_ratio: Some(CommonAspectRatio::WideSixteenByNine),
image_batch_count: Some(1),
prompt: Some("a horse walking through a cyberpunk city at night"),
..base_gpt_image_1_image_request()
};
let plan = request.build().unwrap();
let result = plan.generate_image(&client).await;
println!("Result: {:?}", result);
let response = result.expect("generate_image request failed");
let payload = response.get_artcraft_payload().expect("expected Artcraft payload");
println!("Job token: {:?}", payload.inference_job_token);
}
#[tokio::test]
#[ignore] // manually run — fires a real API request and incurs cost
async fn test_edit_image_gpt_image_1() {
let client = get_artcraft_client();
let image_tokens = vec![
MediaFileToken::new_from_str("m_r45apt2swza6wp8j2z10237t92kkr8"),
];
let request = GenerateImageRequest {
prompt: Some("Change the background to a desert with cacti"),
image_inputs: Some(ImageListRef::MediaFileTokens(&image_tokens)),
aspect_ratio: Some(CommonAspectRatio::Square),
image_batch_count: Some(1),
..base_gpt_image_1_image_request()
};
let plan = request.build().unwrap();
let result = plan.generate_image(&client).await;
println!("Result: {:?}", result);
let response = result.expect("generate_image request failed");
let payload = response.get_artcraft_payload().expect("expected Artcraft payload");
println!("Job token: {:?}", payload.inference_job_token);
}
}
@@ -50,6 +50,7 @@ mod tests {
let client = get_artcraft_client();
let request = GenerateImageRequest {
aspect_ratio: Some(CommonAspectRatio::WideSixteenByNine),
quality: None,
image_batch_count: Some(1),
prompt: Some("a cat walking through a cyberpunk city at night"),
..base_nano_banana_image_request()
@@ -79,6 +80,7 @@ mod tests {
prompt: Some("Change the background to a sunny beach"),
image_inputs: Some(ImageListRef::MediaFileTokens(&image_tokens)),
aspect_ratio: Some(CommonAspectRatio::Auto),
quality: None,
image_batch_count: Some(1),
..base_nano_banana_image_request()
};
@@ -103,6 +105,7 @@ mod tests {
println!("--- text-to-image aspect ratio: {:?} ---", ar);
let request = GenerateImageRequest {
aspect_ratio: Some(ar),
quality: None,
image_batch_count: Some(1),
prompt: Some("a shiba walking through a cyberpunk city at night"),
request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::PayMoreUpgrade,
@@ -138,6 +141,7 @@ mod tests {
prompt: Some("Change the background to a sunny beach"),
image_inputs: Some(ImageListRef::MediaFileTokens(&image_tokens)),
aspect_ratio: Some(ar),
quality: None,
image_batch_count: Some(1),
request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::PayMoreUpgrade,
..base_nano_banana_image_request()
@@ -49,6 +49,7 @@ mod tests {
let request = GenerateImageRequest {
aspect_ratio: Some(CommonAspectRatio::WideSixteenByNine),
resolution: Some(CommonResolution::OneK),
quality: None,
image_batch_count: Some(1),
prompt: Some("a cat walking through a cyberpunk city at night"),
..base_nano_banana_2_image_request()
@@ -51,6 +51,7 @@ mod tests {
let request = GenerateImageRequest {
aspect_ratio: Some(CommonAspectRatio::WideSixteenByNine),
resolution: Some(CommonResolution::OneK),
quality: None,
image_batch_count: Some(1),
prompt: Some("a cat walking through a cyberpunk city at night"),
..base_image_request()
@@ -74,6 +75,7 @@ mod tests {
let request = GenerateImageRequest {
aspect_ratio: Some(CommonAspectRatio::Square),
resolution: Some(CommonResolution::OneK),
quality: None,
image_batch_count: Some(4),
prompt: Some("a dog surfing a wave, cinematic"),
..base_image_request()
@@ -106,6 +108,7 @@ mod tests {
image_inputs: Some(ImageListRef::MediaFileTokens(&image_tokens)),
aspect_ratio: Some(CommonAspectRatio::Auto), // edit mode: preserve source dimensions
resolution: Some(CommonResolution::OneK),
quality: None,
image_batch_count: Some(1),
..base_image_request()
};
@@ -52,6 +52,7 @@ mod tests {
let tokens = vec![MediaFileToken::new_from_str("m_r45apt2swza6wp8j2z10237t92kkr8")];
let request = GenerateImageRequest {
aspect_ratio: Some(CommonAspectRatio::SquareHd),
quality: None,
image_batch_count: Some(1),
prompt: None,
image_inputs: Some(ImageListRef::MediaFileTokens(&tokens)),
@@ -51,6 +51,7 @@ mod tests {
let client = get_artcraft_client();
let request = GenerateImageRequest {
aspect_ratio: Some(CommonAspectRatio::WideSixteenByNine),
quality: None,
image_batch_count: Some(1),
prompt: Some("a cat walking through a cyberpunk city at night"),
..base_seedream_4_image_request()
@@ -80,6 +81,7 @@ mod tests {
prompt: Some("Change the background to a sunny beach"),
image_inputs: Some(ImageListRef::MediaFileTokens(&image_tokens)),
aspect_ratio: Some(CommonAspectRatio::Auto),
quality: None,
image_batch_count: Some(1),
..base_seedream_4_image_request()
};
@@ -104,6 +106,7 @@ mod tests {
println!("--- text-to-image aspect ratio: {:?} ---", ar);
let request = GenerateImageRequest {
aspect_ratio: Some(ar),
quality: None,
image_batch_count: Some(1),
prompt: Some("a shiba walking through a cyberpunk city at night"),
request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::PayMoreUpgrade,
@@ -139,6 +142,7 @@ mod tests {
prompt: Some("Change the background to a sunny beach"),
image_inputs: Some(ImageListRef::MediaFileTokens(&image_tokens)),
aspect_ratio: Some(ar),
quality: None,
image_batch_count: Some(1),
request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::PayMoreUpgrade,
..base_seedream_4_image_request()
@@ -51,6 +51,7 @@ mod tests {
let client = get_artcraft_client();
let request = GenerateImageRequest {
aspect_ratio: Some(CommonAspectRatio::WideSixteenByNine),
quality: None,
image_batch_count: Some(1),
prompt: Some("a corgi walking through a cyberpunk city at night"),
..base_seedream_4p5_image_request()
@@ -80,6 +81,7 @@ mod tests {
prompt: Some("Change the background to a spooky haunted house"),
image_inputs: Some(ImageListRef::MediaFileTokens(&image_tokens)),
aspect_ratio: Some(CommonAspectRatio::Auto2k),
quality: None,
image_batch_count: Some(1),
..base_seedream_4p5_image_request()
};
@@ -104,6 +106,7 @@ mod tests {
println!("--- text-to-image aspect ratio: {:?} ---", ar);
let request = GenerateImageRequest {
aspect_ratio: Some(ar),
quality: None,
image_batch_count: Some(1),
prompt: Some("a corgi walking through a cyberpunk city at night"),
request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::PayMoreUpgrade,
@@ -139,6 +142,7 @@ mod tests {
prompt: Some("Change the background to a spooky haunted house"),
image_inputs: Some(ImageListRef::MediaFileTokens(&image_tokens)),
aspect_ratio: Some(ar),
quality: None,
image_batch_count: Some(1),
request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::PayMoreUpgrade,
..base_seedream_4p5_image_request()
@@ -47,6 +47,7 @@ mod tests {
let client = get_artcraft_client();
let request = GenerateImageRequest {
aspect_ratio: Some(CommonAspectRatio::WideSixteenByNine),
quality: None,
image_batch_count: Some(1),
prompt: Some("a corgi walking through a cyberpunk city at night"),
..base_seedream_5_lite_image_request()
@@ -2,6 +2,7 @@ pub mod generate_image_artcraft_flux_1_dev;
pub mod generate_image_artcraft_flux_1_schnell;
pub mod generate_image_artcraft_flux_pro_1p1;
pub mod generate_image_artcraft_flux_pro_1p1_ultra;
pub mod generate_image_artcraft_gpt_image_1;
pub mod generate_image_artcraft_gpt_image_1p5;
pub mod generate_image_artcraft_nano_banana;
pub mod generate_image_artcraft_nano_banana_2;
@@ -0,0 +1,160 @@
use crate::client::router_fal_client::RouterFalClient;
use crate::errors::artcraft_router_error::ArtcraftRouterError;
use crate::errors::provider_error::ProviderError;
use crate::generate::generate_image::generate_image_response::{
FalImageResponsePayload, GenerateImageResponse,
};
use crate::generate::generate_image::plan::fal::plan_generate_image_fal_gpt_image_1::PlanFalGptImage1;
use fal_client::requests::webhook::image::edit::enqueue_gpt_image_1_edit_image_webhook::{
enqueue_gpt_image_1_edit_image_webhook, EnqueueGptImage1EditImageArgs,
};
use fal_client::requests::webhook::image::text::enqueue_gpt_image_1_text_to_image_webhook::{
enqueue_gpt_image_1_text_to_image_webhook, EnqueueGptImage1TextToImageArgs,
};
pub async fn execute_fal_gpt_image_1(
plan: &PlanFalGptImage1<'_>,
fal_client: &RouterFalClient,
) -> Result<GenerateImageResponse, ArtcraftRouterError> {
let webhook_response = if plan.image_urls.is_empty() {
let args = EnqueueGptImage1TextToImageArgs {
prompt: plan.prompt.unwrap_or(""),
num_images: plan.num_images.to_t2i(),
image_size: plan.image_size.map(|s| s.to_t2i()),
quality: Some(plan.quality.to_t2i()),
background: None,
output_format: None,
webhook_url: fal_client.webhook_url.as_str(),
api_key: &fal_client.api_key,
};
enqueue_gpt_image_1_text_to_image_webhook(args)
.await
.map_err(|e| ArtcraftRouterError::Provider(ProviderError::Fal(e)))?
} else {
let args = EnqueueGptImage1EditImageArgs {
prompt: plan.prompt.unwrap_or(""),
image_urls: plan.image_urls.clone(),
num_images: plan.num_images.to_edit(),
mask_image_url: None,
image_size: plan.image_size.map(|s| s.to_edit()),
quality: Some(plan.quality.to_edit()),
input_fidelity: None,
background: None,
output_format: None,
webhook_url: fal_client.webhook_url.as_str(),
api_key: &fal_client.api_key,
};
enqueue_gpt_image_1_edit_image_webhook(args)
.await
.map_err(|e| ArtcraftRouterError::Provider(ProviderError::Fal(e)))?
};
Ok(GenerateImageResponse::Fal(FalImageResponsePayload {
request_id: webhook_response.request_id,
gateway_request_id: webhook_response.gateway_request_id,
}))
}
#[cfg(test)]
mod tests {
use test_data::web::image_urls::TREX_SKELETON_IMAGE_URL;
use crate::api::common_aspect_ratio::CommonAspectRatio;
use crate::api::common_image_model::CommonImageModel;
use crate::api::image_list_ref::ImageListRef;
use crate::api::provider::Provider;
use crate::client::request_mismatch_mitigation_strategy::RequestMismatchMitigationStrategy;
use crate::generate::generate_image::generate_image_request::GenerateImageRequest;
use crate::generate::generate_image::image_generation_plan::ImageGenerationPlan;
use crate::test_helpers::get_fal_client;
fn base_fal_request() -> GenerateImageRequest<'static> {
GenerateImageRequest {
model: CommonImageModel::GptImage1,
provider: Provider::Fal,
prompt: Some("a cat in space"),
image_inputs: None,
resolution: None,
aspect_ratio: None,
quality: None,
image_batch_count: None,
request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::ErrorOut,
generation_mode_mismatch_strategy: None,
idempotency_token: None,
horizontal_angle: None,
vertical_angle: None,
zoom: None,
}
}
// Build-only smoke test (no network I/O).
#[test]
fn build_text_to_image_plan_smoke() {
let request = GenerateImageRequest {
aspect_ratio: Some(CommonAspectRatio::Square),
image_batch_count: Some(1),
..base_fal_request()
};
let plan = request.build().expect("plan should build");
let ImageGenerationPlan::FalGptImage1(plan) = plan else {
panic!("expected FalGptImage1")
};
assert!(plan.image_urls.is_empty(), "text mode must have no image_urls");
}
// Build-only smoke test for edit mode.
#[test]
fn build_edit_image_plan_smoke() {
let urls = vec!["https://example.com/img.jpg".to_string()];
let request = GenerateImageRequest {
image_inputs: Some(ImageListRef::Urls(&urls)),
..base_fal_request()
};
let plan = request.build().expect("plan should build");
let ImageGenerationPlan::FalGptImage1(plan) = plan else {
panic!("expected FalGptImage1")
};
assert_eq!(plan.image_urls, urls);
}
#[tokio::test]
#[ignore] // manually run — fires a real fal API request and incurs cost
async fn test_text_to_image_gpt_image_1_fal() {
let client = get_fal_client();
let request = GenerateImageRequest {
aspect_ratio: Some(CommonAspectRatio::WideSixteenByNine),
image_batch_count: Some(1),
prompt: Some("a horse walking through a cyberpunk city at night"),
..base_fal_request()
};
let plan = request.build().unwrap();
let result = plan.generate_image(&client).await;
println!("Result: {:?}", result);
let response = result.expect("generate_image request failed");
let payload = response.get_fal_payload().expect("expected Fal payload");
println!("Request id: {:?}", payload.request_id);
}
#[tokio::test]
#[ignore] // manually run — fires a real fal API request and incurs cost
async fn test_edit_image_gpt_image_1_fal() {
let client = get_fal_client();
let urls = vec![
TREX_SKELETON_IMAGE_URL.to_string(),
];
let request = GenerateImageRequest {
image_inputs: Some(ImageListRef::Urls(&urls)),
aspect_ratio: Some(CommonAspectRatio::Square),
image_batch_count: Some(1),
prompt: Some("change the background to a desert"),
..base_fal_request()
};
let plan = request.build().unwrap();
let result = plan.generate_image(&client).await;
println!("Result: {:?}", result);
let response = result.expect("generate_image request failed");
let payload = response.get_fal_payload().expect("expected Fal payload");
println!("Request id: {:?}", payload.request_id);
}
}
@@ -105,6 +105,7 @@ mod tests {
let request = GenerateImageRequest {
aspect_ratio: Some(CommonAspectRatio::WideSixteenByNine),
resolution: Some(CommonResolution::TwoK),
quality: None,
image_batch_count: Some(1),
prompt: Some("a cyberpunk city skyline at dusk, neon lights reflecting on rain-soaked streets"),
..base_fal_image_request()
@@ -129,6 +130,7 @@ mod tests {
let request = GenerateImageRequest {
aspect_ratio: Some(CommonAspectRatio::Square),
resolution: Some(CommonResolution::OneK),
quality: None,
image_batch_count: Some(4),
prompt: Some("a golden retriever surfing a wave, cinematic, 4K"),
..base_fal_image_request()
@@ -161,6 +163,7 @@ mod tests {
image_inputs: Some(ImageListRef::Urls(&image_urls)),
aspect_ratio: Some(CommonAspectRatio::Auto), // edit mode: preserve source dimensions
resolution: Some(CommonResolution::TwoK),
quality: None,
image_batch_count: Some(1),
..base_fal_image_request()
};
@@ -2,6 +2,7 @@ pub mod generate_image_fal_flux_1_dev;
pub mod generate_image_fal_flux_1_schnell;
pub mod generate_image_fal_flux_pro_1p1;
pub mod generate_image_fal_flux_pro_1p1_ultra;
pub mod generate_image_fal_gpt_image_1;
pub mod generate_image_fal_gpt_image_1p5;
pub mod generate_image_fal_seedream_4;
pub mod generate_image_fal_seedream_4p5;
@@ -1,5 +1,6 @@
use crate::api::common_aspect_ratio::CommonAspectRatio;
use crate::api::common_image_model::CommonImageModel;
use crate::api::common_quality::CommonQuality;
use crate::api::common_resolution::CommonResolution;
use crate::api::image_list_ref::ImageListRef;
use crate::api::provider::Provider;
@@ -13,6 +14,7 @@ use crate::generate::generate_image::plan::artcraft::plan_generate_image_artcraf
use crate::generate::generate_image::plan::artcraft::plan_generate_image_artcraft_flux_2_lora_angles::plan_generate_image_artcraft_flux_2_lora_angles;
use crate::generate::generate_image::plan::artcraft::plan_generate_image_artcraft_flux_pro_1p1::plan_generate_image_artcraft_flux_pro_1p1;
use crate::generate::generate_image::plan::artcraft::plan_generate_image_artcraft_flux_pro_1p1_ultra::plan_generate_image_artcraft_flux_pro_1p1_ultra;
use crate::generate::generate_image::plan::artcraft::plan_generate_image_artcraft_gpt_image_1::plan_generate_image_artcraft_gpt_image_1;
use crate::generate::generate_image::plan::artcraft::plan_generate_image_artcraft_gpt_image_1p5::plan_generate_image_artcraft_gpt_image_1p5;
use crate::generate::generate_image::plan::artcraft::plan_generate_image_artcraft_nano_banana::plan_generate_image_artcraft_nano_banana;
use crate::generate::generate_image::plan::artcraft::plan_generate_image_artcraft_nano_banana_2::plan_generate_image_artcraft_nano_banana_2;
@@ -25,6 +27,7 @@ use crate::generate::generate_image::plan::fal::plan_generate_image_fal_flux_1_d
use crate::generate::generate_image::plan::fal::plan_generate_image_fal_flux_1_schnell::plan_generate_image_fal_flux_1_schnell;
use crate::generate::generate_image::plan::fal::plan_generate_image_fal_flux_pro_1p1::plan_generate_image_fal_flux_pro_1p1;
use crate::generate::generate_image::plan::fal::plan_generate_image_fal_flux_pro_1p1_ultra::plan_generate_image_fal_flux_pro_1p1_ultra;
use crate::generate::generate_image::plan::fal::plan_generate_image_fal_gpt_image_1::plan_generate_image_fal_gpt_image_1;
use crate::generate::generate_image::plan::fal::plan_generate_image_fal_gpt_image_1p5::plan_generate_image_fal_gpt_image_1p5;
use crate::generate::generate_image::plan::fal::plan_generate_image_fal_seedream_4::plan_generate_image_fal_seedream_4;
use crate::generate::generate_image::plan::fal::plan_generate_image_fal_seedream_4p5::plan_generate_image_fal_seedream_4p5;
@@ -33,7 +36,7 @@ use crate::generate::generate_image::plan::fal::plan_generate_image_fal_nano_ban
use crate::generate::generate_image::plan::fal::plan_generate_image_fal_nano_banana_2::plan_generate_image_fal_nano_banana_2;
use crate::generate::generate_image::plan::fal::plan_generate_image_fal_nano_banana_pro::plan_generate_image_fal_nano_banana_pro;
#[derive(Debug)]
#[derive(Clone, Debug)]
pub struct GenerateImageRequest<'a> {
/// Which model to use.
pub model: CommonImageModel,
@@ -55,6 +58,9 @@ pub struct GenerateImageRequest<'a> {
/// The aspect ratio to use.
pub aspect_ratio: Option<CommonAspectRatio>,
/// The quality level for generation. Not all models use this.
pub quality: Option<CommonQuality>,
/// How many images to generate.
pub image_batch_count: Option<u16>,
@@ -96,6 +102,7 @@ impl<'a> GenerateImageRequest<'a> {
CommonImageModel::Flux1Schnell => plan_generate_image_artcraft_flux_1_schnell(self),
CommonImageModel::FluxPro11 => plan_generate_image_artcraft_flux_pro_1p1(self),
CommonImageModel::FluxPro11Ultra => plan_generate_image_artcraft_flux_pro_1p1_ultra(self),
CommonImageModel::GptImage1 => plan_generate_image_artcraft_gpt_image_1(self),
CommonImageModel::GptImage1p5 => plan_generate_image_artcraft_gpt_image_1p5(self),
CommonImageModel::NanoBanana => plan_generate_image_artcraft_nano_banana(self),
CommonImageModel::NanoBanana2 => plan_generate_image_artcraft_nano_banana_2(self),
@@ -114,6 +121,7 @@ impl<'a> GenerateImageRequest<'a> {
CommonImageModel::Flux1Schnell => plan_generate_image_fal_flux_1_schnell(self),
CommonImageModel::FluxPro11 => plan_generate_image_fal_flux_pro_1p1(self),
CommonImageModel::FluxPro11Ultra => plan_generate_image_fal_flux_pro_1p1_ultra(self),
CommonImageModel::GptImage1 => plan_generate_image_fal_gpt_image_1(self),
CommonImageModel::GptImage1p5 => plan_generate_image_fal_gpt_image_1p5(self),
CommonImageModel::Seedream4 => plan_generate_image_fal_seedream_4(self),
CommonImageModel::Seedream4p5 => plan_generate_image_fal_seedream_4p5(self),
@@ -1,4 +1,5 @@
/// The result of estimating the cost of an image generation plan.
#[derive(Clone, Debug)]
pub struct ImageGenerationCostEstimate {
pub cost_in_credits: Option<u64>,
pub cost_in_usd_cents: Option<u64>,
@@ -4,6 +4,7 @@ use crate::generate::generate_image::cost::artcraft::estimate_image_cost_artcraf
use crate::generate::generate_image::cost::artcraft::estimate_image_cost_artcraft_flux_1_schnell::estimate_image_cost_artcraft_flux_1_schnell;
use crate::generate::generate_image::cost::artcraft::estimate_image_cost_artcraft_flux_pro_1p1::estimate_image_cost_artcraft_flux_pro_1p1;
use crate::generate::generate_image::cost::artcraft::estimate_image_cost_artcraft_flux_pro_1p1_ultra::estimate_image_cost_artcraft_flux_pro_1p1_ultra;
use crate::generate::generate_image::cost::artcraft::estimate_image_cost_artcraft_gpt_image_1::estimate_image_cost_artcraft_gpt_image_1;
use crate::generate::generate_image::cost::artcraft::estimate_image_cost_artcraft_gpt_image_1p5::estimate_image_cost_artcraft_gpt_image_1p5;
use crate::generate::generate_image::cost::artcraft::estimate_image_cost_artcraft_nano_banana::estimate_image_cost_artcraft_nano_banana;
use crate::generate::generate_image::cost::artcraft::estimate_image_cost_artcraft_nano_banana_2::estimate_image_cost_artcraft_nano_banana_2;
@@ -17,6 +18,7 @@ use crate::generate::generate_image::cost::fal::estimate_image_cost_fal_flux_1_d
use crate::generate::generate_image::cost::fal::estimate_image_cost_fal_flux_1_schnell::estimate_image_cost_fal_flux_1_schnell;
use crate::generate::generate_image::cost::fal::estimate_image_cost_fal_flux_pro_1p1::estimate_image_cost_fal_flux_pro_1p1;
use crate::generate::generate_image::cost::fal::estimate_image_cost_fal_flux_pro_1p1_ultra::estimate_image_cost_fal_flux_pro_1p1_ultra;
use crate::generate::generate_image::cost::fal::estimate_image_cost_fal_gpt_image_1::estimate_image_cost_fal_gpt_image_1;
use crate::generate::generate_image::cost::fal::estimate_image_cost_fal_gpt_image_1p5::estimate_image_cost_fal_gpt_image_1p5;
use crate::generate::generate_image::cost::fal::estimate_image_cost_fal_seedream_4::estimate_image_cost_fal_seedream_4;
use crate::generate::generate_image::cost::fal::estimate_image_cost_fal_seedream_4p5::estimate_image_cost_fal_seedream_4p5;
@@ -28,6 +30,7 @@ use crate::generate::generate_image::execute::artcraft::generate_image_artcraft_
use crate::generate::generate_image::execute::artcraft::generate_image_artcraft_flux_1_schnell::execute_artcraft_flux_1_schnell;
use crate::generate::generate_image::execute::artcraft::generate_image_artcraft_flux_pro_1p1::execute_artcraft_flux_pro_1p1;
use crate::generate::generate_image::execute::artcraft::generate_image_artcraft_flux_pro_1p1_ultra::execute_artcraft_flux_pro_1p1_ultra;
use crate::generate::generate_image::execute::artcraft::generate_image_artcraft_gpt_image_1::execute_artcraft_gpt_image_1;
use crate::generate::generate_image::execute::artcraft::generate_image_artcraft_gpt_image_1p5::execute_artcraft_gpt_image_1p5;
use crate::generate::generate_image::execute::artcraft::generate_image_artcraft_nano_banana::execute_artcraft_nano_banana;
use crate::generate::generate_image::execute::artcraft::generate_image_artcraft_nano_banana_2::execute_artcraft_nano_banana_2;
@@ -41,6 +44,7 @@ use crate::generate::generate_image::execute::fal::generate_image_fal_flux_1_dev
use crate::generate::generate_image::execute::fal::generate_image_fal_flux_1_schnell::execute_fal_flux_1_schnell;
use crate::generate::generate_image::execute::fal::generate_image_fal_flux_pro_1p1::execute_fal_flux_pro_1p1;
use crate::generate::generate_image::execute::fal::generate_image_fal_flux_pro_1p1_ultra::execute_fal_flux_pro_1p1_ultra;
use crate::generate::generate_image::execute::fal::generate_image_fal_gpt_image_1::execute_fal_gpt_image_1;
use crate::generate::generate_image::execute::fal::generate_image_fal_gpt_image_1p5::execute_fal_gpt_image_1p5;
use crate::generate::generate_image::execute::fal::generate_image_fal_seedream_4::execute_fal_seedream_4;
use crate::generate::generate_image::execute::fal::generate_image_fal_seedream_4p5::execute_fal_seedream_4p5;
@@ -54,6 +58,7 @@ use crate::generate::generate_image::plan::artcraft::plan_generate_image_artcraf
use crate::generate::generate_image::plan::artcraft::plan_generate_image_artcraft_flux_1_schnell::PlanArtcraftFlux1Schnell;
use crate::generate::generate_image::plan::artcraft::plan_generate_image_artcraft_flux_pro_1p1::PlanArtcraftFluxPro11;
use crate::generate::generate_image::plan::artcraft::plan_generate_image_artcraft_flux_pro_1p1_ultra::PlanArtcraftFluxPro11Ultra;
use crate::generate::generate_image::plan::artcraft::plan_generate_image_artcraft_gpt_image_1::PlanArtcraftGptImage1;
use crate::generate::generate_image::plan::artcraft::plan_generate_image_artcraft_gpt_image_1p5::PlanArtcraftGptImage1p5;
use crate::generate::generate_image::plan::artcraft::plan_generate_image_artcraft_nano_banana::PlanArtcraftNanaBanana;
use crate::generate::generate_image::plan::artcraft::plan_generate_image_artcraft_nano_banana_2::PlanArtcraftNanaBanana2;
@@ -67,6 +72,7 @@ use crate::generate::generate_image::plan::fal::plan_generate_image_fal_flux_1_d
use crate::generate::generate_image::plan::fal::plan_generate_image_fal_flux_1_schnell::PlanFalFlux1Schnell;
use crate::generate::generate_image::plan::fal::plan_generate_image_fal_flux_pro_1p1::PlanFalFluxPro11;
use crate::generate::generate_image::plan::fal::plan_generate_image_fal_flux_pro_1p1_ultra::PlanFalFluxPro11Ultra;
use crate::generate::generate_image::plan::fal::plan_generate_image_fal_gpt_image_1::PlanFalGptImage1;
use crate::generate::generate_image::plan::fal::plan_generate_image_fal_gpt_image_1p5::PlanFalGptImage1p5;
use crate::generate::generate_image::plan::fal::plan_generate_image_fal_seedream_4::PlanFalSeedream4;
use crate::generate::generate_image::plan::fal::plan_generate_image_fal_seedream_4p5::PlanFalSeedream4p5;
@@ -81,6 +87,7 @@ pub enum ImageGenerationPlan<'a> {
ArtcraftFlux1Schnell(PlanArtcraftFlux1Schnell<'a>),
ArtcraftFluxPro11(PlanArtcraftFluxPro11<'a>),
ArtcraftFluxPro11Ultra(PlanArtcraftFluxPro11Ultra<'a>),
ArtcraftGptImage1(PlanArtcraftGptImage1<'a>),
ArtcraftGptImage1p5(PlanArtcraftGptImage1p5<'a>),
ArtcraftNanaBanana(PlanArtcraftNanaBanana<'a>),
ArtcraftNanaBanana2(PlanArtcraftNanaBanana2<'a>),
@@ -94,6 +101,7 @@ pub enum ImageGenerationPlan<'a> {
FalFlux1Schnell(PlanFalFlux1Schnell<'a>),
FalFluxPro11(PlanFalFluxPro11<'a>),
FalFluxPro11Ultra(PlanFalFluxPro11Ultra<'a>),
FalGptImage1(PlanFalGptImage1<'a>),
FalGptImage1p5(PlanFalGptImage1p5<'a>),
FalSeedream4(PlanFalSeedream4<'a>),
FalSeedream4p5(PlanFalSeedream4p5<'a>),
@@ -125,6 +133,10 @@ impl<'a> ImageGenerationPlan<'a> {
let artcraft_client = client.get_artcraft_client_ref()?;
execute_artcraft_flux_pro_1p1_ultra(plan, artcraft_client).await
}
ImageGenerationPlan::ArtcraftGptImage1(plan) => {
let artcraft_client = client.get_artcraft_client_ref()?;
execute_artcraft_gpt_image_1(plan, artcraft_client).await
}
ImageGenerationPlan::ArtcraftGptImage1p5(plan) => {
let artcraft_client = client.get_artcraft_client_ref()?;
execute_artcraft_gpt_image_1p5(plan, artcraft_client).await
@@ -177,6 +189,10 @@ impl<'a> ImageGenerationPlan<'a> {
let fal_client = client.get_fal_client_ref()?;
execute_fal_flux_pro_1p1_ultra(plan, fal_client).await
}
ImageGenerationPlan::FalGptImage1(plan) => {
let fal_client = client.get_fal_client_ref()?;
execute_fal_gpt_image_1(plan, fal_client).await
}
ImageGenerationPlan::FalGptImage1p5(plan) => {
let fal_client = client.get_fal_client_ref()?;
execute_fal_gpt_image_1p5(plan, fal_client).await
@@ -222,6 +238,9 @@ impl<'a> ImageGenerationPlan<'a> {
ImageGenerationPlan::ArtcraftFluxPro11Ultra(plan) => {
estimate_image_cost_artcraft_flux_pro_1p1_ultra(plan)
}
ImageGenerationPlan::ArtcraftGptImage1(plan) => {
estimate_image_cost_artcraft_gpt_image_1(plan)
}
ImageGenerationPlan::ArtcraftGptImage1p5(plan) => {
estimate_image_cost_artcraft_gpt_image_1p5(plan)
}
@@ -261,6 +280,9 @@ impl<'a> ImageGenerationPlan<'a> {
ImageGenerationPlan::FalFluxPro11Ultra(plan) => {
estimate_image_cost_fal_flux_pro_1p1_ultra(plan)
}
ImageGenerationPlan::FalGptImage1(plan) => {
estimate_image_cost_fal_gpt_image_1(plan)
}
ImageGenerationPlan::FalGptImage1p5(plan) => {
estimate_image_cost_fal_gpt_image_1p5(plan)
}
@@ -2,6 +2,7 @@ pub mod plan_generate_image_artcraft_flux_1_dev;
pub mod plan_generate_image_artcraft_flux_1_schnell;
pub mod plan_generate_image_artcraft_flux_pro_1p1;
pub mod plan_generate_image_artcraft_flux_pro_1p1_ultra;
pub mod plan_generate_image_artcraft_gpt_image_1;
pub mod plan_generate_image_artcraft_gpt_image_1p5;
pub mod plan_generate_image_artcraft_nano_banana;
pub mod plan_generate_image_artcraft_nano_banana_2;
@@ -268,6 +268,7 @@ mod tests {
RequestMismatchMitigationStrategy::PayLessDowngrade,
] {
let request = GenerateImageRequest {
quality: None,
image_batch_count: Some(0),
request_mismatch_mitigation_strategy: strategy,
..base_flux_1_dev_image_request()
@@ -295,6 +296,7 @@ mod tests {
#[test]
fn num_images_out_of_range_error_out() {
let request = GenerateImageRequest {
quality: None,
image_batch_count: Some(5),
request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::ErrorOut,
..base_flux_1_dev_image_request()
@@ -312,6 +314,7 @@ mod tests {
RequestMismatchMitigationStrategy::PayLessDowngrade,
] {
let request = GenerateImageRequest {
quality: None,
image_batch_count: Some(5),
request_mismatch_mitigation_strategy: strategy,
..base_flux_1_dev_image_request()
@@ -264,6 +264,7 @@ mod tests {
RequestMismatchMitigationStrategy::PayLessDowngrade,
] {
let request = GenerateImageRequest {
quality: None,
image_batch_count: Some(0),
request_mismatch_mitigation_strategy: strategy,
..base_flux_1_schnell_image_request()
@@ -291,6 +292,7 @@ mod tests {
#[test]
fn num_images_out_of_range_error_out() {
let request = GenerateImageRequest {
quality: None,
image_batch_count: Some(5),
request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::ErrorOut,
..base_flux_1_schnell_image_request()
@@ -308,6 +310,7 @@ mod tests {
RequestMismatchMitigationStrategy::PayLessDowngrade,
] {
let request = GenerateImageRequest {
quality: None,
image_batch_count: Some(5),
request_mismatch_mitigation_strategy: strategy,
..base_flux_1_schnell_image_request()
@@ -80,7 +80,7 @@ fn plan_image_size(
match aspect_ratio {
None => Ok(None),
Some(CommonAspectRatio::Auto) | Some(CommonAspectRatio::Auto2k) | Some(CommonAspectRatio::Auto4k) => {
Some(CommonAspectRatio::Auto) | Some(CommonAspectRatio::Auto2k) | Some(CommonAspectRatio::Auto3k) | Some(CommonAspectRatio::Auto4k) => {
Ok(Some(S::SquareHd))
}
@@ -224,6 +224,7 @@ mod tests {
let cases = [(1, N::One), (2, N::Two), (3, N::Three), (4, N::Four)];
for (count, expected) in cases {
let request = GenerateImageRequest {
quality: None,
image_batch_count: Some(count),
image_inputs: Some(ImageListRef::MediaFileTokens(&tokens)),
..base_flux_2_lora_angles_image_request()
@@ -268,6 +268,7 @@ mod tests {
RequestMismatchMitigationStrategy::PayLessDowngrade,
] {
let request = GenerateImageRequest {
quality: None,
image_batch_count: Some(0),
request_mismatch_mitigation_strategy: strategy,
..base_flux_pro_1p1_image_request()
@@ -295,6 +296,7 @@ mod tests {
#[test]
fn num_images_out_of_range_error_out() {
let request = GenerateImageRequest {
quality: None,
image_batch_count: Some(5),
request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::ErrorOut,
..base_flux_pro_1p1_image_request()
@@ -312,6 +314,7 @@ mod tests {
RequestMismatchMitigationStrategy::PayLessDowngrade,
] {
let request = GenerateImageRequest {
quality: None,
image_batch_count: Some(5),
request_mismatch_mitigation_strategy: strategy,
..base_flux_pro_1p1_image_request()
@@ -271,6 +271,7 @@ mod tests {
RequestMismatchMitigationStrategy::PayLessDowngrade,
] {
let request = GenerateImageRequest {
quality: None,
image_batch_count: Some(0),
request_mismatch_mitigation_strategy: strategy,
..base_flux_pro_1p1_ultra_image_request()
@@ -298,6 +299,7 @@ mod tests {
#[test]
fn num_images_out_of_range_error_out() {
let request = GenerateImageRequest {
quality: None,
image_batch_count: Some(5),
request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::ErrorOut,
..base_flux_pro_1p1_ultra_image_request()
@@ -315,6 +317,7 @@ mod tests {
RequestMismatchMitigationStrategy::PayLessDowngrade,
] {
let request = GenerateImageRequest {
quality: None,
image_batch_count: Some(5),
request_mismatch_mitigation_strategy: strategy,
..base_flux_pro_1p1_ultra_image_request()
@@ -0,0 +1,537 @@
use crate::api::common_aspect_ratio::CommonAspectRatio;
use crate::api::common_quality::CommonQuality;
use crate::api::image_list_ref::ImageListRef;
use crate::client::request_mismatch_mitigation_strategy::RequestMismatchMitigationStrategy;
use crate::errors::artcraft_router_error::ArtcraftRouterError;
use crate::errors::client_error::ClientError;
use crate::generate::generate_image::generate_image_request::GenerateImageRequest;
use crate::generate::generate_image::image_generation_plan::ImageGenerationPlan;
use artcraft_api_defs::generate::image::edit::gpt_image_1_edit_image::{
GptImage1EditImageImageQuality, GptImage1EditImageImageSize, GptImage1EditImageNumImages,
};
use artcraft_api_defs::generate::image::text::generate_gpt_image_1_text_to_image::{
GenerateGptImage1TextToImageImageQuality, GenerateGptImage1TextToImageImageSize,
GenerateGptImage1TextToImageNumImages,
};
use tokens::tokens::media_files::MediaFileToken;
/// Quality is shared between t2i and edit (Auto / Low / Medium / High).
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ArtcraftGptImage1Quality {
Auto,
Low,
Medium,
High,
}
/// Image size is shared between t2i and edit (Square / Horizontal / Vertical).
/// GPT Image 1 has no native auto size — None means "let the legacy handler
/// pick its default" (which is Square).
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ArtcraftGptImage1Size {
Square,
Horizontal,
Vertical,
}
#[derive(Debug, Clone, Copy)]
pub enum ArtcraftGptImage1NumImages {
One,
Two,
Three,
Four,
}
#[derive(Debug, Clone)]
pub struct PlanArtcraftGptImage1<'a> {
pub prompt: Option<&'a str>,
/// Input images for image editing. None means text-to-image mode.
pub image_inputs: Option<&'a Vec<MediaFileToken>>,
/// Number of input images (0 for text-to-image). This is counted from either
/// media tokens or hydrated URLs, so the cost estimator can use it without
/// needing to inspect image_inputs (which is None for URL-form inputs).
pub num_input_images: u64,
pub image_size: Option<ArtcraftGptImage1Size>,
/// Quality defaults to High when the request leaves it unspecified, matching
/// the legacy storyteller-web handler defaults.
pub quality: ArtcraftGptImage1Quality,
pub num_images: ArtcraftGptImage1NumImages,
pub idempotency_token: String,
}
pub fn plan_generate_image_artcraft_gpt_image_1<'a>(
request: &'a GenerateImageRequest<'a>,
) -> Result<ImageGenerationPlan<'a>, ArtcraftRouterError> {
let strategy = request.request_mismatch_mitigation_strategy;
let image_inputs = resolve_image_list_ref(request.image_inputs)?;
let num_input_images = count_input_images(request.image_inputs);
let image_size = plan_image_size(request.aspect_ratio);
let quality = plan_quality(request.quality);
let num_images = plan_num_images(request.image_batch_count, strategy)?;
Ok(ImageGenerationPlan::ArtcraftGptImage1(PlanArtcraftGptImage1 {
prompt: request.prompt,
image_inputs,
num_input_images,
image_size,
quality,
num_images,
idempotency_token: request.get_or_generate_idempotency_token(),
}))
}
fn resolve_image_list_ref<'a>(
image_list_ref: Option<ImageListRef<'a>>,
) -> Result<Option<&'a Vec<MediaFileToken>>, ArtcraftRouterError> {
match image_list_ref {
None => Ok(None),
Some(ImageListRef::MediaFileTokens(tokens)) => Ok(Some(tokens)),
// Omni-gen distillation hydrates media tokens to URLs before running the
// Artcraft cost path. Cost only depends on quality + size + num_images +
// is_edit_mode (derived before this resolver runs), so URL-form inputs are
// accepted and dropped.
Some(ImageListRef::Urls(_)) => Ok(None),
}
}
fn count_input_images(image_inputs: Option<ImageListRef<'_>>) -> u64 {
match image_inputs {
None => 0,
Some(ImageListRef::MediaFileTokens(tokens)) => tokens.len() as u64,
Some(ImageListRef::Urls(urls)) => urls.len() as u64,
}
}
fn plan_quality(quality: Option<CommonQuality>) -> ArtcraftGptImage1Quality {
match quality {
Some(CommonQuality::Low) => ArtcraftGptImage1Quality::Low,
Some(CommonQuality::Medium) => ArtcraftGptImage1Quality::Medium,
Some(CommonQuality::High) => ArtcraftGptImage1Quality::High,
None => ArtcraftGptImage1Quality::High,
}
}
// GPT Image 1 image sizes: Square (1024x1024), Horizontal (1536x1024),
// Vertical (1024x1536). All 17 CommonAspectRatio variants map to one of these
// or None (let the legacy handler default to Square).
fn plan_image_size(aspect_ratio: Option<CommonAspectRatio>) -> Option<ArtcraftGptImage1Size> {
use ArtcraftGptImage1Size as S;
match aspect_ratio {
None
| Some(CommonAspectRatio::Auto)
| Some(CommonAspectRatio::Auto2k)
| Some(CommonAspectRatio::Auto3k)
| Some(CommonAspectRatio::Auto4k) => None,
Some(CommonAspectRatio::Square) | Some(CommonAspectRatio::SquareHd) => Some(S::Square),
Some(CommonAspectRatio::WideThreeByTwo)
| Some(CommonAspectRatio::WideFourByThree)
| Some(CommonAspectRatio::WideFiveByFour)
| Some(CommonAspectRatio::WideSixteenByNine)
| Some(CommonAspectRatio::WideTwentyOneByNine)
| Some(CommonAspectRatio::Wide) => Some(S::Horizontal),
Some(CommonAspectRatio::TallTwoByThree)
| Some(CommonAspectRatio::TallThreeByFour)
| Some(CommonAspectRatio::TallFourByFive)
| Some(CommonAspectRatio::TallNineBySixteen)
| Some(CommonAspectRatio::TallNineByTwentyOne)
| Some(CommonAspectRatio::Tall) => Some(S::Vertical),
}
}
// GPT Image 1 supports 1, 2, 3, and 4 images.
fn plan_num_images(
image_batch_count: Option<u16>,
strategy: RequestMismatchMitigationStrategy,
) -> Result<ArtcraftGptImage1NumImages, ArtcraftRouterError> {
use ArtcraftGptImage1NumImages as N;
let count = image_batch_count.unwrap_or(1);
match count {
0 => Err(ArtcraftRouterError::Client(ClientError::UserRequestedZeroGenerations)),
1 => Ok(N::One),
2 => Ok(N::Two),
3 => Ok(N::Three),
4 => Ok(N::Four),
_ => match strategy {
RequestMismatchMitigationStrategy::ErrorOut => {
Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption {
field: "image_batch_count",
value: format!("{}", count),
}))
}
RequestMismatchMitigationStrategy::PayMoreUpgrade
| RequestMismatchMitigationStrategy::PayLessDowngrade => Ok(N::Four),
},
}
}
// ── Conversions to legacy storyteller-web request enums ────────────────────
impl ArtcraftGptImage1NumImages {
pub fn to_t2i(self) -> GenerateGptImage1TextToImageNumImages {
use GenerateGptImage1TextToImageNumImages as T;
match self {
Self::One => T::One,
Self::Two => T::Two,
Self::Three => T::Three,
Self::Four => T::Four,
}
}
pub fn to_edit(self) -> GptImage1EditImageNumImages {
use GptImage1EditImageNumImages as E;
match self {
Self::One => E::One,
Self::Two => E::Two,
Self::Three => E::Three,
Self::Four => E::Four,
}
}
pub fn as_u64(self) -> u64 {
match self {
Self::One => 1,
Self::Two => 2,
Self::Three => 3,
Self::Four => 4,
}
}
}
impl ArtcraftGptImage1Quality {
pub fn to_t2i(self) -> GenerateGptImage1TextToImageImageQuality {
use GenerateGptImage1TextToImageImageQuality as T;
match self {
Self::Auto => T::Auto,
Self::Low => T::Low,
Self::Medium => T::Medium,
Self::High => T::High,
}
}
pub fn to_edit(self) -> GptImage1EditImageImageQuality {
use GptImage1EditImageImageQuality as E;
match self {
Self::Auto => E::Auto,
Self::Low => E::Low,
Self::Medium => E::Medium,
Self::High => E::High,
}
}
}
impl ArtcraftGptImage1Size {
pub fn to_t2i(self) -> GenerateGptImage1TextToImageImageSize {
use GenerateGptImage1TextToImageImageSize as T;
match self {
Self::Square => T::Square,
Self::Horizontal => T::Horizontal,
Self::Vertical => T::Vertical,
}
}
pub fn to_edit(self) -> GptImage1EditImageImageSize {
use GptImage1EditImageImageSize as E;
match self {
Self::Square => E::Square,
Self::Horizontal => E::Horizontal,
Self::Vertical => E::Vertical,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::api::common_aspect_ratio::CommonAspectRatio;
use crate::api::image_list_ref::ImageListRef;
use crate::errors::artcraft_router_error::ArtcraftRouterError;
use crate::errors::client_error::ClientError;
use crate::test_helpers::base_gpt_image_1_image_request;
// ── Image size (aspect ratio mapping) ────────────────────────────────────
#[test]
fn image_size_none_is_none() {
let request = GenerateImageRequest { aspect_ratio: None, ..base_gpt_image_1_image_request() };
let ImageGenerationPlan::ArtcraftGptImage1(plan) = request.build().unwrap() else {
panic!("expected ArtcraftGptImage1")
};
assert!(plan.image_size.is_none());
}
#[test]
fn image_size_auto_variants_yield_none() {
for auto_ar in [CommonAspectRatio::Auto, CommonAspectRatio::Auto2k, CommonAspectRatio::Auto4k] {
let request = GenerateImageRequest { aspect_ratio: Some(auto_ar), ..base_gpt_image_1_image_request() };
let ImageGenerationPlan::ArtcraftGptImage1(plan) = request.build().unwrap() else {
panic!("expected ArtcraftGptImage1")
};
assert!(plan.image_size.is_none(), "expected None for {:?}", auto_ar);
}
}
#[test]
fn image_size_square_variants() {
for ar in [CommonAspectRatio::Square, CommonAspectRatio::SquareHd] {
let request = GenerateImageRequest { aspect_ratio: Some(ar), ..base_gpt_image_1_image_request() };
let ImageGenerationPlan::ArtcraftGptImage1(plan) = request.build().unwrap() else {
panic!("expected ArtcraftGptImage1")
};
assert!(matches!(plan.image_size, Some(ArtcraftGptImage1Size::Square)), "expected Square for {:?}", ar);
}
}
#[test]
fn image_size_wide_variants_yield_horizontal() {
let wide_ars = [
CommonAspectRatio::WideThreeByTwo,
CommonAspectRatio::WideFourByThree,
CommonAspectRatio::WideFiveByFour,
CommonAspectRatio::WideSixteenByNine,
CommonAspectRatio::WideTwentyOneByNine,
CommonAspectRatio::Wide,
];
for ar in wide_ars {
let request = GenerateImageRequest { aspect_ratio: Some(ar), ..base_gpt_image_1_image_request() };
let ImageGenerationPlan::ArtcraftGptImage1(plan) = request.build().unwrap() else {
panic!("expected ArtcraftGptImage1")
};
assert!(matches!(plan.image_size, Some(ArtcraftGptImage1Size::Horizontal)), "expected Horizontal for {:?}", ar);
}
}
#[test]
fn image_size_tall_variants_yield_vertical() {
let tall_ars = [
CommonAspectRatio::TallTwoByThree,
CommonAspectRatio::TallThreeByFour,
CommonAspectRatio::TallFourByFive,
CommonAspectRatio::TallNineBySixteen,
CommonAspectRatio::TallNineByTwentyOne,
CommonAspectRatio::Tall,
];
for ar in tall_ars {
let request = GenerateImageRequest { aspect_ratio: Some(ar), ..base_gpt_image_1_image_request() };
let ImageGenerationPlan::ArtcraftGptImage1(plan) = request.build().unwrap() else {
panic!("expected ArtcraftGptImage1")
};
assert!(matches!(plan.image_size, Some(ArtcraftGptImage1Size::Vertical)), "expected Vertical for {:?}", ar);
}
}
// ── Quality default ──────────────────────────────────────────────────────
#[test]
fn quality_defaults_to_high() {
let request = base_gpt_image_1_image_request();
let ImageGenerationPlan::ArtcraftGptImage1(plan) = request.build().unwrap() else {
panic!("expected ArtcraftGptImage1")
};
assert!(matches!(plan.quality, ArtcraftGptImage1Quality::High));
}
// ── Num images ───────────────────────────────────────────────────────────
#[test]
fn num_images_zero_is_always_error() {
for strategy in [
RequestMismatchMitigationStrategy::ErrorOut,
RequestMismatchMitigationStrategy::PayMoreUpgrade,
RequestMismatchMitigationStrategy::PayLessDowngrade,
] {
let request = GenerateImageRequest {
image_batch_count: Some(0),
request_mismatch_mitigation_strategy: strategy,
..base_gpt_image_1_image_request()
};
let result = request.build();
assert!(
matches!(result, Err(ArtcraftRouterError::Client(ClientError::UserRequestedZeroGenerations))),
"expected UserRequestedZeroGenerations with {:?}", strategy,
);
}
}
#[test]
fn num_images_default_is_one() {
let request = GenerateImageRequest { image_batch_count: None, ..base_gpt_image_1_image_request() };
let ImageGenerationPlan::ArtcraftGptImage1(plan) = request.build().unwrap() else {
panic!("expected ArtcraftGptImage1")
};
assert!(matches!(plan.num_images, ArtcraftGptImage1NumImages::One));
}
#[test]
fn num_images_direct_mapping() {
let cases = [
(1, ArtcraftGptImage1NumImages::One),
(2, ArtcraftGptImage1NumImages::Two),
(3, ArtcraftGptImage1NumImages::Three),
(4, ArtcraftGptImage1NumImages::Four),
];
for (count, expected) in cases {
let request = GenerateImageRequest { image_batch_count: Some(count), ..base_gpt_image_1_image_request() };
let ImageGenerationPlan::ArtcraftGptImage1(plan) = request.build().unwrap() else {
panic!("expected ArtcraftGptImage1")
};
assert!(
std::mem::discriminant(&plan.num_images) == std::mem::discriminant(&expected),
"expected {:?} for count {}", expected, count,
);
}
}
#[test]
fn num_images_out_of_range_error_out() {
let request = GenerateImageRequest {
image_batch_count: Some(5),
request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::ErrorOut,
..base_gpt_image_1_image_request()
};
let result = request.build();
assert!(matches!(
result,
Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption { .. }))
));
}
#[test]
fn num_images_out_of_range_clamps_to_four() {
for strategy in [
RequestMismatchMitigationStrategy::PayMoreUpgrade,
RequestMismatchMitigationStrategy::PayLessDowngrade,
] {
let request = GenerateImageRequest {
image_batch_count: Some(9),
request_mismatch_mitigation_strategy: strategy,
..base_gpt_image_1_image_request()
};
let ImageGenerationPlan::ArtcraftGptImage1(plan) = request.build().unwrap() else {
panic!("expected ArtcraftGptImage1")
};
assert!(
matches!(plan.num_images, ArtcraftGptImage1NumImages::Four),
"expected Four for count 9 with {:?}", strategy,
);
}
}
// ── Image inputs (mode detection) ────────────────────────────────────────
#[test]
fn no_image_inputs_is_text_to_image_mode() {
let request = GenerateImageRequest { image_inputs: None, ..base_gpt_image_1_image_request() };
let ImageGenerationPlan::ArtcraftGptImage1(plan) = request.build().unwrap() else {
panic!("expected ArtcraftGptImage1")
};
assert!(plan.image_inputs.is_none());
}
#[test]
fn media_token_image_inputs_is_edit_mode() {
let tokens = vec![];
let request = GenerateImageRequest {
image_inputs: Some(ImageListRef::MediaFileTokens(&tokens)),
..base_gpt_image_1_image_request()
};
let ImageGenerationPlan::ArtcraftGptImage1(plan) = request.build().unwrap() else {
panic!("expected ArtcraftGptImage1")
};
assert!(plan.image_inputs.is_some());
}
#[test]
fn url_image_inputs_are_accepted_for_cost_path() {
// The omni-gen distillation hydrates media tokens to URLs before running
// cost estimation against Artcraft. URLs are accepted (and dropped) since
// cost only depends on quality + size + num_images.
let urls = vec!["https://example.com/image.jpg".to_string()];
let request = GenerateImageRequest {
image_inputs: Some(ImageListRef::Urls(&urls)),
..base_gpt_image_1_image_request()
};
let ImageGenerationPlan::ArtcraftGptImage1(plan) = request.build().unwrap() else {
panic!("expected ArtcraftGptImage1")
};
assert!(plan.image_inputs.is_none());
}
// ── Idempotency token ────────────────────────────────────────────────────
#[test]
fn idempotency_token_is_generated_when_unset() {
let request = base_gpt_image_1_image_request();
let ImageGenerationPlan::ArtcraftGptImage1(plan) = request.build().unwrap() else {
panic!("expected ArtcraftGptImage1")
};
assert!(!plan.idempotency_token.is_empty());
}
#[test]
fn idempotency_token_passthrough() {
let token = "11111111-1111-1111-1111-111111111111";
let request = GenerateImageRequest { idempotency_token: Some(token), ..base_gpt_image_1_image_request() };
let ImageGenerationPlan::ArtcraftGptImage1(plan) = request.build().unwrap() else {
panic!("expected ArtcraftGptImage1")
};
assert_eq!(plan.idempotency_token, token);
}
// ── Conversions to legacy storyteller-web request enums ─────────────────
#[test]
fn num_images_to_t2i_round_trip() {
use GenerateGptImage1TextToImageNumImages as T;
assert!(matches!(ArtcraftGptImage1NumImages::One.to_t2i(), T::One));
assert!(matches!(ArtcraftGptImage1NumImages::Two.to_t2i(), T::Two));
assert!(matches!(ArtcraftGptImage1NumImages::Three.to_t2i(), T::Three));
assert!(matches!(ArtcraftGptImage1NumImages::Four.to_t2i(), T::Four));
}
#[test]
fn num_images_to_edit_round_trip() {
use GptImage1EditImageNumImages as E;
assert!(matches!(ArtcraftGptImage1NumImages::One.to_edit(), E::One));
assert!(matches!(ArtcraftGptImage1NumImages::Two.to_edit(), E::Two));
assert!(matches!(ArtcraftGptImage1NumImages::Three.to_edit(), E::Three));
assert!(matches!(ArtcraftGptImage1NumImages::Four.to_edit(), E::Four));
}
#[test]
fn quality_to_t2i_round_trip() {
use GenerateGptImage1TextToImageImageQuality as T;
assert!(matches!(ArtcraftGptImage1Quality::Auto.to_t2i(), T::Auto));
assert!(matches!(ArtcraftGptImage1Quality::Low.to_t2i(), T::Low));
assert!(matches!(ArtcraftGptImage1Quality::Medium.to_t2i(), T::Medium));
assert!(matches!(ArtcraftGptImage1Quality::High.to_t2i(), T::High));
}
#[test]
fn quality_to_edit_round_trip() {
use GptImage1EditImageImageQuality as E;
assert!(matches!(ArtcraftGptImage1Quality::Auto.to_edit(), E::Auto));
assert!(matches!(ArtcraftGptImage1Quality::Low.to_edit(), E::Low));
assert!(matches!(ArtcraftGptImage1Quality::Medium.to_edit(), E::Medium));
assert!(matches!(ArtcraftGptImage1Quality::High.to_edit(), E::High));
}
#[test]
fn size_to_t2i_round_trip() {
use GenerateGptImage1TextToImageImageSize as T;
assert!(matches!(ArtcraftGptImage1Size::Square.to_t2i(), T::Square));
assert!(matches!(ArtcraftGptImage1Size::Horizontal.to_t2i(), T::Horizontal));
assert!(matches!(ArtcraftGptImage1Size::Vertical.to_t2i(), T::Vertical));
}
#[test]
fn size_to_edit_round_trip() {
use GptImage1EditImageImageSize as E;
assert!(matches!(ArtcraftGptImage1Size::Square.to_edit(), E::Square));
assert!(matches!(ArtcraftGptImage1Size::Horizontal.to_edit(), E::Horizontal));
assert!(matches!(ArtcraftGptImage1Size::Vertical.to_edit(), E::Vertical));
}
}
@@ -1,4 +1,5 @@
use crate::api::common_aspect_ratio::CommonAspectRatio;
use crate::api::common_quality::CommonQuality;
use crate::api::image_list_ref::ImageListRef;
use crate::client::request_mismatch_mitigation_strategy::RequestMismatchMitigationStrategy;
use crate::errors::artcraft_router_error::ArtcraftRouterError;
@@ -30,13 +31,14 @@ pub fn plan_generate_image_artcraft_gpt_image_1p5<'a>(
let image_inputs = resolve_image_list_ref(request.image_inputs)?;
let image_size = plan_image_size(request.aspect_ratio, strategy)?;
let quality = plan_quality(request.quality);
let num_images = plan_num_images(request.image_batch_count, strategy)?;
Ok(ImageGenerationPlan::ArtcraftGptImage1p5(PlanArtcraftGptImage1p5 {
prompt: request.prompt,
image_inputs,
image_size,
quality: GptImage1p5MultiFunctionImageGenQuality::Medium,
quality,
num_images,
idempotency_token: request.get_or_generate_idempotency_token(),
}))
@@ -48,9 +50,19 @@ fn resolve_image_list_ref<'a>(
match image_list_ref {
None => Ok(None),
Some(ImageListRef::MediaFileTokens(tokens)) => Ok(Some(tokens)),
Some(ImageListRef::Urls(_)) => {
Err(ArtcraftRouterError::Client(ClientError::ArtcraftOnlySupportsMediaTokens))
}
// Omni-gen distillation hydrates media tokens to URLs before running the
// Artcraft cost path. Cost only depends on quality + size + num_images, so
// URL-form inputs are accepted and dropped.
Some(ImageListRef::Urls(_)) => Ok(None),
}
}
fn plan_quality(quality: Option<CommonQuality>) -> GptImage1p5MultiFunctionImageGenQuality {
match quality {
Some(CommonQuality::Low) => GptImage1p5MultiFunctionImageGenQuality::Low,
Some(CommonQuality::Medium) => GptImage1p5MultiFunctionImageGenQuality::Medium,
Some(CommonQuality::High) => GptImage1p5MultiFunctionImageGenQuality::High,
None => GptImage1p5MultiFunctionImageGenQuality::High,
}
}
@@ -67,6 +79,7 @@ fn plan_image_size(
None
| Some(CommonAspectRatio::Auto)
| Some(CommonAspectRatio::Auto2k)
| Some(CommonAspectRatio::Auto3k)
| Some(CommonAspectRatio::Auto4k) => Ok(None),
// Square
@@ -195,10 +208,10 @@ mod tests {
// ── Quality ──────────────────────────────────────────────────────────────
#[test]
fn quality_defaults_to_medium() {
fn quality_defaults_to_high() {
let request = base_gpt_image_1p5_image_request();
let ImageGenerationPlan::ArtcraftGptImage1p5(plan) = request.build().unwrap() else { panic!("expected ArtcraftGptImage1p5") };
assert!(matches!(plan.quality, GptImage1p5MultiFunctionImageGenQuality::Medium));
assert!(matches!(plan.quality, GptImage1p5MultiFunctionImageGenQuality::High));
}
// ── Num images ──────────────────────────────────────────────────────────────
@@ -277,17 +290,16 @@ mod tests {
// ── Image inputs ─────────────────────────────────────────────────────────
#[test]
fn url_image_inputs_returns_error() {
fn url_image_inputs_are_accepted_for_cost_path() {
let urls = vec!["https://example.com/image.jpg".to_string()];
let request = GenerateImageRequest {
image_inputs: Some(ImageListRef::Urls(&urls)),
..base_gpt_image_1p5_image_request()
};
let result = request.build();
assert!(matches!(
result,
Err(ArtcraftRouterError::Client(ClientError::ArtcraftOnlySupportsMediaTokens))
));
let ImageGenerationPlan::ArtcraftGptImage1p5(plan) = request.build().unwrap() else {
panic!("expected ArtcraftGptImage1p5")
};
assert!(plan.image_inputs.is_none());
}
#[test]
@@ -45,9 +45,13 @@ fn resolve_image_list_ref<'a>(
match image_list_ref {
None => Ok(None),
Some(ImageListRef::MediaFileTokens(tokens)) => Ok(Some(tokens)),
Some(ImageListRef::Urls(_)) => {
Err(ArtcraftRouterError::Client(ClientError::ArtcraftOnlySupportsMediaTokens))
}
// The omni-gen distillation path hydrates media tokens into URLs before
// running cost estimation against the Artcraft provider. The cost only
// depends on `num_images` and `is_edit_mode` (which is derived from
// `request.image_inputs.is_some()` before this resolver runs), so URL-form
// inputs are accepted here and dropped — there's no Artcraft execution
// path for Nano Banana that would need to read them back.
Some(ImageListRef::Urls(_)) => Ok(None),
}
}
@@ -290,6 +294,7 @@ mod tests {
RequestMismatchMitigationStrategy::PayLessDowngrade,
] {
let request = GenerateImageRequest {
quality: None,
image_batch_count: Some(0),
request_mismatch_mitigation_strategy: strategy,
..base_nano_banana_image_request()
@@ -323,6 +328,7 @@ mod tests {
#[test]
fn num_images_out_of_range_error_out() {
let request = GenerateImageRequest {
quality: None,
image_batch_count: Some(5),
request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::ErrorOut,
..base_nano_banana_image_request()
@@ -341,6 +347,7 @@ mod tests {
RequestMismatchMitigationStrategy::PayLessDowngrade,
] {
let request = GenerateImageRequest {
quality: None,
image_batch_count: Some(5),
request_mismatch_mitigation_strategy: strategy,
..base_nano_banana_image_request()
@@ -356,17 +363,21 @@ mod tests {
// ── Image inputs ─────────────────────────────────────────────────────────
#[test]
fn url_image_inputs_returns_error() {
fn url_image_inputs_are_accepted_for_cost_path() {
// The omni-gen distillation hydrates media tokens to URLs before running
// cost estimation against Artcraft. URLs are accepted (and dropped) since
// cost only depends on num_images + is_edit_mode.
let urls = vec!["https://example.com/image.jpg".to_string()];
let request = GenerateImageRequest {
image_inputs: Some(ImageListRef::Urls(&urls)),
..base_nano_banana_image_request()
};
let result = request.build();
assert!(matches!(
result,
Err(ArtcraftRouterError::Client(ClientError::ArtcraftOnlySupportsMediaTokens))
));
let ImageGenerationPlan::ArtcraftNanaBanana(plan) = request.build().unwrap() else {
panic!("expected ArtcraftNanaBanana")
};
// image_inputs is dropped (None), but edit-mode aspect-ratio handling
// still kicks in because is_edit_mode is derived from request.image_inputs.
assert!(plan.image_inputs.is_none());
}
#[test]
@@ -50,9 +50,12 @@ fn resolve_image_list_ref<'a>(
match image_list_ref {
None => Ok(None),
Some(ImageListRef::MediaFileTokens(tokens)) => Ok(Some(tokens)),
Some(ImageListRef::Urls(_)) => {
Err(ArtcraftRouterError::Client(ClientError::ArtcraftOnlySupportsMediaTokens))
}
// The omni-gen distillation hydrates media tokens to URLs before running
// cost estimation against the Artcraft provider. Cost only depends on
// num_images + resolution + is_edit_mode (derived before this resolver
// runs), so URL-form inputs are accepted and dropped — there's no Artcraft
// execution path for Nano Banana 2 that would need to read them back.
Some(ImageListRef::Urls(_)) => Ok(None),
}
}
@@ -135,8 +138,10 @@ fn plan_resolution(
// No 3K option; default to 2K
Ok(Some(Nb2Res::TwoK))
}
// HalfK, FourEightyP, SevenTwentyP, TenEightyP — nearest supported is OneK
Some(R::HalfK | R::FourEightyP | R::SevenTwentyP | R::TenEightyP) => Ok(Some(Nb2Res::OneK)),
// HalfK is natively supported (and billed at the cheaper 0.5K rate).
Some(R::HalfK) => Ok(Some(Nb2Res::HalfK)),
// FourEightyP, SevenTwentyP, TenEightyP — nearest supported is OneK
Some(R::FourEightyP | R::SevenTwentyP | R::TenEightyP) => Ok(Some(Nb2Res::OneK)),
}
}
@@ -50,9 +50,12 @@ fn resolve_image_list_ref<'a>(
match image_list_ref {
None => Ok(None),
Some(ImageListRef::MediaFileTokens(tokens)) => Ok(Some(tokens)),
Some(ImageListRef::Urls(_)) => {
Err(ArtcraftRouterError::Client(ClientError::ArtcraftOnlySupportsMediaTokens))
}
// The omni-gen distillation hydrates media tokens to URLs before running
// cost estimation against the Artcraft provider. Cost only depends on
// num_images + resolution + is_edit_mode (derived before this resolver
// runs), so URL-form inputs are accepted and dropped — there's no Artcraft
// execution path for Nano Banana Pro that would need to read them back.
Some(ImageListRef::Urls(_)) => Ok(None),
}
}
@@ -354,6 +357,7 @@ mod tests {
RequestMismatchMitigationStrategy::PayLessDowngrade,
] {
let request = GenerateImageRequest {
quality: None,
image_batch_count: Some(0),
request_mismatch_mitigation_strategy: strategy,
..base_image_request()
@@ -387,6 +391,7 @@ mod tests {
#[test]
fn num_images_out_of_range_error_out() {
let request = GenerateImageRequest {
quality: None,
image_batch_count: Some(5),
request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::ErrorOut,
..base_image_request()
@@ -405,6 +410,7 @@ mod tests {
RequestMismatchMitigationStrategy::PayLessDowngrade,
] {
let request = GenerateImageRequest {
quality: None,
image_batch_count: Some(5),
request_mismatch_mitigation_strategy: strategy,
..base_image_request()
@@ -420,17 +426,19 @@ mod tests {
// ── Image inputs ─────────────────────────────────────────────────────────
#[test]
fn url_image_inputs_returns_error() {
fn url_image_inputs_are_accepted_for_cost_path() {
// The omni-gen distillation hydrates media tokens to URLs before running
// cost estimation against Artcraft. URLs are accepted (and dropped) since
// cost only depends on num_images + resolution + is_edit_mode.
let urls = vec!["https://example.com/image.jpg".to_string()];
let request = GenerateImageRequest {
image_inputs: Some(ImageListRef::Urls(&urls)),
..base_image_request()
};
let result = request.build();
assert!(matches!(
result,
Err(ArtcraftRouterError::Client(ClientError::ArtcraftOnlySupportsMediaTokens))
));
let ImageGenerationPlan::ArtcraftNanaBananaPro(plan) = request.build().unwrap() else {
panic!("expected ArtcraftNanaBananaPro")
};
assert!(plan.image_inputs.is_none());
}
#[test]
@@ -82,7 +82,7 @@ fn plan_image_size(
match aspect_ratio {
None => Ok(None),
Some(CommonAspectRatio::Auto) | Some(CommonAspectRatio::Auto2k) | Some(CommonAspectRatio::Auto4k) => {
Some(CommonAspectRatio::Auto) | Some(CommonAspectRatio::Auto2k) | Some(CommonAspectRatio::Auto3k) | Some(CommonAspectRatio::Auto4k) => {
Ok(Some(S::SquareHd))
}
@@ -226,6 +226,7 @@ mod tests {
let cases = [(1, N::One), (2, N::Two), (3, N::Three), (4, N::Four)];
for (count, expected) in cases {
let request = GenerateImageRequest {
quality: None,
image_batch_count: Some(count),
image_inputs: Some(ImageListRef::MediaFileTokens(&tokens)),
..base_qwen_edit_2511_angles_image_request()
@@ -46,9 +46,10 @@ fn resolve_image_list_ref<'a>(
match image_list_ref {
None => Ok(None),
Some(ImageListRef::MediaFileTokens(tokens)) => Ok(Some(tokens)),
Some(ImageListRef::Urls(_)) => {
Err(ArtcraftRouterError::Client(ClientError::ArtcraftOnlySupportsMediaTokens))
}
// Omni-gen distillation hydrates media tokens to URLs before running the
// Artcraft cost path. Cost only depends on num_images, so URL-form inputs
// are accepted and dropped.
Some(ImageListRef::Urls(_)) => Ok(None),
}
}
@@ -71,7 +72,7 @@ fn plan_image_size(
if is_edit_mode { Ok(Some(S::Auto)) } else { Ok(Some(S::Square)) }
}
Some(CommonAspectRatio::Auto2k) => Ok(Some(S::Auto2k)),
Some(CommonAspectRatio::Auto2k) | Some(CommonAspectRatio::Auto3k) => Ok(Some(S::Auto2k)),
Some(CommonAspectRatio::Auto4k) => Ok(Some(S::Auto4k)),
// Square
@@ -295,6 +296,7 @@ mod tests {
RequestMismatchMitigationStrategy::PayLessDowngrade,
] {
let request = GenerateImageRequest {
quality: None,
image_batch_count: Some(0),
request_mismatch_mitigation_strategy: strategy,
..base_seedream_4_image_request()
@@ -322,6 +324,7 @@ mod tests {
#[test]
fn num_images_out_of_range_error_out() {
let request = GenerateImageRequest {
quality: None,
image_batch_count: Some(5),
request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::ErrorOut,
..base_seedream_4_image_request()
@@ -336,6 +339,7 @@ mod tests {
fn num_images_out_of_range_clamps_to_four() {
for strategy in [RequestMismatchMitigationStrategy::PayMoreUpgrade, RequestMismatchMitigationStrategy::PayLessDowngrade] {
let request = GenerateImageRequest {
quality: None,
image_batch_count: Some(5),
request_mismatch_mitigation_strategy: strategy,
..base_seedream_4_image_request()
@@ -348,16 +352,16 @@ mod tests {
// ── Image inputs ──────────────────────────────────────────────────────────
#[test]
fn url_image_inputs_returns_error() {
fn url_image_inputs_are_accepted_for_cost_path() {
let urls = vec!["https://example.com/image.jpg".to_string()];
let request = GenerateImageRequest {
image_inputs: Some(ImageListRef::Urls(&urls)),
..base_seedream_4_image_request()
};
assert!(matches!(
request.build(),
Err(ArtcraftRouterError::Client(ClientError::ArtcraftOnlySupportsMediaTokens))
));
let ImageGenerationPlan::ArtcraftSeedream4(plan) = request.build().unwrap() else {
panic!("expected ArtcraftSeedream4")
};
assert!(plan.image_inputs.is_none());
}
#[test]
@@ -46,9 +46,10 @@ fn resolve_image_list_ref<'a>(
match image_list_ref {
None => Ok(None),
Some(ImageListRef::MediaFileTokens(tokens)) => Ok(Some(tokens)),
Some(ImageListRef::Urls(_)) => {
Err(ArtcraftRouterError::Client(ClientError::ArtcraftOnlySupportsMediaTokens))
}
// Omni-gen distillation hydrates media tokens to URLs before running the
// Artcraft cost path. Cost only depends on num_images, so URL-form inputs
// are accepted and dropped.
Some(ImageListRef::Urls(_)) => Ok(None),
}
}
@@ -71,7 +72,7 @@ fn plan_image_size(
if is_edit_mode { Ok(Some(S::Auto2k)) } else { Ok(Some(S::Square)) }
}
Some(CommonAspectRatio::Auto2k) => Ok(Some(S::Auto2k)),
Some(CommonAspectRatio::Auto2k) | Some(CommonAspectRatio::Auto3k) => Ok(Some(S::Auto2k)),
Some(CommonAspectRatio::Auto4k) => Ok(Some(S::Auto4k)),
// Square
@@ -296,6 +297,7 @@ mod tests {
RequestMismatchMitigationStrategy::PayLessDowngrade,
] {
let request = GenerateImageRequest {
quality: None,
image_batch_count: Some(0),
request_mismatch_mitigation_strategy: strategy,
..base_seedream_4p5_image_request()
@@ -323,6 +325,7 @@ mod tests {
#[test]
fn num_images_out_of_range_error_out() {
let request = GenerateImageRequest {
quality: None,
image_batch_count: Some(5),
request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::ErrorOut,
..base_seedream_4p5_image_request()
@@ -337,6 +340,7 @@ mod tests {
fn num_images_out_of_range_clamps_to_four() {
for strategy in [RequestMismatchMitigationStrategy::PayMoreUpgrade, RequestMismatchMitigationStrategy::PayLessDowngrade] {
let request = GenerateImageRequest {
quality: None,
image_batch_count: Some(5),
request_mismatch_mitigation_strategy: strategy,
..base_seedream_4p5_image_request()
@@ -349,16 +353,16 @@ mod tests {
// ── Image inputs ──────────────────────────────────────────────────────────
#[test]
fn url_image_inputs_returns_error() {
fn url_image_inputs_are_accepted_for_cost_path() {
let urls = vec!["https://example.com/image.jpg".to_string()];
let request = GenerateImageRequest {
image_inputs: Some(ImageListRef::Urls(&urls)),
..base_seedream_4p5_image_request()
};
assert!(matches!(
request.build(),
Err(ArtcraftRouterError::Client(ClientError::ArtcraftOnlySupportsMediaTokens))
));
let ImageGenerationPlan::ArtcraftSeedream4p5(plan) = request.build().unwrap() else {
panic!("expected ArtcraftSeedream4p5")
};
assert!(plan.image_inputs.is_none());
}
#[test]
@@ -46,9 +46,10 @@ fn resolve_image_list_ref<'a>(
match image_list_ref {
None => Ok(None),
Some(ImageListRef::MediaFileTokens(tokens)) => Ok(Some(tokens)),
Some(ImageListRef::Urls(_)) => {
Err(ArtcraftRouterError::Client(ClientError::ArtcraftOnlySupportsMediaTokens))
}
// Omni-gen distillation hydrates media tokens to URLs before running the
// Artcraft cost path. Cost only depends on num_images, so URL-form inputs
// are accepted and dropped.
Some(ImageListRef::Urls(_)) => Ok(None),
}
}
@@ -72,6 +73,7 @@ fn plan_image_size(
}
Some(CommonAspectRatio::Auto2k) => Ok(Some(S::Auto2k)),
Some(CommonAspectRatio::Auto3k) => Ok(Some(S::Auto3k)),
// 5 Lite max is auto_3K; map Auto4k down to Auto3k
Some(CommonAspectRatio::Auto4k) => Ok(Some(S::Auto3k)),
@@ -147,8 +149,6 @@ mod tests {
use super::*;
use crate::api::common_aspect_ratio::CommonAspectRatio;
use crate::api::image_list_ref::ImageListRef;
use crate::errors::artcraft_router_error::ArtcraftRouterError;
use crate::errors::client_error::ClientError;
use crate::test_helpers::base_seedream_5_lite_image_request;
use artcraft_api_defs::generate::image::multi_function::bytedance_seedream_5_lite_multi_function_image_gen::{
BytedanceSeedream5LiteMultiFunctionImageGenImageSize as S,
@@ -2,6 +2,7 @@ pub mod plan_generate_image_fal_flux_1_dev;
pub mod plan_generate_image_fal_flux_1_schnell;
pub mod plan_generate_image_fal_flux_pro_1p1;
pub mod plan_generate_image_fal_flux_pro_1p1_ultra;
pub mod plan_generate_image_fal_gpt_image_1;
pub mod plan_generate_image_fal_gpt_image_1p5;
pub mod plan_generate_image_fal_seedream_4;
pub mod plan_generate_image_fal_seedream_4p5;
@@ -0,0 +1,498 @@
use crate::api::common_aspect_ratio::CommonAspectRatio;
use crate::api::common_quality::CommonQuality;
use crate::api::image_list_ref::ImageListRef;
use crate::client::request_mismatch_mitigation_strategy::RequestMismatchMitigationStrategy;
use crate::errors::artcraft_router_error::ArtcraftRouterError;
use crate::errors::client_error::ClientError;
use crate::generate::generate_image::generate_image_request::GenerateImageRequest;
use crate::generate::generate_image::image_generation_plan::ImageGenerationPlan;
use fal_client::requests::webhook::image::edit::enqueue_gpt_image_1_edit_image_webhook::{
EnqueueGptImage1EditImageNumImages, EnqueueGptImage1EditImageQuality,
EnqueueGptImage1EditImageSize,
};
use fal_client::requests::webhook::image::text::enqueue_gpt_image_1_text_to_image_webhook::{
EnqueueGptImage1TextToImageNumImages, EnqueueGptImage1TextToImageQuality,
EnqueueGptImage1TextToImageSize,
};
#[derive(Debug, Clone, Copy)]
pub enum FalGptImage1NumImages {
One,
Two,
Three,
Four,
}
/// Quality is shared between t2i and edit (Low / Medium / High).
#[derive(Debug, Clone, Copy)]
pub enum FalGptImage1Quality {
Low,
Medium,
High,
}
/// GPT Image 1 supports three image sizes: 1024x1024, 1536x1024, 1024x1536.
#[derive(Debug, Clone, Copy)]
pub enum FalGptImage1ImageSize {
Square,
Horizontal,
Vertical,
}
#[derive(Debug, Clone)]
pub struct PlanFalGptImage1<'a> {
pub prompt: Option<&'a str>,
/// Image URLs for editing. Empty vec = text-to-image mode.
pub image_urls: Vec<String>,
pub image_size: Option<FalGptImage1ImageSize>,
/// Quality defaults to Medium when the request leaves it unspecified.
/// (Matches the fal cost calculator default.)
pub quality: FalGptImage1Quality,
pub num_images: FalGptImage1NumImages,
}
pub fn plan_generate_image_fal_gpt_image_1<'a>(
request: &'a GenerateImageRequest<'a>,
) -> Result<ImageGenerationPlan<'a>, ArtcraftRouterError> {
let strategy = request.request_mismatch_mitigation_strategy;
let image_urls = resolve_image_list_ref(request.image_inputs)?;
let image_size = plan_image_size(request.aspect_ratio);
let quality = plan_quality(request.quality);
let num_images = plan_num_images(request.image_batch_count, strategy)?;
Ok(ImageGenerationPlan::FalGptImage1(PlanFalGptImage1 {
prompt: request.prompt,
image_urls,
image_size,
quality,
num_images,
}))
}
fn resolve_image_list_ref(
image_list_ref: Option<ImageListRef<'_>>,
) -> Result<Vec<String>, ArtcraftRouterError> {
match image_list_ref {
None => Ok(vec![]),
Some(ImageListRef::Urls(urls)) => Ok(urls.clone()),
Some(ImageListRef::MediaFileTokens(_)) => {
Err(ArtcraftRouterError::Client(ClientError::FalOnlySupportsUrls))
}
}
}
fn plan_quality(quality: Option<CommonQuality>) -> FalGptImage1Quality {
match quality {
Some(CommonQuality::Low) => FalGptImage1Quality::Low,
Some(CommonQuality::Medium) => FalGptImage1Quality::Medium,
Some(CommonQuality::High) => FalGptImage1Quality::High,
None => FalGptImage1Quality::High,
}
}
fn plan_image_size(aspect_ratio: Option<CommonAspectRatio>) -> Option<FalGptImage1ImageSize> {
use FalGptImage1ImageSize as S;
match aspect_ratio {
None
| Some(CommonAspectRatio::Auto)
| Some(CommonAspectRatio::Auto2k)
| Some(CommonAspectRatio::Auto3k)
| Some(CommonAspectRatio::Auto4k) => None,
Some(CommonAspectRatio::Square) | Some(CommonAspectRatio::SquareHd) => Some(S::Square),
Some(CommonAspectRatio::WideThreeByTwo)
| Some(CommonAspectRatio::WideFourByThree)
| Some(CommonAspectRatio::WideFiveByFour)
| Some(CommonAspectRatio::WideSixteenByNine)
| Some(CommonAspectRatio::WideTwentyOneByNine)
| Some(CommonAspectRatio::Wide) => Some(S::Horizontal),
Some(CommonAspectRatio::TallTwoByThree)
| Some(CommonAspectRatio::TallThreeByFour)
| Some(CommonAspectRatio::TallFourByFive)
| Some(CommonAspectRatio::TallNineBySixteen)
| Some(CommonAspectRatio::TallNineByTwentyOne)
| Some(CommonAspectRatio::Tall) => Some(S::Vertical),
}
}
fn plan_num_images(
image_batch_count: Option<u16>,
strategy: RequestMismatchMitigationStrategy,
) -> Result<FalGptImage1NumImages, ArtcraftRouterError> {
let count = image_batch_count.unwrap_or(1);
match count {
0 => Err(ArtcraftRouterError::Client(ClientError::UserRequestedZeroGenerations)),
1 => Ok(FalGptImage1NumImages::One),
2 => Ok(FalGptImage1NumImages::Two),
3 => Ok(FalGptImage1NumImages::Three),
4 => Ok(FalGptImage1NumImages::Four),
_ => match strategy {
RequestMismatchMitigationStrategy::ErrorOut => {
Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption {
field: "image_batch_count",
value: format!("{}", count),
}))
}
_ => Ok(FalGptImage1NumImages::Four),
},
}
}
// ── Conversions to fal-client enqueue enums ────────────────────────────────
impl FalGptImage1NumImages {
pub fn to_t2i(self) -> EnqueueGptImage1TextToImageNumImages {
use EnqueueGptImage1TextToImageNumImages as T;
match self {
Self::One => T::One,
Self::Two => T::Two,
Self::Three => T::Three,
Self::Four => T::Four,
}
}
pub fn to_edit(self) -> EnqueueGptImage1EditImageNumImages {
use EnqueueGptImage1EditImageNumImages as E;
match self {
Self::One => E::One,
Self::Two => E::Two,
Self::Three => E::Three,
Self::Four => E::Four,
}
}
pub fn as_u64(self) -> u64 {
match self {
Self::One => 1,
Self::Two => 2,
Self::Three => 3,
Self::Four => 4,
}
}
}
impl FalGptImage1Quality {
pub fn to_t2i(self) -> EnqueueGptImage1TextToImageQuality {
use EnqueueGptImage1TextToImageQuality as T;
match self {
Self::Low => T::Low,
Self::Medium => T::Medium,
Self::High => T::High,
}
}
pub fn to_edit(self) -> EnqueueGptImage1EditImageQuality {
use EnqueueGptImage1EditImageQuality as E;
match self {
Self::Low => E::Low,
Self::Medium => E::Medium,
Self::High => E::High,
}
}
}
impl FalGptImage1ImageSize {
pub fn to_t2i(self) -> EnqueueGptImage1TextToImageSize {
use EnqueueGptImage1TextToImageSize as T;
match self {
Self::Square => T::Square,
Self::Horizontal => T::Horizontal,
Self::Vertical => T::Vertical,
}
}
pub fn to_edit(self) -> EnqueueGptImage1EditImageSize {
use EnqueueGptImage1EditImageSize as E;
match self {
Self::Square => E::Square,
Self::Horizontal => E::Horizontal,
Self::Vertical => E::Vertical,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::api::common_image_model::CommonImageModel;
use crate::api::provider::Provider;
fn base_fal_request() -> GenerateImageRequest<'static> {
GenerateImageRequest {
model: CommonImageModel::GptImage1,
provider: Provider::Fal,
prompt: Some("a cat in space"),
image_inputs: None,
resolution: None,
aspect_ratio: None,
quality: None,
image_batch_count: None,
request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::ErrorOut,
generation_mode_mismatch_strategy: None,
idempotency_token: None,
horizontal_angle: None,
vertical_angle: None,
zoom: None,
}
}
fn build_plan<'a>(request: &'a GenerateImageRequest<'a>) -> PlanFalGptImage1<'a> {
let ImageGenerationPlan::FalGptImage1(plan) =
plan_generate_image_fal_gpt_image_1(request).expect("plan should succeed")
else {
panic!("expected FalGptImage1 variant")
};
plan
}
// ── Mode detection ────────────────────────────────────────────────────────
#[test]
fn no_image_inputs_yields_empty_urls() {
let request = GenerateImageRequest { image_inputs: None, ..base_fal_request() };
let plan = build_plan(&request);
assert!(plan.image_urls.is_empty());
}
#[test]
fn url_image_inputs_are_extracted() {
let urls = vec![
"https://example.com/a.jpg".to_string(),
"https://example.com/b.jpg".to_string(),
];
let request = GenerateImageRequest {
image_inputs: Some(ImageListRef::Urls(&urls)),
..base_fal_request()
};
let plan = build_plan(&request);
assert_eq!(plan.image_urls, urls);
}
#[test]
fn media_token_inputs_return_error() {
let tokens = vec![];
let request = GenerateImageRequest {
image_inputs: Some(ImageListRef::MediaFileTokens(&tokens)),
..base_fal_request()
};
let result = plan_generate_image_fal_gpt_image_1(&request);
assert!(matches!(
result,
Err(ArtcraftRouterError::Client(ClientError::FalOnlySupportsUrls))
));
}
// ── Quality default ──────────────────────────────────────────────────────
#[test]
fn quality_defaults_to_high() {
let request = base_fal_request();
let plan = build_plan(&request);
assert!(matches!(plan.quality, FalGptImage1Quality::High));
}
// ── Image size mapping ───────────────────────────────────────────────────
#[test]
fn image_size_none_is_none() {
let request = GenerateImageRequest { aspect_ratio: None, ..base_fal_request() };
let plan = build_plan(&request);
assert!(plan.image_size.is_none());
}
#[test]
fn image_size_auto_variants_yield_none() {
for auto_ar in [CommonAspectRatio::Auto, CommonAspectRatio::Auto2k, CommonAspectRatio::Auto4k] {
let request = GenerateImageRequest { aspect_ratio: Some(auto_ar), ..base_fal_request() };
let plan = build_plan(&request);
assert!(plan.image_size.is_none(), "expected None for {:?}", auto_ar);
}
}
#[test]
fn image_size_square_variants() {
for ar in [CommonAspectRatio::Square, CommonAspectRatio::SquareHd] {
let request = GenerateImageRequest { aspect_ratio: Some(ar), ..base_fal_request() };
let plan = build_plan(&request);
assert!(matches!(plan.image_size, Some(FalGptImage1ImageSize::Square)), "expected Square for {:?}", ar);
}
}
#[test]
fn image_size_wide_variants_yield_horizontal() {
let wide_ars = [
CommonAspectRatio::WideThreeByTwo,
CommonAspectRatio::WideFourByThree,
CommonAspectRatio::WideFiveByFour,
CommonAspectRatio::WideSixteenByNine,
CommonAspectRatio::WideTwentyOneByNine,
CommonAspectRatio::Wide,
];
for ar in wide_ars {
let request = GenerateImageRequest { aspect_ratio: Some(ar), ..base_fal_request() };
let plan = build_plan(&request);
assert!(matches!(plan.image_size, Some(FalGptImage1ImageSize::Horizontal)), "expected Horizontal for {:?}", ar);
}
}
#[test]
fn image_size_tall_variants_yield_vertical() {
let tall_ars = [
CommonAspectRatio::TallTwoByThree,
CommonAspectRatio::TallThreeByFour,
CommonAspectRatio::TallFourByFive,
CommonAspectRatio::TallNineBySixteen,
CommonAspectRatio::TallNineByTwentyOne,
CommonAspectRatio::Tall,
];
for ar in tall_ars {
let request = GenerateImageRequest { aspect_ratio: Some(ar), ..base_fal_request() };
let plan = build_plan(&request);
assert!(matches!(plan.image_size, Some(FalGptImage1ImageSize::Vertical)), "expected Vertical for {:?}", ar);
}
}
// ── Num images ───────────────────────────────────────────────────────────
#[test]
fn num_images_zero_is_always_error() {
for strategy in [
RequestMismatchMitigationStrategy::ErrorOut,
RequestMismatchMitigationStrategy::PayMoreUpgrade,
RequestMismatchMitigationStrategy::PayLessDowngrade,
] {
let request = GenerateImageRequest {
image_batch_count: Some(0),
request_mismatch_mitigation_strategy: strategy,
..base_fal_request()
};
let result = plan_generate_image_fal_gpt_image_1(&request);
assert!(
matches!(result, Err(ArtcraftRouterError::Client(ClientError::UserRequestedZeroGenerations))),
"expected UserRequestedZeroGenerations with {:?}", strategy,
);
}
}
#[test]
fn num_images_default_is_one() {
let request = GenerateImageRequest { image_batch_count: None, ..base_fal_request() };
let plan = build_plan(&request);
assert!(matches!(plan.num_images, FalGptImage1NumImages::One));
}
#[test]
fn num_images_direct_mapping() {
let cases = [
(1, FalGptImage1NumImages::One),
(2, FalGptImage1NumImages::Two),
(3, FalGptImage1NumImages::Three),
(4, FalGptImage1NumImages::Four),
];
for (count, expected) in cases {
let request = GenerateImageRequest { image_batch_count: Some(count), ..base_fal_request() };
let plan = build_plan(&request);
assert!(
std::mem::discriminant(&plan.num_images) == std::mem::discriminant(&expected),
"expected {:?} for count {}", expected, count,
);
}
}
#[test]
fn num_images_out_of_range_error_out() {
let request = GenerateImageRequest {
image_batch_count: Some(5),
request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::ErrorOut,
..base_fal_request()
};
let result = plan_generate_image_fal_gpt_image_1(&request);
assert!(matches!(
result,
Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption { .. }))
));
}
#[test]
fn num_images_out_of_range_clamps_to_four() {
for strategy in [
RequestMismatchMitigationStrategy::PayMoreUpgrade,
RequestMismatchMitigationStrategy::PayLessDowngrade,
] {
let request = GenerateImageRequest {
image_batch_count: Some(9),
request_mismatch_mitigation_strategy: strategy,
..base_fal_request()
};
let plan = build_plan(&request);
assert!(
matches!(plan.num_images, FalGptImage1NumImages::Four),
"expected Four for count 9 with {:?}", strategy,
);
}
}
// ── as_u64 ───────────────────────────────────────────────────────────────
#[test]
fn num_images_as_u64() {
assert_eq!(FalGptImage1NumImages::One.as_u64(), 1);
assert_eq!(FalGptImage1NumImages::Two.as_u64(), 2);
assert_eq!(FalGptImage1NumImages::Three.as_u64(), 3);
assert_eq!(FalGptImage1NumImages::Four.as_u64(), 4);
}
// ── Conversions to fal-client enqueue enums ─────────────────────────────
#[test]
fn num_images_to_t2i_round_trip() {
use EnqueueGptImage1TextToImageNumImages as T;
assert!(matches!(FalGptImage1NumImages::One.to_t2i(), T::One));
assert!(matches!(FalGptImage1NumImages::Two.to_t2i(), T::Two));
assert!(matches!(FalGptImage1NumImages::Three.to_t2i(), T::Three));
assert!(matches!(FalGptImage1NumImages::Four.to_t2i(), T::Four));
}
#[test]
fn num_images_to_edit_round_trip() {
use EnqueueGptImage1EditImageNumImages as E;
assert!(matches!(FalGptImage1NumImages::One.to_edit(), E::One));
assert!(matches!(FalGptImage1NumImages::Two.to_edit(), E::Two));
assert!(matches!(FalGptImage1NumImages::Three.to_edit(), E::Three));
assert!(matches!(FalGptImage1NumImages::Four.to_edit(), E::Four));
}
#[test]
fn quality_to_t2i_round_trip() {
use EnqueueGptImage1TextToImageQuality as T;
assert!(matches!(FalGptImage1Quality::Low.to_t2i(), T::Low));
assert!(matches!(FalGptImage1Quality::Medium.to_t2i(), T::Medium));
assert!(matches!(FalGptImage1Quality::High.to_t2i(), T::High));
}
#[test]
fn quality_to_edit_round_trip() {
use EnqueueGptImage1EditImageQuality as E;
assert!(matches!(FalGptImage1Quality::Low.to_edit(), E::Low));
assert!(matches!(FalGptImage1Quality::Medium.to_edit(), E::Medium));
assert!(matches!(FalGptImage1Quality::High.to_edit(), E::High));
}
#[test]
fn size_to_t2i_round_trip() {
use EnqueueGptImage1TextToImageSize as T;
assert!(matches!(FalGptImage1ImageSize::Square.to_t2i(), T::Square));
assert!(matches!(FalGptImage1ImageSize::Horizontal.to_t2i(), T::Horizontal));
assert!(matches!(FalGptImage1ImageSize::Vertical.to_t2i(), T::Vertical));
}
#[test]
fn size_to_edit_round_trip() {
use EnqueueGptImage1EditImageSize as E;
assert!(matches!(FalGptImage1ImageSize::Square.to_edit(), E::Square));
assert!(matches!(FalGptImage1ImageSize::Horizontal.to_edit(), E::Horizontal));
assert!(matches!(FalGptImage1ImageSize::Vertical.to_edit(), E::Vertical));
}
}
@@ -1,4 +1,5 @@
use crate::api::common_aspect_ratio::CommonAspectRatio;
use crate::api::common_quality::CommonQuality;
use crate::api::image_list_ref::ImageListRef;
use crate::client::request_mismatch_mitigation_strategy::RequestMismatchMitigationStrategy;
use crate::errors::artcraft_router_error::ArtcraftRouterError;
@@ -53,13 +54,14 @@ pub fn plan_generate_image_fal_gpt_image_1p5<'a>(
let strategy = request.request_mismatch_mitigation_strategy;
let image_urls = resolve_image_list_ref(request.image_inputs)?;
let image_size = plan_image_size(request.aspect_ratio);
let quality = plan_quality(request.quality);
let num_images = plan_num_images(request.image_batch_count, strategy)?;
Ok(ImageGenerationPlan::FalGptImage1p5(PlanFalGptImage1p5 {
prompt: request.prompt,
image_urls,
image_size,
quality: FalGptImage1p5Quality::Medium,
quality,
num_images,
}))
}
@@ -76,12 +78,22 @@ fn resolve_image_list_ref(
}
}
fn plan_quality(quality: Option<CommonQuality>) -> FalGptImage1p5Quality {
match quality {
Some(CommonQuality::Low) => FalGptImage1p5Quality::Low,
Some(CommonQuality::Medium) => FalGptImage1p5Quality::Medium,
Some(CommonQuality::High) => FalGptImage1p5Quality::High,
None => FalGptImage1p5Quality::High,
}
}
fn plan_image_size(aspect_ratio: Option<CommonAspectRatio>) -> Option<FalGptImage1p5ImageSize> {
use FalGptImage1p5ImageSize as S;
match aspect_ratio {
None
| Some(CommonAspectRatio::Auto)
| Some(CommonAspectRatio::Auto2k)
| Some(CommonAspectRatio::Auto3k)
| Some(CommonAspectRatio::Auto4k) => None,
Some(CommonAspectRatio::Square) | Some(CommonAspectRatio::SquareHd) => Some(S::Square),
@@ -256,6 +256,7 @@ mod tests {
image_inputs: None,
resolution: None,
aspect_ratio: None,
quality: None,
image_batch_count: None,
request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::ErrorOut,
generation_mode_mismatch_strategy: None,
@@ -478,7 +479,8 @@ mod tests {
RequestMismatchMitigationStrategy::PayLessDowngrade,
] {
let request = GenerateImageRequest {
image_batch_count: Some(0),
quality: None,
image_batch_count: Some(0),
request_mismatch_mitigation_strategy: strategy,
..base_fal_request()
};
@@ -511,6 +513,7 @@ mod tests {
#[test]
fn num_images_out_of_range_error_out() {
let request = GenerateImageRequest {
quality: None,
image_batch_count: Some(5),
request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::ErrorOut,
..base_fal_request()
@@ -529,7 +532,8 @@ mod tests {
RequestMismatchMitigationStrategy::PayLessDowngrade,
] {
let request = GenerateImageRequest {
image_batch_count: Some(5),
quality: None,
image_batch_count: Some(5),
request_mismatch_mitigation_strategy: strategy,
..base_fal_request()
};
@@ -84,7 +84,7 @@ fn plan_image_size(
Some(CommonAspectRatio::Auto) => {
if is_edit_mode { Ok(Some(S::Auto)) } else { Ok(Some(S::Square)) }
}
Some(CommonAspectRatio::Auto2k) => Ok(Some(S::Auto2k)),
Some(CommonAspectRatio::Auto2k) | Some(CommonAspectRatio::Auto3k) => Ok(Some(S::Auto2k)),
Some(CommonAspectRatio::Auto4k) => Ok(Some(S::Auto4k)),
Some(CommonAspectRatio::Square) => Ok(Some(S::Square)),
@@ -78,7 +78,7 @@ fn plan_image_size(
None => Ok(None),
// No bare Auto for v4.5 — fall back to Auto2k.
Some(CommonAspectRatio::Auto) | Some(CommonAspectRatio::Auto2k) => Ok(Some(S::Auto2k)),
Some(CommonAspectRatio::Auto) | Some(CommonAspectRatio::Auto2k) | Some(CommonAspectRatio::Auto3k) => Ok(Some(S::Auto2k)),
Some(CommonAspectRatio::Auto4k) => Ok(Some(S::Auto4k)),
Some(CommonAspectRatio::Square) => Ok(Some(S::Square)),
@@ -87,6 +87,7 @@ fn plan_image_size(
},
Some(CommonAspectRatio::Auto) | Some(CommonAspectRatio::Auto2k) => Ok(Some(S::Auto2k)),
Some(CommonAspectRatio::Auto3k) => Ok(Some(S::Auto3k)),
// No 4K — fall back to 3K.
Some(CommonAspectRatio::Auto4k) => Ok(Some(S::Auto3k)),
@@ -29,6 +29,7 @@ pub fn base_image_request() -> GenerateImageRequest<'static> {
image_inputs: None,
resolution: None,
aspect_ratio: None,
quality: None,
image_batch_count: None,
request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::ErrorOut,
generation_mode_mismatch_strategy: None,
@@ -55,6 +56,7 @@ pub fn base_fal_image_request() -> GenerateImageRequest<'static> {
image_inputs: None,
resolution: None,
aspect_ratio: None,
quality: None,
image_batch_count: None,
request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::ErrorOut,
generation_mode_mismatch_strategy: None,
@@ -73,6 +75,7 @@ pub fn base_seedream_4_image_request() -> GenerateImageRequest<'static> {
image_inputs: None,
resolution: None,
aspect_ratio: None,
quality: None,
image_batch_count: None,
request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::ErrorOut,
generation_mode_mismatch_strategy: None,
@@ -91,6 +94,7 @@ pub fn base_seedream_4p5_image_request() -> GenerateImageRequest<'static> {
image_inputs: None,
resolution: None,
aspect_ratio: None,
quality: None,
image_batch_count: None,
request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::ErrorOut,
generation_mode_mismatch_strategy: None,
@@ -109,6 +113,7 @@ pub fn base_seedream_5_lite_image_request() -> GenerateImageRequest<'static> {
image_inputs: None,
resolution: None,
aspect_ratio: None,
quality: None,
image_batch_count: None,
request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::ErrorOut,
generation_mode_mismatch_strategy: None,
@@ -127,6 +132,7 @@ pub fn base_nano_banana_2_image_request() -> GenerateImageRequest<'static> {
image_inputs: None,
resolution: None,
aspect_ratio: None,
quality: None,
image_batch_count: None,
request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::ErrorOut,
generation_mode_mismatch_strategy: None,
@@ -145,6 +151,26 @@ pub fn base_nano_banana_image_request() -> GenerateImageRequest<'static> {
image_inputs: None,
resolution: None,
aspect_ratio: None,
quality: None,
image_batch_count: None,
request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::ErrorOut,
generation_mode_mismatch_strategy: None,
idempotency_token: None,
horizontal_angle: None,
vertical_angle: None,
zoom: None,
}
}
pub fn base_gpt_image_1_image_request() -> GenerateImageRequest<'static> {
GenerateImageRequest {
model: CommonImageModel::GptImage1,
provider: Provider::Artcraft,
prompt: Some("a cat in space"),
image_inputs: None,
resolution: None,
aspect_ratio: None,
quality: None,
image_batch_count: None,
request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::ErrorOut,
generation_mode_mismatch_strategy: None,
@@ -163,6 +189,7 @@ pub fn base_gpt_image_1p5_image_request() -> GenerateImageRequest<'static> {
image_inputs: None,
resolution: None,
aspect_ratio: None,
quality: None,
image_batch_count: None,
request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::ErrorOut,
generation_mode_mismatch_strategy: None,
@@ -181,6 +208,7 @@ pub fn base_flux_1_dev_image_request() -> GenerateImageRequest<'static> {
image_inputs: None,
resolution: None,
aspect_ratio: None,
quality: None,
image_batch_count: None,
request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::ErrorOut,
generation_mode_mismatch_strategy: None,
@@ -199,6 +227,7 @@ pub fn base_flux_1_schnell_image_request() -> GenerateImageRequest<'static> {
image_inputs: None,
resolution: None,
aspect_ratio: None,
quality: None,
image_batch_count: None,
request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::ErrorOut,
generation_mode_mismatch_strategy: None,
@@ -217,6 +246,7 @@ pub fn base_flux_pro_1p1_image_request() -> GenerateImageRequest<'static> {
image_inputs: None,
resolution: None,
aspect_ratio: None,
quality: None,
image_batch_count: None,
request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::ErrorOut,
generation_mode_mismatch_strategy: None,
@@ -235,6 +265,7 @@ pub fn base_qwen_edit_2511_angles_image_request() -> GenerateImageRequest<'stati
image_inputs: None,
resolution: None,
aspect_ratio: None,
quality: None,
image_batch_count: None,
request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::ErrorOut,
generation_mode_mismatch_strategy: None,
@@ -253,6 +284,7 @@ pub fn base_flux_2_lora_angles_image_request() -> GenerateImageRequest<'static>
image_inputs: None,
resolution: None,
aspect_ratio: None,
quality: None,
image_batch_count: None,
request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::ErrorOut,
generation_mode_mismatch_strategy: None,
@@ -271,6 +303,7 @@ pub fn base_flux_pro_1p1_ultra_image_request() -> GenerateImageRequest<'static>
image_inputs: None,
resolution: None,
aspect_ratio: None,
quality: None,
image_batch_count: None,
request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::ErrorOut,
generation_mode_mismatch_strategy: None,
@@ -0,0 +1,57 @@
use crate::requests::api::fal_request::FalRequest;
use serde::{Deserialize, Serialize};
/// Non-BYOK edit-image (image-to-image) binding for
/// `fal-ai/gpt-image-1/edit-image`. Fal hosts this model directly; pricing is
/// billed by Fal at their published rates.
#[derive(Debug, Serialize, Deserialize, Default)]
pub struct GptImage1NonByokEditImageInput {
pub prompt: String,
pub image_urls: Vec<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub mask_image_url: Option<String>,
/// "auto", "1024x1024", "1536x1024", "1024x1536"
#[serde(skip_serializing_if = "Option::is_none")]
pub image_size: Option<String>,
/// "low", "medium", "high"
#[serde(skip_serializing_if = "Option::is_none")]
pub quality: Option<String>,
/// "low", "high"
#[serde(skip_serializing_if = "Option::is_none")]
pub input_fidelity: Option<String>,
/// 1 - 4
/// Default: 1
#[serde(skip_serializing_if = "Option::is_none")]
pub num_images: Option<u8>,
/// "auto", "transparent", "opaque"
#[serde(skip_serializing_if = "Option::is_none")]
pub background: Option<String>,
/// "jpeg", "png", "webp"
/// Default: "png"
#[serde(skip_serializing_if = "Option::is_none")]
pub output_format: Option<String>,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct GptImage1NonByokEditImageFile {
pub url: String,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct GptImage1NonByokEditImageOutput {
pub images: Vec<GptImage1NonByokEditImageFile>,
}
pub fn gpt_image_1_non_byok_edit_image(
params: GptImage1NonByokEditImageInput,
) -> FalRequest<GptImage1NonByokEditImageInput, GptImage1NonByokEditImageOutput> {
FalRequest::new("fal-ai/gpt-image-1/edit-image", params)
}
@@ -3,6 +3,7 @@ pub mod http_flux_1_schnell_edit_image;
pub mod http_flux_pro_kontext_max_edit;
pub mod http_gemini_25_flash_edit;
pub mod http_gpt_image_1_edit_image;
pub mod http_gpt_image_1_non_byok_edit_image;
pub mod http_gpt_image_1p5_edit_image;
pub mod http_nano_banana_2_edit_image;
pub mod http_nano_banana_pro_edit_image;
@@ -0,0 +1,49 @@
use crate::requests::api::fal_request::FalRequest;
use serde::{Deserialize, Serialize};
/// Non-BYOK text-to-image binding for `fal-ai/gpt-image-1/text-to-image`.
///
/// Fal hosts this model directly (no caller-supplied OpenAI API key); pricing
/// is billed by Fal at their published rates.
#[derive(Debug, Serialize, Deserialize, Default)]
pub struct GptImage1NonByokTextToImageInput {
pub prompt: String,
/// "auto", "1024x1024", "1536x1024", "1024x1536"
#[serde(skip_serializing_if = "Option::is_none")]
pub image_size: Option<String>,
/// "low", "medium", "high"
#[serde(skip_serializing_if = "Option::is_none")]
pub quality: Option<String>,
/// 1 - 4
/// Default: 1
#[serde(skip_serializing_if = "Option::is_none")]
pub num_images: Option<u8>,
/// "auto", "transparent", "opaque"
#[serde(skip_serializing_if = "Option::is_none")]
pub background: Option<String>,
/// "jpeg", "png", "webp"
/// Default: "png"
#[serde(skip_serializing_if = "Option::is_none")]
pub output_format: Option<String>,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct GptImage1NonByokTextToImageFile {
pub url: String,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct GptImage1NonByokTextToImageOutput {
pub images: Vec<GptImage1NonByokTextToImageFile>,
}
pub fn gpt_image_1_non_byok_text_to_image(
params: GptImage1NonByokTextToImageInput,
) -> FalRequest<GptImage1NonByokTextToImageInput, GptImage1NonByokTextToImageOutput> {
FalRequest::new("fal-ai/gpt-image-1/text-to-image", params)
}
@@ -3,6 +3,7 @@ pub mod http_flux_1_schnell_text_to_image;
pub mod http_flux_pro_11_text_to_image;
pub mod http_flux_pro_11_ultra_text_to_image;
pub mod http_gemini_25_flash_text_to_image;
pub mod http_gpt_image_1_non_byok_text_to_image;
pub mod http_gpt_image_1_text_to_image;
pub mod http_gpt_image_1p5_text_to_image;
pub mod http_nano_banana_2_text_to_image;
@@ -0,0 +1,159 @@
//! Deprecated BYOK (bring-your-own-OpenAI-key) edit-image binding for
//! `fal-ai/gpt-image-1/edit-image/byok`. The non-BYOK replacement lives in
//! `enqueue_gpt_image_1_edit_image_webhook.rs`. Kept for the legacy
//! `gpt_image_1_edit_image_handler` storyteller-web endpoint.
use crate::creds::fal_api_key::FalApiKey;
use crate::creds::open_ai_api_key::OpenAiApiKey;
use crate::error::classify_fal_error::classify_fal_error;
use crate::error::fal_error_plus::FalErrorPlus;
use crate::requests::traits::fal_request_cost_calculator_trait::{FalRequestCostCalculator, UsdCents};
use crate::requests::http::image::edit::http_gpt_image_1_edit_image::{gpt_image_1_edit_image, GptImage1EditImageInput};
use crate::requests::api::webhook_response::WebhookResponse;
use reqwest::IntoUrl;
pub struct GptEditImageByokArgs<'a, V: IntoUrl> {
// Request
pub image_urls: Vec<String>,
pub prompt: &'a str,
pub image_size: GptEditImageSize,
pub num_images: GptEditImageNumImages,
pub quality: GptEditImageQuality,
// Fulfillment
pub api_key: &'a FalApiKey,
pub openai_api_key: &'a OpenAiApiKey,
pub webhook_url: V,
}
#[derive(Copy, Clone, Debug)]
pub enum GptEditImageSize {
Auto,
Square,
Horizontal,
Vertical,
}
#[derive(Copy, Clone, Debug)]
pub enum GptEditImageQuality {
Auto,
Low,
Medium,
High,
}
#[derive(Copy, Clone, Debug)]
pub enum GptEditImageNumImages{
One,
Two,
Three,
Four,
}
// NB: These are BYOK, so they're not Fal's prices
impl <U: IntoUrl> FalRequestCostCalculator for GptEditImageByokArgs<'_, U> {
fn calculate_cost_in_cents(&self) -> UsdCents {
let base_cost = match self.quality {
GptEditImageQuality::Auto => 17,
GptEditImageQuality::Low => 1,
GptEditImageQuality::Medium => 4,
GptEditImageQuality::High => 17,
};
let cost = match self.num_images {
GptEditImageNumImages::One => base_cost,
GptEditImageNumImages::Two => base_cost * 2,
GptEditImageNumImages::Three => base_cost * 3,
GptEditImageNumImages::Four => base_cost * 4,
};
cost as UsdCents
}
}
pub async fn enqueue_gpt_image_1_byok_edit_image_webhook<V: IntoUrl>(
args: GptEditImageByokArgs<'_, V>
) -> Result<WebhookResponse, FalErrorPlus> {
let image_size = match args.image_size {
GptEditImageSize::Auto => "auto",
GptEditImageSize::Square => "1024x1024",
GptEditImageSize::Horizontal => "1536x1024",
GptEditImageSize::Vertical => "1024x1536",
};
let quality = match args.quality {
GptEditImageQuality::Auto => "auto",
GptEditImageQuality::Low => "low",
GptEditImageQuality::Medium => "medium",
GptEditImageQuality::High => "high",
};
let num_images = match args.num_images {
GptEditImageNumImages::One => 1,
GptEditImageNumImages::Two => 2,
GptEditImageNumImages::Three => 3,
GptEditImageNumImages::Four => 4,
};
let request = GptImage1EditImageInput {
image_urls: args.image_urls,
prompt: args.prompt.to_string(),
image_size: image_size.to_string(),
num_images,
quality: quality.to_string(),
openai_api_key: args.openai_api_key.0.to_string(),
};
let result = gpt_image_1_edit_image(request)
.with_api_key(&args.api_key.0)
.queue_webhook(args.webhook_url)
.await;
result.map_err(|err| classify_fal_error(err))
}
#[cfg(test)]
mod tests {
use crate::creds::fal_api_key::FalApiKey;
use crate::creds::open_ai_api_key::OpenAiApiKey;
use crate::requests::webhook::image::edit::enqueue_gpt_image_1_byok_edit_image_webhook::{
enqueue_gpt_image_1_byok_edit_image_webhook, GptEditImageByokArgs, GptEditImageNumImages,
GptEditImageQuality, GptEditImageSize,
};
use errors::AnyhowResult;
use std::fs::read_to_string;
use test_data::web::image_urls::{
ERNEST_SCARED_STUPID_IMAGE_URL, GHOST_IMAGE_URL, TREX_SKELETON_IMAGE_URL,
};
#[tokio::test]
#[ignore]
async fn test() -> AnyhowResult<()> {
// XXX: Don't commit secrets!
let fal_secret = read_to_string("/Users/bt/Artcraft/credentials/fal_api_key.txt")?;
let fal_api_key = FalApiKey::from_str(&fal_secret);
let openai_secret = read_to_string("/Users/bt/Artcraft/credentials/openai_api_key.txt")?;
let openai_api_key = OpenAiApiKey::from_str(&openai_secret);
let args = GptEditImageByokArgs {
image_urls: vec![
GHOST_IMAGE_URL.to_string(),
TREX_SKELETON_IMAGE_URL.to_string(),
ERNEST_SCARED_STUPID_IMAGE_URL.to_string(),
],
prompt: "add the ghost and scared man to the image of the t-rex skeleton, make it look spooky but friendly",
image_size: GptEditImageSize::Horizontal,
num_images: GptEditImageNumImages::One,
quality: GptEditImageQuality::High,
api_key: &fal_api_key,
openai_api_key: &openai_api_key,
webhook_url: "https://example.com/webhook",
};
let _result = enqueue_gpt_image_1_byok_edit_image_webhook(args).await?;
Ok(())
}
}
@@ -1,158 +1,232 @@
use crate::creds::fal_api_key::FalApiKey;
use crate::creds::open_ai_api_key::OpenAiApiKey;
use crate::error::classify_fal_error::classify_fal_error;
use crate::error::fal_error_plus::FalErrorPlus;
use crate::requests::traits::fal_request_cost_calculator_trait::{FalRequestCostCalculator, UsdCents};
use crate::requests::http::image::edit::http_gpt_image_1_edit_image::{gpt_image_1_edit_image, GptImage1EditImageInput};
use crate::requests::api::webhook_response::WebhookResponse;
use crate::requests::http::image::edit::http_gpt_image_1_non_byok_edit_image::{
gpt_image_1_non_byok_edit_image, GptImage1NonByokEditImageInput,
};
use crate::requests::traits::fal_request_cost_calculator_trait::{
FalRequestCostCalculator, UsdCents,
};
use reqwest::IntoUrl;
pub struct GptEditImageByokArgs<'a, V: IntoUrl> {
// Request
pub image_urls: Vec<String>,
/// Typed args for the non-BYOK `fal-ai/gpt-image-1/edit-image` endpoint.
///
/// (The BYOK variant lives in `enqueue_gpt_image_1_byok_edit_image_webhook.rs`
/// and is deprecated.)
pub struct EnqueueGptImage1EditImageArgs<'a, R: IntoUrl> {
// Required
pub prompt: &'a str,
pub image_size: GptEditImageSize,
pub num_images: GptEditImageNumImages,
pub quality: GptEditImageQuality,
pub image_urls: Vec<String>,
pub num_images: EnqueueGptImage1EditImageNumImages,
// Optional
pub mask_image_url: Option<String>,
pub image_size: Option<EnqueueGptImage1EditImageSize>,
pub quality: Option<EnqueueGptImage1EditImageQuality>,
pub input_fidelity: Option<EnqueueGptImage1EditImageInputFidelity>,
pub background: Option<EnqueueGptImage1EditImageBackground>,
pub output_format: Option<EnqueueGptImage1EditImageOutputFormat>,
// Fulfillment
pub webhook_url: R,
pub api_key: &'a FalApiKey,
pub openai_api_key: &'a OpenAiApiKey,
pub webhook_url: V,
}
#[derive(Copy, Clone, Debug)]
pub enum GptEditImageSize {
Auto,
Square,
Horizontal,
Vertical,
}
#[derive(Copy, Clone, Debug)]
pub enum GptEditImageQuality {
Auto,
Low,
Medium,
High,
}
#[derive(Copy, Clone, Debug)]
pub enum GptEditImageNumImages{
pub enum EnqueueGptImage1EditImageNumImages {
One,
Two,
Three,
Four,
}
#[derive(Copy, Clone, Debug)]
pub enum EnqueueGptImage1EditImageSize {
Auto,
/// 1024x1024
Square,
/// 1536x1024
Horizontal,
/// 1024x1536
Vertical,
}
// NB: These are BYOK, so they're not Fal's prices
impl <U: IntoUrl> FalRequestCostCalculator for GptEditImageByokArgs<'_, U> {
#[derive(Copy, Clone, Debug)]
pub enum EnqueueGptImage1EditImageQuality {
Low,
Medium,
High,
}
#[derive(Copy, Clone, Debug)]
pub enum EnqueueGptImage1EditImageInputFidelity {
Low,
High,
}
#[derive(Copy, Clone, Debug)]
pub enum EnqueueGptImage1EditImageBackground {
Auto,
Transparent,
Opaque,
}
#[derive(Copy, Clone, Debug)]
pub enum EnqueueGptImage1EditImageOutputFormat {
Jpeg,
Png,
Webp,
}
impl<U: IntoUrl> FalRequestCostCalculator for EnqueueGptImage1EditImageArgs<'_, U> {
fn calculate_cost_in_cents(&self) -> UsdCents {
// Can't find details, so using this: https://www.reddit.com/r/OpenAI/comments/1krfwa1/pricing_gpt_image_1_model/
// Prompts are billed similarly to other GPT models. Image outputs cost approximately $0.01 (low), $0.04 (medium),
// and $0.17 (high) for square images.
// We're likely losing money on this, but that's okay. Will adjust in the future to be fair to users and us.
let base_cost = match self.quality {
GptEditImageQuality::Auto => 17,
GptEditImageQuality::Low => 1,
GptEditImageQuality::Medium => 4,
GptEditImageQuality::High => 17,
// Per fal docs (fal-ai/gpt-image-1/edit-image), pricing matches
// text-to-image (input image tokens are billed separately and not included
// in this base estimate):
// Low: $0.011 (1024x1024) / $0.016 (other) per image
// Medium: $0.042 (1024x1024) / $0.063 (other) per image
// High: $0.167 (1024x1024) / $0.25 (other) per image
let use_quality = self
.quality
.unwrap_or(EnqueueGptImage1EditImageQuality::Medium);
let is_square = matches!(
self.image_size,
None | Some(EnqueueGptImage1EditImageSize::Square) | Some(EnqueueGptImage1EditImageSize::Auto)
);
let base_cost: u64 = match (use_quality, is_square) {
(EnqueueGptImage1EditImageQuality::Low, true) => 2,
(EnqueueGptImage1EditImageQuality::Low, false) => 2,
(EnqueueGptImage1EditImageQuality::Medium, true) => 5,
(EnqueueGptImage1EditImageQuality::Medium, false) => 7,
(EnqueueGptImage1EditImageQuality::High, true) => 17,
(EnqueueGptImage1EditImageQuality::High, false) => 25,
};
let cost = match self.num_images {
GptEditImageNumImages::One => base_cost,
GptEditImageNumImages::Two => base_cost * 2,
GptEditImageNumImages::Three => base_cost * 3,
GptEditImageNumImages::Four => base_cost * 4,
let n: u64 = match self.num_images {
EnqueueGptImage1EditImageNumImages::One => 1,
EnqueueGptImage1EditImageNumImages::Two => 2,
EnqueueGptImage1EditImageNumImages::Three => 3,
EnqueueGptImage1EditImageNumImages::Four => 4,
};
cost as UsdCents
base_cost * n
}
}
pub async fn enqueue_gpt_image_1_edit_image_webhook<V: IntoUrl>(
args: GptEditImageByokArgs<'_, V>
pub async fn enqueue_gpt_image_1_edit_image_webhook<R: IntoUrl>(
args: EnqueueGptImage1EditImageArgs<'_, R>,
) -> Result<WebhookResponse, FalErrorPlus> {
// auto, 1024x1024, 1536x1024, 1024x1536
let image_size = match args.image_size {
GptEditImageSize::Auto => "auto",
GptEditImageSize::Square => "1024x1024",
GptEditImageSize::Horizontal => "1536x1024",
GptEditImageSize::Vertical => "1024x1536",
};
let quality = match args.quality {
GptEditImageQuality::Auto => "auto",
GptEditImageQuality::Low => "low",
GptEditImageQuality::Medium => "medium",
GptEditImageQuality::High => "high",
let num_images: u8 = match args.num_images {
EnqueueGptImage1EditImageNumImages::One => 1,
EnqueueGptImage1EditImageNumImages::Two => 2,
EnqueueGptImage1EditImageNumImages::Three => 3,
EnqueueGptImage1EditImageNumImages::Four => 4,
};
let num_images = match args.num_images {
GptEditImageNumImages::One => 1,
GptEditImageNumImages::Two => 2,
GptEditImageNumImages::Three => 3,
GptEditImageNumImages::Four => 4,
};
let image_size = args.image_size.map(|s| {
match s {
EnqueueGptImage1EditImageSize::Auto => "auto",
EnqueueGptImage1EditImageSize::Square => "1024x1024",
EnqueueGptImage1EditImageSize::Horizontal => "1536x1024",
EnqueueGptImage1EditImageSize::Vertical => "1024x1536",
}
.to_string()
});
let request = GptImage1EditImageInput {
image_urls: args.image_urls,
let quality = args.quality.map(|q| {
match q {
EnqueueGptImage1EditImageQuality::Low => "low",
EnqueueGptImage1EditImageQuality::Medium => "medium",
EnqueueGptImage1EditImageQuality::High => "high",
}
.to_string()
});
let input_fidelity = args.input_fidelity.map(|f| {
match f {
EnqueueGptImage1EditImageInputFidelity::Low => "low",
EnqueueGptImage1EditImageInputFidelity::High => "high",
}
.to_string()
});
let background = args.background.map(|b| {
match b {
EnqueueGptImage1EditImageBackground::Auto => "auto",
EnqueueGptImage1EditImageBackground::Transparent => "transparent",
EnqueueGptImage1EditImageBackground::Opaque => "opaque",
}
.to_string()
});
let output_format = args.output_format.map(|f| {
match f {
EnqueueGptImage1EditImageOutputFormat::Jpeg => "jpeg",
EnqueueGptImage1EditImageOutputFormat::Png => "png",
EnqueueGptImage1EditImageOutputFormat::Webp => "webp",
}
.to_string()
});
let request = GptImage1NonByokEditImageInput {
prompt: args.prompt.to_string(),
image_size: image_size.to_string(),
num_images,
quality: quality.to_string(),
openai_api_key: args.openai_api_key.0.to_string(),
image_urls: args.image_urls,
mask_image_url: args.mask_image_url,
num_images: Some(num_images),
image_size,
quality,
input_fidelity,
background,
output_format,
};
let result = gpt_image_1_edit_image(request)
.with_api_key(&args.api_key.0)
.queue_webhook(args.webhook_url)
.await;
let result = gpt_image_1_non_byok_edit_image(request)
.with_api_key(&args.api_key.0)
.queue_webhook(args.webhook_url)
.await;
result.map_err(|err| classify_fal_error(err))
}
#[cfg(test)]
mod tests {
use crate::creds::fal_api_key::FalApiKey;
use crate::creds::open_ai_api_key::OpenAiApiKey;
use crate::requests::webhook::image::edit::enqueue_gpt_image_1_edit_image_webhook::{enqueue_gpt_image_1_edit_image_webhook, GptEditImageByokArgs, GptEditImageNumImages, GptEditImageQuality, GptEditImageSize};
use crate::requests::webhook::image::edit::enqueue_gpt_image_1_edit_image_webhook::{
enqueue_gpt_image_1_edit_image_webhook, EnqueueGptImage1EditImageArgs,
EnqueueGptImage1EditImageNumImages, EnqueueGptImage1EditImageQuality,
EnqueueGptImage1EditImageSize,
};
use errors::AnyhowResult;
use std::fs::read_to_string;
use test_data::web::image_urls::{ERNEST_SCARED_STUPID_IMAGE_URL, GHOST_IMAGE_URL, GRASSY_HILL_TRANSPARENT_IMAGE_URL};
use test_data::web::image_urls::{
ERNEST_SCARED_STUPID_IMAGE_URL, GHOST_IMAGE_URL, TREX_SKELETON_IMAGE_URL,
};
#[tokio::test]
#[ignore]
async fn test() -> AnyhowResult<()> {
let image_url = "https://cdn-2.fakeyou.com/media/3/4/h/f/s/34hfsmt8e38rvne6mwa4pwbxr6292sgy/image_34hfsmt8e38rvne6mwa4pwbxr6292sgy.png";
// XXX: Don't commit secrets!
let secret = read_to_string("/Users/bt/Artcraft/credentials/fal_api_key.txt")?;
let api_key = FalApiKey::from_str(&secret);
let fal_api_key = FalApiKey::from_str(&secret);
let secret = read_to_string("/Users/bt/Artcraft/credentials/openai_api_key.txt")?;
let open_ai_api_key = OpenAiApiKey::from_str(&secret);
let args = GptEditImageByokArgs {
let args = EnqueueGptImage1EditImageArgs {
prompt: "add the ghost and scared man to the image of the t-rex skeleton, make it look spooky but friendly",
image_urls: vec![
ERNEST_SCARED_STUPID_IMAGE_URL.to_string(),
GHOST_IMAGE_URL.to_string(),
GRASSY_HILL_TRANSPARENT_IMAGE_URL.to_string(),
TREX_SKELETON_IMAGE_URL.to_string(),
ERNEST_SCARED_STUPID_IMAGE_URL.to_string(),
],
prompt: "put the man and the ghost on the grassy hill. the man is scared of the friendly ghost.",
api_key: &fal_api_key,
openai_api_key: &open_ai_api_key,
num_images: EnqueueGptImage1EditImageNumImages::One,
mask_image_url: None,
image_size: Some(EnqueueGptImage1EditImageSize::Horizontal),
quality: Some(EnqueueGptImage1EditImageQuality::Medium),
input_fidelity: None,
background: None,
output_format: None,
api_key: &api_key,
webhook_url: "https://example.com/webhook",
image_size: GptEditImageSize::Horizontal,
num_images: GptEditImageNumImages::One,
quality: GptEditImageQuality::High,
};
let result = enqueue_gpt_image_1_edit_image_webhook(args).await?;
let _result = enqueue_gpt_image_1_edit_image_webhook(args).await?;
Ok(())
}
@@ -6,6 +6,7 @@ pub mod enqueue_bytedance_seedream_v5_lite_edit_image_webhook;
pub mod enqueue_nano_banana_2_edit_image_webhook;
pub mod enqueue_flux_pro_kontext_max_edit_webhook;
pub mod enqueue_gemini_25_flash_edit_webhook;
pub mod enqueue_gpt_image_1_byok_edit_image_webhook;
pub mod enqueue_gpt_image_1_edit_image_webhook;
pub mod enqueue_gpt_image_1p5_edit_image_webhook;
pub mod enqueue_nano_banana_pro_edit_image_webhook;
@@ -0,0 +1,154 @@
//! Deprecated BYOK (bring-your-own-OpenAI-key) text-to-image binding for
//! `fal-ai/gpt-image-1/text-to-image/byok`. The non-BYOK replacement lives in
//! `enqueue_gpt_image_1_text_to_image_webhook.rs`. Kept for the legacy
//! `generate_gpt_image_1_text_to_image_handler` storyteller-web endpoint.
use crate::creds::fal_api_key::FalApiKey;
use crate::creds::open_ai_api_key::OpenAiApiKey;
use crate::error::classify_fal_error::classify_fal_error;
use crate::error::fal_error_plus::FalErrorPlus;
use crate::requests::traits::fal_request_cost_calculator_trait::{FalRequestCostCalculator, UsdCents};
use crate::requests::http::image::text::http_gpt_image_1_text_to_image::{gpt_image_1_text_to_image, GptImage1TextToImageInput};
use crate::requests::api::webhook_response::WebhookResponse;
use reqwest::IntoUrl;
pub struct GptTextToImageByokArgs<'a, V: IntoUrl> {
// Request
pub prompt: &'a str,
pub image_size: GptTextToImageSize,
pub num_images: GptTextToImageNumImages,
pub quality: GptTextToImageQuality,
// Fulfillment
pub api_key: &'a FalApiKey,
pub openai_api_key: &'a OpenAiApiKey,
pub webhook_url: V,
}
#[derive(Copy, Clone, Debug)]
pub enum GptTextToImageSize{
Auto,
Square,
Horizontal,
Vertical,
}
#[derive(Copy, Clone, Debug)]
pub enum GptTextToImageQuality {
Auto,
Low,
Medium,
High,
}
#[derive(Copy, Clone, Debug)]
pub enum GptTextToImageNumImages{
One,
Two,
Three,
Four,
}
// NB: These are BYOK, so they're not Fal's prices
impl <U: IntoUrl> FalRequestCostCalculator for GptTextToImageByokArgs<'_, U> {
fn calculate_cost_in_cents(&self) -> UsdCents {
// Can't find details, so using this: https://www.reddit.com/r/OpenAI/comments/1krfwa1/pricing_gpt_image_1_model/
// Prompts are billed similarly to other GPT models. Image outputs cost approximately $0.01 (low), $0.04 (medium),
// and $0.17 (high) for square images.
// We're likely losing money on this, but that's okay. Will adjust in the future to be fair to users and us.
let base_cost = match self.quality {
GptTextToImageQuality::Auto => 17,
GptTextToImageQuality::Low => 1,
GptTextToImageQuality::Medium => 4,
GptTextToImageQuality::High => 17,
};
let cost = match self.num_images {
GptTextToImageNumImages::One => base_cost,
GptTextToImageNumImages::Two => base_cost * 2,
GptTextToImageNumImages::Three => base_cost * 3,
GptTextToImageNumImages::Four => base_cost * 4,
};
cost as UsdCents
}
}
pub async fn enqueue_gpt_image_1_byok_text_to_image_webhook<V: IntoUrl>(
args: GptTextToImageByokArgs<'_, V>
) -> Result<WebhookResponse, FalErrorPlus> {
// auto, 1024x1024, 1536x1024, 1024x1536
let image_size = match args.image_size {
GptTextToImageSize::Auto => "auto",
GptTextToImageSize::Square => "1024x1024",
GptTextToImageSize::Horizontal => "1536x1024",
GptTextToImageSize::Vertical => "1024x1536",
};
let quality = match args.quality {
GptTextToImageQuality::Auto => "auto",
GptTextToImageQuality::Low => "low",
GptTextToImageQuality::Medium => "medium",
GptTextToImageQuality::High => "high",
};
let num_images = match args.num_images {
GptTextToImageNumImages::One => 1,
GptTextToImageNumImages::Two => 2,
GptTextToImageNumImages::Three => 3,
GptTextToImageNumImages::Four => 4,
};
let request = GptImage1TextToImageInput {
prompt: args.prompt.to_string(),
image_size: image_size.to_string(),
num_images,
quality: quality.to_string(),
openai_api_key: args.openai_api_key.0.to_string(),
};
let result = gpt_image_1_text_to_image(request)
.with_api_key(&args.api_key.0)
.queue_webhook(args.webhook_url)
.await;
result.map_err(|err| classify_fal_error(err))
}
#[cfg(test)]
mod tests {
use crate::creds::fal_api_key::FalApiKey;
use crate::creds::open_ai_api_key::OpenAiApiKey;
use crate::requests::webhook::image::text::enqueue_gpt_image_1_byok_text_to_image_webhook::{
enqueue_gpt_image_1_byok_text_to_image_webhook, GptTextToImageByokArgs,
GptTextToImageNumImages, GptTextToImageQuality, GptTextToImageSize,
};
use errors::AnyhowResult;
use std::fs::read_to_string;
#[tokio::test]
#[ignore]
async fn test() -> AnyhowResult<()> {
// XXX: Don't commit secrets!
let fal_secret = read_to_string("/Users/bt/Artcraft/credentials/fal_api_key.txt")?;
let fal_api_key = FalApiKey::from_str(&fal_secret);
let openai_secret = read_to_string("/Users/bt/Artcraft/credentials/openai_api_key.txt")?;
let openai_api_key = OpenAiApiKey::from_str(&openai_secret);
let args = GptTextToImageByokArgs {
prompt: "an anime girl riding on the back of a t-rex",
image_size: GptTextToImageSize::Horizontal,
num_images: GptTextToImageNumImages::One,
quality: GptTextToImageQuality::High,
api_key: &fal_api_key,
openai_api_key: &openai_api_key,
webhook_url: "https://example.com/webhook",
};
let _result = enqueue_gpt_image_1_byok_text_to_image_webhook(args).await?;
Ok(())
}
}
@@ -1,122 +1,179 @@
use crate::creds::fal_api_key::FalApiKey;
use crate::creds::open_ai_api_key::OpenAiApiKey;
use crate::error::classify_fal_error::classify_fal_error;
use crate::error::fal_error_plus::FalErrorPlus;
use crate::requests::traits::fal_request_cost_calculator_trait::{FalRequestCostCalculator, UsdCents};
use crate::requests::http::image::text::http_gpt_image_1_text_to_image::{gpt_image_1_text_to_image, GptImage1TextToImageInput};
use crate::requests::api::webhook_response::WebhookResponse;
use crate::requests::http::image::text::http_gpt_image_1_non_byok_text_to_image::{
gpt_image_1_non_byok_text_to_image, GptImage1NonByokTextToImageInput,
};
use crate::requests::traits::fal_request_cost_calculator_trait::{
FalRequestCostCalculator, UsdCents,
};
use reqwest::IntoUrl;
pub struct GptTextToImageByokArgs<'a, V: IntoUrl> {
// Request
/// Typed args for the non-BYOK `fal-ai/gpt-image-1/text-to-image` endpoint.
///
/// (The BYOK variant lives in `enqueue_gpt_image_1_byok_text_to_image_webhook.rs`
/// and is deprecated.)
pub struct EnqueueGptImage1TextToImageArgs<'a, R: IntoUrl> {
// Required
pub prompt: &'a str,
pub image_size: GptTextToImageSize,
pub num_images: GptTextToImageNumImages,
pub quality: GptTextToImageQuality,
pub num_images: EnqueueGptImage1TextToImageNumImages,
// Optional
pub image_size: Option<EnqueueGptImage1TextToImageSize>,
pub quality: Option<EnqueueGptImage1TextToImageQuality>,
pub background: Option<EnqueueGptImage1TextToImageBackground>,
pub output_format: Option<EnqueueGptImage1TextToImageOutputFormat>,
// Fulfillment
pub webhook_url: R,
pub api_key: &'a FalApiKey,
pub openai_api_key: &'a OpenAiApiKey,
pub webhook_url: V,
}
#[derive(Copy, Clone, Debug)]
pub enum GptTextToImageSize{
Auto,
Square,
Horizontal,
Vertical,
}
#[derive(Copy, Clone, Debug)]
pub enum GptTextToImageQuality {
Auto,
Low,
Medium,
High,
}
#[derive(Copy, Clone, Debug)]
pub enum GptTextToImageNumImages{
pub enum EnqueueGptImage1TextToImageNumImages {
One,
Two,
Three,
Four,
}
#[derive(Copy, Clone, Debug)]
pub enum EnqueueGptImage1TextToImageSize {
Auto,
/// 1024x1024
Square,
/// 1536x1024
Horizontal,
/// 1024x1536
Vertical,
}
// NB: These are BYOK, so they're not Fal's prices
impl <U: IntoUrl> FalRequestCostCalculator for GptTextToImageByokArgs<'_, U> {
#[derive(Copy, Clone, Debug)]
pub enum EnqueueGptImage1TextToImageQuality {
Low,
Medium,
High,
}
#[derive(Copy, Clone, Debug)]
pub enum EnqueueGptImage1TextToImageBackground {
Auto,
Transparent,
Opaque,
}
#[derive(Copy, Clone, Debug)]
pub enum EnqueueGptImage1TextToImageOutputFormat {
Jpeg,
Png,
Webp,
}
impl<U: IntoUrl> FalRequestCostCalculator for EnqueueGptImage1TextToImageArgs<'_, U> {
fn calculate_cost_in_cents(&self) -> UsdCents {
// Can't find details, so using this: https://www.reddit.com/r/OpenAI/comments/1krfwa1/pricing_gpt_image_1_model/
// Prompts are billed similarly to other GPT models. Image outputs cost approximately $0.01 (low), $0.04 (medium),
// and $0.17 (high) for square images.
// We're likely losing money on this, but that's okay. Will adjust in the future to be fair to users and us.
let base_cost = match self.quality {
GptTextToImageQuality::Auto => 17,
GptTextToImageQuality::Low => 1,
GptTextToImageQuality::Medium => 4,
GptTextToImageQuality::High => 17,
// Per fal docs (fal-ai/gpt-image-1/text-to-image):
// Low: $0.011 (1024x1024) / $0.016 (other) per image
// Medium: $0.042 (1024x1024) / $0.063 (other) per image
// High: $0.167 (1024x1024) / $0.25 (other) per image
// Default quality is Medium when unspecified, square when size unspecified.
let use_quality = self
.quality
.unwrap_or(EnqueueGptImage1TextToImageQuality::Medium);
let is_square = matches!(
self.image_size,
None | Some(EnqueueGptImage1TextToImageSize::Square) | Some(EnqueueGptImage1TextToImageSize::Auto)
);
let base_cost: u64 = match (use_quality, is_square) {
(EnqueueGptImage1TextToImageQuality::Low, true) => 2,
(EnqueueGptImage1TextToImageQuality::Low, false) => 2,
(EnqueueGptImage1TextToImageQuality::Medium, true) => 5,
(EnqueueGptImage1TextToImageQuality::Medium, false) => 7,
(EnqueueGptImage1TextToImageQuality::High, true) => 17,
(EnqueueGptImage1TextToImageQuality::High, false) => 25,
};
let cost = match self.num_images {
GptTextToImageNumImages::One => base_cost,
GptTextToImageNumImages::Two => base_cost * 2,
GptTextToImageNumImages::Three => base_cost * 3,
GptTextToImageNumImages::Four => base_cost * 4,
let n: u64 = match self.num_images {
EnqueueGptImage1TextToImageNumImages::One => 1,
EnqueueGptImage1TextToImageNumImages::Two => 2,
EnqueueGptImage1TextToImageNumImages::Three => 3,
EnqueueGptImage1TextToImageNumImages::Four => 4,
};
cost as UsdCents
base_cost * n
}
}
pub async fn enqueue_gpt_image_1_text_to_image_webhook<V: IntoUrl>(
args: GptTextToImageByokArgs<'_, V>
pub async fn enqueue_gpt_image_1_text_to_image_webhook<R: IntoUrl>(
args: EnqueueGptImage1TextToImageArgs<'_, R>,
) -> Result<WebhookResponse, FalErrorPlus> {
// auto, 1024x1024, 1536x1024, 1024x1536
let image_size = match args.image_size {
GptTextToImageSize::Auto => "auto",
GptTextToImageSize::Square => "1024x1024",
GptTextToImageSize::Horizontal => "1536x1024",
GptTextToImageSize::Vertical => "1024x1536",
let num_images: u8 = match args.num_images {
EnqueueGptImage1TextToImageNumImages::One => 1,
EnqueueGptImage1TextToImageNumImages::Two => 2,
EnqueueGptImage1TextToImageNumImages::Three => 3,
EnqueueGptImage1TextToImageNumImages::Four => 4,
};
let quality = match args.quality {
GptTextToImageQuality::Auto => "auto",
GptTextToImageQuality::Low => "low",
GptTextToImageQuality::Medium => "medium",
GptTextToImageQuality::High => "high",
};
let image_size = args.image_size.map(|s| {
match s {
EnqueueGptImage1TextToImageSize::Auto => "auto",
EnqueueGptImage1TextToImageSize::Square => "1024x1024",
EnqueueGptImage1TextToImageSize::Horizontal => "1536x1024",
EnqueueGptImage1TextToImageSize::Vertical => "1024x1536",
}
.to_string()
});
let num_images = match args.num_images {
GptTextToImageNumImages::One => 1,
GptTextToImageNumImages::Two => 2,
GptTextToImageNumImages::Three => 3,
GptTextToImageNumImages::Four => 4,
};
let quality = args.quality.map(|q| {
match q {
EnqueueGptImage1TextToImageQuality::Low => "low",
EnqueueGptImage1TextToImageQuality::Medium => "medium",
EnqueueGptImage1TextToImageQuality::High => "high",
}
.to_string()
});
let request = GptImage1TextToImageInput {
let background = args.background.map(|b| {
match b {
EnqueueGptImage1TextToImageBackground::Auto => "auto",
EnqueueGptImage1TextToImageBackground::Transparent => "transparent",
EnqueueGptImage1TextToImageBackground::Opaque => "opaque",
}
.to_string()
});
let output_format = args.output_format.map(|f| {
match f {
EnqueueGptImage1TextToImageOutputFormat::Jpeg => "jpeg",
EnqueueGptImage1TextToImageOutputFormat::Png => "png",
EnqueueGptImage1TextToImageOutputFormat::Webp => "webp",
}
.to_string()
});
let request = GptImage1NonByokTextToImageInput {
prompt: args.prompt.to_string(),
image_size: image_size.to_string(),
num_images,
quality: quality.to_string(),
openai_api_key: args.openai_api_key.0.to_string(),
num_images: Some(num_images),
image_size,
quality,
background,
output_format,
};
let result = gpt_image_1_text_to_image(request)
.with_api_key(&args.api_key.0)
.queue_webhook(args.webhook_url)
.await;
let result = gpt_image_1_non_byok_text_to_image(request)
.with_api_key(&args.api_key.0)
.queue_webhook(args.webhook_url)
.await;
result.map_err(|err| classify_fal_error(err))
}
#[cfg(test)]
mod tests {
use crate::creds::fal_api_key::FalApiKey;
use crate::creds::open_ai_api_key::OpenAiApiKey;
use crate::requests::webhook::image::text::enqueue_gpt_image_1_text_to_image_webhook::{enqueue_gpt_image_1_text_to_image_webhook, GptTextToImageByokArgs, GptTextToImageNumImages, GptTextToImageQuality, GptTextToImageSize};
use crate::requests::webhook::image::text::enqueue_gpt_image_1_text_to_image_webhook::{
enqueue_gpt_image_1_text_to_image_webhook, EnqueueGptImage1TextToImageArgs,
EnqueueGptImage1TextToImageNumImages, EnqueueGptImage1TextToImageQuality,
EnqueueGptImage1TextToImageSize,
};
use errors::AnyhowResult;
use std::fs::read_to_string;
@@ -125,24 +182,20 @@ mod tests {
async fn test() -> AnyhowResult<()> {
// XXX: Don't commit secrets!
let secret = read_to_string("/Users/bt/Artcraft/credentials/fal_api_key.txt")?;
let api_key = FalApiKey::from_str(&secret);
let fal_api_key = FalApiKey::from_str(&secret);
let secret = read_to_string("/Users/bt/Artcraft/credentials/openai_api_key.txt")?;
let open_ai_api_key = OpenAiApiKey::from_str(&secret);
let args = GptTextToImageByokArgs {
prompt: "put the man and the ghost on the grassy hill. the man is scared of the friendly ghost.",
api_key: &fal_api_key,
openai_api_key: &open_ai_api_key,
let args = EnqueueGptImage1TextToImageArgs {
prompt: "an anime girl riding on the back of a t-rex",
num_images: EnqueueGptImage1TextToImageNumImages::One,
image_size: Some(EnqueueGptImage1TextToImageSize::Horizontal),
quality: Some(EnqueueGptImage1TextToImageQuality::Medium),
background: None,
output_format: None,
api_key: &api_key,
webhook_url: "https://example.com/webhook",
image_size: GptTextToImageSize::Horizontal,
num_images: GptTextToImageNumImages::One,
quality: GptTextToImageQuality::High,
};
let result = enqueue_gpt_image_1_text_to_image_webhook(args).await?;
let _result = enqueue_gpt_image_1_text_to_image_webhook(args).await?;
Ok(())
}
@@ -7,6 +7,7 @@ pub mod enqueue_flux_1_schnell_text_to_image_webhook;
pub mod enqueue_flux_pro_11_text_to_image_webhook;
pub mod enqueue_flux_pro_11_ultra_text_to_image_webhook;
pub mod enqueue_gemini_25_flash_text_to_image_webhook;
pub mod enqueue_gpt_image_1_byok_text_to_image_webhook;
pub mod enqueue_gpt_image_1_text_to_image_webhook;
pub mod enqueue_gpt_image_1p5_text_to_image_webhook;
pub mod enqueue_nano_banana_pro_text_to_image_webhook;
@@ -34,6 +34,8 @@ pub enum CommonAspectRatio {
Tall,
#[serde(rename = "auto_2k")]
Auto2k,
#[serde(rename = "auto_3k")]
Auto3k,
#[serde(rename = "auto_4k")]
Auto4k,
SquareHd,
@@ -61,6 +63,7 @@ impl CommonAspectRatio {
Self::Wide => "wide",
Self::Tall => "tall",
Self::Auto2k => "auto_2k",
Self::Auto3k => "auto_3k",
Self::Auto4k => "auto_4k",
Self::SquareHd => "square_hd",
}
@@ -83,6 +86,7 @@ impl CommonAspectRatio {
"wide" => Ok(Self::Wide),
"tall" => Ok(Self::Tall),
"auto_2k" => Ok(Self::Auto2k),
"auto_3k" => Ok(Self::Auto3k),
"auto_4k" => Ok(Self::Auto4k),
"square_hd" => Ok(Self::SquareHd),
_ => Err(EnumError::CouldNotConvertFromString(value.to_string())),
@@ -106,6 +110,7 @@ impl CommonAspectRatio {
Self::Wide,
Self::Tall,
Self::Auto2k,
Self::Auto3k,
Self::Auto4k,
Self::SquareHd,
])
@@ -139,6 +144,7 @@ mod tests {
assert_serialization(CommonAspectRatio::Wide, "wide");
assert_serialization(CommonAspectRatio::Tall, "tall");
assert_serialization(CommonAspectRatio::Auto2k, "auto_2k");
assert_serialization(CommonAspectRatio::Auto3k, "auto_3k");
assert_serialization(CommonAspectRatio::Auto4k, "auto_4k");
assert_serialization(CommonAspectRatio::SquareHd, "square_hd");
}
@@ -160,6 +166,7 @@ mod tests {
assert_eq!(CommonAspectRatio::Wide.to_str(), "wide");
assert_eq!(CommonAspectRatio::Tall.to_str(), "tall");
assert_eq!(CommonAspectRatio::Auto2k.to_str(), "auto_2k");
assert_eq!(CommonAspectRatio::Auto3k.to_str(), "auto_3k");
assert_eq!(CommonAspectRatio::Auto4k.to_str(), "auto_4k");
assert_eq!(CommonAspectRatio::SquareHd.to_str(), "square_hd");
}
@@ -181,6 +188,7 @@ mod tests {
assert_eq!(CommonAspectRatio::from_str("wide").unwrap(), CommonAspectRatio::Wide);
assert_eq!(CommonAspectRatio::from_str("tall").unwrap(), CommonAspectRatio::Tall);
assert_eq!(CommonAspectRatio::from_str("auto_2k").unwrap(), CommonAspectRatio::Auto2k);
assert_eq!(CommonAspectRatio::from_str("auto_3k").unwrap(), CommonAspectRatio::Auto3k);
assert_eq!(CommonAspectRatio::from_str("auto_4k").unwrap(), CommonAspectRatio::Auto4k);
assert_eq!(CommonAspectRatio::from_str("square_hd").unwrap(), CommonAspectRatio::SquareHd);
}
@@ -199,7 +207,7 @@ mod tests {
#[test]
fn all_variants() {
let variants = CommonAspectRatio::all_variants();
assert_eq!(variants.len(), 17);
assert_eq!(variants.len(), 18);
}
}
@@ -13,6 +13,8 @@ pub enum CommonImageModel {
FluxPro11,
#[serde(rename = "flux_pro_1p1_ultra")]
FluxPro11Ultra,
#[serde(rename = "gpt_image_1")]
GptImage1,
#[serde(rename = "gpt_image_1p5")]
GptImage1p5,
#[serde(rename = "nano_banana")]
@@ -37,6 +39,7 @@ impl CommonImageModel {
Self::Flux1Schnell => CommonModelType::Flux1Schnell,
Self::FluxPro11 => CommonModelType::FluxPro11,
Self::FluxPro11Ultra => CommonModelType::FluxPro11Ultra,
Self::GptImage1 => CommonModelType::GptImage1,
Self::GptImage1p5 => CommonModelType::GptImage1p5,
Self::NanoBanana => CommonModelType::NanoBanana,
Self::NanoBanana2 => CommonModelType::NanoBanana2,
@@ -60,6 +63,7 @@ mod tests {
(CommonImageModel::Flux1Schnell, CommonModelType::Flux1Schnell),
(CommonImageModel::FluxPro11, CommonModelType::FluxPro11),
(CommonImageModel::FluxPro11Ultra, CommonModelType::FluxPro11Ultra),
(CommonImageModel::GptImage1, CommonModelType::GptImage1),
(CommonImageModel::GptImage1p5, CommonModelType::GptImage1p5),
(CommonImageModel::NanoBanana, CommonModelType::NanoBanana),
(CommonImageModel::NanoBanana2, CommonModelType::NanoBanana2),
@@ -4,6 +4,7 @@ use enums::common::generation::common_image_model::CommonImageModel;
use enums::common::generation::common_resolution::CommonResolution;
use enums::common::generation_provider::GenerationProvider;
use once_cell::sync::Lazy;
use enums::common::generation::common_quality::CommonQuality;
pub const OMNI_GEN_IMAGE_MODELS_AND_PROVIDERS: Lazy<OmniGenImageModelsResponse> = Lazy::new(|| {
let models = build_omni_gen_image_models();
@@ -188,6 +189,29 @@ fn build_omni_gen_image_models() -> Vec<OmniGenImageModelDetails> {
..Default::default()
});
models.push(OmniGenImageModelDetails {
model: CommonImageModel::GptImage1,
full_name: Some("GPT Image 1".to_string()),
text_prompt_supported: Some(true),
image_refs_supported: Some(true),
aspect_ratio_options: Some(vec![
CommonAspectRatio::Square,
CommonAspectRatio::WideThreeByTwo,
CommonAspectRatio::TallTwoByThree,
]),
aspect_ratio_default: Some(CommonAspectRatio::Square),
quality_options: Some(vec![
CommonQuality::High,
CommonQuality::Medium,
CommonQuality::Low,
]),
default_quality: Some(CommonQuality::High),
batch_size_min: Some(1),
batch_size_max: Some(4),
batch_size_default: Some(1),
..Default::default()
});
models.push(OmniGenImageModelDetails {
model: CommonImageModel::GptImage1p5,
full_name: Some("GPT Image 1.5".to_string()),
@@ -199,6 +223,12 @@ fn build_omni_gen_image_models() -> Vec<OmniGenImageModelDetails> {
CommonAspectRatio::TallTwoByThree,
]),
aspect_ratio_default: Some(CommonAspectRatio::Square),
quality_options: Some(vec![
CommonQuality::High,
CommonQuality::Medium,
CommonQuality::Low,
]),
default_quality: Some(CommonQuality::High),
batch_size_min: Some(1),
batch_size_max: Some(4),
batch_size_default: Some(1),
@@ -259,6 +289,7 @@ fn build_omni_gen_image_models() -> Vec<OmniGenImageModelDetails> {
image_refs_supported: Some(true),
aspect_ratio_options: Some(vec![
CommonAspectRatio::Auto2k,
CommonAspectRatio::Auto3k,
CommonAspectRatio::Square,
CommonAspectRatio::SquareHd,
CommonAspectRatio::WideFourByThree,
@@ -267,7 +298,7 @@ fn build_omni_gen_image_models() -> Vec<OmniGenImageModelDetails> {
CommonAspectRatio::TallNineBySixteen,
]),
aspect_ratio_default: Some(CommonAspectRatio::Square),
aspect_ratio_default_when_editing: Some(CommonAspectRatio::Auto2k),
aspect_ratio_default_when_editing: Some(CommonAspectRatio::Auto3k),
batch_size_min: Some(1),
batch_size_max: Some(4),
batch_size_default: Some(1),
@@ -45,6 +45,7 @@ pub async fn estimate_image_cost_handler(
image_inputs: None, // TODO: Only some models charge for this - we'll need to add later.
resolution: router_resolution,
aspect_ratio: router_aspect_ratio,
quality: None,
image_batch_count: request.image_batch_count,
request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::PayLessDowngrade,
generation_mode_mismatch_strategy: Some(GenerationModeMismatchStrategy::GenerateAnyway),
@@ -129,6 +130,7 @@ fn map_image_model(model: CommonImageModel) -> Result<RouterImageModel, HandlerE
CommonImageModel::Flux1Schnell => RouterImageModel::Flux1Schnell,
CommonImageModel::FluxPro11 => RouterImageModel::FluxPro11,
CommonImageModel::FluxPro11Ultra => RouterImageModel::FluxPro11Ultra,
CommonImageModel::GptImage1 => RouterImageModel::GptImage1,
CommonImageModel::GptImage1p5 => RouterImageModel::GptImage1p5,
CommonImageModel::NanoBanana => RouterImageModel::NanoBanana,
CommonImageModel::NanoBanana2 => RouterImageModel::NanoBanana2,
@@ -157,6 +159,7 @@ fn map_aspect_ratio(ar: CommonAspectRatio) -> RouterAspectRatio {
CommonAspectRatio::Wide => RouterAspectRatio::Wide,
CommonAspectRatio::Tall => RouterAspectRatio::Tall,
CommonAspectRatio::Auto2k => RouterAspectRatio::Auto2k,
CommonAspectRatio::Auto3k => RouterAspectRatio::Auto3k,
CommonAspectRatio::Auto4k => RouterAspectRatio::Auto4k,
CommonAspectRatio::SquareHd => RouterAspectRatio::SquareHd,
}
@@ -168,6 +168,7 @@ fn map_aspect_ratio(ar: CommonAspectRatio) -> RouterAspectRatio {
CommonAspectRatio::Wide => RouterAspectRatio::Wide,
CommonAspectRatio::Tall => RouterAspectRatio::Tall,
CommonAspectRatio::Auto2k => RouterAspectRatio::Auto2k,
CommonAspectRatio::Auto3k => RouterAspectRatio::Auto3k,
CommonAspectRatio::Auto4k => RouterAspectRatio::Auto4k,
CommonAspectRatio::SquareHd => RouterAspectRatio::SquareHd,
}
@@ -1,5 +1,6 @@
use std::sync::Arc;
use crate::billing::wallets::attempt_wallet_deduction::attempt_wallet_deduction_else_common_web_error;
use crate::http_server::common_responses::common_web_error::CommonWebError;
use crate::http_server::common_responses::media::media_links_builder::MediaLinksBuilder;
use crate::http_server::endpoints::generate::common::payments_error_test::payments_error_test;
@@ -12,30 +13,26 @@ use artcraft_api_defs::generate::image::edit::gemini_25_flash_edit_image::{Gemin
use bucket_paths::legacy::typified_paths::public::media_files::bucket_file_path::MediaFileBucketPath;
use enums::by_table::prompt_context_items::prompt_context_semantic_type::PromptContextSemanticType;
use enums::by_table::prompts::prompt_type::PromptType;
use enums::common::generation_provider::GenerationProvider;
use enums::common::generation::common_model_type::CommonModelType;
use enums::common::visibility::Visibility;
use enums::common::generation::common_generation_mode::CommonGenerationMode;
use enums::common::generation::common_model_type::CommonModelType;
use enums::common::generation_provider::GenerationProvider;
use enums::common::visibility::Visibility;
use fal_client::creds::open_ai_api_key::OpenAiApiKey;
use fal_client::requests::traits::fal_request_cost_calculator_trait::FalRequestCostCalculator;
use fal_client::requests::webhook::image::edit::enqueue_gemini_25_flash_edit_webhook::{enqueue_gemini_25_flash_edit_webhook, Gemini25FlashEditArgs, Gemini25FlashEditNumImages};
use fal_client::requests::webhook::image::edit::enqueue_gpt_image_1_edit_image_webhook::enqueue_gpt_image_1_edit_image_webhook;
use fal_client::requests::webhook::image::edit::enqueue_gpt_image_1_edit_image_webhook::GptEditImageNumImages;
use fal_client::requests::webhook::image::edit::enqueue_gpt_image_1_edit_image_webhook::{GptEditImageByokArgs, GptEditImageQuality, GptEditImageSize};
use http_server_common::request::get_request_ip::get_request_ip;
use log::{error, info, warn};
use mysql_queries::queries::generic_inference::fal::insert_generic_inference_job_for_fal_queue::insert_generic_inference_job_for_fal_queue;
use mysql_queries::queries::generic_inference::fal::insert_generic_inference_job_for_fal_queue::FalCategory;
use mysql_queries::queries::generic_inference::fal::insert_generic_inference_job_for_fal_queue::InsertGenericInferenceForFalArgs;
use mysql_queries::queries::generic_inference::fal::insert_generic_inference_job_for_fal_queue_with_apriori_job_token::{insert_generic_inference_job_for_fal_queue_with_apriori_job_token, InsertGenericInferenceForFalWithAprioriJobTokenArgs};
use mysql_queries::queries::idepotency_tokens::insert_idempotency_token::insert_idempotency_token;
use mysql_queries::queries::media_files::get::batch_get_media_files_by_tokens::{batch_get_media_files_by_tokens, batch_get_media_files_by_tokens_with_connection};
use mysql_queries::queries::prompt_context_items::insert_batch_prompt_context_items::{insert_batch_prompt_context_items, InsertBatchArgs, PromptContextItem};
use mysql_queries::queries::prompts::insert_prompt::{insert_prompt, InsertPromptArgs};
use sqlx::Acquire;
use utoipa::ToSchema;
use fal_client::requests::traits::fal_request_cost_calculator_trait::FalRequestCostCalculator;
use mysql_queries::queries::generic_inference::fal::insert_generic_inference_job_for_fal_queue_with_apriori_job_token::{insert_generic_inference_job_for_fal_queue_with_apriori_job_token, InsertGenericInferenceForFalWithAprioriJobTokenArgs};
use tokens::tokens::generic_inference_jobs::InferenceJobToken;
use crate::billing::wallets::attempt_wallet_deduction::attempt_wallet_deduction_else_common_web_error;
use utoipa::ToSchema;
/// Gemini 2.5 Flash Image Editing
#[utoipa::path(
@@ -13,21 +13,22 @@ use artcraft_api_defs::generate::image::edit::gpt_image_1_edit_image::{GptImage1
use bucket_paths::legacy::typified_paths::public::media_files::bucket_file_path::MediaFileBucketPath;
use enums::by_table::prompt_context_items::prompt_context_semantic_type::PromptContextSemanticType;
use enums::by_table::prompts::prompt_type::PromptType;
use enums::common::generation_provider::GenerationProvider;
use enums::common::generation::common_model_type::CommonModelType;
use enums::common::visibility::Visibility;
use enums::common::generation::common_generation_mode::CommonGenerationMode;
use enums::common::generation::common_aspect_ratio::CommonAspectRatio;
use enums::common::generation::common_generation_mode::CommonGenerationMode;
use enums::common::generation::common_model_type::CommonModelType;
use enums::common::generation_provider::GenerationProvider;
use enums::common::visibility::Visibility;
use fal_client::creds::open_ai_api_key::OpenAiApiKey;
use fal_client::requests::traits::fal_request_cost_calculator_trait::FalRequestCostCalculator;
use fal_client::requests::webhook::image::edit::enqueue_gpt_image_1_edit_image_webhook::enqueue_gpt_image_1_edit_image_webhook;
use fal_client::requests::webhook::image::edit::enqueue_gpt_image_1_edit_image_webhook::GptEditImageNumImages;
use fal_client::requests::webhook::image::edit::enqueue_gpt_image_1_edit_image_webhook::{GptEditImageByokArgs, GptEditImageQuality, GptEditImageSize};
use fal_client::requests::webhook::image::edit::enqueue_gpt_image_1_byok_edit_image_webhook::enqueue_gpt_image_1_byok_edit_image_webhook;
use fal_client::requests::webhook::image::edit::enqueue_gpt_image_1_byok_edit_image_webhook::GptEditImageNumImages;
use fal_client::requests::webhook::image::edit::enqueue_gpt_image_1_byok_edit_image_webhook::{GptEditImageByokArgs, GptEditImageQuality, GptEditImageSize};
use http_server_common::request::get_request_ip::get_request_ip;
use log::{error, info, warn};
use mysql_queries::queries::generic_inference::fal::insert_generic_inference_job_for_fal_queue::insert_generic_inference_job_for_fal_queue;
use mysql_queries::queries::generic_inference::fal::insert_generic_inference_job_for_fal_queue::FalCategory;
use mysql_queries::queries::generic_inference::fal::insert_generic_inference_job_for_fal_queue::InsertGenericInferenceForFalArgs;
use mysql_queries::queries::generic_inference::fal::insert_generic_inference_job_for_fal_queue_with_apriori_job_token::{insert_generic_inference_job_for_fal_queue_with_apriori_job_token, InsertGenericInferenceForFalWithAprioriJobTokenArgs};
use mysql_queries::queries::idepotency_tokens::insert_idempotency_token::insert_idempotency_token;
use mysql_queries::queries::media_files::get::batch_get_media_files_by_tokens::{batch_get_media_files_by_tokens, batch_get_media_files_by_tokens_with_connection};
use mysql_queries::queries::prompt_context_items::insert_batch_prompt_context_items::{insert_batch_prompt_context_items, InsertBatchArgs, PromptContextItem};
@@ -35,7 +36,6 @@ use mysql_queries::queries::prompts::insert_prompt::{insert_prompt, InsertPrompt
use sqlx::Acquire;
use tokens::tokens::generic_inference_jobs::InferenceJobToken;
use utoipa::ToSchema;
use mysql_queries::queries::generic_inference::fal::insert_generic_inference_job_for_fal_queue_with_apriori_job_token::{insert_generic_inference_job_for_fal_queue_with_apriori_job_token, InsertGenericInferenceForFalWithAprioriJobTokenArgs};
/// Gpt Image 1 Image Editing
#[utoipa::path(
@@ -195,7 +195,7 @@ pub async fn gpt_image_1_edit_image_handler(
&mut mysql_connection,
).await?;
let fal_result = enqueue_gpt_image_1_edit_image_webhook(args)
let fal_result = enqueue_gpt_image_1_byok_edit_image_webhook(args)
.await
.map_err(|err| {
warn!("Error calling enqueue_gpt_image_1_edit_image_webhook: {:?}", err);
@@ -12,14 +12,14 @@ use actix_web::{web, HttpRequest};
use artcraft_api_defs::generate::image::text::generate_gpt_image_1_text_to_image::{GenerateGptImage1TextToImageImageQuality, GenerateGptImage1TextToImageImageSize, GenerateGptImage1TextToImageNumImages, GenerateGptImage1TextToImageRequest, GenerateGptImage1TextToImageResponse};
use bucket_paths::legacy::typified_paths::public::media_files::bucket_file_path::MediaFileBucketPath;
use enums::by_table::prompts::prompt_type::PromptType;
use enums::common::generation_provider::GenerationProvider;
use enums::common::generation::common_model_type::CommonModelType;
use enums::common::visibility::Visibility;
use enums::common::generation::common_generation_mode::CommonGenerationMode;
use enums::common::generation::common_aspect_ratio::CommonAspectRatio;
use enums::common::generation::common_generation_mode::CommonGenerationMode;
use enums::common::generation::common_model_type::CommonModelType;
use enums::common::generation_provider::GenerationProvider;
use enums::common::visibility::Visibility;
use fal_client::creds::open_ai_api_key::OpenAiApiKey;
use fal_client::requests::traits::fal_request_cost_calculator_trait::FalRequestCostCalculator;
use fal_client::requests::webhook::image::text::enqueue_gpt_image_1_text_to_image_webhook::{enqueue_gpt_image_1_text_to_image_webhook, GptTextToImageByokArgs, GptTextToImageNumImages, GptTextToImageQuality, GptTextToImageSize};
use fal_client::requests::webhook::image::text::enqueue_gpt_image_1_byok_text_to_image_webhook::{enqueue_gpt_image_1_byok_text_to_image_webhook, GptTextToImageByokArgs, GptTextToImageNumImages, GptTextToImageQuality, GptTextToImageSize};
use http_server_common::request::get_request_ip::get_request_ip;
use log::{error, info, warn};
use mysql_queries::queries::generic_inference::fal::insert_generic_inference_job_for_fal_queue::insert_generic_inference_job_for_fal_queue;
@@ -145,7 +145,7 @@ pub async fn generate_gpt_image_1_text_to_image_handler(
&mut mysql_connection,
).await?;
let fal_result = enqueue_gpt_image_1_text_to_image_webhook(args)
let fal_result = enqueue_gpt_image_1_byok_text_to_image_webhook(args)
.await
.map_err(|err| {
warn!("Error calling enqueue_gpt_image_1_text_to_image_webhook: {:?}", err);
@@ -8,7 +8,7 @@ use artcraft_router::api::provider::Provider;
use log::warn;
use crate::http_server::common_responses::advanced_common_web_error::AdvancedCommonWebError;
use crate::http_server::endpoints::omni_gen::generate::image::transform_request::hydrate_to_router_request;
use crate::http_server::endpoints::omni_gen::generate::image::distill_helper::hydrate_to_router_request::hydrate_to_router_request;
use crate::state::server_state::ServerState;
/// Estimate the cost of an image generation.
@@ -9,7 +9,7 @@ use artcraft_router::errors::artcraft_router_error::ArtcraftRouterError;
use log::warn;
use crate::http_server::common_responses::advanced_common_web_error::AdvancedCommonWebError;
use crate::http_server::endpoints::omni_gen::generate::video::transform_request::hydrate_to_router_request;
use crate::http_server::endpoints::omni_gen::generate::video::hydrate_to_router_request::hydrate_to_router_request;
use crate::state::server_state::ServerState;
/// Estimate the cost of a video generation.
@@ -1,6 +1,7 @@
use artcraft_api_defs::omni_gen::cost_and_generate_requests::omni_gen_image_cost_and_generate_request::OmniGenImageCostAndGenerateRequest;
use artcraft_router::api::common_aspect_ratio::CommonAspectRatio as CommonAspectRatioRouter;
use artcraft_router::api::common_image_model::CommonImageModel as CommonImageModelRouter;
use artcraft_router::api::common_quality::CommonQuality as CommonQualityRouter;
use artcraft_router::api::common_resolution::CommonResolution as CommonResolutionRouter;
use artcraft_router::api::image_list_ref::ImageListRef;
use artcraft_router::api::provider::Provider;
@@ -8,6 +9,7 @@ use artcraft_router::client::request_mismatch_mitigation_strategy::RequestMismat
use artcraft_router::generate::generate_image::generate_image_request::GenerateImageRequest;
use enums::common::generation::common_aspect_ratio::CommonAspectRatio as CommonAspectRatioEnum;
use enums::common::generation::common_image_model::CommonImageModel as CommonImageModelEnum;
use enums::common::generation::common_quality::CommonQuality as CommonQualityEnum;
use enums::common::generation::common_resolution::CommonResolution as CommonResolutionEnum;
use crate::http_server::common_responses::advanced_common_web_error::AdvancedCommonWebError;
@@ -45,6 +47,17 @@ fn convert_resolution(
})
}
fn convert_quality(
quality: &CommonQualityEnum,
) -> Result<CommonQualityRouter, AdvancedCommonWebError> {
let json = serde_json::to_string(quality)?;
serde_json::from_str(&json).map_err(|e| {
AdvancedCommonWebError::BadInputWithSimpleMessage(
format!("Unsupported quality: {}", e),
)
})
}
pub fn hydrate_to_router_request(
request: &OmniGenImageCostAndGenerateRequest,
) -> Result<GenerateImageRequest<'_>, AdvancedCommonWebError> {
@@ -63,6 +76,10 @@ pub fn hydrate_to_router_request(
.map(convert_resolution)
.transpose()?;
let quality = request.quality.as_ref()
.map(convert_quality)
.transpose()?;
Ok(GenerateImageRequest {
model,
provider: Provider::Artcraft,
@@ -71,6 +88,7 @@ pub fn hydrate_to_router_request(
.map(ImageListRef::MediaFileTokens),
resolution,
aspect_ratio,
quality,
image_batch_count: request.image_batch_count,
horizontal_angle: request.horizontal_angle,
vertical_angle: request.vertical_angle,
@@ -0,0 +1,3 @@
pub(crate) mod hydrate_to_router_request;
pub(crate) mod request_to_costs;
pub(crate) mod request_to_plan;
@@ -0,0 +1,233 @@
//! Distillation step for the omni-gen image endpoints.
//!
//! Takes a raw [`OmniGenImageCostAndGenerateRequest`] plus a (pre-computed)
//! `MediaFileToken -> Url` map and produces a fully self-contained
//! [`DistilledImageRequest`] holding:
//! - the (private) router request, kept for inspection in tests / debugging
//! - the [`ImageGenerationCostEstimate`] (Artcraft provider, what we bill on)
//! - the [`ImageGenerationPlan`] (Fal provider, what we actually execute)
//!
//! All borrows inside `request` and `plan` resolve to heap data owned by the
//! returned struct, so it has no caller-managed lifetimes and can be moved /
//! returned freely. Tests can construct one in isolation and assert against
//! its `cost` / `plan` directly.
use std::collections::HashMap;
use log::warn;
use url::Url;
use artcraft_api_defs::omni_gen::cost_and_generate_requests::omni_gen_image_cost_and_generate_request::OmniGenImageCostAndGenerateRequest;
use artcraft_router::api::image_list_ref::ImageListRef;
use artcraft_router::api::provider::Provider;
use artcraft_router::generate::generate_image::generate_image_request::GenerateImageRequest;
use artcraft_router::generate::generate_image::image_generation_cost_estimate::ImageGenerationCostEstimate;
use artcraft_router::generate::generate_image::image_generation_plan::ImageGenerationPlan;
use tokens::tokens::media_files::MediaFileToken;
use crate::http_server::common_responses::advanced_common_web_error::AdvancedCommonWebError;
use super::distill_helper::hydrate_to_router_request::hydrate_to_router_request;
/// Self-contained, owned representation of a fully-distilled omni-gen image
/// request: the router request, the bill-on cost estimate, and the executable
/// plan, all in one place.
///
/// Borrows inside `request` and `plan` point into the boxed owned data fields
/// below; those boxes live for the lifetime of `Self`, so the borrows are
/// always valid. Construct exclusively via [`distill_image_request`].
pub struct DistilledImageRequest {
// Owned heap-stable backing data for the borrows in `request` / `plan`.
// These boxes are never moved out and outlive both `request` and `plan` by
// virtue of being dropped together with them when `Self` is dropped.
// SAFETY backing for the lifetime erasure performed in `distill_image_request`.
_owned_image_input_urls: Option<Box<Vec<String>>>,
_owned_prompt: Option<Box<String>>,
_owned_idempotency_token: Option<Box<String>>,
/// The fully-built router request, with `provider = Fal` so it matches the
/// plan we actually execute. Private — useful for tests / debugging only.
///
/// Borrows inside this request point into the `_owned_*` boxes above. Access
/// only via [`DistilledImageRequest::request`] (an accessor that ties the
/// returned reference to `&self`) — never moved out, otherwise the borrows
/// would dangle once the boxes are dropped with `Self`.
request: GenerateImageRequest<'static>,
/// Cost estimate as computed by the Artcraft provider — this is what we bill on.
pub cost: ImageGenerationCostEstimate,
/// Execution plan as computed by the Fal provider — what we hand to the router.
///
/// Borrows inside this plan point into the `_owned_*` boxes above (and into
/// `request`, which itself points into them). Access only via
/// [`DistilledImageRequest::plan`] — moving the plan out of `Self` would
/// dangle the borrows once the boxes are dropped.
plan: ImageGenerationPlan<'static>,
}
impl DistilledImageRequest {
/// Borrow the execution plan. The returned reference's lifetime is tied to
/// `&self`, so the plan can never outlive the boxes that back its borrows.
pub fn plan(&self) -> &ImageGenerationPlan<'_> {
// SAFETY: the plan was constructed with `'static` borrows that actually
// point into `self._owned_*`. Re-binding the lifetime to `&self` exposes
// the true invariant to callers.
&self.plan
}
/// Borrow the underlying router request. Useful for tests / debugging.
/// (Same lifetime invariant as [`Self::plan`].)
#[allow(dead_code)]
pub(crate) fn request(&self) -> &GenerateImageRequest<'_> {
&self.request
}
}
/// Build a [`DistilledImageRequest`] from a raw API request and a pre-computed
/// `MediaFileToken -> Url` hydration map.
///
/// The hydration map should already have been resolved by the caller (via
/// `lookup_image_urls_as_map` or equivalent) — distillation does no I/O.
///
/// Returns `BadInputWithSimpleMessage` if any image media token referenced by
/// the request is missing from the hydration map.
pub fn distill_image_request(
request: &OmniGenImageCostAndGenerateRequest,
media_file_hydration_map: Option<&HashMap<MediaFileToken, Url>>,
) -> Result<DistilledImageRequest, AdvancedCommonWebError> {
// 1. Convert the raw API request into a router request (still borrowing
// from `request`).
let initial = hydrate_to_router_request(request)?;
// 2. Hoist every borrowed field into an owned `Box` so the resulting
// DistilledImageRequest doesn't borrow from the caller's `request`.
let owned_prompt: Option<Box<String>> = initial.prompt.map(|s| Box::new(s.to_string()));
let owned_idempotency_token: Option<Box<String>> =
initial.idempotency_token.map(|s| Box::new(s.to_string()));
let owned_image_input_urls: Option<Box<Vec<String>>> = build_owned_image_input_urls(
request.image_media_tokens.as_ref(),
media_file_hydration_map,
)?;
// 3. Build a `GenerateImageRequest<'static>` whose borrows point into the
// owned boxes from step 2.
//
// SAFETY: each box lives in `Self` for the entire lifetime of the returned
// DistilledImageRequest, and the 'static-typed borrows we synthesize here
// are never exposed outside of `Self`. The plan / request fields can only
// be accessed through `&self`, so the borrows can never outlive the boxes.
let prompt_static: Option<&'static str> = owned_prompt.as_deref().map(|s: &String| {
let raw: *const str = s.as_str();
unsafe { &*raw }
});
let idempotency_static: Option<&'static str> = owned_idempotency_token
.as_deref()
.map(|s: &String| {
let raw: *const str = s.as_str();
unsafe { &*raw }
});
let image_inputs_static: Option<ImageListRef<'static>> =
owned_image_input_urls.as_deref().map(|v: &Vec<String>| {
let raw: *const Vec<String> = v;
ImageListRef::Urls(unsafe { &*raw })
});
// The "canonical" request used for both billing and execution.
// Provider here is Fal (what we'll actually execute against). The cost path
// below builds a sibling request with provider = Artcraft.
let request_static: GenerateImageRequest<'static> = GenerateImageRequest {
model: initial.model,
provider: Provider::Fal,
prompt: prompt_static,
image_inputs: image_inputs_static,
resolution: initial.resolution,
aspect_ratio: initial.aspect_ratio,
quality: initial.quality,
image_batch_count: initial.image_batch_count,
horizontal_angle: initial.horizontal_angle,
vertical_angle: initial.vertical_angle,
zoom: initial.zoom,
request_mismatch_mitigation_strategy: initial.request_mismatch_mitigation_strategy,
generation_mode_mismatch_strategy: initial.generation_mode_mismatch_strategy,
idempotency_token: idempotency_static,
};
// 4. Cost estimate. Always use the Artcraft provider for billing regardless
// of which provider executes the request — that's what the user is paying for.
let cost_request = GenerateImageRequest {
provider: Provider::Artcraft,
..request_static.clone()
};
let cost_plan = cost_request.build().map_err(|e| {
warn!("Failed to build cost plan during distillation: {}", e);
AdvancedCommonWebError::from_error(e)
})?;
let cost: ImageGenerationCostEstimate = cost_plan.estimate_costs();
// The cost plan borrows into `cost_request` which is about to be dropped;
// `cost` is owned data so this is fine.
drop(cost_plan);
drop(cost_request);
// 5. Execution plan. Built from the Fal-flavored canonical request. The plan
// borrows from the request, which in turn borrows from the owned boxes —
// so the plan's borrows resolve to heap-stable data inside `Self`.
//
// SAFETY: the borrow we hand to `build()` is `&'static`, justified by the
// same invariants as above (the underlying data lives for the lifetime of
// the returned DistilledImageRequest).
let plan: ImageGenerationPlan<'static> = {
let request_ref: &'static GenerateImageRequest<'static> = unsafe {
let raw: *const GenerateImageRequest<'static> = &request_static;
&*raw
};
request_ref.build().map_err(|e| {
warn!("Failed to build image generation plan during distillation: {}", e);
AdvancedCommonWebError::from_error(e)
})?
};
Ok(DistilledImageRequest {
_owned_image_input_urls: owned_image_input_urls,
_owned_prompt: owned_prompt,
_owned_idempotency_token: owned_idempotency_token,
request: request_static,
cost,
plan,
})
}
/// Build the owned `Vec<String>` of image input URLs from the raw token list +
/// hydration map. Errors out if any token referenced by the request is missing
/// from the hydration map.
fn build_owned_image_input_urls(
image_media_tokens: Option<&Vec<MediaFileToken>>,
hydration_map: Option<&HashMap<MediaFileToken, Url>>,
) -> Result<Option<Box<Vec<String>>>, AdvancedCommonWebError> {
let tokens = match image_media_tokens {
Some(tokens) if !tokens.is_empty() => tokens,
_ => return Ok(None),
};
let map = hydration_map.ok_or_else(|| {
AdvancedCommonWebError::BadInputWithSimpleMessage(
"image_media_tokens supplied but no hydration map was provided".to_string(),
)
})?;
let mut urls: Vec<String> = Vec::with_capacity(tokens.len());
for token in tokens {
match map.get(token) {
Some(url) => urls.push(url.to_string()),
None => {
return Err(AdvancedCommonWebError::BadInputWithSimpleMessage(format!(
"Image media token not found in hydration map: {:?}",
token
)));
}
}
}
Ok(Some(Box::new(urls)))
}
@@ -1,5 +1,7 @@
pub mod omni_gen_image_generate_handler;
mod request_to_costs;
mod request_to_plan;
pub(crate) mod distill_helper;
pub(crate) mod distill_image_request;
mod resolve_media_tokens;
pub(crate) mod transform_request;
#[cfg(test)]
mod tests;
@@ -1,9 +1,11 @@
use std::collections::HashMap;
use std::sync::Arc;
use actix_web::web::Json;
use actix_web::{web, HttpRequest};
use log::{error, info, warn};
use sqlx::Acquire;
use url::Url;
use artcraft_api_defs::omni_gen::cost_and_generate_requests::omni_gen_image_cost_and_generate_request::OmniGenImageCostAndGenerateRequest;
use artcraft_api_defs::omni_gen::generate_response::omni_gen_image_generate_response::OmniGenImageGenerateResponse;
@@ -25,17 +27,16 @@ use mysql_queries::queries::prompt_context_items::insert_batch_prompt_context_it
};
use mysql_queries::queries::prompts::insert_prompt::{insert_prompt, InsertPromptArgs};
use tokens::tokens::generic_inference_jobs::InferenceJobToken;
use tokens::tokens::media_files::MediaFileToken;
use crate::billing::wallets::attempt_wallet_deduction::attempt_wallet_deduction_else_common_web_error;
use crate::http_server::common_responses::advanced_common_web_error::AdvancedCommonWebError;
use crate::http_server::endpoints::generate::common::payments_error_test::payments_error_test;
use crate::http_server::validations::validate_idempotency_token_format::validate_idempotency_token_format;
use crate::state::server_state::ServerState;
use crate::util::lookup::lookup_image_urls_as_map::lookup_image_urls_as_map;
use super::request_to_costs::request_to_costs;
use super::request_to_plan::request_to_plan;
use super::resolve_media_tokens::{resolve_media_tokens, apply_resolved_media};
use super::transform_request::hydrate_to_router_request;
use super::distill_image_request::distill_image_request;
/// Generate an image using the omni-gen unified endpoint.
#[utoipa::path(
@@ -59,22 +60,10 @@ pub async fn omni_gen_image_generate_handler(
payments_error_test(&request.prompt.as_deref().unwrap_or(""))?;
// ==================== TRANSFORM REQUEST + PLAN ==================== //
let mut generate_request = hydrate_to_router_request(&request)?;
let maybe_prompt_model_type: Option<CommonModelType> = request.model
.as_ref()
.map(|m| m.to_common_model_type());
// ==================== COST ==================== //
info!("\n\n Generate request: {:?}\n\n", generate_request);
info!(">>> Building cost estimate...");
let cost_estimate = request_to_costs(&generate_request)?;
// ==================== SESSION ==================== //
let mut mysql_connection = server_state.mysql_pool.acquire().await?;
@@ -115,27 +104,42 @@ pub async fn omni_gen_image_generate_handler(
})?;
// ==================== RESOLVE MEDIA TOKENS ==================== //
// Look up media file tokens BEFORE distilling. distill_image_request takes
// a pre-computed `MediaFileToken -> Url` map and does no I/O of its own.
let resolved_media = resolve_media_tokens(
&request,
&http_request,
&mut mysql_connection,
server_state.server_environment,
).await?;
let media_file_hydration_map: Option<HashMap<MediaFileToken, Url>> = match request.image_media_tokens.as_ref() {
Some(tokens) if !tokens.is_empty() => {
info!("Resolving {} image media tokens to CDN URLs", tokens.len());
let raw = lookup_image_urls_as_map(
&http_request,
&mut mysql_connection,
server_state.server_environment,
tokens,
).await?;
let parsed: HashMap<MediaFileToken, Url> = raw.into_iter()
.filter_map(|(token, url_str)| match Url::parse(&url_str) {
Ok(url) => Some((token, url)),
Err(err) => {
warn!("Failed to parse media file URL {:?}: {:?}", url_str, err);
None
}
})
.collect();
Some(parsed)
}
_ => None,
};
apply_resolved_media(&mut generate_request, &resolved_media);
// ==================== DISTILL ==================== //
info!("\n\n Updated request: {:?}\n\n", generate_request);
let distilled = distill_image_request(&request, media_file_hydration_map.as_ref())?;
// ==================== PLAN ==================== //
let plan = request_to_plan(&mut generate_request)?;
info!("\n\n Plan: {:?}\n\n", plan);
info!(">>> Distilled cost: {:?}", distilled.cost);
info!(">>> Distilled plan: {:?}", distilled.plan());
// ==================== BILLING ==================== //
let cost = cost_estimate.cost_in_credits.unwrap_or(0);
let cost = distilled.cost.cost_in_credits.unwrap_or(0);
info!("Charging wallet: {} credits", cost);
@@ -159,7 +163,7 @@ pub async fn omni_gen_image_generate_handler(
let router_client = artcraft_router::client::router_client::RouterClient::Fal(fal_client);
let generation_response = plan.generate_image(&router_client)
let generation_response = distilled.plan().generate_image(&router_client)
.await
.map_err(|e| {
warn!("Image generation failed: {:?}", e);
@@ -0,0 +1,12 @@
mod test_flux_1_dev;
mod test_flux_1_schnell;
mod test_flux_pro_1p1;
mod test_flux_pro_1p1_ultra;
mod test_gpt_image_1;
mod test_gpt_image_1p5;
mod test_nano_banana;
mod test_nano_banana_2;
mod test_nano_banana_pro;
mod test_seedream_4;
mod test_seedream_4p5;
mod test_seedream_5_lite;
@@ -0,0 +1,421 @@
//! Tests for omni-gen against the Flux 1 Dev model.
//!
//! These exercise [`distill_image_request`] end-to-end without firing any
//! provider calls — both the cost estimate (Artcraft provider, what we bill
//! on) and the execution plan (Fal provider, what we'd send to fal.ai) are
//! computed synchronously from the request.
//!
//! Flux 1 Dev is text-to-image only, so no image refs are exercised.
#[cfg(test)]
mod tests {
use std::collections::HashMap;
use artcraft_api_defs::omni_gen::cost_and_generate_requests::omni_gen_image_cost_and_generate_request::OmniGenImageCostAndGenerateRequest;
use enums::common::generation::common_aspect_ratio::CommonAspectRatio;
use enums::common::generation::common_image_model::CommonImageModel;
use tokens::tokens::media_files::MediaFileToken;
use url::Url;
use crate::http_server::endpoints::omni_gen::generate::image::distill_image_request::{
distill_image_request, DistilledImageRequest,
};
// ── Helpers ────────────────────────────────────────────────────────────────
/// Build a minimal Flux 1 Dev omni request with only the fields we care about
/// in tests filled in.
fn make_request(
prompt: Option<&str>,
aspect_ratio: Option<CommonAspectRatio>,
image_batch_count: Option<u16>,
) -> OmniGenImageCostAndGenerateRequest {
OmniGenImageCostAndGenerateRequest {
idempotency_token: Some("11111111-1111-1111-1111-111111111111".to_string()),
model: Some(CommonImageModel::Flux1Dev),
prompt: prompt.map(|s| s.to_string()),
image_media_tokens: None,
resolution: None,
aspect_ratio,
quality: None,
image_batch_count,
horizontal_angle: None,
vertical_angle: None,
zoom: None,
}
}
/// Distill a request and panic with a descriptive message on failure.
/// Flux 1 Dev never uses image refs, so we always pass an empty hydration map.
fn distill(request: &OmniGenImageCostAndGenerateRequest) -> DistilledImageRequest {
let empty: HashMap<MediaFileToken, Url> = HashMap::new();
distill_image_request(request, Some(&empty))
.expect("distill_image_request should succeed for Flux 1 Dev")
}
// ────────────────────────────────────────────────────────────────────────────
// COST
// ────────────────────────────────────────────────────────────────────────────
//
// Pricing comes from `estimate_image_cost_artcraft_flux_1_dev`:
// $0.02 / image (2 credits per image; 1 credit == 1 USD cent)
//
// The artcraft cost path is what omni bills on, so this matches what the
// user is actually charged.
//
// (Note: the Fal cost path would also be 3¢/image for flux_1_dev — but the
// omni endpoint always uses the Artcraft path for billing.)
mod cost {
use super::*;
fn cost_for_batch(image_batch_count: Option<u16>) -> (Option<u64>, Option<u64>) {
let request = make_request(Some("a happy puppy"), None, image_batch_count);
let distilled = distill(&request);
(
distilled.cost.cost_in_credits,
distilled.cost.cost_in_usd_cents,
)
}
#[test]
fn default_batch_costs_2_cents() {
// No batch specified → defaults to 1 image → 2¢
let (credits, cents) = cost_for_batch(None);
assert_eq!(credits, Some(2));
assert_eq!(cents, Some(2));
}
#[test]
fn batch_of_one_costs_2_cents() {
let (credits, cents) = cost_for_batch(Some(1));
assert_eq!(credits, Some(2));
assert_eq!(cents, Some(2));
}
#[test]
fn batch_of_two_costs_4_cents() {
let (credits, cents) = cost_for_batch(Some(2));
assert_eq!(credits, Some(4));
assert_eq!(cents, Some(4));
}
#[test]
fn batch_of_three_costs_6_cents() {
let (credits, cents) = cost_for_batch(Some(3));
assert_eq!(credits, Some(6));
assert_eq!(cents, Some(6));
}
#[test]
fn batch_of_four_costs_8_cents() {
let (credits, cents) = cost_for_batch(Some(4));
assert_eq!(credits, Some(8));
assert_eq!(cents, Some(8));
}
#[test]
fn batch_above_max_clamps_and_costs_8_cents() {
// PayMoreUpgrade strategy clamps batches >4 down to the max of 4.
let (credits, cents) = cost_for_batch(Some(7));
assert_eq!(credits, Some(8));
assert_eq!(cents, Some(8));
}
#[test]
fn cost_is_independent_of_aspect_ratio() {
let aspect_ratios = [
None,
Some(CommonAspectRatio::Square),
Some(CommonAspectRatio::SquareHd),
Some(CommonAspectRatio::WideSixteenByNine),
Some(CommonAspectRatio::TallNineBySixteen),
Some(CommonAspectRatio::Auto),
];
for ar in aspect_ratios {
let request = make_request(Some("p"), ar, Some(2));
let distilled = distill(&request);
assert_eq!(
distilled.cost.cost_in_usd_cents,
Some(4),
"expected 4¢ regardless of aspect ratio (got {:?})",
ar,
);
}
}
#[test]
fn cost_is_independent_of_prompt() {
let with_prompt = distill(&make_request(Some("a cat"), None, Some(3)));
let without_prompt = distill(&make_request(None, None, Some(3)));
assert_eq!(with_prompt.cost.cost_in_usd_cents, Some(6));
assert_eq!(without_prompt.cost.cost_in_usd_cents, Some(6));
}
#[test]
fn cost_metadata_flags_are_default() {
let request = make_request(Some("p"), None, Some(1));
let distilled = distill(&request);
assert!(!distilled.cost.is_free);
assert!(!distilled.cost.is_unlimited);
assert!(!distilled.cost.is_rate_limited);
assert!(!distilled.cost.has_watermark);
}
}
// ────────────────────────────────────────────────────────────────────────────
// PLAN
// ────────────────────────────────────────────────────────────────────────────
//
// The distilled plan is the Fal-side `PlanFalFlux1Dev`:
// - prompt: passthrough
// - maybe_image_url: always None (Flux 1 Dev never gets image refs in tests)
// - aspect_ratio: mapped from CommonAspectRatio with sensible fallbacks
// - num_images: mapped from image_batch_count (default 1, clamped to 1..=4)
mod plan {
use super::*;
use artcraft_router::generate::generate_image::image_generation_plan::ImageGenerationPlan;
use artcraft_router::generate::generate_image::plan::fal::plan_generate_image_fal_flux_1_dev::{
FalFlux1DevNumImages, PlanFalFlux1Dev,
};
use fal_client::requests::webhook::image::text::enqueue_flux_1_dev_text_to_image_webhook::Flux1DevAspectRatio;
/// Distill and run an assertion against the inner FalFlux1Dev plan.
///
/// We pass the plan by reference (rather than moving it out of `distilled`)
/// because the plan's borrows are tied to data owned by `distilled` —
/// moving it out would dangle those borrows.
fn with_fal_plan<F: FnOnce(&PlanFalFlux1Dev<'_>)>(
request: &OmniGenImageCostAndGenerateRequest,
assertion: F,
) {
let distilled = distill(request);
match distilled.plan() {
ImageGenerationPlan::FalFlux1Dev(plan) => assertion(plan),
other => panic!("expected ImageGenerationPlan::FalFlux1Dev, got {:?}", other),
}
}
fn assert_plan_for_aspect_ratio<F: FnOnce(&PlanFalFlux1Dev<'_>)>(
ar: Option<CommonAspectRatio>,
assertion: F,
) {
with_fal_plan(&make_request(Some("p"), ar, Some(1)), assertion);
}
fn assert_plan_for_batch<F: FnOnce(&PlanFalFlux1Dev<'_>)>(
batch: Option<u16>,
assertion: F,
) {
with_fal_plan(&make_request(Some("p"), None, batch), assertion);
}
// ── Aspect ratio mapping ────────────────────────────────────────────────
#[test]
fn default_aspect_ratio_is_square() {
assert_plan_for_aspect_ratio(None, |plan| {
assert!(matches!(plan.aspect_ratio, Flux1DevAspectRatio::Square));
});
}
#[test]
fn square_input_yields_square() {
assert_plan_for_aspect_ratio(Some(CommonAspectRatio::Square), |plan| {
assert!(matches!(plan.aspect_ratio, Flux1DevAspectRatio::Square));
});
}
#[test]
fn square_hd_input_yields_square_hd() {
assert_plan_for_aspect_ratio(Some(CommonAspectRatio::SquareHd), |plan| {
assert!(matches!(plan.aspect_ratio, Flux1DevAspectRatio::SquareHd));
});
}
#[test]
fn wide_4x3_yields_landscape_4_3() {
assert_plan_for_aspect_ratio(Some(CommonAspectRatio::WideFourByThree), |plan| {
assert!(matches!(plan.aspect_ratio, Flux1DevAspectRatio::LandscapeFourByThree));
});
}
#[test]
fn wide_16x9_yields_landscape_16_9() {
assert_plan_for_aspect_ratio(Some(CommonAspectRatio::WideSixteenByNine), |plan| {
assert!(matches!(plan.aspect_ratio, Flux1DevAspectRatio::LandscapeSixteenByNine));
});
}
#[test]
fn wide_alias_yields_landscape_16_9() {
assert_plan_for_aspect_ratio(Some(CommonAspectRatio::Wide), |plan| {
assert!(matches!(plan.aspect_ratio, Flux1DevAspectRatio::LandscapeSixteenByNine));
});
}
#[test]
fn tall_3x4_yields_portrait_3_4() {
assert_plan_for_aspect_ratio(Some(CommonAspectRatio::TallThreeByFour), |plan| {
assert!(matches!(plan.aspect_ratio, Flux1DevAspectRatio::PortraitThreeByFour));
});
}
#[test]
fn tall_9x16_yields_portrait_9_16() {
assert_plan_for_aspect_ratio(Some(CommonAspectRatio::TallNineBySixteen), |plan| {
assert!(matches!(plan.aspect_ratio, Flux1DevAspectRatio::PortraitNineBySixteen));
});
}
#[test]
fn tall_alias_yields_portrait_9_16() {
assert_plan_for_aspect_ratio(Some(CommonAspectRatio::Tall), |plan| {
assert!(matches!(plan.aspect_ratio, Flux1DevAspectRatio::PortraitNineBySixteen));
});
}
// ── Aspect ratio fallback mapping ───────────────────────────────────────
//
// Aspect ratios that Flux 1 Dev doesn't natively support get mapped to the
// closest supported ratio under the PayMoreUpgrade mismatch strategy that
// omni-gen uses.
#[test]
fn wide_5x4_falls_back_to_landscape_4_3() {
assert_plan_for_aspect_ratio(Some(CommonAspectRatio::WideFiveByFour), |plan| {
assert!(matches!(plan.aspect_ratio, Flux1DevAspectRatio::LandscapeFourByThree));
});
}
#[test]
fn wide_3x2_falls_back_to_landscape_4_3() {
assert_plan_for_aspect_ratio(Some(CommonAspectRatio::WideThreeByTwo), |plan| {
assert!(matches!(plan.aspect_ratio, Flux1DevAspectRatio::LandscapeFourByThree));
});
}
#[test]
fn wide_21x9_falls_back_to_landscape_16_9() {
assert_plan_for_aspect_ratio(Some(CommonAspectRatio::WideTwentyOneByNine), |plan| {
assert!(matches!(plan.aspect_ratio, Flux1DevAspectRatio::LandscapeSixteenByNine));
});
}
#[test]
fn tall_4x5_falls_back_to_portrait_3_4() {
assert_plan_for_aspect_ratio(Some(CommonAspectRatio::TallFourByFive), |plan| {
assert!(matches!(plan.aspect_ratio, Flux1DevAspectRatio::PortraitThreeByFour));
});
}
#[test]
fn tall_2x3_falls_back_to_portrait_3_4() {
assert_plan_for_aspect_ratio(Some(CommonAspectRatio::TallTwoByThree), |plan| {
assert!(matches!(plan.aspect_ratio, Flux1DevAspectRatio::PortraitThreeByFour));
});
}
#[test]
fn tall_9x21_falls_back_to_portrait_9_16() {
assert_plan_for_aspect_ratio(Some(CommonAspectRatio::TallNineByTwentyOne), |plan| {
assert!(matches!(plan.aspect_ratio, Flux1DevAspectRatio::PortraitNineBySixteen));
});
}
// ── Auto* aspect ratios ─────────────────────────────────────────────────
#[test]
fn auto_yields_square() {
assert_plan_for_aspect_ratio(Some(CommonAspectRatio::Auto), |plan| {
assert!(matches!(plan.aspect_ratio, Flux1DevAspectRatio::Square));
});
}
#[test]
fn auto_2k_yields_square() {
assert_plan_for_aspect_ratio(Some(CommonAspectRatio::Auto2k), |plan| {
assert!(matches!(plan.aspect_ratio, Flux1DevAspectRatio::Square));
});
}
#[test]
fn auto_4k_yields_square() {
assert_plan_for_aspect_ratio(Some(CommonAspectRatio::Auto4k), |plan| {
assert!(matches!(plan.aspect_ratio, Flux1DevAspectRatio::Square));
});
}
// ── Num images mapping ──────────────────────────────────────────────────
#[test]
fn default_batch_count_is_one() {
assert_plan_for_batch(None, |plan| {
assert!(matches!(plan.num_images, FalFlux1DevNumImages::One));
});
}
#[test]
fn batch_of_one_yields_one() {
assert_plan_for_batch(Some(1), |plan| {
assert!(matches!(plan.num_images, FalFlux1DevNumImages::One));
});
}
#[test]
fn batch_of_two_yields_two() {
assert_plan_for_batch(Some(2), |plan| {
assert!(matches!(plan.num_images, FalFlux1DevNumImages::Two));
});
}
#[test]
fn batch_of_three_yields_three() {
assert_plan_for_batch(Some(3), |plan| {
assert!(matches!(plan.num_images, FalFlux1DevNumImages::Three));
});
}
#[test]
fn batch_of_four_yields_four() {
assert_plan_for_batch(Some(4), |plan| {
assert!(matches!(plan.num_images, FalFlux1DevNumImages::Four));
});
}
#[test]
fn batch_above_four_clamps_to_four() {
// PayMoreUpgrade strategy clamps over-large batches down to the max.
assert_plan_for_batch(Some(9), |plan| {
assert!(matches!(plan.num_images, FalFlux1DevNumImages::Four));
});
}
// ── Prompt passthrough ──────────────────────────────────────────────────
#[test]
fn prompt_is_passed_through() {
with_fal_plan(&make_request(Some("a corgi in a hat"), None, Some(1)), |plan| {
assert_eq!(plan.prompt, Some("a corgi in a hat"));
});
}
#[test]
fn missing_prompt_is_none() {
with_fal_plan(&make_request(None, None, Some(1)), |plan| {
assert_eq!(plan.prompt, None);
});
}
// ── Image refs (Flux 1 Dev never has them) ──────────────────────────────
#[test]
fn image_url_is_always_none() {
// Flux 1 Dev is text-to-image only — `maybe_image_url` is always None
// because we never pass image_media_tokens.
with_fal_plan(&make_request(Some("p"), None, Some(1)), |plan| {
assert!(plan.maybe_image_url.is_none());
});
}
}
}
@@ -0,0 +1,529 @@
//! Tests for omni-gen against the Flux 1 Schnell model.
//!
//! These exercise [`distill_image_request`] end-to-end without firing any
//! provider calls — both the cost estimate (Artcraft provider, what we'd
//! bill on) and the execution plan (Fal provider, what we'd send to fal.ai)
//! are computed synchronously from the request.
//!
//! Flux 1 Schnell is text-to-image only, so no image refs are exercised.
//! It's also currently free / unlimited — we don't charge anything for it.
#[cfg(test)]
mod tests {
use std::collections::HashMap;
use artcraft_api_defs::omni_gen::cost_and_generate_requests::omni_gen_image_cost_and_generate_request::OmniGenImageCostAndGenerateRequest;
use enums::common::generation::common_aspect_ratio::CommonAspectRatio;
use enums::common::generation::common_image_model::CommonImageModel;
use tokens::tokens::media_files::MediaFileToken;
use url::Url;
use crate::http_server::endpoints::omni_gen::generate::image::distill_image_request::{
distill_image_request, DistilledImageRequest,
};
// ── Helpers ────────────────────────────────────────────────────────────────
/// Build a minimal Flux 1 Schnell omni request with only the fields we care
/// about in tests filled in.
fn make_request(
prompt: Option<&str>,
aspect_ratio: Option<CommonAspectRatio>,
image_batch_count: Option<u16>,
) -> OmniGenImageCostAndGenerateRequest {
OmniGenImageCostAndGenerateRequest {
idempotency_token: Some("11111111-1111-1111-1111-111111111111".to_string()),
model: Some(CommonImageModel::Flux1Schnell),
prompt: prompt.map(|s| s.to_string()),
image_media_tokens: None,
resolution: None,
aspect_ratio,
quality: None,
image_batch_count,
horizontal_angle: None,
vertical_angle: None,
zoom: None,
}
}
/// Distill a request and panic with a descriptive message on failure.
/// Flux 1 Schnell never uses image refs, so we always pass an empty hydration map.
fn distill(request: &OmniGenImageCostAndGenerateRequest) -> DistilledImageRequest {
let empty: HashMap<MediaFileToken, Url> = HashMap::new();
distill_image_request(request, Some(&empty))
.expect("distill_image_request should succeed for Flux 1 Schnell")
}
// ────────────────────────────────────────────────────────────────────────────
// COST
// ────────────────────────────────────────────────────────────────────────────
//
// Pricing comes from `estimate_image_cost_artcraft_flux_1_schnell`:
// Flux 1 Schnell is currently FREE / UNLIMITED — we don't charge for it.
// - cost_in_credits: None
// - cost_in_usd_cents: None
// - is_free: true
// - is_unlimited: true
//
// The artcraft cost path is what omni would bill on.
mod cost {
use super::*;
fn distilled_for(image_batch_count: Option<u16>) -> DistilledImageRequest {
distill(&make_request(Some("a happy puppy"), None, image_batch_count))
}
#[test]
fn default_batch_is_free() {
let distilled = distilled_for(None);
assert_eq!(distilled.cost.cost_in_credits, None);
assert_eq!(distilled.cost.cost_in_usd_cents, None);
assert!(distilled.cost.is_free);
assert!(distilled.cost.is_unlimited);
}
#[test]
fn batch_of_one_is_free() {
let distilled = distilled_for(Some(1));
assert_eq!(distilled.cost.cost_in_credits, None);
assert_eq!(distilled.cost.cost_in_usd_cents, None);
assert!(distilled.cost.is_free);
assert!(distilled.cost.is_unlimited);
}
#[test]
fn batch_of_two_is_free() {
let distilled = distilled_for(Some(2));
assert_eq!(distilled.cost.cost_in_credits, None);
assert_eq!(distilled.cost.cost_in_usd_cents, None);
assert!(distilled.cost.is_free);
assert!(distilled.cost.is_unlimited);
}
#[test]
fn batch_of_three_is_free() {
let distilled = distilled_for(Some(3));
assert_eq!(distilled.cost.cost_in_credits, None);
assert_eq!(distilled.cost.cost_in_usd_cents, None);
assert!(distilled.cost.is_free);
assert!(distilled.cost.is_unlimited);
}
#[test]
fn batch_of_four_is_free() {
let distilled = distilled_for(Some(4));
assert_eq!(distilled.cost.cost_in_credits, None);
assert_eq!(distilled.cost.cost_in_usd_cents, None);
assert!(distilled.cost.is_free);
assert!(distilled.cost.is_unlimited);
}
#[test]
fn batch_above_max_is_still_free() {
// PayMoreUpgrade strategy clamps batches >4 down to the max of 4 — and
// 4-of-free is still free.
let distilled = distilled_for(Some(7));
assert_eq!(distilled.cost.cost_in_credits, None);
assert_eq!(distilled.cost.cost_in_usd_cents, None);
assert!(distilled.cost.is_free);
assert!(distilled.cost.is_unlimited);
}
#[test]
fn cost_is_independent_of_aspect_ratio() {
let aspect_ratios = [
None,
Some(CommonAspectRatio::Square),
Some(CommonAspectRatio::SquareHd),
Some(CommonAspectRatio::WideSixteenByNine),
Some(CommonAspectRatio::TallNineBySixteen),
Some(CommonAspectRatio::Auto),
];
for ar in aspect_ratios {
let request = make_request(Some("p"), ar, Some(2));
let distilled = distill(&request);
assert_eq!(
distilled.cost.cost_in_usd_cents, None,
"expected free regardless of aspect ratio (got {:?})", ar,
);
assert!(distilled.cost.is_free);
assert!(distilled.cost.is_unlimited);
}
}
#[test]
fn cost_is_independent_of_prompt() {
let with_prompt = distill(&make_request(Some("a cat"), None, Some(3)));
let without_prompt = distill(&make_request(None, None, Some(3)));
assert_eq!(with_prompt.cost.cost_in_usd_cents, None);
assert_eq!(without_prompt.cost.cost_in_usd_cents, None);
assert!(with_prompt.cost.is_free);
assert!(without_prompt.cost.is_free);
}
#[test]
fn cost_metadata_flags() {
let request = make_request(Some("p"), None, Some(1));
let distilled = distill(&request);
assert!(distilled.cost.is_free);
assert!(distilled.cost.is_unlimited);
// Free / unlimited but not rate-limited and no watermark.
assert!(!distilled.cost.is_rate_limited);
assert!(!distilled.cost.has_watermark);
}
}
// ────────────────────────────────────────────────────────────────────────────
// PLAN
// ────────────────────────────────────────────────────────────────────────────
//
// The distilled plan is the Fal-side `PlanFalFlux1Schnell`:
// - prompt: passthrough
// - maybe_image_url: always None (Flux 1 Schnell never gets image refs in tests)
// - t2i_aspect_ratio: text-to-image aspect ratio (always set; defaults to Square)
// - edit_image_size: image-edit size (only populated when caller passes an
// aspect ratio; None when no aspect ratio was supplied)
// - num_images: mapped from image_batch_count (default 1, clamped to 1..=4)
mod plan {
use super::*;
use artcraft_router::generate::generate_image::image_generation_plan::ImageGenerationPlan;
use artcraft_router::generate::generate_image::plan::fal::plan_generate_image_fal_flux_1_schnell::{
FalFlux1SchnellNumImages, PlanFalFlux1Schnell,
};
use fal_client::requests::webhook::image::edit::enqueue_flux_1_schnell_edit_image_webhook::Flux1SchnellEditImageSize;
use fal_client::requests::webhook::image::text::enqueue_flux_1_schnell_text_to_image_webhook::Flux1SchnellAspectRatio;
/// Distill and run an assertion against the inner FalFlux1Schnell plan.
///
/// We pass the plan by reference (rather than moving it out of `distilled`)
/// because the plan's borrows are tied to data owned by `distilled` —
/// moving it out would dangle those borrows.
fn with_fal_plan<F: FnOnce(&PlanFalFlux1Schnell<'_>)>(
request: &OmniGenImageCostAndGenerateRequest,
assertion: F,
) {
let distilled = distill(request);
match distilled.plan() {
ImageGenerationPlan::FalFlux1Schnell(plan) => assertion(plan),
other => panic!("expected ImageGenerationPlan::FalFlux1Schnell, got {:?}", other),
}
}
fn assert_plan_for_aspect_ratio<F: FnOnce(&PlanFalFlux1Schnell<'_>)>(
ar: Option<CommonAspectRatio>,
assertion: F,
) {
with_fal_plan(&make_request(Some("p"), ar, Some(1)), assertion);
}
fn assert_plan_for_batch<F: FnOnce(&PlanFalFlux1Schnell<'_>)>(
batch: Option<u16>,
assertion: F,
) {
with_fal_plan(&make_request(Some("p"), None, batch), assertion);
}
// ── t2i_aspect_ratio direct mapping ─────────────────────────────────────
#[test]
fn default_t2i_aspect_ratio_is_square() {
assert_plan_for_aspect_ratio(None, |plan| {
assert!(matches!(plan.t2i_aspect_ratio, Flux1SchnellAspectRatio::Square));
});
}
#[test]
fn square_input_yields_square() {
assert_plan_for_aspect_ratio(Some(CommonAspectRatio::Square), |plan| {
assert!(matches!(plan.t2i_aspect_ratio, Flux1SchnellAspectRatio::Square));
});
}
#[test]
fn square_hd_input_yields_square_hd() {
assert_plan_for_aspect_ratio(Some(CommonAspectRatio::SquareHd), |plan| {
assert!(matches!(plan.t2i_aspect_ratio, Flux1SchnellAspectRatio::SquareHd));
});
}
#[test]
fn wide_4x3_yields_landscape_4_3() {
assert_plan_for_aspect_ratio(Some(CommonAspectRatio::WideFourByThree), |plan| {
assert!(matches!(plan.t2i_aspect_ratio, Flux1SchnellAspectRatio::LandscapeFourByThree));
});
}
#[test]
fn wide_16x9_yields_landscape_16_9() {
assert_plan_for_aspect_ratio(Some(CommonAspectRatio::WideSixteenByNine), |plan| {
assert!(matches!(plan.t2i_aspect_ratio, Flux1SchnellAspectRatio::LandscapeSixteenByNine));
});
}
#[test]
fn wide_alias_yields_landscape_16_9() {
assert_plan_for_aspect_ratio(Some(CommonAspectRatio::Wide), |plan| {
assert!(matches!(plan.t2i_aspect_ratio, Flux1SchnellAspectRatio::LandscapeSixteenByNine));
});
}
#[test]
fn tall_3x4_yields_portrait_3_4() {
assert_plan_for_aspect_ratio(Some(CommonAspectRatio::TallThreeByFour), |plan| {
assert!(matches!(plan.t2i_aspect_ratio, Flux1SchnellAspectRatio::PortraitThreeByFour));
});
}
#[test]
fn tall_9x16_yields_portrait_9_16() {
assert_plan_for_aspect_ratio(Some(CommonAspectRatio::TallNineBySixteen), |plan| {
assert!(matches!(plan.t2i_aspect_ratio, Flux1SchnellAspectRatio::PortraitNineBySixteen));
});
}
#[test]
fn tall_alias_yields_portrait_9_16() {
assert_plan_for_aspect_ratio(Some(CommonAspectRatio::Tall), |plan| {
assert!(matches!(plan.t2i_aspect_ratio, Flux1SchnellAspectRatio::PortraitNineBySixteen));
});
}
// ── t2i_aspect_ratio nearest-neighbour fallbacks ────────────────────────
//
// Aspect ratios that Flux 1 Schnell doesn't natively support get mapped to
// the closest supported ratio under the PayMoreUpgrade mismatch strategy
// that omni-gen uses.
#[test]
fn wide_5x4_falls_back_to_landscape_4_3() {
assert_plan_for_aspect_ratio(Some(CommonAspectRatio::WideFiveByFour), |plan| {
assert!(matches!(plan.t2i_aspect_ratio, Flux1SchnellAspectRatio::LandscapeFourByThree));
});
}
#[test]
fn wide_3x2_falls_back_to_landscape_4_3() {
assert_plan_for_aspect_ratio(Some(CommonAspectRatio::WideThreeByTwo), |plan| {
assert!(matches!(plan.t2i_aspect_ratio, Flux1SchnellAspectRatio::LandscapeFourByThree));
});
}
#[test]
fn wide_21x9_falls_back_to_landscape_16_9() {
assert_plan_for_aspect_ratio(Some(CommonAspectRatio::WideTwentyOneByNine), |plan| {
assert!(matches!(plan.t2i_aspect_ratio, Flux1SchnellAspectRatio::LandscapeSixteenByNine));
});
}
#[test]
fn tall_4x5_falls_back_to_portrait_3_4() {
assert_plan_for_aspect_ratio(Some(CommonAspectRatio::TallFourByFive), |plan| {
assert!(matches!(plan.t2i_aspect_ratio, Flux1SchnellAspectRatio::PortraitThreeByFour));
});
}
#[test]
fn tall_2x3_falls_back_to_portrait_3_4() {
assert_plan_for_aspect_ratio(Some(CommonAspectRatio::TallTwoByThree), |plan| {
assert!(matches!(plan.t2i_aspect_ratio, Flux1SchnellAspectRatio::PortraitThreeByFour));
});
}
#[test]
fn tall_9x21_falls_back_to_portrait_9_16() {
assert_plan_for_aspect_ratio(Some(CommonAspectRatio::TallNineByTwentyOne), |plan| {
assert!(matches!(plan.t2i_aspect_ratio, Flux1SchnellAspectRatio::PortraitNineBySixteen));
});
}
// ── Auto* aspect ratios collapse to Square (text-to-image) ──────────────
#[test]
fn auto_yields_square() {
assert_plan_for_aspect_ratio(Some(CommonAspectRatio::Auto), |plan| {
assert!(matches!(plan.t2i_aspect_ratio, Flux1SchnellAspectRatio::Square));
});
}
#[test]
fn auto_2k_yields_square() {
assert_plan_for_aspect_ratio(Some(CommonAspectRatio::Auto2k), |plan| {
assert!(matches!(plan.t2i_aspect_ratio, Flux1SchnellAspectRatio::Square));
});
}
#[test]
fn auto_4k_yields_square() {
assert_plan_for_aspect_ratio(Some(CommonAspectRatio::Auto4k), |plan| {
assert!(matches!(plan.t2i_aspect_ratio, Flux1SchnellAspectRatio::Square));
});
}
// ── edit_image_size mapping ─────────────────────────────────────────────
//
// The plan also computes an `edit_image_size` from the same aspect ratio
// input. It's None when no aspect ratio was supplied, and otherwise mirrors
// the t2i mapping (with a Square fallback for unsupported variants).
#[test]
fn no_aspect_ratio_yields_no_edit_image_size() {
assert_plan_for_aspect_ratio(None, |plan| {
assert!(plan.edit_image_size.is_none());
});
}
#[test]
fn square_yields_edit_image_size_square() {
assert_plan_for_aspect_ratio(Some(CommonAspectRatio::Square), |plan| {
assert!(matches!(plan.edit_image_size, Some(Flux1SchnellEditImageSize::Square)));
});
}
#[test]
fn square_hd_yields_edit_image_size_square_hd() {
assert_plan_for_aspect_ratio(Some(CommonAspectRatio::SquareHd), |plan| {
assert!(matches!(plan.edit_image_size, Some(Flux1SchnellEditImageSize::SquareHd)));
});
}
#[test]
fn wide_4x3_yields_edit_image_size_landscape_4_3() {
assert_plan_for_aspect_ratio(Some(CommonAspectRatio::WideFourByThree), |plan| {
assert!(matches!(plan.edit_image_size, Some(Flux1SchnellEditImageSize::LandscapeFourByThree)));
});
}
#[test]
fn wide_16x9_yields_edit_image_size_landscape_16_9() {
assert_plan_for_aspect_ratio(Some(CommonAspectRatio::WideSixteenByNine), |plan| {
assert!(matches!(plan.edit_image_size, Some(Flux1SchnellEditImageSize::LandscapeSixteenByNine)));
});
}
#[test]
fn wide_alias_yields_edit_image_size_landscape_16_9() {
assert_plan_for_aspect_ratio(Some(CommonAspectRatio::Wide), |plan| {
assert!(matches!(plan.edit_image_size, Some(Flux1SchnellEditImageSize::LandscapeSixteenByNine)));
});
}
#[test]
fn tall_3x4_yields_edit_image_size_portrait_3_4() {
assert_plan_for_aspect_ratio(Some(CommonAspectRatio::TallThreeByFour), |plan| {
assert!(matches!(plan.edit_image_size, Some(Flux1SchnellEditImageSize::PortraitThreeByFour)));
});
}
#[test]
fn tall_9x16_yields_edit_image_size_portrait_9_16() {
assert_plan_for_aspect_ratio(Some(CommonAspectRatio::TallNineBySixteen), |plan| {
assert!(matches!(plan.edit_image_size, Some(Flux1SchnellEditImageSize::PortraitNineBySixteen)));
});
}
#[test]
fn tall_alias_yields_edit_image_size_portrait_9_16() {
assert_plan_for_aspect_ratio(Some(CommonAspectRatio::Tall), |plan| {
assert!(matches!(plan.edit_image_size, Some(Flux1SchnellEditImageSize::PortraitNineBySixteen)));
});
}
/// Aspect ratios that don't have a direct edit-image-size mapping (Auto,
/// the Wide5x4 / Wide3x2 / Wide21x9 family, the Tall4x5 / Tall2x3 /
/// Tall9x21 family) all collapse to `Some(Square)` per the plan's catch-all.
#[test]
fn auto_yields_edit_image_size_square() {
assert_plan_for_aspect_ratio(Some(CommonAspectRatio::Auto), |plan| {
assert!(matches!(plan.edit_image_size, Some(Flux1SchnellEditImageSize::Square)));
});
}
#[test]
fn wide_5x4_yields_edit_image_size_square() {
assert_plan_for_aspect_ratio(Some(CommonAspectRatio::WideFiveByFour), |plan| {
assert!(matches!(plan.edit_image_size, Some(Flux1SchnellEditImageSize::Square)));
});
}
#[test]
fn tall_2x3_yields_edit_image_size_square() {
assert_plan_for_aspect_ratio(Some(CommonAspectRatio::TallTwoByThree), |plan| {
assert!(matches!(plan.edit_image_size, Some(Flux1SchnellEditImageSize::Square)));
});
}
// ── Num images mapping ──────────────────────────────────────────────────
#[test]
fn default_batch_count_is_one() {
assert_plan_for_batch(None, |plan| {
assert!(matches!(plan.num_images, FalFlux1SchnellNumImages::One));
});
}
#[test]
fn batch_of_one_yields_one() {
assert_plan_for_batch(Some(1), |plan| {
assert!(matches!(plan.num_images, FalFlux1SchnellNumImages::One));
});
}
#[test]
fn batch_of_two_yields_two() {
assert_plan_for_batch(Some(2), |plan| {
assert!(matches!(plan.num_images, FalFlux1SchnellNumImages::Two));
});
}
#[test]
fn batch_of_three_yields_three() {
assert_plan_for_batch(Some(3), |plan| {
assert!(matches!(plan.num_images, FalFlux1SchnellNumImages::Three));
});
}
#[test]
fn batch_of_four_yields_four() {
assert_plan_for_batch(Some(4), |plan| {
assert!(matches!(plan.num_images, FalFlux1SchnellNumImages::Four));
});
}
#[test]
fn batch_above_four_clamps_to_four() {
// PayMoreUpgrade strategy clamps over-large batches down to the max.
assert_plan_for_batch(Some(9), |plan| {
assert!(matches!(plan.num_images, FalFlux1SchnellNumImages::Four));
});
}
// ── Prompt passthrough ──────────────────────────────────────────────────
#[test]
fn prompt_is_passed_through() {
with_fal_plan(&make_request(Some("a corgi in a hat"), None, Some(1)), |plan| {
assert_eq!(plan.prompt, Some("a corgi in a hat"));
});
}
#[test]
fn missing_prompt_is_none() {
with_fal_plan(&make_request(None, None, Some(1)), |plan| {
assert_eq!(plan.prompt, None);
});
}
// ── Image refs (Flux 1 Schnell never has them) ──────────────────────────
#[test]
fn image_url_is_always_none() {
// Flux 1 Schnell is text-to-image only — `maybe_image_url` is always
// None because we never pass image_media_tokens.
with_fal_plan(&make_request(Some("p"), None, Some(1)), |plan| {
assert!(plan.maybe_image_url.is_none());
});
}
}
}
@@ -0,0 +1,412 @@
//! Tests for omni-gen against the Flux Pro 1.1 model.
//!
//! These exercise [`distill_image_request`] end-to-end without firing any
//! provider calls — both the cost estimate (Artcraft provider, what we bill
//! on) and the execution plan (Fal provider, what we'd send to fal.ai) are
//! computed synchronously from the request.
//!
//! Flux Pro 1.1 is text-to-image only, so no image refs are exercised.
#[cfg(test)]
mod tests {
use std::collections::HashMap;
use artcraft_api_defs::omni_gen::cost_and_generate_requests::omni_gen_image_cost_and_generate_request::OmniGenImageCostAndGenerateRequest;
use enums::common::generation::common_aspect_ratio::CommonAspectRatio;
use enums::common::generation::common_image_model::CommonImageModel;
use tokens::tokens::media_files::MediaFileToken;
use url::Url;
use crate::http_server::endpoints::omni_gen::generate::image::distill_image_request::{
distill_image_request, DistilledImageRequest,
};
// ── Helpers ────────────────────────────────────────────────────────────────
/// Build a minimal Flux Pro 1.1 omni request with only the fields we care
/// about in tests filled in.
fn make_request(
prompt: Option<&str>,
aspect_ratio: Option<CommonAspectRatio>,
image_batch_count: Option<u16>,
) -> OmniGenImageCostAndGenerateRequest {
OmniGenImageCostAndGenerateRequest {
idempotency_token: Some("11111111-1111-1111-1111-111111111111".to_string()),
model: Some(CommonImageModel::FluxPro11),
prompt: prompt.map(|s| s.to_string()),
image_media_tokens: None,
resolution: None,
aspect_ratio,
quality: None,
image_batch_count,
horizontal_angle: None,
vertical_angle: None,
zoom: None,
}
}
/// Distill a request and panic with a descriptive message on failure.
/// Flux Pro 1.1 never uses image refs, so we always pass an empty hydration map.
fn distill(request: &OmniGenImageCostAndGenerateRequest) -> DistilledImageRequest {
let empty: HashMap<MediaFileToken, Url> = HashMap::new();
distill_image_request(request, Some(&empty))
.expect("distill_image_request should succeed for Flux Pro 1.1")
}
// ────────────────────────────────────────────────────────────────────────────
// COST
// ────────────────────────────────────────────────────────────────────────────
//
// Pricing comes from `estimate_image_cost_artcraft_flux_pro_1p1`:
// $0.04 / image (4 credits per image; 1 credit == 1 USD cent)
//
// This matches what the legacy `generate_flux_pro_11_text_to_image_handler`
// charges via `FluxPro11Args::calculate_cost_in_cents` (which is also $0.04
// per image — Fal docs: "$0.04 per megapixel, billed by rounding up to the
// nearest megapixel" at the default ~1MP image size).
//
// The artcraft cost path is what omni bills on.
mod cost {
use super::*;
fn cost_for_batch(image_batch_count: Option<u16>) -> (Option<u64>, Option<u64>) {
let request = make_request(Some("a happy puppy"), None, image_batch_count);
let distilled = distill(&request);
(
distilled.cost.cost_in_credits,
distilled.cost.cost_in_usd_cents,
)
}
#[test]
fn default_batch_costs_4_cents() {
// No batch specified → defaults to 1 image → 4¢
let (credits, cents) = cost_for_batch(None);
assert_eq!(credits, Some(4));
assert_eq!(cents, Some(4));
}
#[test]
fn batch_of_one_costs_4_cents() {
let (credits, cents) = cost_for_batch(Some(1));
assert_eq!(credits, Some(4));
assert_eq!(cents, Some(4));
}
#[test]
fn batch_of_two_costs_8_cents() {
let (credits, cents) = cost_for_batch(Some(2));
assert_eq!(credits, Some(8));
assert_eq!(cents, Some(8));
}
#[test]
fn batch_of_three_costs_12_cents() {
let (credits, cents) = cost_for_batch(Some(3));
assert_eq!(credits, Some(12));
assert_eq!(cents, Some(12));
}
#[test]
fn batch_of_four_costs_16_cents() {
let (credits, cents) = cost_for_batch(Some(4));
assert_eq!(credits, Some(16));
assert_eq!(cents, Some(16));
}
#[test]
fn batch_above_max_clamps_and_costs_16_cents() {
// PayMoreUpgrade strategy clamps batches >4 down to the max of 4.
let (credits, cents) = cost_for_batch(Some(7));
assert_eq!(credits, Some(16));
assert_eq!(cents, Some(16));
}
#[test]
fn cost_is_independent_of_aspect_ratio() {
let aspect_ratios = [
None,
Some(CommonAspectRatio::Square),
Some(CommonAspectRatio::SquareHd),
Some(CommonAspectRatio::WideSixteenByNine),
Some(CommonAspectRatio::TallNineBySixteen),
Some(CommonAspectRatio::Auto),
];
for ar in aspect_ratios {
let request = make_request(Some("p"), ar, Some(2));
let distilled = distill(&request);
assert_eq!(
distilled.cost.cost_in_usd_cents,
Some(8),
"expected 8¢ regardless of aspect ratio (got {:?})",
ar,
);
}
}
#[test]
fn cost_is_independent_of_prompt() {
let with_prompt = distill(&make_request(Some("a cat"), None, Some(3)));
let without_prompt = distill(&make_request(None, None, Some(3)));
assert_eq!(with_prompt.cost.cost_in_usd_cents, Some(12));
assert_eq!(without_prompt.cost.cost_in_usd_cents, Some(12));
}
#[test]
fn cost_metadata_flags_are_default() {
let request = make_request(Some("p"), None, Some(1));
let distilled = distill(&request);
assert!(!distilled.cost.is_free);
assert!(!distilled.cost.is_unlimited);
assert!(!distilled.cost.is_rate_limited);
assert!(!distilled.cost.has_watermark);
}
}
// ────────────────────────────────────────────────────────────────────────────
// PLAN
// ────────────────────────────────────────────────────────────────────────────
//
// The distilled plan is the Fal-side `PlanFalFluxPro11`:
// - prompt: passthrough
// - aspect_ratio: mapped from CommonAspectRatio with sensible fallbacks
// - num_images: mapped from image_batch_count (default 1, clamped to 1..=4)
//
// (Flux Pro 1.1 has no image_url field — it's text-to-image only.)
mod plan {
use super::*;
use artcraft_router::generate::generate_image::image_generation_plan::ImageGenerationPlan;
use artcraft_router::generate::generate_image::plan::fal::plan_generate_image_fal_flux_pro_1p1::{
FalFluxPro11NumImages, PlanFalFluxPro11,
};
use fal_client::requests::webhook::image::text::enqueue_flux_pro_11_text_to_image_webhook::FluxPro11AspectRatio;
/// Distill and run an assertion against the inner FalFluxPro11 plan.
///
/// We pass the plan by reference (rather than moving it out of `distilled`)
/// because the plan's borrows are tied to data owned by `distilled` —
/// moving it out would dangle those borrows.
fn with_fal_plan<F: FnOnce(&PlanFalFluxPro11<'_>)>(
request: &OmniGenImageCostAndGenerateRequest,
assertion: F,
) {
let distilled = distill(request);
match distilled.plan() {
ImageGenerationPlan::FalFluxPro11(plan) => assertion(plan),
other => panic!("expected ImageGenerationPlan::FalFluxPro11, got {:?}", other),
}
}
fn assert_plan_for_aspect_ratio<F: FnOnce(&PlanFalFluxPro11<'_>)>(
ar: Option<CommonAspectRatio>,
assertion: F,
) {
with_fal_plan(&make_request(Some("p"), ar, Some(1)), assertion);
}
fn assert_plan_for_batch<F: FnOnce(&PlanFalFluxPro11<'_>)>(
batch: Option<u16>,
assertion: F,
) {
with_fal_plan(&make_request(Some("p"), None, batch), assertion);
}
// ── Aspect ratio direct mappings ────────────────────────────────────────
#[test]
fn default_aspect_ratio_is_square() {
assert_plan_for_aspect_ratio(None, |plan| {
assert!(matches!(plan.aspect_ratio, FluxPro11AspectRatio::Square));
});
}
#[test]
fn square_input_yields_square() {
assert_plan_for_aspect_ratio(Some(CommonAspectRatio::Square), |plan| {
assert!(matches!(plan.aspect_ratio, FluxPro11AspectRatio::Square));
});
}
#[test]
fn square_hd_input_yields_square_hd() {
assert_plan_for_aspect_ratio(Some(CommonAspectRatio::SquareHd), |plan| {
assert!(matches!(plan.aspect_ratio, FluxPro11AspectRatio::SquareHd));
});
}
#[test]
fn wide_4x3_yields_landscape_4_3() {
assert_plan_for_aspect_ratio(Some(CommonAspectRatio::WideFourByThree), |plan| {
assert!(matches!(plan.aspect_ratio, FluxPro11AspectRatio::LandscapeFourByThree));
});
}
#[test]
fn wide_16x9_yields_landscape_16_9() {
assert_plan_for_aspect_ratio(Some(CommonAspectRatio::WideSixteenByNine), |plan| {
assert!(matches!(plan.aspect_ratio, FluxPro11AspectRatio::LandscapeSixteenByNine));
});
}
#[test]
fn wide_alias_yields_landscape_16_9() {
assert_plan_for_aspect_ratio(Some(CommonAspectRatio::Wide), |plan| {
assert!(matches!(plan.aspect_ratio, FluxPro11AspectRatio::LandscapeSixteenByNine));
});
}
#[test]
fn tall_3x4_yields_portrait_3_4() {
assert_plan_for_aspect_ratio(Some(CommonAspectRatio::TallThreeByFour), |plan| {
assert!(matches!(plan.aspect_ratio, FluxPro11AspectRatio::PortraitThreeByFour));
});
}
#[test]
fn tall_9x16_yields_portrait_9_16() {
assert_plan_for_aspect_ratio(Some(CommonAspectRatio::TallNineBySixteen), |plan| {
assert!(matches!(plan.aspect_ratio, FluxPro11AspectRatio::PortraitNineBySixteen));
});
}
#[test]
fn tall_alias_yields_portrait_9_16() {
assert_plan_for_aspect_ratio(Some(CommonAspectRatio::Tall), |plan| {
assert!(matches!(plan.aspect_ratio, FluxPro11AspectRatio::PortraitNineBySixteen));
});
}
// ── Aspect ratio nearest-neighbour fallbacks ────────────────────────────
//
// Aspect ratios that Flux Pro 1.1 doesn't natively support get mapped to
// the closest supported ratio under the PayMoreUpgrade mismatch strategy
// that omni-gen uses.
#[test]
fn wide_5x4_falls_back_to_landscape_4_3() {
assert_plan_for_aspect_ratio(Some(CommonAspectRatio::WideFiveByFour), |plan| {
assert!(matches!(plan.aspect_ratio, FluxPro11AspectRatio::LandscapeFourByThree));
});
}
#[test]
fn wide_3x2_falls_back_to_landscape_4_3() {
assert_plan_for_aspect_ratio(Some(CommonAspectRatio::WideThreeByTwo), |plan| {
assert!(matches!(plan.aspect_ratio, FluxPro11AspectRatio::LandscapeFourByThree));
});
}
#[test]
fn wide_21x9_falls_back_to_landscape_16_9() {
assert_plan_for_aspect_ratio(Some(CommonAspectRatio::WideTwentyOneByNine), |plan| {
assert!(matches!(plan.aspect_ratio, FluxPro11AspectRatio::LandscapeSixteenByNine));
});
}
#[test]
fn tall_4x5_falls_back_to_portrait_3_4() {
assert_plan_for_aspect_ratio(Some(CommonAspectRatio::TallFourByFive), |plan| {
assert!(matches!(plan.aspect_ratio, FluxPro11AspectRatio::PortraitThreeByFour));
});
}
#[test]
fn tall_2x3_falls_back_to_portrait_3_4() {
assert_plan_for_aspect_ratio(Some(CommonAspectRatio::TallTwoByThree), |plan| {
assert!(matches!(plan.aspect_ratio, FluxPro11AspectRatio::PortraitThreeByFour));
});
}
#[test]
fn tall_9x21_falls_back_to_portrait_9_16() {
assert_plan_for_aspect_ratio(Some(CommonAspectRatio::TallNineByTwentyOne), |plan| {
assert!(matches!(plan.aspect_ratio, FluxPro11AspectRatio::PortraitNineBySixteen));
});
}
// ── Auto* aspect ratios ─────────────────────────────────────────────────
#[test]
fn auto_yields_square() {
assert_plan_for_aspect_ratio(Some(CommonAspectRatio::Auto), |plan| {
assert!(matches!(plan.aspect_ratio, FluxPro11AspectRatio::Square));
});
}
#[test]
fn auto_2k_yields_square() {
assert_plan_for_aspect_ratio(Some(CommonAspectRatio::Auto2k), |plan| {
assert!(matches!(plan.aspect_ratio, FluxPro11AspectRatio::Square));
});
}
#[test]
fn auto_4k_yields_square() {
assert_plan_for_aspect_ratio(Some(CommonAspectRatio::Auto4k), |plan| {
assert!(matches!(plan.aspect_ratio, FluxPro11AspectRatio::Square));
});
}
// ── Num images mapping ──────────────────────────────────────────────────
#[test]
fn default_batch_count_is_one() {
assert_plan_for_batch(None, |plan| {
assert!(matches!(plan.num_images, FalFluxPro11NumImages::One));
});
}
#[test]
fn batch_of_one_yields_one() {
assert_plan_for_batch(Some(1), |plan| {
assert!(matches!(plan.num_images, FalFluxPro11NumImages::One));
});
}
#[test]
fn batch_of_two_yields_two() {
assert_plan_for_batch(Some(2), |plan| {
assert!(matches!(plan.num_images, FalFluxPro11NumImages::Two));
});
}
#[test]
fn batch_of_three_yields_three() {
assert_plan_for_batch(Some(3), |plan| {
assert!(matches!(plan.num_images, FalFluxPro11NumImages::Three));
});
}
#[test]
fn batch_of_four_yields_four() {
assert_plan_for_batch(Some(4), |plan| {
assert!(matches!(plan.num_images, FalFluxPro11NumImages::Four));
});
}
#[test]
fn batch_above_four_clamps_to_four() {
// PayMoreUpgrade strategy clamps over-large batches down to the max.
assert_plan_for_batch(Some(9), |plan| {
assert!(matches!(plan.num_images, FalFluxPro11NumImages::Four));
});
}
// ── Prompt passthrough ──────────────────────────────────────────────────
#[test]
fn prompt_is_passed_through() {
with_fal_plan(&make_request(Some("a corgi in a hat"), None, Some(1)), |plan| {
assert_eq!(plan.prompt, Some("a corgi in a hat"));
});
}
#[test]
fn missing_prompt_is_none() {
with_fal_plan(&make_request(None, None, Some(1)), |plan| {
assert_eq!(plan.prompt, None);
});
}
}
}
@@ -0,0 +1,417 @@
//! Tests for omni-gen against the Flux Pro 1.1 Ultra model.
//!
//! These exercise [`distill_image_request`] end-to-end without firing any
//! provider calls — both the cost estimate (Artcraft provider, what we bill
//! on) and the execution plan (Fal provider, what we'd send to fal.ai) are
//! computed synchronously from the request.
//!
//! Flux Pro 1.1 Ultra is text-to-image only, so no image refs are exercised.
#[cfg(test)]
mod tests {
use std::collections::HashMap;
use artcraft_api_defs::omni_gen::cost_and_generate_requests::omni_gen_image_cost_and_generate_request::OmniGenImageCostAndGenerateRequest;
use enums::common::generation::common_aspect_ratio::CommonAspectRatio;
use enums::common::generation::common_image_model::CommonImageModel;
use tokens::tokens::media_files::MediaFileToken;
use url::Url;
use crate::http_server::endpoints::omni_gen::generate::image::distill_image_request::{
distill_image_request, DistilledImageRequest,
};
// ── Helpers ────────────────────────────────────────────────────────────────
/// Build a minimal Flux Pro 1.1 Ultra omni request with only the fields we
/// care about in tests filled in.
fn make_request(
prompt: Option<&str>,
aspect_ratio: Option<CommonAspectRatio>,
image_batch_count: Option<u16>,
) -> OmniGenImageCostAndGenerateRequest {
OmniGenImageCostAndGenerateRequest {
idempotency_token: Some("11111111-1111-1111-1111-111111111111".to_string()),
model: Some(CommonImageModel::FluxPro11Ultra),
prompt: prompt.map(|s| s.to_string()),
image_media_tokens: None,
resolution: None,
aspect_ratio,
quality: None,
image_batch_count,
horizontal_angle: None,
vertical_angle: None,
zoom: None,
}
}
/// Distill a request and panic with a descriptive message on failure.
/// Flux Pro 1.1 Ultra never uses image refs, so we always pass an empty
/// hydration map.
fn distill(request: &OmniGenImageCostAndGenerateRequest) -> DistilledImageRequest {
let empty: HashMap<MediaFileToken, Url> = HashMap::new();
distill_image_request(request, Some(&empty))
.expect("distill_image_request should succeed for Flux Pro 1.1 Ultra")
}
// ────────────────────────────────────────────────────────────────────────────
// COST
// ────────────────────────────────────────────────────────────────────────────
//
// Pricing comes from `estimate_image_cost_artcraft_flux_pro_1p1_ultra`:
// $0.06 / image (6 credits per image; 1 credit == 1 USD cent)
//
// This matches what the legacy
// `generate_flux_pro_11_ultra_text_to_image_handler` charges via
// `FluxPro11UltraArgs::calculate_cost_in_cents` (Fal docs:
// "$0.06 per image", flat).
mod cost {
use super::*;
fn cost_for_batch(image_batch_count: Option<u16>) -> (Option<u64>, Option<u64>) {
let request = make_request(Some("a happy puppy"), None, image_batch_count);
let distilled = distill(&request);
(
distilled.cost.cost_in_credits,
distilled.cost.cost_in_usd_cents,
)
}
#[test]
fn default_batch_costs_6_cents() {
// No batch specified → defaults to 1 image → 6¢
let (credits, cents) = cost_for_batch(None);
assert_eq!(credits, Some(6));
assert_eq!(cents, Some(6));
}
#[test]
fn batch_of_one_costs_6_cents() {
let (credits, cents) = cost_for_batch(Some(1));
assert_eq!(credits, Some(6));
assert_eq!(cents, Some(6));
}
#[test]
fn batch_of_two_costs_12_cents() {
let (credits, cents) = cost_for_batch(Some(2));
assert_eq!(credits, Some(12));
assert_eq!(cents, Some(12));
}
#[test]
fn batch_of_three_costs_18_cents() {
let (credits, cents) = cost_for_batch(Some(3));
assert_eq!(credits, Some(18));
assert_eq!(cents, Some(18));
}
#[test]
fn batch_of_four_costs_24_cents() {
let (credits, cents) = cost_for_batch(Some(4));
assert_eq!(credits, Some(24));
assert_eq!(cents, Some(24));
}
#[test]
fn batch_above_max_clamps_and_costs_24_cents() {
// PayMoreUpgrade strategy clamps batches >4 down to the max of 4.
let (credits, cents) = cost_for_batch(Some(7));
assert_eq!(credits, Some(24));
assert_eq!(cents, Some(24));
}
#[test]
fn cost_is_independent_of_aspect_ratio() {
let aspect_ratios = [
None,
Some(CommonAspectRatio::Square),
Some(CommonAspectRatio::SquareHd),
Some(CommonAspectRatio::WideSixteenByNine),
Some(CommonAspectRatio::WideTwentyOneByNine),
Some(CommonAspectRatio::TallNineBySixteen),
Some(CommonAspectRatio::TallNineByTwentyOne),
Some(CommonAspectRatio::Auto),
];
for ar in aspect_ratios {
let request = make_request(Some("p"), ar, Some(2));
let distilled = distill(&request);
assert_eq!(
distilled.cost.cost_in_usd_cents,
Some(12),
"expected 12¢ regardless of aspect ratio (got {:?})",
ar,
);
}
}
#[test]
fn cost_is_independent_of_prompt() {
let with_prompt = distill(&make_request(Some("a cat"), None, Some(3)));
let without_prompt = distill(&make_request(None, None, Some(3)));
assert_eq!(with_prompt.cost.cost_in_usd_cents, Some(18));
assert_eq!(without_prompt.cost.cost_in_usd_cents, Some(18));
}
#[test]
fn cost_metadata_flags_are_default() {
let request = make_request(Some("p"), None, Some(1));
let distilled = distill(&request);
assert!(!distilled.cost.is_free);
assert!(!distilled.cost.is_unlimited);
assert!(!distilled.cost.is_rate_limited);
assert!(!distilled.cost.has_watermark);
}
}
// ────────────────────────────────────────────────────────────────────────────
// PLAN
// ────────────────────────────────────────────────────────────────────────────
//
// The distilled plan is the Fal-side `PlanFalFluxPro11Ultra`:
// - prompt: passthrough
// - aspect_ratio: mapped from CommonAspectRatio (Ultra has more native
// ratios than 1.1, including 21:9, 3:2, 9:21, 2:3)
// - num_images: mapped from image_batch_count (default 1, clamped to 1..=4)
//
// (Flux Pro 1.1 Ultra has no image_url field — text-to-image only.)
mod plan {
use super::*;
use artcraft_router::generate::generate_image::image_generation_plan::ImageGenerationPlan;
use artcraft_router::generate::generate_image::plan::fal::plan_generate_image_fal_flux_pro_1p1_ultra::{
FalFluxPro11UltraNumImages, PlanFalFluxPro11Ultra,
};
use fal_client::requests::webhook::image::text::enqueue_flux_pro_11_ultra_text_to_image_webhook::FluxPro11UltraAspectRatio;
/// Distill and run an assertion against the inner FalFluxPro11Ultra plan.
///
/// We pass the plan by reference (rather than moving it out of `distilled`)
/// because the plan's borrows are tied to data owned by `distilled` —
/// moving it out would dangle those borrows.
fn with_fal_plan<F: FnOnce(&PlanFalFluxPro11Ultra<'_>)>(
request: &OmniGenImageCostAndGenerateRequest,
assertion: F,
) {
let distilled = distill(request);
match distilled.plan() {
ImageGenerationPlan::FalFluxPro11Ultra(plan) => assertion(plan),
other => panic!("expected ImageGenerationPlan::FalFluxPro11Ultra, got {:?}", other),
}
}
fn assert_plan_for_aspect_ratio<F: FnOnce(&PlanFalFluxPro11Ultra<'_>)>(
ar: Option<CommonAspectRatio>,
assertion: F,
) {
with_fal_plan(&make_request(Some("p"), ar, Some(1)), assertion);
}
fn assert_plan_for_batch<F: FnOnce(&PlanFalFluxPro11Ultra<'_>)>(
batch: Option<u16>,
assertion: F,
) {
with_fal_plan(&make_request(Some("p"), None, batch), assertion);
}
// ── Aspect ratio direct mappings ────────────────────────────────────────
#[test]
fn default_aspect_ratio_is_square() {
assert_plan_for_aspect_ratio(None, |plan| {
assert!(matches!(plan.aspect_ratio, FluxPro11UltraAspectRatio::Square));
});
}
#[test]
fn square_input_yields_square() {
assert_plan_for_aspect_ratio(Some(CommonAspectRatio::Square), |plan| {
assert!(matches!(plan.aspect_ratio, FluxPro11UltraAspectRatio::Square));
});
}
#[test]
fn square_hd_yields_square() {
// Ultra collapses SquareHd back to plain Square.
assert_plan_for_aspect_ratio(Some(CommonAspectRatio::SquareHd), |plan| {
assert!(matches!(plan.aspect_ratio, FluxPro11UltraAspectRatio::Square));
});
}
#[test]
fn wide_4x3_yields_landscape_4_3() {
assert_plan_for_aspect_ratio(Some(CommonAspectRatio::WideFourByThree), |plan| {
assert!(matches!(plan.aspect_ratio, FluxPro11UltraAspectRatio::LandscapeFourByThree));
});
}
#[test]
fn wide_3x2_yields_landscape_3_2() {
// Ultra-only direct mapping (1.1 doesn't have this).
assert_plan_for_aspect_ratio(Some(CommonAspectRatio::WideThreeByTwo), |plan| {
assert!(matches!(plan.aspect_ratio, FluxPro11UltraAspectRatio::LandscapeThreeByTwo));
});
}
#[test]
fn wide_16x9_yields_landscape_16_9() {
assert_plan_for_aspect_ratio(Some(CommonAspectRatio::WideSixteenByNine), |plan| {
assert!(matches!(plan.aspect_ratio, FluxPro11UltraAspectRatio::LandscapeSixteenByNine));
});
}
#[test]
fn wide_alias_yields_landscape_16_9() {
assert_plan_for_aspect_ratio(Some(CommonAspectRatio::Wide), |plan| {
assert!(matches!(plan.aspect_ratio, FluxPro11UltraAspectRatio::LandscapeSixteenByNine));
});
}
#[test]
fn wide_21x9_yields_landscape_21_9() {
// Ultra-only direct mapping (1.1 falls back to 16:9).
assert_plan_for_aspect_ratio(Some(CommonAspectRatio::WideTwentyOneByNine), |plan| {
assert!(matches!(plan.aspect_ratio, FluxPro11UltraAspectRatio::LandscapeTwentyOneByNine));
});
}
#[test]
fn tall_3x4_yields_portrait_3_4() {
assert_plan_for_aspect_ratio(Some(CommonAspectRatio::TallThreeByFour), |plan| {
assert!(matches!(plan.aspect_ratio, FluxPro11UltraAspectRatio::PortraitThreeByFour));
});
}
#[test]
fn tall_2x3_yields_portrait_2_3() {
// Ultra-only direct mapping (1.1 falls back to 3:4).
assert_plan_for_aspect_ratio(Some(CommonAspectRatio::TallTwoByThree), |plan| {
assert!(matches!(plan.aspect_ratio, FluxPro11UltraAspectRatio::PortraitTwoByThree));
});
}
#[test]
fn tall_9x16_yields_portrait_9_16() {
assert_plan_for_aspect_ratio(Some(CommonAspectRatio::TallNineBySixteen), |plan| {
assert!(matches!(plan.aspect_ratio, FluxPro11UltraAspectRatio::PortraitNineBySixteen));
});
}
#[test]
fn tall_alias_yields_portrait_9_16() {
assert_plan_for_aspect_ratio(Some(CommonAspectRatio::Tall), |plan| {
assert!(matches!(plan.aspect_ratio, FluxPro11UltraAspectRatio::PortraitNineBySixteen));
});
}
#[test]
fn tall_9x21_yields_portrait_9_21() {
// Ultra-only direct mapping (1.1 falls back to 9:16).
assert_plan_for_aspect_ratio(Some(CommonAspectRatio::TallNineByTwentyOne), |plan| {
assert!(matches!(plan.aspect_ratio, FluxPro11UltraAspectRatio::PortraitNineByTwentyOne));
});
}
// ── Aspect ratio nearest-neighbour fallbacks ────────────────────────────
//
// Ultra natively supports almost everything; only 5:4 and 4:5 fall back.
#[test]
fn wide_5x4_falls_back_to_landscape_4_3() {
assert_plan_for_aspect_ratio(Some(CommonAspectRatio::WideFiveByFour), |plan| {
assert!(matches!(plan.aspect_ratio, FluxPro11UltraAspectRatio::LandscapeFourByThree));
});
}
#[test]
fn tall_4x5_falls_back_to_portrait_3_4() {
assert_plan_for_aspect_ratio(Some(CommonAspectRatio::TallFourByFive), |plan| {
assert!(matches!(plan.aspect_ratio, FluxPro11UltraAspectRatio::PortraitThreeByFour));
});
}
// ── Auto* aspect ratios ─────────────────────────────────────────────────
#[test]
fn auto_yields_square() {
assert_plan_for_aspect_ratio(Some(CommonAspectRatio::Auto), |plan| {
assert!(matches!(plan.aspect_ratio, FluxPro11UltraAspectRatio::Square));
});
}
#[test]
fn auto_2k_yields_square() {
assert_plan_for_aspect_ratio(Some(CommonAspectRatio::Auto2k), |plan| {
assert!(matches!(plan.aspect_ratio, FluxPro11UltraAspectRatio::Square));
});
}
#[test]
fn auto_4k_yields_square() {
assert_plan_for_aspect_ratio(Some(CommonAspectRatio::Auto4k), |plan| {
assert!(matches!(plan.aspect_ratio, FluxPro11UltraAspectRatio::Square));
});
}
// ── Num images mapping ──────────────────────────────────────────────────
#[test]
fn default_batch_count_is_one() {
assert_plan_for_batch(None, |plan| {
assert!(matches!(plan.num_images, FalFluxPro11UltraNumImages::One));
});
}
#[test]
fn batch_of_one_yields_one() {
assert_plan_for_batch(Some(1), |plan| {
assert!(matches!(plan.num_images, FalFluxPro11UltraNumImages::One));
});
}
#[test]
fn batch_of_two_yields_two() {
assert_plan_for_batch(Some(2), |plan| {
assert!(matches!(plan.num_images, FalFluxPro11UltraNumImages::Two));
});
}
#[test]
fn batch_of_three_yields_three() {
assert_plan_for_batch(Some(3), |plan| {
assert!(matches!(plan.num_images, FalFluxPro11UltraNumImages::Three));
});
}
#[test]
fn batch_of_four_yields_four() {
assert_plan_for_batch(Some(4), |plan| {
assert!(matches!(plan.num_images, FalFluxPro11UltraNumImages::Four));
});
}
#[test]
fn batch_above_four_clamps_to_four() {
// PayMoreUpgrade strategy clamps over-large batches down to the max.
assert_plan_for_batch(Some(9), |plan| {
assert!(matches!(plan.num_images, FalFluxPro11UltraNumImages::Four));
});
}
// ── Prompt passthrough ──────────────────────────────────────────────────
#[test]
fn prompt_is_passed_through() {
with_fal_plan(&make_request(Some("a corgi in a hat"), None, Some(1)), |plan| {
assert_eq!(plan.prompt, Some("a corgi in a hat"));
});
}
#[test]
fn missing_prompt_is_none() {
with_fal_plan(&make_request(None, None, Some(1)), |plan| {
assert_eq!(plan.prompt, None);
});
}
}
}
@@ -0,0 +1,823 @@
//! Tests for omni-gen against the GPT Image 1 model.
//!
//! GPT Image 1 is multi-function: text-to-image and image-to-image (edit).
//! The omni-gen distillation routes through Fal for the execution plan and
//! Artcraft for billing.
//!
//! Artcraft-tier pricing (the rate the user is billed):
//!
//! Output image cost (per output image):
//! Low – 2¢ (square/unset) / 2¢ (wide or tall)
//! Medium – 5¢ (square/unset) / 7¢ (wide or tall)
//! High – 17¢ (square/unset) / 25¢ (wide or tall)
//! None → defaults to High
//!
//! Input image cost (edit mode only, per input image):
//! 2¢ per input image (high-fidelity token estimate)
//!
//! The Fal execution plan maps quality from the omni request (defaulting to
//! Medium when unspecified) and maps aspect ratios to three image sizes:
//! Square (1024×1024), Horizontal (1536×1024), Vertical (1024×1536).
//! Auto/unset maps to None (API default).
#[cfg(test)]
mod tests {
use std::collections::HashMap;
use artcraft_api_defs::omni_gen::cost_and_generate_requests::omni_gen_image_cost_and_generate_request::OmniGenImageCostAndGenerateRequest;
use enums::common::generation::common_aspect_ratio::CommonAspectRatio;
use enums::common::generation::common_image_model::CommonImageModel;
use enums::common::generation::common_quality::CommonQuality;
use tokens::tokens::media_files::MediaFileToken;
use url::Url;
use crate::http_server::endpoints::omni_gen::generate::image::distill_image_request::{
distill_image_request, DistilledImageRequest,
};
// ── Helpers ────────────────────────────────────────────────────────────────
fn make_request(
prompt: Option<&str>,
aspect_ratio: Option<CommonAspectRatio>,
quality: Option<CommonQuality>,
image_batch_count: Option<u16>,
image_media_tokens: Option<Vec<MediaFileToken>>,
) -> OmniGenImageCostAndGenerateRequest {
OmniGenImageCostAndGenerateRequest {
idempotency_token: Some("11111111-1111-1111-1111-111111111111".to_string()),
model: Some(CommonImageModel::GptImage1),
prompt: prompt.map(|s| s.to_string()),
image_media_tokens,
resolution: None,
aspect_ratio,
quality,
image_batch_count,
horizontal_angle: None,
vertical_angle: None,
zoom: None,
}
}
fn fake_image_refs(n: usize) -> (Vec<MediaFileToken>, HashMap<MediaFileToken, Url>) {
let mut tokens = Vec::with_capacity(n);
let mut map = HashMap::with_capacity(n);
for i in 0..n {
let token = MediaFileToken::new_from_str(&format!("mf_test{:028}", i));
let url = Url::parse(&format!("https://fake.example.com/img{}.png", i)).unwrap();
map.insert(token.clone(), url);
tokens.push(token);
}
(tokens, map)
}
fn distill_text(request: &OmniGenImageCostAndGenerateRequest) -> DistilledImageRequest {
let empty: HashMap<MediaFileToken, Url> = HashMap::new();
distill_image_request(request, Some(&empty))
.expect("distill_image_request should succeed for GPT Image 1 (text)")
}
fn distill_edit(
request: &OmniGenImageCostAndGenerateRequest,
hydration: &HashMap<MediaFileToken, Url>,
) -> DistilledImageRequest {
distill_image_request(request, Some(hydration))
.expect("distill_image_request should succeed for GPT Image 1 (edit)")
}
// ────────────────────────────────────────────────────────────────────────────
// COST
// ────────────────────────────────────────────────────────────────────────────
mod cost {
use super::*;
mod text {
use super::*;
fn cost(
quality: Option<CommonQuality>,
aspect_ratio: Option<CommonAspectRatio>,
batch: Option<u16>,
) -> u64 {
let request = make_request(Some("p"), aspect_ratio, quality, batch, None);
distill_text(&request).cost.cost_in_usd_cents.unwrap()
}
// ── Default quality (None → High) ────────────────────────────────────
#[test]
fn default_quality_square_costs_17() {
assert_eq!(cost(None, Some(CommonAspectRatio::Square), Some(1)), 17);
}
#[test]
fn default_quality_wide_costs_25() {
assert_eq!(cost(None, Some(CommonAspectRatio::WideSixteenByNine), Some(1)), 25);
}
#[test]
fn default_quality_tall_costs_25() {
assert_eq!(cost(None, Some(CommonAspectRatio::TallNineBySixteen), Some(1)), 25);
}
#[test]
fn default_quality_unset_size_costs_17() {
// Unset aspect_ratio → None image_size → treated as square = 17¢
assert_eq!(cost(None, None, Some(1)), 17);
}
#[test]
fn default_quality_auto_costs_17() {
assert_eq!(cost(None, Some(CommonAspectRatio::Auto), Some(1)), 17);
}
// ── Low quality (2¢/image regardless of size) ────────────────────────
#[test]
fn low_square_costs_2() {
assert_eq!(cost(Some(CommonQuality::Low), Some(CommonAspectRatio::Square), Some(1)), 2);
}
#[test]
fn low_wide_costs_2() {
assert_eq!(cost(Some(CommonQuality::Low), Some(CommonAspectRatio::WideSixteenByNine), Some(1)), 2);
}
#[test]
fn low_tall_costs_2() {
assert_eq!(cost(Some(CommonQuality::Low), Some(CommonAspectRatio::TallNineBySixteen), Some(1)), 2);
}
#[test]
fn low_unset_costs_2() {
assert_eq!(cost(Some(CommonQuality::Low), None, Some(1)), 2);
}
#[test]
fn low_four_images_costs_8() {
assert_eq!(cost(Some(CommonQuality::Low), None, Some(4)), 8);
}
#[test]
fn low_batch_above_max_clamps_to_8() {
assert_eq!(cost(Some(CommonQuality::Low), None, Some(7)), 8);
}
// ── Medium quality (5¢ square, 7¢ wide/tall) ─────────────────────────
#[test]
fn medium_square_costs_5() {
assert_eq!(cost(Some(CommonQuality::Medium), Some(CommonAspectRatio::Square), Some(1)), 5);
}
#[test]
fn medium_wide_costs_7() {
assert_eq!(cost(Some(CommonQuality::Medium), Some(CommonAspectRatio::WideSixteenByNine), Some(1)), 7);
}
#[test]
fn medium_tall_costs_7() {
assert_eq!(cost(Some(CommonQuality::Medium), Some(CommonAspectRatio::TallNineBySixteen), Some(1)), 7);
}
#[test]
fn medium_unset_costs_5() {
assert_eq!(cost(Some(CommonQuality::Medium), None, Some(1)), 5);
}
#[test]
fn medium_square_four_images_costs_20() {
assert_eq!(cost(Some(CommonQuality::Medium), Some(CommonAspectRatio::Square), Some(4)), 20);
}
#[test]
fn medium_wide_four_images_costs_28() {
assert_eq!(cost(Some(CommonQuality::Medium), Some(CommonAspectRatio::WideSixteenByNine), Some(4)), 28);
}
// ── High quality (17¢ square, 25¢ wide/tall) ─────────────────────────
#[test]
fn high_square_costs_17() {
assert_eq!(cost(Some(CommonQuality::High), Some(CommonAspectRatio::Square), Some(1)), 17);
}
#[test]
fn high_wide_costs_25() {
assert_eq!(cost(Some(CommonQuality::High), Some(CommonAspectRatio::WideSixteenByNine), Some(1)), 25);
}
#[test]
fn high_tall_costs_25() {
assert_eq!(cost(Some(CommonQuality::High), Some(CommonAspectRatio::TallNineBySixteen), Some(1)), 25);
}
#[test]
fn high_unset_costs_17() {
assert_eq!(cost(Some(CommonQuality::High), None, Some(1)), 17);
}
#[test]
fn high_square_four_images_costs_68() {
assert_eq!(cost(Some(CommonQuality::High), Some(CommonAspectRatio::Square), Some(4)), 68);
}
#[test]
fn high_wide_four_images_costs_100() {
assert_eq!(cost(Some(CommonQuality::High), Some(CommonAspectRatio::WideSixteenByNine), Some(4)), 100);
}
#[test]
fn high_batch_above_max_clamps_to_four() {
assert_eq!(cost(Some(CommonQuality::High), None, Some(9)), 68);
}
// ── Batch scaling ────────────────────────────────────────────────────
#[test]
fn high_square_batch_scaling() {
assert_eq!(cost(Some(CommonQuality::High), Some(CommonAspectRatio::Square), Some(1)), 17);
assert_eq!(cost(Some(CommonQuality::High), Some(CommonAspectRatio::Square), Some(2)), 34);
assert_eq!(cost(Some(CommonQuality::High), Some(CommonAspectRatio::Square), Some(3)), 51);
assert_eq!(cost(Some(CommonQuality::High), Some(CommonAspectRatio::Square), Some(4)), 68);
}
// ── Metadata flags ───────────────────────────────────────────────────
#[test]
fn cost_metadata_flags_are_default() {
let request = make_request(Some("p"), None, None, Some(1), None);
let distilled = distill_text(&request);
assert!(!distilled.cost.is_free);
assert!(!distilled.cost.is_unlimited);
assert!(!distilled.cost.is_rate_limited);
assert!(!distilled.cost.has_watermark);
}
// ── Cost is independent of prompt ─────────────────────────────────────
#[test]
fn cost_is_independent_of_prompt() {
let with_prompt = distill_text(&make_request(Some("a cat"), None, None, Some(1), None));
let without_prompt = distill_text(&make_request(None, None, None, Some(1), None));
assert_eq!(with_prompt.cost.cost_in_usd_cents, without_prompt.cost.cost_in_usd_cents);
}
}
mod edit {
use super::*;
fn cost_edit(
quality: Option<CommonQuality>,
aspect_ratio: Option<CommonAspectRatio>,
batch: Option<u16>,
num_input_images: usize,
) -> u64 {
let (tokens, hydration) = fake_image_refs(num_input_images);
let request = make_request(Some("p"), aspect_ratio, quality, batch, Some(tokens));
distill_edit(&request, &hydration).cost.cost_in_usd_cents.unwrap()
}
// ── Input image cost adds 2¢ per input ──────────────────────────────
#[test]
fn edit_high_square_one_output_one_input() {
// 17¢ output + 1×2¢ input = 19¢
assert_eq!(cost_edit(Some(CommonQuality::High), Some(CommonAspectRatio::Square), Some(1), 1), 19);
}
#[test]
fn edit_high_square_one_output_three_inputs() {
// 17¢ output + 3×2¢ input = 23¢
assert_eq!(cost_edit(Some(CommonQuality::High), Some(CommonAspectRatio::Square), Some(1), 3), 23);
}
#[test]
fn edit_high_square_one_output_five_inputs() {
// 17¢ output + 5×2¢ input = 27¢
assert_eq!(cost_edit(Some(CommonQuality::High), Some(CommonAspectRatio::Square), Some(1), 5), 27);
}
#[test]
fn edit_high_wide_two_outputs_one_input() {
// 2×25¢ output + 1×2¢ input = 52¢
assert_eq!(cost_edit(Some(CommonQuality::High), Some(CommonAspectRatio::WideSixteenByNine), Some(2), 1), 52);
}
#[test]
fn edit_medium_wide_two_outputs_five_inputs() {
// 2×7¢ output + 5×2¢ input = 24¢
assert_eq!(cost_edit(Some(CommonQuality::Medium), Some(CommonAspectRatio::WideSixteenByNine), Some(2), 5), 24);
}
#[test]
fn edit_low_square_one_output_two_inputs() {
// 2¢ output + 2×2¢ input = 6¢
assert_eq!(cost_edit(Some(CommonQuality::Low), Some(CommonAspectRatio::Square), Some(1), 2), 6);
}
#[test]
fn edit_low_tall_four_outputs_one_input() {
// 4×2¢ output + 1×2¢ input = 10¢
assert_eq!(cost_edit(Some(CommonQuality::Low), Some(CommonAspectRatio::TallNineBySixteen), Some(4), 1), 10);
}
// ── Default quality in edit mode (None → High) ──────────────────────
#[test]
fn edit_default_quality_square_one_output_one_input() {
// 17¢ output + 2¢ input = 19¢
assert_eq!(cost_edit(None, Some(CommonAspectRatio::Square), Some(1), 1), 19);
}
#[test]
fn edit_default_quality_wide_one_output_one_input() {
// 25¢ output + 2¢ input = 27¢
assert_eq!(cost_edit(None, Some(CommonAspectRatio::WideSixteenByNine), Some(1), 1), 27);
}
// ── Quality × size matrix in edit mode (1 output, 1 input) ──────────
#[test]
fn edit_low_square() {
// 2¢ + 2¢ = 4¢
assert_eq!(cost_edit(Some(CommonQuality::Low), Some(CommonAspectRatio::Square), Some(1), 1), 4);
}
#[test]
fn edit_low_wide() {
// 2¢ + 2¢ = 4¢
assert_eq!(cost_edit(Some(CommonQuality::Low), Some(CommonAspectRatio::WideSixteenByNine), Some(1), 1), 4);
}
#[test]
fn edit_medium_square() {
// 5¢ + 2¢ = 7¢
assert_eq!(cost_edit(Some(CommonQuality::Medium), Some(CommonAspectRatio::Square), Some(1), 1), 7);
}
#[test]
fn edit_medium_wide() {
// 7¢ + 2¢ = 9¢
assert_eq!(cost_edit(Some(CommonQuality::Medium), Some(CommonAspectRatio::WideSixteenByNine), Some(1), 1), 9);
}
#[test]
fn edit_medium_tall() {
// 7¢ + 2¢ = 9¢
assert_eq!(cost_edit(Some(CommonQuality::Medium), Some(CommonAspectRatio::TallNineBySixteen), Some(1), 1), 9);
}
#[test]
fn edit_high_square() {
// 17¢ + 2¢ = 19¢
assert_eq!(cost_edit(Some(CommonQuality::High), Some(CommonAspectRatio::Square), Some(1), 1), 19);
}
#[test]
fn edit_high_wide() {
// 25¢ + 2¢ = 27¢
assert_eq!(cost_edit(Some(CommonQuality::High), Some(CommonAspectRatio::WideSixteenByNine), Some(1), 1), 27);
}
#[test]
fn edit_high_tall() {
// 25¢ + 2¢ = 27¢
assert_eq!(cost_edit(Some(CommonQuality::High), Some(CommonAspectRatio::TallNineBySixteen), Some(1), 1), 27);
}
// ── Batch above max clamps ──────────────────────────────────────────
#[test]
fn edit_batch_above_max_clamps() {
// High square 4×17 + 1×2 = 70¢ (clamped from 9 to 4)
assert_eq!(cost_edit(Some(CommonQuality::High), Some(CommonAspectRatio::Square), Some(9), 1), 70);
}
// ── Cost matches text + 2¢ per input image ─────────────────────────
#[test]
fn edit_and_text_cost_differ_by_input_image_cost() {
for q in [CommonQuality::Low, CommonQuality::Medium, CommonQuality::High] {
let text = distill_text(&make_request(Some("p"), None, Some(q), Some(1), None))
.cost.cost_in_usd_cents.unwrap();
let (tokens, hydration) = fake_image_refs(1);
let edit = distill_edit(
&make_request(Some("p"), None, Some(q), Some(1), Some(tokens)),
&hydration,
).cost.cost_in_usd_cents.unwrap();
assert_eq!(
edit - text,
2,
"expected edit to cost 2¢ more than text for quality {:?}",
q,
);
}
}
// ── Metadata flags ───────────────────────────────────────────────────
#[test]
fn cost_metadata_flags_are_default() {
let (tokens, hydration) = fake_image_refs(1);
let request = make_request(Some("p"), None, None, Some(1), Some(tokens));
let distilled = distill_edit(&request, &hydration);
assert!(!distilled.cost.is_free);
assert!(!distilled.cost.is_unlimited);
assert!(!distilled.cost.is_rate_limited);
assert!(!distilled.cost.has_watermark);
}
}
}
// ────────────────────────────────────────────────────────────────────────────
// PLAN
// ────────────────────────────────────────────────────────────────────────────
mod plan {
use super::*;
use artcraft_router::generate::generate_image::image_generation_plan::ImageGenerationPlan;
use artcraft_router::generate::generate_image::plan::fal::plan_generate_image_fal_gpt_image_1::{
FalGptImage1ImageSize, FalGptImage1NumImages, FalGptImage1Quality, PlanFalGptImage1,
};
fn with_text_plan<F: FnOnce(&PlanFalGptImage1<'_>)>(
request: &OmniGenImageCostAndGenerateRequest,
assertion: F,
) {
let distilled = distill_text(request);
match distilled.plan() {
ImageGenerationPlan::FalGptImage1(plan) => assertion(plan),
other => panic!("expected ImageGenerationPlan::FalGptImage1, got {:?}", other),
}
}
fn with_edit_plan<F: FnOnce(&PlanFalGptImage1<'_>)>(
request: &OmniGenImageCostAndGenerateRequest,
hydration: &HashMap<MediaFileToken, Url>,
assertion: F,
) {
let distilled = distill_edit(request, hydration);
match distilled.plan() {
ImageGenerationPlan::FalGptImage1(plan) => assertion(plan),
other => panic!("expected ImageGenerationPlan::FalGptImage1, got {:?}", other),
}
}
mod text {
use super::*;
// ── Quality mapping ───────────────────────────────────────────────────
#[test]
fn default_quality_is_high() {
with_text_plan(&make_request(Some("p"), None, None, Some(1), None), |plan| {
assert!(matches!(plan.quality, FalGptImage1Quality::High));
});
}
#[test]
fn low_quality_passes_through() {
with_text_plan(&make_request(Some("p"), None, Some(CommonQuality::Low), Some(1), None), |plan| {
assert!(matches!(plan.quality, FalGptImage1Quality::Low));
});
}
#[test]
fn medium_quality_passes_through() {
with_text_plan(&make_request(Some("p"), None, Some(CommonQuality::Medium), Some(1), None), |plan| {
assert!(matches!(plan.quality, FalGptImage1Quality::Medium));
});
}
#[test]
fn high_quality_passes_through() {
with_text_plan(&make_request(Some("p"), None, Some(CommonQuality::High), Some(1), None), |plan| {
assert!(matches!(plan.quality, FalGptImage1Quality::High));
});
}
// ── Mode detection ────────────────────────────────────────────────────
#[test]
fn text_mode_has_empty_image_urls() {
with_text_plan(&make_request(Some("p"), None, None, Some(1), None), |plan| {
assert!(plan.image_urls.is_empty());
});
}
// ── Image size mappings ───────────────────────────────────────────────
#[test]
fn default_image_size_is_none() {
with_text_plan(&make_request(Some("p"), None, None, Some(1), None), |plan| {
assert!(plan.image_size.is_none());
});
}
#[test]
fn auto_variants_yield_none() {
for ar in [CommonAspectRatio::Auto, CommonAspectRatio::Auto2k, CommonAspectRatio::Auto4k] {
with_text_plan(&make_request(Some("p"), Some(ar), None, Some(1), None), |plan| {
assert!(plan.image_size.is_none(), "expected None for {:?}", ar);
});
}
}
#[test]
fn square_yields_square() {
for ar in [CommonAspectRatio::Square, CommonAspectRatio::SquareHd] {
with_text_plan(&make_request(Some("p"), Some(ar), None, Some(1), None), |plan| {
assert!(matches!(plan.image_size, Some(FalGptImage1ImageSize::Square)), "expected Square for {:?}", ar);
});
}
}
#[test]
fn wide_variants_yield_horizontal() {
let wide_ars = [
CommonAspectRatio::WideFiveByFour,
CommonAspectRatio::WideFourByThree,
CommonAspectRatio::WideThreeByTwo,
CommonAspectRatio::WideSixteenByNine,
CommonAspectRatio::WideTwentyOneByNine,
CommonAspectRatio::Wide,
];
for ar in wide_ars {
with_text_plan(&make_request(Some("p"), Some(ar), None, Some(1), None), |plan| {
assert!(matches!(plan.image_size, Some(FalGptImage1ImageSize::Horizontal)), "expected Horizontal for {:?}", ar);
});
}
}
#[test]
fn tall_variants_yield_vertical() {
let tall_ars = [
CommonAspectRatio::TallFourByFive,
CommonAspectRatio::TallThreeByFour,
CommonAspectRatio::TallTwoByThree,
CommonAspectRatio::TallNineBySixteen,
CommonAspectRatio::TallNineByTwentyOne,
CommonAspectRatio::Tall,
];
for ar in tall_ars {
with_text_plan(&make_request(Some("p"), Some(ar), None, Some(1), None), |plan| {
assert!(matches!(plan.image_size, Some(FalGptImage1ImageSize::Vertical)), "expected Vertical for {:?}", ar);
});
}
}
// ── Num images mapping ────────────────────────────────────────────────
#[test]
fn default_batch_count_is_one() {
with_text_plan(&make_request(Some("p"), None, None, None, None), |plan| {
assert!(matches!(plan.num_images, FalGptImage1NumImages::One));
});
}
#[test]
fn batch_direct_mapping() {
let cases = [
(1u16, FalGptImage1NumImages::One),
(2, FalGptImage1NumImages::Two),
(3, FalGptImage1NumImages::Three),
(4, FalGptImage1NumImages::Four),
];
for (count, expected) in cases {
with_text_plan(&make_request(Some("p"), None, None, Some(count), None), |plan| {
assert!(
std::mem::discriminant(&plan.num_images) == std::mem::discriminant(&expected),
"expected {:?} for count {}", expected, count,
);
});
}
}
#[test]
fn batch_above_four_clamps_to_four() {
with_text_plan(&make_request(Some("p"), None, None, Some(9), None), |plan| {
assert!(matches!(plan.num_images, FalGptImage1NumImages::Four));
});
}
// ── Prompt passthrough ────────────────────────────────────────────────
#[test]
fn prompt_is_passed_through() {
with_text_plan(&make_request(Some("a corgi in a hat"), None, None, Some(1), None), |plan| {
assert_eq!(plan.prompt, Some("a corgi in a hat"));
});
}
#[test]
fn missing_prompt_is_none() {
with_text_plan(&make_request(None, None, None, Some(1), None), |plan| {
assert_eq!(plan.prompt, None);
});
}
}
mod edit {
use super::*;
// ── Mode detection / image-url passthrough ────────────────────────────
#[test]
fn edit_mode_populates_image_urls() {
let (tokens, hydration) = fake_image_refs(1);
with_edit_plan(
&make_request(Some("p"), None, None, Some(1), Some(tokens)),
&hydration,
|plan| {
assert_eq!(plan.image_urls.len(), 1);
assert!(plan.image_urls[0].starts_with("https://fake.example.com/"));
},
);
}
#[test]
fn edit_mode_with_two_image_refs() {
let (tokens, hydration) = fake_image_refs(2);
with_edit_plan(
&make_request(Some("p"), None, None, Some(1), Some(tokens)),
&hydration,
|plan| {
assert_eq!(plan.image_urls.len(), 2);
},
);
}
#[test]
fn edit_mode_with_five_image_refs() {
let (tokens, hydration) = fake_image_refs(5);
with_edit_plan(
&make_request(Some("p"), None, None, Some(1), Some(tokens)),
&hydration,
|plan| {
assert_eq!(plan.image_urls.len(), 5);
},
);
}
#[test]
fn edit_image_urls_are_hydrated_from_map() {
let (tokens, hydration) = fake_image_refs(3);
let expected: Vec<String> = tokens.iter()
.map(|t| hydration.get(t).unwrap().to_string())
.collect();
with_edit_plan(
&make_request(Some("p"), None, None, Some(1), Some(tokens.clone())),
&hydration,
|plan| {
assert_eq!(plan.image_urls, expected);
},
);
}
// ── Quality mapping in edit mode ──────────────────────────────────────
#[test]
fn default_quality_is_high_in_edit_mode() {
let (tokens, hydration) = fake_image_refs(1);
with_edit_plan(
&make_request(Some("p"), None, None, Some(1), Some(tokens)),
&hydration,
|plan| {
assert!(matches!(plan.quality, FalGptImage1Quality::High));
},
);
}
#[test]
fn low_quality_passes_through_in_edit_mode() {
let (tokens, hydration) = fake_image_refs(1);
with_edit_plan(
&make_request(Some("p"), None, Some(CommonQuality::Low), Some(1), Some(tokens)),
&hydration,
|plan| {
assert!(matches!(plan.quality, FalGptImage1Quality::Low));
},
);
}
#[test]
fn high_quality_passes_through_in_edit_mode() {
let (tokens, hydration) = fake_image_refs(1);
with_edit_plan(
&make_request(Some("p"), None, Some(CommonQuality::High), Some(1), Some(tokens)),
&hydration,
|plan| {
assert!(matches!(plan.quality, FalGptImage1Quality::High));
},
);
}
// ── Image size in edit mode ──────────────────────────────────────────
#[test]
fn edit_default_image_size_is_none() {
let (tokens, hydration) = fake_image_refs(1);
with_edit_plan(
&make_request(Some("p"), None, None, Some(1), Some(tokens)),
&hydration,
|plan| { assert!(plan.image_size.is_none()); },
);
}
#[test]
fn edit_square_yields_square() {
let (tokens, hydration) = fake_image_refs(1);
with_edit_plan(
&make_request(Some("p"), Some(CommonAspectRatio::Square), None, Some(1), Some(tokens)),
&hydration,
|plan| { assert!(matches!(plan.image_size, Some(FalGptImage1ImageSize::Square))); },
);
}
#[test]
fn edit_wide_yields_horizontal() {
let (tokens, hydration) = fake_image_refs(1);
with_edit_plan(
&make_request(Some("p"), Some(CommonAspectRatio::WideSixteenByNine), None, Some(1), Some(tokens)),
&hydration,
|plan| { assert!(matches!(plan.image_size, Some(FalGptImage1ImageSize::Horizontal))); },
);
}
#[test]
fn edit_tall_yields_vertical() {
let (tokens, hydration) = fake_image_refs(1);
with_edit_plan(
&make_request(Some("p"), Some(CommonAspectRatio::TallNineBySixteen), None, Some(1), Some(tokens)),
&hydration,
|plan| { assert!(matches!(plan.image_size, Some(FalGptImage1ImageSize::Vertical))); },
);
}
#[test]
fn edit_auto_yields_none() {
let (tokens, hydration) = fake_image_refs(1);
with_edit_plan(
&make_request(Some("p"), Some(CommonAspectRatio::Auto), None, Some(1), Some(tokens)),
&hydration,
|plan| { assert!(plan.image_size.is_none()); },
);
}
// ── Num images in edit mode ──────────────────────────────────────────
#[test]
fn edit_default_batch_count_is_one() {
let (tokens, hydration) = fake_image_refs(1);
with_edit_plan(
&make_request(Some("p"), None, None, None, Some(tokens)),
&hydration,
|plan| { assert!(matches!(plan.num_images, FalGptImage1NumImages::One)); },
);
}
#[test]
fn edit_batch_of_four_yields_four() {
let (tokens, hydration) = fake_image_refs(1);
with_edit_plan(
&make_request(Some("p"), None, None, Some(4), Some(tokens)),
&hydration,
|plan| { assert!(matches!(plan.num_images, FalGptImage1NumImages::Four)); },
);
}
#[test]
fn edit_batch_above_four_clamps_to_four() {
let (tokens, hydration) = fake_image_refs(1);
with_edit_plan(
&make_request(Some("p"), None, None, Some(9), Some(tokens)),
&hydration,
|plan| { assert!(matches!(plan.num_images, FalGptImage1NumImages::Four)); },
);
}
// ── Prompt passthrough in edit mode ────────────────────────────────────
#[test]
fn edit_prompt_is_passed_through() {
let (tokens, hydration) = fake_image_refs(1);
with_edit_plan(
&make_request(Some("make it shiny"), None, None, Some(1), Some(tokens)),
&hydration,
|plan| { assert_eq!(plan.prompt, Some("make it shiny")); },
);
}
#[test]
fn edit_missing_prompt_is_none() {
let (tokens, hydration) = fake_image_refs(1);
with_edit_plan(
&make_request(None, None, None, Some(1), Some(tokens)),
&hydration,
|plan| { assert_eq!(plan.prompt, None); },
);
}
}
}
}

Some files were not shown because too many files have changed in this diff Show More