Compare commits

...
Author SHA1 Message Date
Brandon Thomas 6d81f93b22 rename variants 2026-03-04 03:41:19 -05:00
Brandon Thomas 95fa5006bc port gpt-image-1.5 and nano banana to artcraft_router 2026-03-04 03:38:57 -05:00
21 changed files with 1288 additions and 535 deletions
@@ -5,8 +5,14 @@ use serde_derive::{Deserialize, Serialize};
#[derive(Copy, Clone, Debug, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum CommonImageModel {
#[serde(rename = "gpt_image_1p5")]
GptImage1p5,
#[serde(rename = "nano_banana")]
NanaBanana,
#[serde(rename = "nano_banana_pro")]
NanaBananaPro,
#[serde(rename = "seedream_4")]
Seedream4,
#[serde(rename = "seedream_4p5")]
Seedream4p5,
}
@@ -0,0 +1,119 @@
use artcraft_api_defs::generate::image::multi_function::gpt_image_1p5_multi_function_image_gen::{
GptImage1p5MultiFunctionImageGenNumImages, GptImage1p5MultiFunctionImageGenQuality,
GptImage1p5MultiFunctionImageGenSize,
};
use crate::generate::generate_image::image_generation_cost_estimate::ImageGenerationCostEstimate;
use crate::generate::generate_image::plan::artcraft::plan_generate_image_artcraft_gpt_image_1p5::PlanArtcraftGptImage1p5;
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)
//
// From Fal (text 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.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.
//
// 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,
},
};
let num_images: u64 = match plan.num_images {
GptImage1p5MultiFunctionImageGenNumImages::One => 1,
GptImage1p5MultiFunctionImageGenNumImages::Two => 2,
GptImage1p5MultiFunctionImageGenNumImages::Three => 3,
GptImage1p5MultiFunctionImageGenNumImages::Four => 4,
};
let cost_in_usd_cents = cost_per_image * num_images;
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::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 {
let request = GenerateImageRequest {
model: CommonImageModel::GptImage1p5,
provider: Provider::Artcraft,
prompt: None,
image_inputs: None,
resolution: None,
aspect_ratio,
image_batch_count: Some(image_batch_count),
request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::ErrorOut,
idempotency_token: None,
};
request.build()
.expect("build should succeed")
.estimate_costs()
.cost_in_usd_cents
.expect("cost_in_usd_cents should be present")
}
#[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);
}
#[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);
}
#[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);
}
}
@@ -0,0 +1,65 @@
use artcraft_api_defs::generate::image::multi_function::nano_banana_multi_function_image_gen::NanoBananaMultiFunctionImageGenNumImages;
use crate::generate::generate_image::image_generation_cost_estimate::ImageGenerationCostEstimate;
use crate::generate::generate_image::plan::artcraft::plan_generate_image_artcraft_nano_banana::PlanArtcraftNanaBanana;
pub(crate) fn estimate_image_cost_artcraft_nano_banana(
plan: &PlanArtcraftNanaBanana<'_>,
) -> ImageGenerationCostEstimate {
// Pricing: $0.04/image (4 cents). 1 credit = 1 USD cent.
let cost_per_image: u64 = 4;
let num_images: u64 = match plan.num_images {
NanoBananaMultiFunctionImageGenNumImages::One => 1,
NanoBananaMultiFunctionImageGenNumImages::Two => 2,
NanoBananaMultiFunctionImageGenNumImages::Three => 3,
NanoBananaMultiFunctionImageGenNumImages::Four => 4,
};
let cost_in_usd_cents = cost_per_image * num_images;
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_image_model::CommonImageModel;
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) -> u64 {
let request = GenerateImageRequest {
model: CommonImageModel::NanaBanana,
provider: Provider::Artcraft,
prompt: None,
image_inputs: None,
resolution: None,
aspect_ratio: None,
image_batch_count: Some(image_batch_count),
request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::ErrorOut,
idempotency_token: None,
};
request.build()
.expect("build should succeed")
.estimate_costs()
.cost_in_usd_cents
.expect("cost_in_usd_cents should be present")
}
#[test]
fn test_estimate_cost_usd_cents() {
// $0.04/image = 4 cents each
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);
}
}
@@ -1,3 +1,5 @@
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_pro;
pub mod estimate_image_cost_artcraft_seedream_4;
pub mod estimate_image_cost_artcraft_seedream_4p5;
@@ -0,0 +1,164 @@
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_1p5::PlanArtcraftGptImage1p5;
use artcraft_api_defs::generate::image::multi_function::gpt_image_1p5_multi_function_image_gen::GptImage1p5MultiFunctionImageGenRequest;
use artcraft_client::endpoints::generate::image::multi_function::gpt_image_1p5_multi_function_image_gen_image::gpt_image_1p5_multi_function_image_gen;
pub async fn execute_artcraft_gpt_image_1p5(
plan: &PlanArtcraftGptImage1p5<'_>,
artcraft_client: &RouterArtcraftClient,
) -> Result<GenerateImageResponse, ArtcraftRouterError> {
let request = GptImage1p5MultiFunctionImageGenRequest {
uuid_idempotency_token: plan.idempotency_token.clone(),
prompt: plan.prompt.map(|p| p.to_string()),
image_media_tokens: plan.image_inputs.map(|tokens| tokens.to_owned()),
mask_image_token: None,
num_images: Some(plan.num_images),
image_size: plan.image_size,
background: None,
quality: Some(plan.quality),
input_fidelity: None,
output_format: None,
};
let response = gpt_image_1p5_multi_function_image_gen(
&artcraft_client.api_host,
Some(&artcraft_client.credentials),
request,
)
.await
.map_err(|err| ArtcraftRouterError::Provider(ProviderError::Storyteller(err)))?;
Ok(GenerateImageResponse::Artcraft(ArtcraftImageResponsePayload {
inference_job_token: response.inference_job_token,
}))
}
#[cfg(test)]
mod tests {
use crate::api::common_aspect_ratio::CommonAspectRatio;
use crate::api::image_list_ref::ImageListRef;
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_gpt_image_1p5_image_request, get_artcraft_client};
use strum::IntoEnumIterator;
use tokens::tokens::media_files::MediaFileToken;
#[tokio::test]
#[ignore] // manually run — fires a real API request and incurs cost
async fn test_text_to_image_gpt_image_1p5() {
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_1p5_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);
assert_eq!(1, 2); // NB: Intentional failure to inspect the response above.
}
#[tokio::test]
#[ignore] // manually run — fires a real API request and incurs cost
async fn test_edit_image_gpt_image_1p5() {
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::Auto),
image_batch_count: Some(1),
..base_gpt_image_1p5_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);
assert_eq!(1, 2); // NB: Intentional failure to inspect the response above.
}
#[tokio::test]
#[ignore] // manually run — fires one real API request per aspect ratio variant (expensive)
async fn test_text_to_image_gpt_image_1p5_all_aspect_ratios() {
let client = get_artcraft_client();
for ar in CommonAspectRatio::iter() {
println!("--- text-to-image aspect ratio: {:?} ---", ar);
let request = GenerateImageRequest {
aspect_ratio: Some(ar),
image_batch_count: Some(1),
prompt: Some("a bear walking through a abandoned desert city at night"),
request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::PayMoreUpgrade,
..base_gpt_image_1p5_image_request()
};
match request.build() {
Err(e) => println!(" plan error: {:?}", e),
Ok(plan) => match plan.generate_image(&client).await {
Ok(response) => {
let payload = response.get_artcraft_payload().expect("expected Artcraft payload");
println!(" job token: {:?}", payload.inference_job_token);
}
Err(e) => println!(" generate error: {:?}", e),
},
}
}
assert_eq!(1, 2); // NB: Intentional failure to inspect the responses above.
}
#[tokio::test]
#[ignore] // manually run — fires one real API request per aspect ratio variant (expensive)
async fn test_edit_image_gpt_image_1p5_all_aspect_ratios() {
let client = get_artcraft_client();
let image_tokens = vec![
MediaFileToken::new_from_str("m_r45apt2swza6wp8j2z10237t92kkr8"),
];
for ar in CommonAspectRatio::iter() {
println!("--- image-edit aspect ratio: {:?} ---", ar);
let request = GenerateImageRequest {
prompt: Some("Change the background to an alien world"),
image_inputs: Some(ImageListRef::MediaFileTokens(&image_tokens)),
aspect_ratio: Some(ar),
image_batch_count: Some(1),
request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::PayMoreUpgrade,
..base_gpt_image_1p5_image_request()
};
match request.build() {
Err(e) => println!(" plan error: {:?}", e),
Ok(plan) => match plan.generate_image(&client).await {
Ok(response) => {
let payload = response.get_artcraft_payload().expect("expected Artcraft payload");
println!(" job token: {:?}", payload.inference_job_token);
}
Err(e) => println!(" generate error: {:?}", e),
},
}
}
assert_eq!(1, 2); // NB: Intentional failure to inspect the responses above.
}
}
@@ -0,0 +1,159 @@
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_nano_banana::PlanArtcraftNanaBanana;
use artcraft_api_defs::generate::image::multi_function::nano_banana_multi_function_image_gen::NanoBananaMultiFunctionImageGenRequest;
use artcraft_client::endpoints::generate::image::multi_function::nano_banana_multi_function_image_gen_image::nano_banana_multi_function_image_gen;
pub async fn execute_artcraft_nano_banana(
plan: &PlanArtcraftNanaBanana<'_>,
artcraft_client: &RouterArtcraftClient,
) -> Result<GenerateImageResponse, ArtcraftRouterError> {
let request = NanoBananaMultiFunctionImageGenRequest {
uuid_idempotency_token: plan.idempotency_token.clone(),
prompt: plan.prompt.map(|p| p.to_string()),
image_media_tokens: plan.image_inputs.map(|tokens| tokens.to_owned()),
num_images: Some(plan.num_images),
aspect_ratio: plan.aspect_ratio,
};
let response = nano_banana_multi_function_image_gen(
&artcraft_client.api_host,
Some(&artcraft_client.credentials),
request,
)
.await
.map_err(|err| ArtcraftRouterError::Provider(ProviderError::Storyteller(err)))?;
Ok(GenerateImageResponse::Artcraft(ArtcraftImageResponsePayload {
inference_job_token: response.inference_job_token,
}))
}
#[cfg(test)]
mod tests {
use crate::api::common_aspect_ratio::CommonAspectRatio;
use crate::api::image_list_ref::ImageListRef;
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_nano_banana_image_request, get_artcraft_client};
use strum::IntoEnumIterator;
use tokens::tokens::media_files::MediaFileToken;
#[tokio::test]
#[ignore] // manually run — fires a real API request and incurs cost
async fn test_text_to_image_nano_banana() {
let client = get_artcraft_client();
let request = GenerateImageRequest {
aspect_ratio: Some(CommonAspectRatio::WideSixteenByNine),
image_batch_count: Some(1),
prompt: Some("a cat walking through a cyberpunk city at night"),
..base_nano_banana_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);
assert_eq!(1, 2); // NB: Intentional failure to inspect the response above.
}
#[tokio::test]
#[ignore] // manually run — fires a real API request and incurs cost
async fn test_edit_image_nano_banana() {
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 sunny beach"),
image_inputs: Some(ImageListRef::MediaFileTokens(&image_tokens)),
aspect_ratio: Some(CommonAspectRatio::Auto),
image_batch_count: Some(1),
..base_nano_banana_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);
assert_eq!(1, 2); // NB: Intentional failure to inspect the response above.
}
#[tokio::test]
#[ignore] // manually run — fires one real API request per aspect ratio variant (expensive)
async fn test_text_to_image_nano_banana_all_aspect_ratios() {
let client = get_artcraft_client();
for ar in CommonAspectRatio::iter() {
println!("--- text-to-image aspect ratio: {:?} ---", ar);
let request = GenerateImageRequest {
aspect_ratio: Some(ar),
image_batch_count: Some(1),
prompt: Some("a shiba walking through a cyberpunk city at night"),
request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::PayMoreUpgrade,
..base_nano_banana_image_request()
};
match request.build() {
Err(e) => println!(" plan error: {:?}", e),
Ok(plan) => match plan.generate_image(&client).await {
Ok(response) => {
let payload = response.get_artcraft_payload().expect("expected Artcraft payload");
println!(" job token: {:?}", payload.inference_job_token);
}
Err(e) => println!(" generate error: {:?}", e),
},
}
}
assert_eq!(1, 2); // NB: Intentional failure to inspect the responses above.
}
#[tokio::test]
#[ignore] // manually run — fires one real API request per aspect ratio variant (expensive)
async fn test_edit_image_nano_banana_all_aspect_ratios() {
let client = get_artcraft_client();
let image_tokens = vec![
MediaFileToken::new_from_str("m_r45apt2swza6wp8j2z10237t92kkr8"),
];
for ar in CommonAspectRatio::iter() {
println!("--- image-edit aspect ratio: {:?} ---", ar);
let request = GenerateImageRequest {
prompt: Some("Change the background to a sunny beach"),
image_inputs: Some(ImageListRef::MediaFileTokens(&image_tokens)),
aspect_ratio: Some(ar),
image_batch_count: Some(1),
request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::PayMoreUpgrade,
..base_nano_banana_image_request()
};
match request.build() {
Err(e) => println!(" plan error: {:?}", e),
Ok(plan) => match plan.generate_image(&client).await {
Ok(response) => {
let payload = response.get_artcraft_payload().expect("expected Artcraft payload");
println!(" job token: {:?}", payload.inference_job_token);
}
Err(e) => println!(" generate error: {:?}", e),
},
}
}
assert_eq!(1, 2); // NB: Intentional failure to inspect the responses above.
}
}
@@ -1,3 +1,5 @@
pub mod generate_image_artcraft_gpt_image_1p5;
pub mod generate_image_artcraft_nano_banana;
pub mod generate_image_artcraft_nano_banana_pro;
pub mod generate_image_artcraft_seedream_4;
pub mod generate_image_artcraft_seedream_4p5;
@@ -7,6 +7,8 @@ use crate::client::request_mismatch_mitigation_strategy::RequestMismatchMitigati
use crate::errors::artcraft_router_error::ArtcraftRouterError;
use crate::errors::client_error::ClientError;
use crate::generate::generate_image::image_generation_plan::ImageGenerationPlan;
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_pro::plan_generate_image_artcraft_nano_banana_pro;
use crate::generate::generate_image::plan::artcraft::plan_generate_image_artcraft_seedream_4::plan_generate_image_artcraft_seedream_4;
use crate::generate::generate_image::plan::artcraft::plan_generate_image_artcraft_seedream_4p5::plan_generate_image_artcraft_seedream_4p5;
@@ -49,6 +51,12 @@ impl<'a> GenerateImageRequest<'a> {
pub fn build(&self) -> Result<ImageGenerationPlan<'_>, ArtcraftRouterError> {
match self.provider {
Provider::Artcraft => match self.model {
CommonImageModel::GptImage1p5 => {
plan_generate_image_artcraft_gpt_image_1p5(self).map(ImageGenerationPlan::ArtcraftGptImage1p5)
}
CommonImageModel::NanaBanana => {
plan_generate_image_artcraft_nano_banana(self).map(ImageGenerationPlan::ArtcraftNanaBanana)
}
CommonImageModel::NanaBananaPro => {
plan_generate_image_artcraft_nano_banana_pro(self).map(ImageGenerationPlan::ArtcraftNanaBananaPro)
}
@@ -63,10 +71,13 @@ impl<'a> GenerateImageRequest<'a> {
CommonImageModel::NanaBananaPro => {
plan_generate_image_fal_nano_banana_pro(self).map(ImageGenerationPlan::FalNanaBananaPro)
}
CommonImageModel::Seedream4 | CommonImageModel::Seedream4p5 => {
CommonImageModel::GptImage1p5
| CommonImageModel::NanaBanana
| CommonImageModel::Seedream4
| CommonImageModel::Seedream4p5 => {
Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption {
field: "provider",
value: "Seedream is only available on the Artcraft provider".to_string(),
value: format!("{:?} is only available on the Artcraft provider", self.model),
}))
}
},
@@ -1,15 +1,21 @@
use crate::client::router_client::RouterClient;
use crate::errors::artcraft_router_error::ArtcraftRouterError;
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_pro::estimate_image_cost_artcraft_nano_banana_pro;
use crate::generate::generate_image::cost::artcraft::estimate_image_cost_artcraft_seedream_4::estimate_image_cost_artcraft_seedream_4;
use crate::generate::generate_image::cost::artcraft::estimate_image_cost_artcraft_seedream_4p5::estimate_image_cost_artcraft_seedream_4p5;
use crate::generate::generate_image::cost::fal::estimate_image_cost_fal_nano_banana_pro::estimate_image_cost_fal_nano_banana_pro;
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_pro::execute_artcraft_nano_banana_pro;
use crate::generate::generate_image::execute::artcraft::generate_image_artcraft_seedream_4::execute_artcraft_seedream_4;
use crate::generate::generate_image::execute::artcraft::generate_image_artcraft_seedream_4p5::execute_artcraft_seedream_4p5;
use crate::generate::generate_image::execute::fal::generate_image_fal_nano_banana_pro::execute_fal_nano_banana_pro;
use crate::generate::generate_image::generate_image_response::GenerateImageResponse;
use crate::generate::generate_image::image_generation_cost_estimate::ImageGenerationCostEstimate;
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_pro::PlanArtcraftNanaBananaPro;
use crate::generate::generate_image::plan::artcraft::plan_generate_image_artcraft_seedream_4::PlanArtcraftSeedream4;
use crate::generate::generate_image::plan::artcraft::plan_generate_image_artcraft_seedream_4p5::PlanArtcraftSeedream4p5;
@@ -17,6 +23,8 @@ use crate::generate::generate_image::plan::fal::plan_generate_image_fal_nano_ban
#[derive(Debug)]
pub enum ImageGenerationPlan<'a> {
ArtcraftGptImage1p5(PlanArtcraftGptImage1p5<'a>),
ArtcraftNanaBanana(PlanArtcraftNanaBanana<'a>),
ArtcraftNanaBananaPro(PlanArtcraftNanaBananaPro<'a>),
ArtcraftSeedream4(PlanArtcraftSeedream4<'a>),
ArtcraftSeedream4p5(PlanArtcraftSeedream4p5<'a>),
@@ -29,6 +37,14 @@ impl<'a> ImageGenerationPlan<'a> {
client: &RouterClient,
) -> Result<GenerateImageResponse, ArtcraftRouterError> {
match self {
ImageGenerationPlan::ArtcraftGptImage1p5(plan) => {
let artcraft_client = client.get_artcraft_client_ref()?;
execute_artcraft_gpt_image_1p5(plan, artcraft_client).await
}
ImageGenerationPlan::ArtcraftNanaBanana(plan) => {
let artcraft_client = client.get_artcraft_client_ref()?;
execute_artcraft_nano_banana(plan, artcraft_client).await
}
ImageGenerationPlan::ArtcraftNanaBananaPro(plan) => {
let artcraft_client = client.get_artcraft_client_ref()?;
execute_artcraft_nano_banana_pro(plan, artcraft_client).await
@@ -50,6 +66,12 @@ impl<'a> ImageGenerationPlan<'a> {
pub fn estimate_costs(&self) -> ImageGenerationCostEstimate {
match self {
ImageGenerationPlan::ArtcraftGptImage1p5(plan) => {
estimate_image_cost_artcraft_gpt_image_1p5(plan)
}
ImageGenerationPlan::ArtcraftNanaBanana(plan) => {
estimate_image_cost_artcraft_nano_banana(plan)
}
ImageGenerationPlan::ArtcraftNanaBananaPro(plan) => {
estimate_image_cost_artcraft_nano_banana_pro(plan)
}
@@ -1,3 +1,5 @@
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_pro;
pub mod plan_generate_image_artcraft_seedream_4;
pub mod plan_generate_image_artcraft_seedream_4p5;
@@ -0,0 +1,310 @@
use crate::api::common_aspect_ratio::CommonAspectRatio;
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 artcraft_api_defs::generate::image::multi_function::gpt_image_1p5_multi_function_image_gen::{
GptImage1p5MultiFunctionImageGenNumImages, GptImage1p5MultiFunctionImageGenQuality,
GptImage1p5MultiFunctionImageGenSize,
};
use tokens::tokens::media_files::MediaFileToken;
#[derive(Debug, Clone)]
pub struct PlanArtcraftGptImage1p5<'a> {
pub prompt: Option<&'a str>,
/// Input images for image editing. None means text-to-image mode.
pub image_inputs: Option<&'a Vec<MediaFileToken>>,
pub image_size: Option<GptImage1p5MultiFunctionImageGenSize>,
/// Quality defaults to Medium when not specified in the request.
pub quality: GptImage1p5MultiFunctionImageGenQuality,
pub num_images: GptImage1p5MultiFunctionImageGenNumImages,
pub idempotency_token: String,
}
pub fn plan_generate_image_artcraft_gpt_image_1p5<'a>(
request: &'a GenerateImageRequest<'a>,
) -> Result<PlanArtcraftGptImage1p5<'a>, ArtcraftRouterError> {
let strategy = request.request_mismatch_mitigation_strategy;
let image_inputs = resolve_image_list_ref(request.image_inputs)?;
let image_size = plan_image_size(request.aspect_ratio, strategy)?;
let num_images = plan_num_images(request.image_batch_count, strategy)?;
Ok(PlanArtcraftGptImage1p5 {
prompt: request.prompt,
image_inputs,
image_size,
quality: GptImage1p5MultiFunctionImageGenQuality::Medium,
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)),
Some(ImageListRef::Urls(_)) => {
Err(ArtcraftRouterError::Client(ClientError::ArtcraftOnlySupportsMediaTokens))
}
}
}
// GPT Image 1.5 image sizes: Square (1024x1024), Wide (1536x1024), Tall (1024x1536).
// All 17 CommonAspectRatio variants map to one of these three sizes or None.
// Auto aspect ratios yield None (let the API choose its default).
fn plan_image_size(
aspect_ratio: Option<CommonAspectRatio>,
_strategy: RequestMismatchMitigationStrategy,
) -> Result<Option<GptImage1p5MultiFunctionImageGenSize>, ArtcraftRouterError> {
use GptImage1p5MultiFunctionImageGenSize as GS;
match aspect_ratio {
// No preference or auto — let the model use its default
None
| Some(CommonAspectRatio::Auto)
| Some(CommonAspectRatio::Auto2k)
| Some(CommonAspectRatio::Auto4k) => Ok(None),
// Square
Some(CommonAspectRatio::Square) | Some(CommonAspectRatio::SquareHd) => Ok(Some(GS::Square)),
// Wide
Some(CommonAspectRatio::WideThreeByTwo)
| Some(CommonAspectRatio::WideFourByThree)
| Some(CommonAspectRatio::WideFiveByFour)
| Some(CommonAspectRatio::WideSixteenByNine)
| Some(CommonAspectRatio::WideTwentyOneByNine)
| Some(CommonAspectRatio::Wide) => Ok(Some(GS::Wide)),
// Tall
Some(CommonAspectRatio::TallTwoByThree)
| Some(CommonAspectRatio::TallThreeByFour)
| Some(CommonAspectRatio::TallFourByFive)
| Some(CommonAspectRatio::TallNineBySixteen)
| Some(CommonAspectRatio::TallNineByTwentyOne)
| Some(CommonAspectRatio::Tall) => Ok(Some(GS::Tall)),
}
}
// GPT Image 1.5 supports 1, 2, 3, and 4 images.
fn plan_num_images(
image_batch_count: Option<u16>,
strategy: RequestMismatchMitigationStrategy,
) -> Result<GptImage1p5MultiFunctionImageGenNumImages, ArtcraftRouterError> {
use GptImage1p5MultiFunctionImageGenNumImages as GN;
let count = image_batch_count.unwrap_or(1);
match count {
0 => Err(ArtcraftRouterError::Client(ClientError::UserRequestedZeroGenerations)),
1 => Ok(GN::One),
2 => Ok(GN::Two),
3 => Ok(GN::Three),
4 => Ok(GN::Four),
_ => match strategy {
RequestMismatchMitigationStrategy::ErrorOut => {
Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption {
field: "image_batch_count",
value: format!("{}", count),
}))
}
RequestMismatchMitigationStrategy::PayMoreUpgrade => Ok(GN::Four),
RequestMismatchMitigationStrategy::PayLessDowngrade => Ok(GN::Four),
},
}
}
#[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::generate::generate_image::image_generation_plan::ImageGenerationPlan;
use crate::test_helpers::base_gpt_image_1p5_image_request;
use artcraft_api_defs::generate::image::multi_function::gpt_image_1p5_multi_function_image_gen::{
GptImage1p5MultiFunctionImageGenNumImages as GN,
GptImage1p5MultiFunctionImageGenQuality,
GptImage1p5MultiFunctionImageGenSize as GS,
};
// ── Image size (aspect ratio mapping) ────────────────────────────────────
#[test]
fn image_size_none_is_none() {
let request = GenerateImageRequest { aspect_ratio: None, ..base_gpt_image_1p5_image_request() };
let ImageGenerationPlan::ArtcraftGptImage1p5(plan) = request.build().unwrap() else { panic!("expected ArtcraftGptImage1p5") };
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_1p5_image_request() };
let ImageGenerationPlan::ArtcraftGptImage1p5(plan) = request.build().unwrap() else { panic!("expected ArtcraftGptImage1p5") };
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_1p5_image_request() };
let ImageGenerationPlan::ArtcraftGptImage1p5(plan) = request.build().unwrap() else { panic!("expected ArtcraftGptImage1p5") };
assert!(matches!(plan.image_size, Some(GS::Square)), "expected Square for {:?}", ar);
}
}
#[test]
fn image_size_wide_variants() {
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_1p5_image_request() };
let ImageGenerationPlan::ArtcraftGptImage1p5(plan) = request.build().unwrap() else { panic!("expected ArtcraftGptImage1p5") };
assert!(matches!(plan.image_size, Some(GS::Wide)), "expected Wide for {:?}", ar);
}
}
#[test]
fn image_size_tall_variants() {
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_1p5_image_request() };
let ImageGenerationPlan::ArtcraftGptImage1p5(plan) = request.build().unwrap() else { panic!("expected ArtcraftGptImage1p5") };
assert!(matches!(plan.image_size, Some(GS::Tall)), "expected Tall for {:?}", ar);
}
}
// ── Quality ──────────────────────────────────────────────────────────────
#[test]
fn quality_defaults_to_medium() {
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));
}
// ── 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_1p5_image_request()
};
let result = request.build();
assert!(
matches!(result, Err(ArtcraftRouterError::Client(ClientError::UserRequestedZeroGenerations))),
"expected UserRequestedZeroGenerations with {:?}", strategy,
);
}
}
#[test]
fn num_images_direct_mapping() {
let cases = [
(1, GN::One),
(2, GN::Two),
(3, GN::Three),
(4, GN::Four),
];
for (count, expected) in cases {
let request = GenerateImageRequest { image_batch_count: Some(count), ..base_gpt_image_1p5_image_request() };
let ImageGenerationPlan::ArtcraftGptImage1p5(plan) = request.build().unwrap() else { panic!("expected ArtcraftGptImage1p5") };
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_1p5_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(5),
request_mismatch_mitigation_strategy: strategy,
..base_gpt_image_1p5_image_request()
};
let ImageGenerationPlan::ArtcraftGptImage1p5(plan) = request.build().unwrap() else { panic!("expected ArtcraftGptImage1p5") };
assert!(
matches!(plan.num_images, GN::Four),
"expected Four for count 5 with {:?}", strategy,
);
}
}
// ── Image inputs ─────────────────────────────────────────────────────────
#[test]
fn url_image_inputs_returns_error() {
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))
));
}
#[test]
fn no_image_inputs_is_text_to_image_mode() {
let request = GenerateImageRequest { image_inputs: None, ..base_gpt_image_1p5_image_request() };
let ImageGenerationPlan::ArtcraftGptImage1p5(plan) = request.build().unwrap() else { panic!("expected ArtcraftGptImage1p5") };
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_1p5_image_request()
};
let ImageGenerationPlan::ArtcraftGptImage1p5(plan) = request.build().unwrap() else { panic!("expected ArtcraftGptImage1p5") };
assert!(plan.image_inputs.is_some());
}
}
@@ -0,0 +1,389 @@
use crate::api::common_aspect_ratio::CommonAspectRatio;
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 artcraft_api_defs::generate::image::multi_function::nano_banana_multi_function_image_gen::{
NanoBananaMultiFunctionImageGenAspectRatio, NanoBananaMultiFunctionImageGenNumImages,
};
use tokens::tokens::media_files::MediaFileToken;
#[derive(Debug, Clone)]
pub struct PlanArtcraftNanaBanana<'a> {
pub prompt: Option<&'a str>,
/// Input images for image editing. None means text-to-image mode.
pub image_inputs: Option<&'a Vec<MediaFileToken>>,
pub aspect_ratio: Option<NanoBananaMultiFunctionImageGenAspectRatio>,
pub num_images: NanoBananaMultiFunctionImageGenNumImages,
pub idempotency_token: String,
}
pub fn plan_generate_image_artcraft_nano_banana<'a>(
request: &'a GenerateImageRequest<'a>,
) -> Result<PlanArtcraftNanaBanana<'a>, ArtcraftRouterError> {
let strategy = request.request_mismatch_mitigation_strategy;
let is_edit_mode = request.image_inputs.is_some();
let image_inputs = resolve_image_list_ref(request.image_inputs)?;
let aspect_ratio = plan_aspect_ratio(request.aspect_ratio, is_edit_mode, strategy)?;
let num_images = plan_num_images(request.image_batch_count, strategy)?;
Ok(PlanArtcraftNanaBanana {
prompt: request.prompt,
image_inputs,
aspect_ratio,
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)),
Some(ImageListRef::Urls(_)) => {
Err(ArtcraftRouterError::Client(ClientError::ArtcraftOnlySupportsMediaTokens))
}
}
}
// Nano Banana supported aspect ratios:
// Text-to-image: OneByOne, FiveByFour, FourByThree, ThreeByTwo, SixteenByNine, TwentyOneByNine,
// FourByFive, ThreeByFour, TwoByThree, NineBySixteen
// Image editing: Auto (uses source image AR), plus all text-to-image ratios
fn plan_aspect_ratio(
aspect_ratio: Option<CommonAspectRatio>,
is_edit_mode: bool,
strategy: RequestMismatchMitigationStrategy,
) -> Result<Option<NanoBananaMultiFunctionImageGenAspectRatio>, ArtcraftRouterError> {
use NanoBananaMultiFunctionImageGenAspectRatio as NbAr;
match aspect_ratio {
// No preference — let the model use its default
None => Ok(None),
// Auto: only valid in edit mode (use source image dimensions)
Some(CommonAspectRatio::Auto)
| Some(CommonAspectRatio::Auto2k)
| Some(CommonAspectRatio::Auto4k) => {
if is_edit_mode {
Ok(Some(NbAr::Auto))
} else {
// Auto not valid for text-to-image; fall back to square default
Ok(Some(NbAr::OneByOne))
}
}
// Direct mappings
Some(CommonAspectRatio::Square) | Some(CommonAspectRatio::SquareHd) => Ok(Some(NbAr::OneByOne)),
Some(CommonAspectRatio::WideFiveByFour) => Ok(Some(NbAr::FiveByFour)),
Some(CommonAspectRatio::WideFourByThree) => Ok(Some(NbAr::FourByThree)),
Some(CommonAspectRatio::WideThreeByTwo) => Ok(Some(NbAr::ThreeByTwo)),
Some(CommonAspectRatio::WideSixteenByNine) | Some(CommonAspectRatio::Wide) => Ok(Some(NbAr::SixteenByNine)),
Some(CommonAspectRatio::WideTwentyOneByNine) => Ok(Some(NbAr::TwentyOneByNine)),
Some(CommonAspectRatio::TallFourByFive) => Ok(Some(NbAr::FourByFive)),
Some(CommonAspectRatio::TallThreeByFour) => Ok(Some(NbAr::ThreeByFour)),
Some(CommonAspectRatio::TallTwoByThree) => Ok(Some(NbAr::TwoByThree)),
Some(CommonAspectRatio::TallNineBySixteen) | Some(CommonAspectRatio::Tall) => Ok(Some(NbAr::NineBySixteen)),
// Mismatch — TallNineByTwentyOne has no direct equivalent
Some(unsupported) => match strategy {
RequestMismatchMitigationStrategy::ErrorOut => {
Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption {
field: "aspect_ratio",
value: format!("{:?}", unsupported),
}))
}
RequestMismatchMitigationStrategy::PayMoreUpgrade
| RequestMismatchMitigationStrategy::PayLessDowngrade => {
Ok(Some(nearest_aspect_ratio(unsupported)))
}
},
}
}
/// Pick the nearest supported aspect ratio for unsupported inputs.
fn nearest_aspect_ratio(aspect_ratio: CommonAspectRatio) -> NanoBananaMultiFunctionImageGenAspectRatio {
use NanoBananaMultiFunctionImageGenAspectRatio as NbAr;
match aspect_ratio {
// TallNineByTwentyOne (0.43) — nearest is NineBySixteen (0.5625)
CommonAspectRatio::TallNineByTwentyOne => NbAr::NineBySixteen,
_ => NbAr::OneByOne,
}
}
// Nano Banana supports 1, 2, 3, and 4 images.
fn plan_num_images(
image_batch_count: Option<u16>,
strategy: RequestMismatchMitigationStrategy,
) -> Result<NanoBananaMultiFunctionImageGenNumImages, ArtcraftRouterError> {
use NanoBananaMultiFunctionImageGenNumImages as NbN;
let count = image_batch_count.unwrap_or(1);
match count {
0 => Err(ArtcraftRouterError::Client(ClientError::UserRequestedZeroGenerations)),
1 => Ok(NbN::One),
2 => Ok(NbN::Two),
3 => Ok(NbN::Three),
4 => Ok(NbN::Four),
_ => match strategy {
RequestMismatchMitigationStrategy::ErrorOut => {
Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption {
field: "image_batch_count",
value: format!("{}", count),
}))
}
RequestMismatchMitigationStrategy::PayMoreUpgrade => Ok(NbN::Four),
RequestMismatchMitigationStrategy::PayLessDowngrade => Ok(NbN::Four),
},
}
}
#[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::generate::generate_image::image_generation_plan::ImageGenerationPlan;
use crate::test_helpers::base_nano_banana_image_request;
use artcraft_api_defs::generate::image::multi_function::nano_banana_multi_function_image_gen::{
NanoBananaMultiFunctionImageGenAspectRatio as NbAr,
NanoBananaMultiFunctionImageGenNumImages as NbN,
};
// ── Aspect ratio ────────────────────────────────────────────────────────────
#[test]
fn aspect_ratio_none_is_none() {
let request = GenerateImageRequest { aspect_ratio: None, ..base_nano_banana_image_request() };
let ImageGenerationPlan::ArtcraftNanaBanana(plan) = request.build().unwrap() else { panic!("expected ArtcraftNanaBanana") };
assert!(plan.aspect_ratio.is_none());
}
#[test]
fn aspect_ratio_direct_square() {
for ar in [CommonAspectRatio::Square, CommonAspectRatio::SquareHd] {
let request = GenerateImageRequest { aspect_ratio: Some(ar), ..base_nano_banana_image_request() };
let ImageGenerationPlan::ArtcraftNanaBanana(plan) = request.build().unwrap() else { panic!("expected ArtcraftNanaBanana") };
assert!(matches!(plan.aspect_ratio, Some(NbAr::OneByOne)), "expected OneByOne for {:?}", ar);
}
}
#[test]
fn aspect_ratio_direct_wide() {
let cases = [
(CommonAspectRatio::WideFiveByFour, NbAr::FiveByFour),
(CommonAspectRatio::WideFourByThree, NbAr::FourByThree),
(CommonAspectRatio::WideThreeByTwo, NbAr::ThreeByTwo),
(CommonAspectRatio::WideSixteenByNine, NbAr::SixteenByNine),
(CommonAspectRatio::Wide, NbAr::SixteenByNine),
(CommonAspectRatio::WideTwentyOneByNine, NbAr::TwentyOneByNine),
];
for (common, expected) in cases {
let request = GenerateImageRequest { aspect_ratio: Some(common), ..base_nano_banana_image_request() };
let ImageGenerationPlan::ArtcraftNanaBanana(plan) = request.build().unwrap() else { panic!("expected ArtcraftNanaBanana") };
assert!(
matches!(plan.aspect_ratio, Some(ar) if std::mem::discriminant(&ar) == std::mem::discriminant(&expected)),
"expected {:?} for {:?}", expected, common,
);
}
}
#[test]
fn aspect_ratio_direct_tall() {
let cases = [
(CommonAspectRatio::TallFourByFive, NbAr::FourByFive),
(CommonAspectRatio::TallThreeByFour, NbAr::ThreeByFour),
(CommonAspectRatio::TallTwoByThree, NbAr::TwoByThree),
(CommonAspectRatio::TallNineBySixteen, NbAr::NineBySixteen),
(CommonAspectRatio::Tall, NbAr::NineBySixteen),
];
for (common, expected) in cases {
let request = GenerateImageRequest { aspect_ratio: Some(common), ..base_nano_banana_image_request() };
let ImageGenerationPlan::ArtcraftNanaBanana(plan) = request.build().unwrap() else { panic!("expected ArtcraftNanaBanana") };
assert!(
matches!(plan.aspect_ratio, Some(ar) if std::mem::discriminant(&ar) == std::mem::discriminant(&expected)),
"expected {:?} for {:?}", expected, common,
);
}
}
#[test]
fn aspect_ratio_auto_in_edit_mode_yields_auto() {
for auto_ar in [CommonAspectRatio::Auto, CommonAspectRatio::Auto2k, CommonAspectRatio::Auto4k] {
let tokens = vec![];
let request = GenerateImageRequest {
aspect_ratio: Some(auto_ar),
image_inputs: Some(ImageListRef::MediaFileTokens(&tokens)),
..base_nano_banana_image_request()
};
let ImageGenerationPlan::ArtcraftNanaBanana(plan) = request.build().unwrap() else { panic!("expected ArtcraftNanaBanana") };
assert!(
matches!(plan.aspect_ratio, Some(NbAr::Auto)),
"expected Auto in edit mode for {:?}", auto_ar,
);
}
}
#[test]
fn aspect_ratio_auto_in_text_to_image_mode_falls_back_to_square() {
for auto_ar in [CommonAspectRatio::Auto, CommonAspectRatio::Auto2k, CommonAspectRatio::Auto4k] {
let request = GenerateImageRequest {
aspect_ratio: Some(auto_ar),
image_inputs: None,
..base_nano_banana_image_request()
};
let ImageGenerationPlan::ArtcraftNanaBanana(plan) = request.build().unwrap() else { panic!("expected ArtcraftNanaBanana") };
assert!(
matches!(plan.aspect_ratio, Some(NbAr::OneByOne)),
"expected OneByOne fallback in text-to-image for {:?}", auto_ar,
);
}
}
#[test]
fn aspect_ratio_unsupported_error_out() {
// TallNineByTwentyOne has no direct equivalent
let request = GenerateImageRequest {
aspect_ratio: Some(CommonAspectRatio::TallNineByTwentyOne),
request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::ErrorOut,
..base_nano_banana_image_request()
};
let result = request.build();
assert!(matches!(
result,
Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption { .. }))
));
}
#[test]
fn aspect_ratio_unsupported_nearest_match() {
// TallNineByTwentyOne (0.43) is nearest to NineBySixteen (0.5625)
for strategy in [
RequestMismatchMitigationStrategy::PayMoreUpgrade,
RequestMismatchMitigationStrategy::PayLessDowngrade,
] {
let request = GenerateImageRequest {
aspect_ratio: Some(CommonAspectRatio::TallNineByTwentyOne),
request_mismatch_mitigation_strategy: strategy,
..base_nano_banana_image_request()
};
let ImageGenerationPlan::ArtcraftNanaBanana(plan) = request.build().unwrap() else { panic!("expected ArtcraftNanaBanana") };
assert!(
matches!(plan.aspect_ratio, Some(NbAr::NineBySixteen)),
"expected NineBySixteen nearest match with {:?}", strategy,
);
}
}
// ── 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_nano_banana_image_request()
};
let result = request.build();
assert!(
matches!(result, Err(ArtcraftRouterError::Client(ClientError::UserRequestedZeroGenerations))),
"expected UserRequestedZeroGenerations with {:?}", strategy,
);
}
}
#[test]
fn num_images_direct_mapping() {
let cases = [
(1, NbN::One),
(2, NbN::Two),
(3, NbN::Three),
(4, NbN::Four),
];
for (count, expected) in cases {
let request = GenerateImageRequest { image_batch_count: Some(count), ..base_nano_banana_image_request() };
let ImageGenerationPlan::ArtcraftNanaBanana(plan) = request.build().unwrap() else { panic!("expected ArtcraftNanaBanana") };
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_nano_banana_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(5),
request_mismatch_mitigation_strategy: strategy,
..base_nano_banana_image_request()
};
let ImageGenerationPlan::ArtcraftNanaBanana(plan) = request.build().unwrap() else { panic!("expected ArtcraftNanaBanana") };
assert!(
matches!(plan.num_images, NbN::Four),
"expected Four for count 5 with {:?}", strategy,
);
}
}
// ── Image inputs ─────────────────────────────────────────────────────────
#[test]
fn url_image_inputs_returns_error() {
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))
));
}
#[test]
fn no_image_inputs_is_text_to_image_mode() {
let request = GenerateImageRequest { image_inputs: None, ..base_nano_banana_image_request() };
let ImageGenerationPlan::ArtcraftNanaBanana(plan) = request.build().unwrap() else { panic!("expected ArtcraftNanaBanana") };
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_nano_banana_image_request()
};
let ImageGenerationPlan::ArtcraftNanaBanana(plan) = request.build().unwrap() else { panic!("expected ArtcraftNanaBanana") };
assert!(plan.image_inputs.is_some());
}
}
@@ -85,6 +85,34 @@ pub fn base_seedream_4p5_image_request() -> GenerateImageRequest<'static> {
}
}
pub fn base_nano_banana_image_request() -> GenerateImageRequest<'static> {
GenerateImageRequest {
model: CommonImageModel::NanaBanana,
provider: Provider::Artcraft,
prompt: Some("a cat in space"),
image_inputs: None,
resolution: None,
aspect_ratio: None,
image_batch_count: None,
request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::ErrorOut,
idempotency_token: None,
}
}
pub fn base_gpt_image_1p5_image_request() -> GenerateImageRequest<'static> {
GenerateImageRequest {
model: CommonImageModel::GptImage1p5,
provider: Provider::Artcraft,
prompt: Some("a cat in space"),
image_inputs: None,
resolution: None,
aspect_ratio: None,
image_batch_count: None,
request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::ErrorOut,
idempotency_token: None,
}
}
pub fn base_video_request() -> GenerateVideoRequest<'static> {
GenerateVideoRequest {
model: CommonVideoModel::Seedance2p0,
@@ -1,150 +0,0 @@
use crate::core::api_adapters::aspect_ratio::convert::aspect_ratio_to_artcraft_gpt_image_1::aspect_ratio_to_artcraft_gpt_image_1;
use crate::core::api_adapters::aspect_ratio::convert::aspect_ratio_to_artcraft_gpt_image_1p5::aspect_ratio_to_artcraft_gpt_image_1p5;
use crate::core::api_adapters::aspect_ratio::convert::aspect_ratio_to_artcraft_nano_banana_pro::aspect_ratio_to_artcraft_nano_banana_pro;
use crate::core::commands::enqueue::generate_error::{BadInputReason, GenerateError};
use crate::core::commands::enqueue::image_edit::enqueue_edit_image_command::{EditImageQuality, EditImageSize, EnqueueEditImageCommand};
use crate::core::commands::enqueue::task_enqueue_success::TaskEnqueueSuccess;
use crate::core::events::basic_sendable_event_trait::BasicSendableEvent;
use crate::core::events::generation_events::common::{GenerationAction, GenerationModel, GenerationServiceProvider};
use crate::core::events::generation_events::generation_enqueue_failure_event::GenerationEnqueueFailureEvent;
use crate::core::state::app_env_configs::app_env_configs::AppEnvConfigs;
use crate::core::state::data_dir::app_data_root::AppDataRoot;
use crate::core::state::provider_priority::ProviderPriorityStore;
use crate::services::sora::state::sora_credential_manager::SoraCredentialManager;
use crate::services::sora::state::sora_task_queue::SoraTaskQueue;
use crate::services::storyteller::state::storyteller_credential_manager::StorytellerCredentialManager;
use anyhow::anyhow;
use artcraft_api_defs::generate::image::multi_function::gpt_image_1p5_multi_function_image_gen::{GptImage1p5MultiFunctionImageGenNumImages, GptImage1p5MultiFunctionImageGenQuality, GptImage1p5MultiFunctionImageGenRequest, GptImage1p5MultiFunctionImageGenSize};
use enums::common::generation_provider::GenerationProvider;
use enums::tauri::tasks::task_type::TaskType;
use idempotency::uuid::generate_random_uuid;
use log::{error, info};
use artcraft_client::endpoints::generate::image::edit::gpt_image_1_edit_image::gpt_image_1_edit_image;
use artcraft_client::endpoints::generate::image::multi_function::gpt_image_1p5_multi_function_image_gen_image::gpt_image_1p5_multi_function_image_gen;
use tauri::AppHandle;
const MAX_IMAGES: usize = 4;
pub async fn handle_artcraft_gpt_image_1p5_edit(
request: &EnqueueEditImageCommand,
app: &AppHandle,
app_data_root: &AppDataRoot,
app_env_configs: &AppEnvConfigs,
storyteller_creds_manager: &StorytellerCredentialManager,
) -> Result<TaskEnqueueSuccess, GenerateError> {
let creds = match storyteller_creds_manager.get_credentials()? {
Some(creds) => creds,
None => {
return Err(GenerateError::needs_storyteller_credentials());
},
};
info!("Calling Artcraft gpt-image-1.5 (edit) ...");
let uuid_idempotency_token = generate_random_uuid();
let image_quality = request.image_quality
.map(|quality| match quality {
EditImageQuality::Auto => GptImage1p5MultiFunctionImageGenQuality::High,
EditImageQuality::High => GptImage1p5MultiFunctionImageGenQuality::High,
EditImageQuality::Medium => GptImage1p5MultiFunctionImageGenQuality::Medium,
EditImageQuality::Low => GptImage1p5MultiFunctionImageGenQuality::Low,
});
let image_size = get_aspect_ratio(request);
let num_images = match request.image_count {
None => None,
Some(1) => Some(GptImage1p5MultiFunctionImageGenNumImages::One),
Some(2) => Some(GptImage1p5MultiFunctionImageGenNumImages::Two),
Some(3) => Some(GptImage1p5MultiFunctionImageGenNumImages::Three),
Some(4) => Some(GptImage1p5MultiFunctionImageGenNumImages::Four),
Some(other) => {
return Err(GenerateError::BadInput(BadInputReason::InvalidNumberOfRequestedImages {
min: 1,
max: 4,
requested: other,
}));
},
};
let mut media_tokens = Vec::with_capacity(10);
if let Some(scene_image_media_token) = request.scene_image_media_token.clone() {
media_tokens.push(scene_image_media_token);
}
if let Some(image_media_tokens) = request.image_media_tokens.as_ref() {
media_tokens.extend_from_slice(image_media_tokens);
}
if media_tokens.len() > MAX_IMAGES {
return Err(GenerateError::BadInput(BadInputReason::InvalidNumberOfInputImages {
min: 1,
max: MAX_IMAGES as u32,
provided: media_tokens.len() as u32,
}));
}
let request = GptImage1p5MultiFunctionImageGenRequest {
uuid_idempotency_token,
prompt: Some(request.prompt.clone()),
image_media_tokens: Some(media_tokens),
quality: image_quality,
image_size,
num_images,
// Only for inpainting
mask_image_token: None,
// Not provided
input_fidelity: None,
output_format: None,
background: None,
};
let result = gpt_image_1p5_multi_function_image_gen(
&app_env_configs.storyteller_host,
Some(&creds),
request,
).await;
let job_id = match result {
Ok(enqueued) => {
// TODO(bt,2025-07-05): Enqueue job token?
info!("Successfully enqueued Artcraft gpt-image-1.5. Job token: {}",
enqueued.inference_job_token);
enqueued.inference_job_token
}
Err(err) => {
error!("Failed to use Artcraft gpt-image-1.5: {:?}", err);
return Err(GenerateError::from(err));
}
};
Ok(TaskEnqueueSuccess {
provider: GenerationProvider::Artcraft,
model: Some(GenerationModel::GptImage1p5),
provider_job_id: Some(job_id.to_string()),
task_type: TaskType::ImageGeneration,
})
}
fn get_aspect_ratio(request: &EnqueueEditImageCommand) -> Option<GptImage1p5MultiFunctionImageGenSize> {
if let Some(common_aspect_ratio) = request.common_aspect_ratio {
// Handle modern aspect ratio
let aspect = aspect_ratio_to_artcraft_gpt_image_1p5(common_aspect_ratio);
return Some(aspect);
}
if let Some(aspect_ratio) = request.aspect_ratio {
// Handle deprecated aspect ratio
return match aspect_ratio {
EditImageSize::Auto => Some(GptImage1p5MultiFunctionImageGenSize::Square),
EditImageSize::Square => Some(GptImage1p5MultiFunctionImageGenSize::Square),
EditImageSize::Wide => Some(GptImage1p5MultiFunctionImageGenSize::Wide),
EditImageSize::Tall => Some(GptImage1p5MultiFunctionImageGenSize::Tall),
}
}
None
}
@@ -1,141 +0,0 @@
use crate::core::api_adapters::aspect_ratio::convert::aspect_ratio_to_artcraft_nano_banana::aspect_ratio_to_artcraft_nano_banana;
use crate::core::api_adapters::aspect_ratio::convert::aspect_ratio_to_artcraft_nano_banana_pro::aspect_ratio_to_artcraft_nano_banana_pro;
use crate::core::commands::enqueue::generate_error::{BadInputReason, GenerateError};
use crate::core::commands::enqueue::image_edit::enqueue_edit_image_command::{EditImageQuality, EditImageSize, EnqueueEditImageCommand};
use crate::core::commands::enqueue::task_enqueue_success::TaskEnqueueSuccess;
use crate::core::events::basic_sendable_event_trait::BasicSendableEvent;
use crate::core::events::generation_events::common::{GenerationAction, GenerationModel, GenerationServiceProvider};
use crate::core::events::generation_events::generation_enqueue_failure_event::GenerationEnqueueFailureEvent;
use crate::core::state::app_env_configs::app_env_configs::AppEnvConfigs;
use crate::core::state::data_dir::app_data_root::AppDataRoot;
use crate::core::state::provider_priority::ProviderPriorityStore;
use crate::services::sora::state::sora_credential_manager::SoraCredentialManager;
use crate::services::sora::state::sora_task_queue::SoraTaskQueue;
use crate::services::storyteller::state::storyteller_credential_manager::StorytellerCredentialManager;
use anyhow::anyhow;
use artcraft_api_defs::generate::image::edit::flux_pro_kontext_max_edit_image::{FluxProKontextMaxEditImageNumImages, FluxProKontextMaxEditImageRequest};
use artcraft_api_defs::generate::image::edit::gemini_25_flash_edit_image::{Gemini25FlashEditImageNumImages, Gemini25FlashEditImageRequest};
use artcraft_api_defs::generate::image::edit::gpt_image_1_edit_image::{GptImage1EditImageImageQuality, GptImage1EditImageImageSize, GptImage1EditImageNumImages, GptImage1EditImageRequest};
use artcraft_api_defs::generate::image::multi_function::nano_banana_multi_function_image_gen::{NanoBananaMultiFunctionImageGenAspectRatio, NanoBananaMultiFunctionImageGenNumImages, NanoBananaMultiFunctionImageGenRequest};
use artcraft_api_defs::generate::image::multi_function::nano_banana_pro_multi_function_image_gen::{NanoBananaProMultiFunctionImageGenAspectRatio, NanoBananaProMultiFunctionImageGenRequest};
use enums::common::generation_provider::GenerationProvider;
use enums::tauri::tasks::task_type::TaskType;
use idempotency::uuid::generate_random_uuid;
use log::{error, info};
use artcraft_client::endpoints::generate::image::edit::flux_pro_kontext_max_edit_image::flux_pro_kontext_max_edit_image;
use artcraft_client::endpoints::generate::image::edit::gemini_25_flash_edit_image::gemini_25_flash_edit_image;
use artcraft_client::endpoints::generate::image::edit::gpt_image_1_edit_image::gpt_image_1_edit_image;
use artcraft_client::endpoints::generate::image::multi_function::nano_banana_multi_function_image_gen_image::nano_banana_multi_function_image_gen;
use artcraft_client::endpoints::generate::image::multi_function::nano_banana_pro_multi_function_image_gen_image::nano_banana_pro_multi_function_image_gen;
use tauri::AppHandle;
pub(super) const MAX_IMAGES: usize = 10;
pub async fn handle_artcraft_nano_banana_edit(
request: &EnqueueEditImageCommand,
app: &AppHandle,
app_data_root: &AppDataRoot,
app_env_configs: &AppEnvConfigs,
storyteller_creds_manager: &StorytellerCredentialManager,
) -> Result<TaskEnqueueSuccess, GenerateError> {
let creds = match storyteller_creds_manager.get_credentials()? {
Some(creds) => creds,
None => {
return Err(GenerateError::needs_storyteller_credentials());
},
};
info!("Calling Artcraft Nano Banana (edit) ...");
let uuid_idempotency_token = generate_random_uuid();
let aspect_ratio = get_aspect_ratio(request);
let num_images = match request.image_count {
None => None,
Some(1) => Some(NanoBananaMultiFunctionImageGenNumImages::One),
Some(2) => Some(NanoBananaMultiFunctionImageGenNumImages::Two),
Some(3) => Some(NanoBananaMultiFunctionImageGenNumImages::Three),
Some(4) => Some(NanoBananaMultiFunctionImageGenNumImages::Four),
Some(other) => {
return Err(GenerateError::BadInput(BadInputReason::InvalidNumberOfRequestedImages {
min: 1,
max: 4,
requested: other,
}));
},
};
let mut media_tokens = Vec::with_capacity(10);
if let Some(scene_image_media_token) = request.scene_image_media_token.clone() {
media_tokens.push(scene_image_media_token);
}
if let Some(image_media_tokens) = request.image_media_tokens.as_ref() {
media_tokens.extend_from_slice(image_media_tokens);
}
if media_tokens.len() > MAX_IMAGES {
return Err(GenerateError::BadInput(BadInputReason::InvalidNumberOfInputImages {
min: 1,
max: MAX_IMAGES as u32,
provided: media_tokens.len() as u32,
}));
}
let request = NanoBananaMultiFunctionImageGenRequest {
uuid_idempotency_token,
prompt: Some(request.prompt.clone()),
image_media_tokens: Some(media_tokens),
num_images,
aspect_ratio,
};
let result = nano_banana_multi_function_image_gen(
&app_env_configs.storyteller_host,
Some(&creds),
request,
).await;
let job_id = match result {
Ok(enqueued) => {
// TODO(bt,2025-07-05): Enqueue job token?
info!("Successfully enqueued Artcraft Nano Banana. Job token: {}",
enqueued.inference_job_token);
enqueued.inference_job_token
}
Err(err) => {
error!("Failed to use Artcraft Nano Banana: {:?}", err);
return Err(GenerateError::from(err));
}
};
Ok(TaskEnqueueSuccess {
provider: GenerationProvider::Artcraft,
model: Some(GenerationModel::Gemini25Flash),
provider_job_id: Some(job_id.to_string()),
task_type: TaskType::ImageGeneration,
})
}
fn get_aspect_ratio(request: &EnqueueEditImageCommand) -> Option<NanoBananaMultiFunctionImageGenAspectRatio> {
if let Some(common_aspect_ratio) = request.common_aspect_ratio {
// Handle modern aspect ratio
let aspect = aspect_ratio_to_artcraft_nano_banana(common_aspect_ratio);
return Some(aspect);
}
if let Some(aspect_ratio) = request.aspect_ratio {
// Handle deprecated aspect ratio
return match aspect_ratio {
EditImageSize::Auto => Some(NanoBananaMultiFunctionImageGenAspectRatio::Auto),
EditImageSize::Square => Some(NanoBananaMultiFunctionImageGenAspectRatio::OneByOne),
EditImageSize::Wide => Some(NanoBananaMultiFunctionImageGenAspectRatio::SixteenByNine),
EditImageSize::Tall => Some(NanoBananaMultiFunctionImageGenAspectRatio::NineBySixteen),
}
}
None
}
@@ -1,8 +1,6 @@
use crate::core::commands::enqueue::generate_error::GenerateError;
use crate::core::commands::enqueue::image_edit::artcraft::handle_artcraft_flux_kontext_edit::handle_artcraft_flux_kontext_edit;
use crate::core::commands::enqueue::image_edit::artcraft::handle_artcraft_gpt_image_1_edit::handle_artcraft_gpt_image_1_edit;
use crate::core::commands::enqueue::image_edit::artcraft::handle_artcraft_gpt_image_1p5_edit::handle_artcraft_gpt_image_1p5_edit;
use crate::core::commands::enqueue::image_edit::artcraft::handle_artcraft_nano_banana_edit::handle_artcraft_nano_banana_edit;
use crate::core::commands::enqueue::image_edit::artcraft::handle_image_edit_artcraft_via_router::handle_image_edit_artcraft_via_router;
use crate::core::commands::enqueue::image_edit::enqueue_edit_image_command::{EnqueueEditImageCommand, ImageEditModel};
use crate::core::commands::enqueue::task_enqueue_success::TaskEnqueueSuccess;
@@ -25,11 +23,11 @@ pub async fn handle_image_edit_artcraft(
match model {
ImageEditModel::FluxProKontextMax => handle_artcraft_flux_kontext_edit(request, app, app_data_root, app_env_configs, storyteller_creds_manager).await,
ImageEditModel::Gemini25Flash | ImageEditModel::NanoBanana => {
handle_artcraft_nano_banana_edit( request, app, app_data_root, app_env_configs, storyteller_creds_manager).await
handle_image_edit_artcraft_via_router(request, app_env_configs, storyteller_creds_manager, CommonImageModel::NanaBanana, GenerationModel::NanoBanana).await
},
ImageEditModel::NanoBananaPro => handle_image_edit_artcraft_via_router(request, app_env_configs, storyteller_creds_manager, CommonImageModel::NanaBananaPro, GenerationModel::NanoBananaPro).await,
ImageEditModel::GptImage1 => handle_artcraft_gpt_image_1_edit(request, app, app_data_root, app_env_configs, storyteller_creds_manager).await,
ImageEditModel::GptImage1p5 => handle_artcraft_gpt_image_1p5_edit(request, app, app_data_root, app_env_configs, storyteller_creds_manager).await,
ImageEditModel::GptImage1p5 => handle_image_edit_artcraft_via_router(request, app_env_configs, storyteller_creds_manager, CommonImageModel::GptImage1p5, GenerationModel::GptImage1p5).await,
ImageEditModel::Seedream4 => handle_image_edit_artcraft_via_router(request, app_env_configs, storyteller_creds_manager, CommonImageModel::Seedream4, GenerationModel::Seedream4).await,
ImageEditModel::Seedream4p5 => handle_image_edit_artcraft_via_router(request, app_env_configs, storyteller_creds_manager, CommonImageModel::Seedream4p5, GenerationModel::Seedream4p5).await,
}
@@ -1,7 +1,5 @@
mod handle_image_edit_artcraft_via_router;
mod handle_artcraft_flux_kontext_edit;
mod handle_artcraft_gpt_image_1_edit;
mod handle_artcraft_gpt_image_1p5_edit;
mod handle_artcraft_nano_banana_edit;
pub (super) mod handle_image_edit_artcraft;
@@ -1,118 +0,0 @@
use crate::core::api_adapters::aspect_ratio::convert::aspect_ratio_to_artcraft_gpt_image_1p5::aspect_ratio_to_artcraft_gpt_image_1p5;
use crate::core::commands::enqueue::generate_error::GenerateError;
use crate::core::commands::enqueue::image_edit::enqueue_edit_image_command::{EditImageQuality, EditImageSize, EnqueueEditImageCommand};
use crate::core::commands::enqueue::task_enqueue_success::TaskEnqueueSuccess;
use crate::core::commands::enqueue::text_to_image::enqueue_text_to_image_command::{EnqueueTextToImageRequest, TextToImageSize};
use crate::core::events::basic_sendable_event_trait::BasicSendableEvent;
use crate::core::events::generation_events::common::{GenerationAction, GenerationModel, GenerationServiceProvider};
use crate::core::events::generation_events::generation_enqueue_failure_event::GenerationEnqueueFailureEvent;
use crate::core::state::app_env_configs::app_env_configs::AppEnvConfigs;
use crate::core::state::data_dir::app_data_root::AppDataRoot;
use crate::core::state::provider_priority::ProviderPriorityStore;
use crate::services::sora::state::sora_credential_manager::SoraCredentialManager;
use crate::services::sora::state::sora_task_queue::SoraTaskQueue;
use crate::services::storyteller::state::storyteller_credential_manager::StorytellerCredentialManager;
use artcraft_api_defs::generate::image::multi_function::gpt_image_1p5_multi_function_image_gen::{GptImage1p5MultiFunctionImageGenNumImages, GptImage1p5MultiFunctionImageGenQuality, GptImage1p5MultiFunctionImageGenRequest, GptImage1p5MultiFunctionImageGenSize};
use enums::common::generation_provider::GenerationProvider;
use enums::tauri::tasks::task_type::TaskType;
use idempotency::uuid::generate_random_uuid;
use log::{error, info};
use std::time::Duration;
use artcraft_client::endpoints::generate::image::multi_function::gpt_image_1p5_multi_function_image_gen_image::gpt_image_1p5_multi_function_image_gen;
use tauri::AppHandle;
pub async fn handle_artcraft_gpt_image_1p5_text_to_image(
request: &EnqueueTextToImageRequest,
app_env_configs: &AppEnvConfigs,
storyteller_creds_manager: &StorytellerCredentialManager,
) -> Result<TaskEnqueueSuccess, GenerateError> {
let creds = match storyteller_creds_manager.get_credentials()? {
Some(creds) => creds,
None => {
return Err(GenerateError::needs_storyteller_credentials());
},
};
info!("Calling Artcraft gpt-image-1.5 ...");
let uuid_idempotency_token = generate_random_uuid();
let aspect_ratio = get_aspect_ratio(request);
let num_images = match request.number_images {
None => None,
Some(1) => Some(GptImage1p5MultiFunctionImageGenNumImages::One),
Some(2) => Some(GptImage1p5MultiFunctionImageGenNumImages::Two),
Some(3) => Some(GptImage1p5MultiFunctionImageGenNumImages::Three),
Some(4) => Some(GptImage1p5MultiFunctionImageGenNumImages::Four),
// TODO: Error
//Some(other) => {
// return Err(InternalImageError::InvalidNumberOfRequestedImages {
// min: 1,
// max: 4,
// requested: other,
// });
//},
_ => Some(GptImage1p5MultiFunctionImageGenNumImages::One), // Default to one image if invalid number
};
let request = GptImage1p5MultiFunctionImageGenRequest {
uuid_idempotency_token,
prompt: request.prompt.clone(),
image_size: aspect_ratio,
num_images,
image_media_tokens: request.image_media_tokens.clone(),
mask_image_token: None,
input_fidelity: None,
// Not provided
output_format: None,
background: None,
quality: None,
};
let result = gpt_image_1p5_multi_function_image_gen(
&app_env_configs.storyteller_host,
Some(&creds),
request,
).await;
let job_id = match result {
Ok(enqueued) => {
info!("Successfully enqueued Artcraft gpt-image-1.5. Job token: {}",
enqueued.inference_job_token);
enqueued.inference_job_token
}
Err(err) => {
error!("Failed to use Artcraft gpt-image-1.5: {:?}", err);
return Err(GenerateError::from(err));
}
};
Ok(TaskEnqueueSuccess {
provider: GenerationProvider::Artcraft,
model: Some(GenerationModel::GptImage1p5),
provider_job_id: Some(job_id.to_string()),
task_type: TaskType::ImageGeneration,
})
}
fn get_aspect_ratio(request: &EnqueueTextToImageRequest) -> Option<GptImage1p5MultiFunctionImageGenSize> {
if let Some(common_aspect_ratio) = request.common_aspect_ratio {
// Handle modern aspect ratio
let aspect = aspect_ratio_to_artcraft_gpt_image_1p5(common_aspect_ratio);
return Some(aspect);
}
if let Some(aspect_ratio) = request.aspect_ratio {
// Handle deprecated aspect ratio
return match aspect_ratio {
TextToImageSize::Auto => Some(GptImage1p5MultiFunctionImageGenSize::Square),
TextToImageSize::Square => Some(GptImage1p5MultiFunctionImageGenSize::Square),
TextToImageSize::Wide => Some(GptImage1p5MultiFunctionImageGenSize::Wide),
TextToImageSize::Tall => Some(GptImage1p5MultiFunctionImageGenSize::Tall),
}
}
None
}
@@ -1,109 +0,0 @@
use crate::core::api_adapters::aspect_ratio::convert::aspect_ratio_to_artcraft_nano_banana::aspect_ratio_to_artcraft_nano_banana;
use crate::core::commands::enqueue::generate_error::{GenerateError, MissingCredentialsReason, ProviderFailureReason};
use crate::core::commands::enqueue::image_edit::enqueue_edit_image_command::{EditImageQuality, EditImageSize};
use crate::core::commands::enqueue::task_enqueue_success::TaskEnqueueSuccess;
use crate::core::commands::enqueue::text_to_image::enqueue_text_to_image_command::{EnqueueTextToImageRequest, TextToImageSize};
use crate::core::events::basic_sendable_event_trait::BasicSendableEvent;
use crate::core::events::generation_events::common::{GenerationAction, GenerationModel, GenerationServiceProvider};
use crate::core::events::generation_events::generation_enqueue_failure_event::GenerationEnqueueFailureEvent;
use crate::core::state::app_env_configs::app_env_configs::AppEnvConfigs;
use crate::core::state::data_dir::app_data_root::AppDataRoot;
use crate::core::state::provider_priority::ProviderPriorityStore;
use crate::services::sora::state::sora_credential_manager::SoraCredentialManager;
use crate::services::sora::state::sora_task_queue::SoraTaskQueue;
use crate::services::storyteller::state::storyteller_credential_manager::StorytellerCredentialManager;
use artcraft_api_defs::generate::image::edit::gemini_25_flash_edit_image::{Gemini25FlashEditImageNumImages, Gemini25FlashEditImageRequest};
use artcraft_api_defs::generate::image::multi_function::nano_banana_multi_function_image_gen::{NanoBananaMultiFunctionImageGenAspectRatio, NanoBananaMultiFunctionImageGenNumImages, NanoBananaMultiFunctionImageGenRequest};
use artcraft_api_defs::generate::image::multi_function::nano_banana_pro_multi_function_image_gen::NanoBananaProMultiFunctionImageGenAspectRatio;
use artcraft_api_defs::generate::image::text::generate_gpt_image_1_text_to_image::{GenerateGptImage1TextToImageImageQuality, GenerateGptImage1TextToImageImageSize, GenerateGptImage1TextToImageNumImages, GenerateGptImage1TextToImageRequest};
use enums::common::generation_provider::GenerationProvider;
use enums::tauri::tasks::task_type::TaskType;
use idempotency::uuid::generate_random_uuid;
use log::{error, info};
use std::time::Duration;
use artcraft_client::endpoints::generate::image::multi_function::nano_banana_multi_function_image_gen_image::nano_banana_multi_function_image_gen;
use artcraft_client::endpoints::generate::image::multi_function::nano_banana_pro_multi_function_image_gen_image::nano_banana_pro_multi_function_image_gen;
use tauri::AppHandle;
pub async fn handle_artcraft_nano_banana_text_to_image(
request: &EnqueueTextToImageRequest,
app_env_configs: &AppEnvConfigs,
storyteller_creds_manager: &StorytellerCredentialManager,
) -> Result<TaskEnqueueSuccess, GenerateError> {
let creds = match storyteller_creds_manager.get_credentials()? {
Some(creds) => creds,
None => {
return Err(GenerateError::MissingCredentials(MissingCredentialsReason::NeedsStorytellerCredentials));
},
};
info!("Calling Artcraft Nano Banana...");
let uuid_idempotency_token = generate_random_uuid();
let num_images = match request.number_images {
None => None,
Some(1) => Some(NanoBananaMultiFunctionImageGenNumImages::One),
Some(2) => Some(NanoBananaMultiFunctionImageGenNumImages::Two),
Some(3) => Some(NanoBananaMultiFunctionImageGenNumImages::Three),
Some(4) => Some(NanoBananaMultiFunctionImageGenNumImages::Four),
_ => Some(NanoBananaMultiFunctionImageGenNumImages::One), // Default to one image if invalid number
};
let aspect_ratio = get_aspect_ratio(request);
let request = NanoBananaMultiFunctionImageGenRequest {
uuid_idempotency_token,
prompt: request.prompt.clone(),
image_media_tokens: request.image_media_tokens.clone(),
num_images,
aspect_ratio,
};
let result = nano_banana_multi_function_image_gen(
&app_env_configs.storyteller_host,
Some(&creds),
request,
).await;
let job_id = match result {
Ok(enqueued) => {
// TODO(bt,2025-07-05): Enqueue job token?
info!("Successfully enqueued Artcraft Nano Banana. Job token: {}",
enqueued.inference_job_token);
enqueued.inference_job_token
}
Err(err) => {
error!("Failed to use Artcraft Nano Banana: {:?}", err);
return Err(GenerateError::ProviderFailure(ProviderFailureReason::StorytellerError(err)));
}
};
Ok(TaskEnqueueSuccess {
provider: GenerationProvider::Artcraft,
model: Some(GenerationModel::NanoBanana),
provider_job_id: Some(job_id.to_string()),
task_type: TaskType::ImageGeneration,
})
}
fn get_aspect_ratio(request: &EnqueueTextToImageRequest) -> Option<NanoBananaMultiFunctionImageGenAspectRatio> {
if let Some(common_aspect_ratio) = request.common_aspect_ratio {
// Handle modern aspect ratio
let aspect = aspect_ratio_to_artcraft_nano_banana(common_aspect_ratio);
return Some(aspect);
}
if let Some(aspect_ratio) = request.aspect_ratio {
// Handle deprecated aspect ratio
return match aspect_ratio {
TextToImageSize::Auto => Some(NanoBananaMultiFunctionImageGenAspectRatio::Auto),
TextToImageSize::Square => Some(NanoBananaMultiFunctionImageGenAspectRatio::OneByOne),
TextToImageSize::Wide => Some(NanoBananaMultiFunctionImageGenAspectRatio::SixteenByNine),
TextToImageSize::Tall => Some(NanoBananaMultiFunctionImageGenAspectRatio::NineBySixteen),
}
}
None
}
@@ -5,9 +5,7 @@ use crate::core::commands::enqueue::text_to_image::artcraft::handle_artcraft_flu
use crate::core::commands::enqueue::text_to_image::artcraft::handle_artcraft_flux_pro_1p1_text_to_image::handle_artcraft_flux_pro_1p1_text_to_image;
use crate::core::commands::enqueue::text_to_image::artcraft::handle_artcraft_flux_pro_1p1_ultra_text_to_image::handle_artcraft_flux_pro_1p1_ultra_text_to_image;
use crate::core::commands::enqueue::text_to_image::artcraft::handle_artcraft_gpt_image_1_text_to_image::handle_artcraft_gpt_image_1_text_to_image;
use crate::core::commands::enqueue::text_to_image::artcraft::handle_artcraft_gpt_image_1p5_text_to_image::handle_artcraft_gpt_image_1p5_text_to_image;
use crate::core::commands::enqueue::text_to_image::artcraft::handle_text_to_image_artcraft_via_router::handle_text_to_image_artcraft_via_router;
use crate::core::commands::enqueue::text_to_image::artcraft::handle_artcraft_nano_banana_text_to_image::handle_artcraft_nano_banana_text_to_image;
use crate::core::commands::enqueue::text_to_image::enqueue_text_to_image_command::{EnqueueTextToImageRequest, TextToImageModel};
use crate::core::commands::enqueue::text_to_image::text_to_image_models::text_to_image_model_to_model_type;
use crate::core::events::generation_events::common::GenerationModel;
@@ -42,9 +40,9 @@ pub async fn handle_text_to_image_artcraft(
TextToImageModel::FluxPro11 =>handle_artcraft_flux_pro_1p1_text_to_image(request, app_env_configs, storyteller_creds_manager).await,
TextToImageModel::FluxPro11Ultra => handle_artcraft_flux_pro_1p1_ultra_text_to_image(request, app_env_configs, storyteller_creds_manager).await,
TextToImageModel::GptImage1 => handle_artcraft_gpt_image_1_text_to_image(request, app_env_configs, storyteller_creds_manager).await,
TextToImageModel::GptImage1p5 => handle_artcraft_gpt_image_1p5_text_to_image(request, app_env_configs, storyteller_creds_manager).await,
TextToImageModel::GptImage1p5 => handle_text_to_image_artcraft_via_router(request, app_env_configs, storyteller_creds_manager, CommonImageModel::GptImage1p5, GenerationModel::GptImage1p5).await,
TextToImageModel::Gemini25Flash | TextToImageModel::NanoBanana => {
handle_artcraft_nano_banana_text_to_image(request, app_env_configs, storyteller_creds_manager).await
handle_text_to_image_artcraft_via_router(request, app_env_configs, storyteller_creds_manager, CommonImageModel::NanaBanana, GenerationModel::NanoBanana).await
},
TextToImageModel::NanoBananaPro => handle_text_to_image_artcraft_via_router(request, app_env_configs, storyteller_creds_manager, CommonImageModel::NanaBananaPro, GenerationModel::NanoBananaPro).await,
TextToImageModel::Seedream4 => handle_text_to_image_artcraft_via_router(request, app_env_configs, storyteller_creds_manager, CommonImageModel::Seedream4, GenerationModel::Seedream4).await,
@@ -1,10 +1,8 @@
pub (super) mod handle_text_to_image_artcraft;
mod handle_text_to_image_artcraft_via_router;
mod handle_artcraft_gpt_image_1_text_to_image;
mod handle_artcraft_gpt_image_1p5_text_to_image;
mod handle_artcraft_nano_banana_text_to_image;
mod handle_artcraft_flux_pro_1p1_ultra_text_to_image;
mod handle_artcraft_flux_1_dev_text_to_image;
mod handle_artcraft_flux_1_schnell_text_to_image;
mod handle_artcraft_flux_pro_1p1_text_to_image;
mod handle_artcraft_flux_1_schnell_text_to_image;
mod handle_artcraft_flux_pro_1p1_ultra_text_to_image;
mod handle_artcraft_gpt_image_1_text_to_image;