Compare commits

...
Author SHA1 Message Date
Brandon Thomas c20e2c8d4e power users 2026-05-17 23:47:06 -04:00
Brandon Thomas 6c5d5e37a3 fix queries 2026-05-17 23:33:24 -04:00
Brandon Thomas 4fe6810cba more queries 2026-05-17 23:27:28 -04:00
Brandon Thomas 615bcee892 DAUs query 2026-05-17 23:22:15 -04:00
Brandon Thomas 36df1f780e router seedance global (#1531) 2026-05-16 22:32:54 -04:00
Brandon Thomas 5d7992e8eb failures refunded info (#1530) 2026-05-16 19:31:38 -04:00
Brandon Thomas ffa92e8712 gmi improvements (#1529) 2026-05-16 17:27:12 -04:00
Tanish Mankuandbflatastic d570aa396c Webapp: Add pagescene support (#1511)
Co-authored-by: bflatastic <bombayapirat@gmail.com>
2026-05-16 12:28:24 -07:00
Brandon Thomas 97087453c3 gmicloud job (#1528) 2026-05-16 13:07:34 -04:00
Brandon Thomas 084e1f68b8 seedance global (#1527) 2026-05-16 11:52:25 -04:00
Brandon Thomas a1bc472125 gmicloud (#1526) 2026-05-16 08:37:03 -04:00
BFlat 43fcccce65 web/webapp: username, email, password change 2026-05-16 15:12:59 +07:00
Brandon Thomas 00132478b1 gpt pricing; fix test failures (#1524) 2026-05-15 17:12:21 -04:00
Brandon Thomas 7ea0bb0dd2 gpt-image-2 with HD options; refactor image omni gen and pricing pipelines (#1523) 2026-05-15 10:49:56 -04:00
bflatastic 9645efce7d web/webapp download fixes 2026-05-15 20:49:33 +07:00
bflatastic 4a3d239735 web/webapp: fix cors error on file download 2026-05-15 20:42:32 +07:00
Brandon Thomas 9c0033c673 Implement artcraft router and add tests (#1521) 2026-05-14 21:39:04 -04:00
Brandon Thomas e0e3daa16f gpt image resolution (#1520) 2026-05-14 20:46:06 -04:00
Brandon Thomas 08f169cebf fal gpt-image implementations using new api (#1518) 2026-05-14 18:54:12 -04:00
Brandon Thomas dec59caf11 artcraft 0.32 (#1516) 2026-05-14 10:21:59 -04:00
BFlat eaf89abb65 web: mobile menu to show referrals link 2026-05-14 14:57:12 +07:00
Brandon Thomas bd5601ffba cors 2026-05-14 03:54:49 -04:00
BFlat 63dbed7411 web/webapp: added username referrals query support 2026-05-14 14:25:53 +07:00
Brandon Thomas c0d4f4c7aa debug referral info 2026-05-14 02:21:20 -04:00
Brandon Thomas d234cef007 redirects 2026-05-14 02:07:33 -04:00
Brandon Thomas 7da69691a0 document types 2026-05-14 01:34:52 -04:00
Brandon Thomas 10733d9d23 moderator endpoints for referrals (#1515) 2026-05-14 01:22:09 -04:00
BFlat 6b0d210b57 web/webapp: user-facing referrals dashboard page (#1514)
- Adds a new **Referrals** page in both the marketing site and the
webapp, gated by the new `referrals` feature flag
- Users can share two kinds of invite links:
  - Their username link (`?u={username}`) - works out of the box
- Up to 5 custom codes (`?r={code}`) they can create and delete
themselves
- Site: link lives in the profile dropdown (desktop + mobile)
- Webapp: link lives in the sidebar under a new "Invite" section
- Users without the flag see nothing - no menu entry, and direct visits
redirect home
2026-05-14 11:58:20 +07:00
576 changed files with 22794 additions and 11101 deletions
@@ -0,0 +1,102 @@
{
"db_name": "MySQL",
"query": "\nSELECT\n jobs.token as `job_token: tokens::tokens::generic_inference_jobs::InferenceJobToken`,\n jobs.maybe_external_third_party_id as `request_id`,\n jobs.maybe_creator_user_token as `maybe_creator_user_token: tokens::tokens::users::UserToken`,\n jobs.maybe_creator_anonymous_visitor_token as `maybe_creator_anonymous_visitor_token: tokens::tokens::anonymous_visitor_tracking::AnonymousVisitorTrackingToken`,\n jobs.creator_ip_address,\n jobs.creator_set_visibility as `creator_set_visibility: enums::common::visibility::Visibility`,\n jobs.maybe_prompt_token as `maybe_prompt_token: tokens::tokens::prompts::PromptToken`,\n jobs.maybe_wallet_ledger_entry_token as `maybe_wallet_ledger_entry_token: tokens::tokens::wallet_ledger_entries::WalletLedgerEntryToken`\n\nFROM generic_inference_jobs as jobs\n\nWHERE jobs.maybe_external_third_party = ?\n AND jobs.status NOT IN ('complete_success', 'complete_failure')\n AND jobs.maybe_external_third_party_id IS NOT NULL\n\nLIMIT 25000\n ",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "job_token: tokens::tokens::generic_inference_jobs::InferenceJobToken",
"type_info": {
"type": "VarString",
"flags": "NOT_NULL | UNIQUE_KEY | BINARY | NO_DEFAULT_VALUE",
"char_set": 224,
"max_size": 128
}
},
{
"ordinal": 1,
"name": "request_id",
"type_info": {
"type": "VarString",
"flags": "MULTIPLE_KEY | BINARY",
"char_set": 224,
"max_size": 256
}
},
{
"ordinal": 2,
"name": "maybe_creator_user_token: tokens::tokens::users::UserToken",
"type_info": {
"type": "VarString",
"flags": "MULTIPLE_KEY | BINARY",
"char_set": 224,
"max_size": 128
}
},
{
"ordinal": 3,
"name": "maybe_creator_anonymous_visitor_token: tokens::tokens::anonymous_visitor_tracking::AnonymousVisitorTrackingToken",
"type_info": {
"type": "VarString",
"flags": "MULTIPLE_KEY | BINARY",
"char_set": 224,
"max_size": 128
}
},
{
"ordinal": 4,
"name": "creator_ip_address",
"type_info": {
"type": "VarString",
"flags": "NOT_NULL | MULTIPLE_KEY | BINARY | NO_DEFAULT_VALUE",
"char_set": 224,
"max_size": 160
}
},
{
"ordinal": 5,
"name": "creator_set_visibility: enums::common::visibility::Visibility",
"type_info": {
"type": "String",
"flags": "NOT_NULL | BINARY | ENUM",
"char_set": 224,
"max_size": 28
}
},
{
"ordinal": 6,
"name": "maybe_prompt_token: tokens::tokens::prompts::PromptToken",
"type_info": {
"type": "VarString",
"flags": "BINARY",
"char_set": 224,
"max_size": 128
}
},
{
"ordinal": 7,
"name": "maybe_wallet_ledger_entry_token: tokens::tokens::wallet_ledger_entries::WalletLedgerEntryToken",
"type_info": {
"type": "VarString",
"flags": "BINARY",
"char_set": 224,
"max_size": 128
}
}
],
"parameters": {
"Right": 1
},
"nullable": [
false,
true,
true,
true,
false,
false,
true,
true
]
},
"hash": "1ddb2207db50e2dbfb37d26c006bffe2d8f5fb011855d5a86d01f395c36df759"
}
@@ -0,0 +1,146 @@
{
"db_name": "MySQL",
"query": "\nSELECT\n ur.id as `id: u64`,\n ur.invited_user_token as `invited_user_token: UserToken`,\n invited.username as `invited_username: String`,\n invited.display_name as `invited_display_name: String`,\n invited.email_address as `invited_email_address: String`,\n ur.referrer_user_token as `referrer_user_token: UserToken`,\n referrer.username as `referrer_username: String`,\n referrer.display_name as `referrer_display_name: String`,\n ur.maybe_referral_code_token as `maybe_referral_code_token: UserReferralCodeToken`,\n ur.maybe_referral_url,\n ur.maybe_landing_url,\n ur.created_at\nFROM user_referrals ur\nJOIN users invited ON invited.token = ur.invited_user_token\nJOIN users referrer ON referrer.token = ur.referrer_user_token\nORDER BY ur.id DESC\nLIMIT ?\n ",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "id: u64",
"type_info": {
"type": "LongLong",
"flags": "NOT_NULL | PRIMARY_KEY | UNSIGNED | AUTO_INCREMENT",
"char_set": 63,
"max_size": 20
}
},
{
"ordinal": 1,
"name": "invited_user_token: UserToken",
"type_info": {
"type": "VarString",
"flags": "NOT_NULL | UNIQUE_KEY | BINARY | NO_DEFAULT_VALUE",
"char_set": 224,
"max_size": 128
}
},
{
"ordinal": 2,
"name": "invited_username: String",
"type_info": {
"type": "VarString",
"flags": "NOT_NULL | UNIQUE_KEY | BINARY | NO_DEFAULT_VALUE",
"char_set": 224,
"max_size": 80
}
},
{
"ordinal": 3,
"name": "invited_display_name: String",
"type_info": {
"type": "VarString",
"flags": "NOT_NULL | BINARY | NO_DEFAULT_VALUE",
"char_set": 224,
"max_size": 80
}
},
{
"ordinal": 4,
"name": "invited_email_address: String",
"type_info": {
"type": "VarString",
"flags": "NOT_NULL | UNIQUE_KEY | BINARY | NO_DEFAULT_VALUE",
"char_set": 224,
"max_size": 1020
}
},
{
"ordinal": 5,
"name": "referrer_user_token: UserToken",
"type_info": {
"type": "VarString",
"flags": "NOT_NULL | MULTIPLE_KEY | BINARY | NO_DEFAULT_VALUE",
"char_set": 224,
"max_size": 128
}
},
{
"ordinal": 6,
"name": "referrer_username: String",
"type_info": {
"type": "VarString",
"flags": "NOT_NULL | UNIQUE_KEY | BINARY | NO_DEFAULT_VALUE",
"char_set": 224,
"max_size": 80
}
},
{
"ordinal": 7,
"name": "referrer_display_name: String",
"type_info": {
"type": "VarString",
"flags": "NOT_NULL | BINARY | NO_DEFAULT_VALUE",
"char_set": 224,
"max_size": 80
}
},
{
"ordinal": 8,
"name": "maybe_referral_code_token: UserReferralCodeToken",
"type_info": {
"type": "VarString",
"flags": "BINARY",
"char_set": 224,
"max_size": 128
}
},
{
"ordinal": 9,
"name": "maybe_referral_url",
"type_info": {
"type": "VarString",
"flags": "BINARY",
"char_set": 224,
"max_size": 1020
}
},
{
"ordinal": 10,
"name": "maybe_landing_url",
"type_info": {
"type": "VarString",
"flags": "BINARY",
"char_set": 224,
"max_size": 1020
}
},
{
"ordinal": 11,
"name": "created_at",
"type_info": {
"type": "Datetime",
"flags": "NOT_NULL | MULTIPLE_KEY | BINARY",
"char_set": 63,
"max_size": 19
}
}
],
"parameters": {
"Right": 1
},
"nullable": [
false,
false,
false,
false,
false,
false,
false,
false,
true,
true,
true,
false
]
},
"hash": "507a60f98f6da63620abc28a5a83772f29ffcfdfcc8d30bfa7c611d73073ca16"
}
@@ -0,0 +1,12 @@
{
"db_name": "MySQL",
"query": "\nINSERT INTO generic_inference_jobs\nSET\n token = ?,\n uuid_idempotency_token = ?,\n\n job_type = ?,\n\n maybe_external_third_party = ?,\n maybe_external_third_party_id = ?,\n\n product_category = ?,\n inference_category = ?,\n\n maybe_model_type = NULL,\n maybe_model_token = NULL,\n\n maybe_input_source_token = NULL,\n maybe_input_source_token_type = NULL,\n\n maybe_download_url = NULL,\n maybe_cover_image_media_file_token = NULL,\n\n maybe_prompt_token = ?,\n\n maybe_raw_inference_text = NULL,\n\n maybe_inference_args = NULL,\n\n maybe_creator_user_token = ?,\n maybe_creator_anonymous_visitor_token = ?,\n creator_ip_address = ?,\n creator_set_visibility = ?,\n\n priority_level = 0,\n is_keepalive_required = FALSE,\n max_duration_seconds = 0,\n\n is_debug_request = FALSE,\n maybe_routing_tag = NULL,\n\n maybe_debug_log_event_token = ?,\n\n frontend_failure_category = NULL,\n failure_reason = NULL,\n\n status = ?\n ",
"describe": {
"columns": [],
"parameters": {
"Right": 14
},
"nullable": []
},
"hash": "99dae5c5f3b3a8e9ce105bff9a9754c3aee92dbf217288c9a7eb925004e3d259"
}
@@ -0,0 +1,146 @@
{
"db_name": "MySQL",
"query": "\nSELECT\n ur.id as `id: u64`,\n ur.invited_user_token as `invited_user_token: UserToken`,\n invited.username as `invited_username: String`,\n invited.display_name as `invited_display_name: String`,\n invited.email_address as `invited_email_address: String`,\n ur.referrer_user_token as `referrer_user_token: UserToken`,\n referrer.username as `referrer_username: String`,\n referrer.display_name as `referrer_display_name: String`,\n ur.maybe_referral_code_token as `maybe_referral_code_token: UserReferralCodeToken`,\n ur.maybe_referral_url,\n ur.maybe_landing_url,\n ur.created_at\nFROM user_referrals ur\nJOIN users invited ON invited.token = ur.invited_user_token\nJOIN users referrer ON referrer.token = ur.referrer_user_token\nWHERE ur.id < ?\nORDER BY ur.id DESC\nLIMIT ?\n ",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "id: u64",
"type_info": {
"type": "LongLong",
"flags": "NOT_NULL | PRIMARY_KEY | UNSIGNED | AUTO_INCREMENT",
"char_set": 63,
"max_size": 20
}
},
{
"ordinal": 1,
"name": "invited_user_token: UserToken",
"type_info": {
"type": "VarString",
"flags": "NOT_NULL | UNIQUE_KEY | BINARY | NO_DEFAULT_VALUE",
"char_set": 224,
"max_size": 128
}
},
{
"ordinal": 2,
"name": "invited_username: String",
"type_info": {
"type": "VarString",
"flags": "NOT_NULL | UNIQUE_KEY | BINARY | NO_DEFAULT_VALUE",
"char_set": 224,
"max_size": 80
}
},
{
"ordinal": 3,
"name": "invited_display_name: String",
"type_info": {
"type": "VarString",
"flags": "NOT_NULL | BINARY | NO_DEFAULT_VALUE",
"char_set": 224,
"max_size": 80
}
},
{
"ordinal": 4,
"name": "invited_email_address: String",
"type_info": {
"type": "VarString",
"flags": "NOT_NULL | UNIQUE_KEY | BINARY | NO_DEFAULT_VALUE",
"char_set": 224,
"max_size": 1020
}
},
{
"ordinal": 5,
"name": "referrer_user_token: UserToken",
"type_info": {
"type": "VarString",
"flags": "NOT_NULL | MULTIPLE_KEY | BINARY | NO_DEFAULT_VALUE",
"char_set": 224,
"max_size": 128
}
},
{
"ordinal": 6,
"name": "referrer_username: String",
"type_info": {
"type": "VarString",
"flags": "NOT_NULL | UNIQUE_KEY | BINARY | NO_DEFAULT_VALUE",
"char_set": 224,
"max_size": 80
}
},
{
"ordinal": 7,
"name": "referrer_display_name: String",
"type_info": {
"type": "VarString",
"flags": "NOT_NULL | BINARY | NO_DEFAULT_VALUE",
"char_set": 224,
"max_size": 80
}
},
{
"ordinal": 8,
"name": "maybe_referral_code_token: UserReferralCodeToken",
"type_info": {
"type": "VarString",
"flags": "BINARY",
"char_set": 224,
"max_size": 128
}
},
{
"ordinal": 9,
"name": "maybe_referral_url",
"type_info": {
"type": "VarString",
"flags": "BINARY",
"char_set": 224,
"max_size": 1020
}
},
{
"ordinal": 10,
"name": "maybe_landing_url",
"type_info": {
"type": "VarString",
"flags": "BINARY",
"char_set": 224,
"max_size": 1020
}
},
{
"ordinal": 11,
"name": "created_at",
"type_info": {
"type": "Datetime",
"flags": "NOT_NULL | MULTIPLE_KEY | BINARY",
"char_set": 63,
"max_size": 19
}
}
],
"parameters": {
"Right": 2
},
"nullable": [
false,
false,
false,
false,
false,
false,
false,
false,
true,
true,
true,
false
]
},
"hash": "b2049fa1c9f76c73a9a22144de716b47dccdd35caeefb3721e75ec9aaf4614f0"
}
@@ -0,0 +1,146 @@
{
"db_name": "MySQL",
"query": "\nSELECT\n ur.id as `id: u64`,\n ur.invited_user_token as `invited_user_token: UserToken`,\n invited.username as `invited_username: String`,\n invited.display_name as `invited_display_name: String`,\n invited.email_address as `invited_email_address: String`,\n ur.referrer_user_token as `referrer_user_token: UserToken`,\n referrer.username as `referrer_username: String`,\n referrer.display_name as `referrer_display_name: String`,\n ur.maybe_referral_code_token as `maybe_referral_code_token: UserReferralCodeToken`,\n ur.maybe_referral_url,\n ur.maybe_landing_url,\n ur.created_at\nFROM user_referrals ur\nJOIN users invited ON invited.token = ur.invited_user_token\nJOIN users referrer ON referrer.token = ur.referrer_user_token\nWHERE referrer.username = ?\nORDER BY ur.id DESC\nLIMIT ?\n ",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "id: u64",
"type_info": {
"type": "LongLong",
"flags": "NOT_NULL | PRIMARY_KEY | UNSIGNED | AUTO_INCREMENT",
"char_set": 63,
"max_size": 20
}
},
{
"ordinal": 1,
"name": "invited_user_token: UserToken",
"type_info": {
"type": "VarString",
"flags": "NOT_NULL | UNIQUE_KEY | BINARY | NO_DEFAULT_VALUE",
"char_set": 224,
"max_size": 128
}
},
{
"ordinal": 2,
"name": "invited_username: String",
"type_info": {
"type": "VarString",
"flags": "NOT_NULL | UNIQUE_KEY | BINARY | NO_DEFAULT_VALUE",
"char_set": 224,
"max_size": 80
}
},
{
"ordinal": 3,
"name": "invited_display_name: String",
"type_info": {
"type": "VarString",
"flags": "NOT_NULL | BINARY | NO_DEFAULT_VALUE",
"char_set": 224,
"max_size": 80
}
},
{
"ordinal": 4,
"name": "invited_email_address: String",
"type_info": {
"type": "VarString",
"flags": "NOT_NULL | UNIQUE_KEY | BINARY | NO_DEFAULT_VALUE",
"char_set": 224,
"max_size": 1020
}
},
{
"ordinal": 5,
"name": "referrer_user_token: UserToken",
"type_info": {
"type": "VarString",
"flags": "NOT_NULL | MULTIPLE_KEY | BINARY | NO_DEFAULT_VALUE",
"char_set": 224,
"max_size": 128
}
},
{
"ordinal": 6,
"name": "referrer_username: String",
"type_info": {
"type": "VarString",
"flags": "NOT_NULL | UNIQUE_KEY | BINARY | NO_DEFAULT_VALUE",
"char_set": 224,
"max_size": 80
}
},
{
"ordinal": 7,
"name": "referrer_display_name: String",
"type_info": {
"type": "VarString",
"flags": "NOT_NULL | BINARY | NO_DEFAULT_VALUE",
"char_set": 224,
"max_size": 80
}
},
{
"ordinal": 8,
"name": "maybe_referral_code_token: UserReferralCodeToken",
"type_info": {
"type": "VarString",
"flags": "BINARY",
"char_set": 224,
"max_size": 128
}
},
{
"ordinal": 9,
"name": "maybe_referral_url",
"type_info": {
"type": "VarString",
"flags": "BINARY",
"char_set": 224,
"max_size": 1020
}
},
{
"ordinal": 10,
"name": "maybe_landing_url",
"type_info": {
"type": "VarString",
"flags": "BINARY",
"char_set": 224,
"max_size": 1020
}
},
{
"ordinal": 11,
"name": "created_at",
"type_info": {
"type": "Datetime",
"flags": "NOT_NULL | MULTIPLE_KEY | BINARY",
"char_set": 63,
"max_size": 19
}
}
],
"parameters": {
"Right": 2
},
"nullable": [
false,
false,
false,
false,
false,
false,
false,
false,
true,
true,
true,
false
]
},
"hash": "e73dfa3af6a1a8f510f084435da86571e096f3d7e597fb01d6397d380b135341"
}
@@ -0,0 +1,146 @@
{
"db_name": "MySQL",
"query": "\nSELECT\n ur.id as `id: u64`,\n ur.invited_user_token as `invited_user_token: UserToken`,\n invited.username as `invited_username: String`,\n invited.display_name as `invited_display_name: String`,\n invited.email_address as `invited_email_address: String`,\n ur.referrer_user_token as `referrer_user_token: UserToken`,\n referrer.username as `referrer_username: String`,\n referrer.display_name as `referrer_display_name: String`,\n ur.maybe_referral_code_token as `maybe_referral_code_token: UserReferralCodeToken`,\n ur.maybe_referral_url,\n ur.maybe_landing_url,\n ur.created_at\nFROM user_referrals ur\nJOIN users invited ON invited.token = ur.invited_user_token\nJOIN users referrer ON referrer.token = ur.referrer_user_token\nWHERE referrer.username = ?\n AND ur.id < ?\nORDER BY ur.id DESC\nLIMIT ?\n ",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "id: u64",
"type_info": {
"type": "LongLong",
"flags": "NOT_NULL | PRIMARY_KEY | UNSIGNED | AUTO_INCREMENT",
"char_set": 63,
"max_size": 20
}
},
{
"ordinal": 1,
"name": "invited_user_token: UserToken",
"type_info": {
"type": "VarString",
"flags": "NOT_NULL | UNIQUE_KEY | BINARY | NO_DEFAULT_VALUE",
"char_set": 224,
"max_size": 128
}
},
{
"ordinal": 2,
"name": "invited_username: String",
"type_info": {
"type": "VarString",
"flags": "NOT_NULL | UNIQUE_KEY | BINARY | NO_DEFAULT_VALUE",
"char_set": 224,
"max_size": 80
}
},
{
"ordinal": 3,
"name": "invited_display_name: String",
"type_info": {
"type": "VarString",
"flags": "NOT_NULL | BINARY | NO_DEFAULT_VALUE",
"char_set": 224,
"max_size": 80
}
},
{
"ordinal": 4,
"name": "invited_email_address: String",
"type_info": {
"type": "VarString",
"flags": "NOT_NULL | UNIQUE_KEY | BINARY | NO_DEFAULT_VALUE",
"char_set": 224,
"max_size": 1020
}
},
{
"ordinal": 5,
"name": "referrer_user_token: UserToken",
"type_info": {
"type": "VarString",
"flags": "NOT_NULL | MULTIPLE_KEY | BINARY | NO_DEFAULT_VALUE",
"char_set": 224,
"max_size": 128
}
},
{
"ordinal": 6,
"name": "referrer_username: String",
"type_info": {
"type": "VarString",
"flags": "NOT_NULL | UNIQUE_KEY | BINARY | NO_DEFAULT_VALUE",
"char_set": 224,
"max_size": 80
}
},
{
"ordinal": 7,
"name": "referrer_display_name: String",
"type_info": {
"type": "VarString",
"flags": "NOT_NULL | BINARY | NO_DEFAULT_VALUE",
"char_set": 224,
"max_size": 80
}
},
{
"ordinal": 8,
"name": "maybe_referral_code_token: UserReferralCodeToken",
"type_info": {
"type": "VarString",
"flags": "BINARY",
"char_set": 224,
"max_size": 128
}
},
{
"ordinal": 9,
"name": "maybe_referral_url",
"type_info": {
"type": "VarString",
"flags": "BINARY",
"char_set": 224,
"max_size": 1020
}
},
{
"ordinal": 10,
"name": "maybe_landing_url",
"type_info": {
"type": "VarString",
"flags": "BINARY",
"char_set": 224,
"max_size": 1020
}
},
{
"ordinal": 11,
"name": "created_at",
"type_info": {
"type": "Datetime",
"flags": "NOT_NULL | MULTIPLE_KEY | BINARY",
"char_set": 63,
"max_size": 19
}
}
],
"parameters": {
"Right": 3
},
"nullable": [
false,
false,
false,
false,
false,
false,
false,
false,
true,
true,
true,
false
]
},
"hash": "fb021a5fe7a6c0e284968d917f9d612953629d20a06efccc3fce3d930a7fe266"
}
Generated
+52
View File
@@ -773,6 +773,7 @@ dependencies = [
"artcraft_client",
"enums",
"fal_client",
"gmicloud_client",
"log",
"muapi_client",
"reqwest 0.12.23",
@@ -4690,6 +4691,56 @@ version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d2fabcfbdc87f4758337ca535fb41a6d701b65693ce38287d856d1674551ec9b"
[[package]]
name = "gmicloud-job"
version = "0.0.1"
dependencies = [
"actix-web",
"actix_helpers",
"anyhow",
"bootstrap",
"bucket_paths",
"cloud_storage",
"concurrency",
"easyenv",
"enums",
"errors",
"gmicloud_client",
"hashing",
"jobs_common",
"log",
"mysql_queries",
"pager",
"reqwest 0.12.23",
"rootly_client",
"rootly_config",
"serde",
"serde_derive",
"serde_json",
"server_environment",
"shared_env_var_config",
"sqlx",
"tokens",
"tokio",
"workspace-build-acceleration",
]
[[package]]
name = "gmicloud_client"
version = "0.0.1"
dependencies = [
"errors",
"log",
"reqwest 0.12.23",
"serde",
"serde_derive",
"serde_json",
"test_data",
"test_utils",
"tokio",
"workspace-build-acceleration",
]
[[package]]
name = "gobject-sys"
version = "0.18.0"
@@ -11279,6 +11330,7 @@ dependencies = [
"futures 0.1.31",
"futures 0.3.32",
"futures-util",
"gmicloud_client",
"google_sign_in",
"hashing",
"hostname 0.4.0",
+3
View File
@@ -14,6 +14,7 @@ members = [
"crates/api_clients/google_drive_downloader",
"crates/api_clients/google_sign_in",
"crates/api_clients/grok_client",
"crates/api_clients/gmicloud_client",
"crates/api_clients/midjourney_client",
"crates/api_clients/muapi_client",
"crates/api_clients/beeble_client",
@@ -123,6 +124,7 @@ members = [
"crates/schema/public/tokens",
# Services: API servers, jobs, workers, etc.
"crates/service/job/gmicloud_job",
"crates/service/job/seedance2_pro_job",
"crates/service/job/video_thumbnail_job",
"crates/service/job/worldlabs_job",
@@ -242,6 +244,7 @@ enums = { path = "crates/schema/public/enums" }
error_utils = { path = "crates/lib/error_utils" }
errors = { path = "crates/lib/errors" }
fal_client = { path = "crates/api_clients/fal_client" }
gmicloud_client = { path = "crates/api_clients/gmicloud_client" }
ffmpeg_utils = { path = "crates/lib/ffmpeg_utils" }
file_to_base64_url = { path = "crates/lib/files/file_to_base64_url" }
filesys = { path = "crates/lib/files/filesys" }
@@ -0,0 +1,30 @@
-- Daily active "power users" for ArtCraft since January 1st, 2026.
-- A "power user" is a user who created more than 10 ArtCraft inference jobs in the calendar month.
-- This query then counts how many such power users were active on each day.
SELECT
DATE(j.created_at) AS day,
COUNT(DISTINCT j.maybe_creator_user_token) AS daily_active_power_users
FROM generic_inference_jobs j
INNER JOIN users u ON u.token = j.maybe_creator_user_token
INNER JOIN (
SELECT maybe_creator_user_token, DATE_FORMAT(created_at, '%Y-%m') AS month
FROM generic_inference_jobs
WHERE created_at >= '2026-01-01'
AND maybe_creator_user_token IS NOT NULL
AND job_type NOT IN (
'gpt_sovits', 'tacotron2', 'rvc_v2', 'f5_tts',
'so_vits_svc', 'styletts2', 'face_fusion', 'seed_vc', 'comfy_ui'
)
GROUP BY maybe_creator_user_token, month
HAVING COUNT(*) > 10
) power ON power.maybe_creator_user_token = j.maybe_creator_user_token
AND power.month = DATE_FORMAT(j.created_at, '%Y-%m')
WHERE j.created_at >= '2026-01-01'
AND j.maybe_creator_user_token IS NOT NULL
AND j.job_type NOT IN (
'gpt_sovits', 'tacotron2', 'rvc_v2', 'f5_tts',
'so_vits_svc', 'styletts2', 'face_fusion', 'seed_vc', 'comfy_ui'
)
GROUP BY day
ORDER BY day DESC;
@@ -0,0 +1,24 @@
-- Daily active users for ArtCraft since January 1st, 2026.
-- A "daily active user" is a user who created at least one ArtCraft inference job that day.
SELECT
DATE(j.created_at) AS day,
COUNT(DISTINCT j.maybe_creator_user_token) AS daily_active_users
FROM generic_inference_jobs j
INNER JOIN users u ON u.token = j.maybe_creator_user_token
WHERE j.created_at >= '2026-01-01'
AND j.maybe_creator_user_token IS NOT NULL
AND j.job_type NOT IN (
'gpt_sovits',
'tacotron2',
'rvc_v2',
'f5_tts',
'so_vits_svc',
'styletts2',
'face_fusion',
'seed_vc',
'comfy_ui'
)
GROUP BY DATE(j.created_at)
ORDER BY day DESC;
@@ -0,0 +1,23 @@
-- Monthly active "power users" for ArtCraft since January 1st, 2026.
-- A "power user" is a user who created more than 10 ArtCraft inference jobs in the calendar month.
SELECT
month,
COUNT(*) AS monthly_active_power_users
FROM (
SELECT
maybe_creator_user_token,
DATE_FORMAT(j.created_at, '%Y-%m') AS month
FROM generic_inference_jobs j
INNER JOIN users u ON u.token = j.maybe_creator_user_token
WHERE j.created_at >= '2026-01-01'
AND j.maybe_creator_user_token IS NOT NULL
AND j.job_type NOT IN (
'gpt_sovits', 'tacotron2', 'rvc_v2', 'f5_tts',
'so_vits_svc', 'styletts2', 'face_fusion', 'seed_vc', 'comfy_ui'
)
GROUP BY maybe_creator_user_token, month
HAVING COUNT(*) > 10
) power
GROUP BY month
ORDER BY month DESC;
@@ -0,0 +1,23 @@
-- Monthly active users for ArtCraft since January 1st, 2026.
-- A "monthly active user" is a user who created at least one ArtCraft inference job that month.
SELECT
DATE_FORMAT(j.created_at, '%Y-%m') AS month,
COUNT(DISTINCT j.maybe_creator_user_token) AS monthly_active_users
FROM generic_inference_jobs j
INNER JOIN users u ON u.token = j.maybe_creator_user_token
WHERE j.created_at >= '2026-01-01'
AND j.maybe_creator_user_token IS NOT NULL
AND j.job_type NOT IN (
'gpt_sovits',
'tacotron2',
'rvc_v2',
'f5_tts',
'so_vits_svc',
'styletts2',
'face_fusion',
'seed_vc',
'comfy_ui'
)
GROUP BY month
ORDER BY month DESC;
@@ -0,0 +1,30 @@
-- Weekly active "power users" for ArtCraft since January 1st, 2026.
-- A "power user" is a user who created more than 10 ArtCraft inference jobs in the calendar month.
-- This query then counts how many such power users were active in each week.
SELECT
DATE(DATE_SUB(j.created_at, INTERVAL WEEKDAY(j.created_at) DAY)) AS week_start,
COUNT(DISTINCT j.maybe_creator_user_token) AS weekly_active_power_users
FROM generic_inference_jobs j
INNER JOIN users u ON u.token = j.maybe_creator_user_token
INNER JOIN (
SELECT maybe_creator_user_token, DATE_FORMAT(created_at, '%Y-%m') AS month
FROM generic_inference_jobs
WHERE created_at >= '2026-01-01'
AND maybe_creator_user_token IS NOT NULL
AND job_type NOT IN (
'gpt_sovits', 'tacotron2', 'rvc_v2', 'f5_tts',
'so_vits_svc', 'styletts2', 'face_fusion', 'seed_vc', 'comfy_ui'
)
GROUP BY maybe_creator_user_token, month
HAVING COUNT(*) > 10
) power ON power.maybe_creator_user_token = j.maybe_creator_user_token
AND power.month = DATE_FORMAT(j.created_at, '%Y-%m')
WHERE j.created_at >= '2026-01-01'
AND j.maybe_creator_user_token IS NOT NULL
AND j.job_type NOT IN (
'gpt_sovits', 'tacotron2', 'rvc_v2', 'f5_tts',
'so_vits_svc', 'styletts2', 'face_fusion', 'seed_vc', 'comfy_ui'
)
GROUP BY week_start
ORDER BY week_start DESC;
@@ -0,0 +1,23 @@
-- Weekly active users for ArtCraft since January 1st, 2026.
-- A "weekly active user" is a user who created at least one ArtCraft inference job that week.
SELECT
DATE(DATE_SUB(j.created_at, INTERVAL WEEKDAY(j.created_at) DAY)) AS week_start,
COUNT(DISTINCT j.maybe_creator_user_token) AS weekly_active_users
FROM generic_inference_jobs j
INNER JOIN users u ON u.token = j.maybe_creator_user_token
WHERE j.created_at >= '2026-01-01'
AND j.maybe_creator_user_token IS NOT NULL
AND j.job_type NOT IN (
'gpt_sovits',
'tacotron2',
'rvc_v2',
'f5_tts',
'so_vits_svc',
'styletts2',
'face_fusion',
'seed_vc',
'comfy_ui'
)
GROUP BY week_start
ORDER BY week_start DESC;
@@ -1,5 +1,6 @@
pub mod alerts;
pub mod jobs;
pub mod user;
pub mod user_referrals;
pub mod wallet_ledger_entries;
pub mod wallets;
@@ -0,0 +1,47 @@
use chrono::{DateTime, Utc};
use serde_derive::{Deserialize, Serialize};
use tokens::tokens::user_referral_codes::UserReferralCodeToken;
use tokens::tokens::users::UserToken;
use utoipa::{IntoParams, ToSchema};
// --- Request ---
#[derive(Deserialize, ToSchema, IntoParams)]
pub struct ListGlobalUserReferralsQueryParams {
pub cursor: Option<String>,
pub limit: Option<u32>,
}
// --- Response ---
#[derive(Serialize, ToSchema)]
pub struct ListGlobalUserReferralsSuccessResponse {
pub success: bool,
pub referrals: Vec<UserReferralResponse>,
pub maybe_cursor: Option<String>,
}
#[derive(Serialize, ToSchema)]
pub struct UserReferralResponse {
pub invited_user: InvitedUserDetails,
pub referrer_user: ReferrerUserDetails,
pub maybe_referral_code_token: Option<UserReferralCodeToken>,
pub maybe_referral_url: Option<String>,
pub maybe_landing_url: Option<String>,
pub created_at: DateTime<Utc>,
}
#[derive(Serialize, ToSchema)]
pub struct InvitedUserDetails {
pub token: UserToken,
pub username: String,
pub display_name: String,
pub email_address: String,
}
#[derive(Serialize, ToSchema)]
pub struct ReferrerUserDetails {
pub token: UserToken,
pub username: String,
pub display_name: String,
}
@@ -0,0 +1,26 @@
use serde_derive::{Deserialize, Serialize};
use utoipa::{IntoParams, ToSchema};
use crate::moderation::user_referrals::list_global_user_referrals::UserReferralResponse;
// --- Request ---
#[derive(Deserialize, ToSchema, IntoParams)]
pub struct ListUserReferralsForUserQueryParams {
pub cursor: Option<String>,
pub limit: Option<u32>,
}
#[derive(Deserialize, ToSchema)]
pub struct ListUserReferralsForUserPathInfo {
pub username: String,
}
// --- Response ---
#[derive(Serialize, ToSchema)]
pub struct ListUserReferralsForUserSuccessResponse {
pub success: bool,
pub referrals: Vec<UserReferralResponse>,
pub maybe_cursor: Option<String>,
}
@@ -0,0 +1,2 @@
pub mod list_global_user_referrals;
pub mod list_user_referrals_for_user;
@@ -23,4 +23,10 @@ pub struct OmniGenImageCostResponse {
/// Whether the output will have a watermark.
pub has_watermark: bool,
/// Whether failures are refunded.
/// True: 100% yes
/// False: 100% no
/// None: Unknown or variable
pub failures_are_refunded: Option<bool>,
}
@@ -18,9 +18,15 @@ pub struct OmniGenVideoCostResponse {
/// Whether the user has unlimited generations.
pub is_unlimited: bool,
/// Whether the user is rate limited.
/// Whether the user is rate-limited.
pub is_rate_limited: bool,
/// Whether the output will have a watermark.
pub has_watermark: bool,
/// Whether failures are refunded.
/// True: 100% yes
/// False: 100% no
/// None: Unknown or variable
pub failures_are_refunded: Option<bool>,
}
@@ -20,6 +20,7 @@ artcraft_api_defs.workspace = true
enums.workspace = true
artcraft_client.workspace = true
fal_client.workspace = true
gmicloud_client.workspace = true
muapi_client.workspace = true
seedance2pro_client.workspace = true
tokens.workspace = true
@@ -41,6 +41,12 @@ pub enum CommonVideoModel {
#[serde(rename = "seedance_2p0_fast")]
Seedance2p0Fast,
#[serde(rename = "seedance_2p0_g")]
Seedance2p0Global,
#[serde(rename = "seedance_2p0_fast_g")]
Seedance2p0FastGlobal,
#[serde(rename = "happy_horse_1p0")]
HappyHorse1p0,
@@ -6,6 +6,7 @@ use serde_derive::{Deserialize, Serialize};
pub enum Provider {
Artcraft,
Fal,
GmiCloud,
Muapi,
Seedance2Pro,
}
@@ -6,5 +6,6 @@ pub mod router_artcraft_client;
pub mod router_client;
pub mod router_fal_client;
pub mod router_fal_webhook_optional_client;
pub mod router_gmicloud_client;
pub mod router_muapi_client;
pub mod router_seedance2pro_client;
@@ -1,6 +1,7 @@
use crate::client::router_artcraft_client::RouterArtcraftClient;
use crate::client::router_fal_client::RouterFalClient;
use crate::client::router_fal_webhook_optional_client::RouterFalWebhookOptionalClient;
use crate::client::router_gmicloud_client::RouterGmiCloudClient;
use crate::client::router_muapi_client::RouterMuapiClient;
use crate::client::router_seedance2pro_client::RouterSeedance2ProClient;
use crate::errors::client_error::{ClientError, ClientType};
@@ -9,6 +10,7 @@ pub struct MultiRouterClient {
pub(crate) artcraft_client: Option<RouterArtcraftClient>,
pub(crate) fal_client: Option<RouterFalClient>,
pub(crate) fal_webhook_optional_client: Option<RouterFalWebhookOptionalClient>,
pub(crate) gmicloud_client: Option<RouterGmiCloudClient>,
pub(crate) muapi_client: Option<RouterMuapiClient>,
pub(crate) seedance2pro_client: Option<RouterSeedance2ProClient>,
}
@@ -23,11 +25,17 @@ impl MultiRouterClient {
self.fal_client.as_ref()
.ok_or(ClientError::ClientNotConfigured(ClientType::Fal))
}
pub fn get_fal_webhook_optional_client_ref(&self) -> Result<&RouterFalWebhookOptionalClient, ClientError> {
self.fal_webhook_optional_client.as_ref()
.ok_or(ClientError::ClientNotConfigured(ClientType::FalWebhookOptional))
}
pub fn get_gmicloud_client_ref(&self) -> Result<&RouterGmiCloudClient, ClientError> {
self.gmicloud_client.as_ref()
.ok_or(ClientError::ClientNotConfigured(ClientType::GmiCloud))
}
pub fn get_muapi_client_ref(&self) -> Result<&RouterMuapiClient, ClientError> {
self.muapi_client.as_ref()
.ok_or(ClientError::ClientNotConfigured(ClientType::Muapi))
@@ -2,6 +2,7 @@ use crate::client::multi_router_client::MultiRouterClient;
use crate::client::router_artcraft_client::RouterArtcraftClient;
use crate::client::router_fal_client::RouterFalClient;
use crate::client::router_fal_webhook_optional_client::RouterFalWebhookOptionalClient;
use crate::client::router_gmicloud_client::RouterGmiCloudClient;
use crate::client::router_muapi_client::RouterMuapiClient;
use crate::client::router_seedance2pro_client::RouterSeedance2ProClient;
@@ -9,6 +10,7 @@ pub struct MultiRouterClientBuilder {
artcraft_client: Option<RouterArtcraftClient>,
fal_client: Option<RouterFalClient>,
fal_webhook_optional_client: Option<RouterFalWebhookOptionalClient>,
gmicloud_client: Option<RouterGmiCloudClient>,
muapi_client: Option<RouterMuapiClient>,
seedance2pro_client: Option<RouterSeedance2ProClient>,
}
@@ -19,6 +21,7 @@ impl MultiRouterClientBuilder {
artcraft_client: None,
fal_client: None,
fal_webhook_optional_client: None,
gmicloud_client: None,
muapi_client: None,
seedance2pro_client: None,
}
@@ -33,12 +36,17 @@ impl MultiRouterClientBuilder {
self.fal_client = Some(client);
self
}
pub fn set_fal_webhook_optional_client(mut self, client: RouterFalWebhookOptionalClient) -> Self {
self.fal_webhook_optional_client = Some(client);
self
}
pub fn set_gmicloud_client(mut self, client: RouterGmiCloudClient) -> Self {
self.gmicloud_client = Some(client);
self
}
pub fn set_muapi_client(mut self, client: RouterMuapiClient) -> Self {
self.muapi_client = Some(client);
self
@@ -54,6 +62,7 @@ impl MultiRouterClientBuilder {
artcraft_client: self.artcraft_client,
fal_client: self.fal_client,
fal_webhook_optional_client: self.fal_webhook_optional_client,
gmicloud_client: self.gmicloud_client,
muapi_client: self.muapi_client,
seedance2pro_client: self.seedance2pro_client,
}
@@ -2,6 +2,7 @@ use crate::client::multi_router_client::MultiRouterClient;
use crate::client::router_artcraft_client::RouterArtcraftClient;
use crate::client::router_fal_client::RouterFalClient;
use crate::client::router_fal_webhook_optional_client::RouterFalWebhookOptionalClient;
use crate::client::router_gmicloud_client::RouterGmiCloudClient;
use crate::client::router_muapi_client::RouterMuapiClient;
use crate::client::router_seedance2pro_client::RouterSeedance2ProClient;
use crate::errors::client_error::{ClientError, ClientType};
@@ -11,6 +12,7 @@ pub enum RouterClient {
Artcraft(RouterArtcraftClient),
Fal(RouterFalClient),
FalWebhookOptional(RouterFalWebhookOptionalClient),
GmiCloud(RouterGmiCloudClient),
Muapi(RouterMuapiClient),
Seedance2Pro(RouterSeedance2ProClient),
}
@@ -40,6 +42,14 @@ impl RouterClient {
}
}
pub fn get_gmicloud_client_ref(&self) -> Result<&RouterGmiCloudClient, ClientError> {
match self {
RouterClient::GmiCloud(client) => Ok(client),
RouterClient::Multi(multi) => multi.get_gmicloud_client_ref(),
_ => Err(ClientError::ClientNotConfigured(ClientType::GmiCloud)),
}
}
pub fn get_muapi_client_ref(&self) -> Result<&RouterMuapiClient, ClientError> {
match self {
RouterClient::Muapi(client) => Ok(client),
@@ -0,0 +1,11 @@
use gmicloud_client::creds::gmicloud_api_key::GmiCloudApiKey;
pub struct RouterGmiCloudClient {
pub(crate) api_key: GmiCloudApiKey,
}
impl RouterGmiCloudClient {
pub fn new(api_key: GmiCloudApiKey) -> Self {
RouterGmiCloudClient { api_key }
}
}
@@ -8,6 +8,7 @@ pub enum ClientType {
Artcraft,
Fal,
FalWebhookOptional,
GmiCloud,
Muapi,
Seedance2Pro,
}
@@ -18,6 +19,7 @@ impl Display for ClientType {
Self::Artcraft => write!(f, "Artcraft"),
Self::Fal => write!(f, "Fal"),
Self::FalWebhookOptional => write!(f, "FalWebhookOptional"),
Self::GmiCloud => write!(f, "GmiCloud"),
Self::Muapi => write!(f, "Muapi"),
Self::Seedance2Pro => write!(f, "Seedance2Pro"),
}
@@ -2,6 +2,7 @@ use std::error::Error;
use std::fmt::{Display, Formatter};
use artcraft_client::error::storyteller_error::StorytellerError;
use fal_client::error::fal_error_plus::FalErrorPlus;
use gmicloud_client::error::gmicloud_error::GmiCloudError;
use muapi_client::error::muapi_error::MuapiError;
use seedance2pro_client::error::seedance2pro_error::Seedance2ProError;
@@ -9,6 +10,7 @@ use seedance2pro_client::error::seedance2pro_error::Seedance2ProError;
pub enum ProviderError {
Storyteller(StorytellerError),
Fal(FalErrorPlus),
GmiCloud(GmiCloudError),
Muapi(MuapiError),
Seedance2Pro(Seedance2ProError),
}
@@ -20,6 +22,7 @@ impl Display for ProviderError {
match self {
Self::Storyteller(e) => write!(f, "Storyteller provider error: {}", e),
Self::Fal(e) => write!(f, "Fal provider error: {}", e),
Self::GmiCloud(e) => write!(f, "GmiCloud provider error: {}", e),
Self::Muapi(e) => write!(f, "Muapi provider error: {}", e),
Self::Seedance2Pro(e) => write!(f, "Seedance2Pro provider error: {}", e),
}
@@ -38,6 +41,12 @@ impl From<FalErrorPlus> for ProviderError {
}
}
impl From<GmiCloudError> for ProviderError {
fn from(error: GmiCloudError) -> Self {
Self::GmiCloud(error)
}
}
impl From<MuapiError> for ProviderError {
fn from(error: MuapiError) -> Self {
Self::Muapi(error)
@@ -25,6 +25,7 @@ pub(crate) fn estimate_image_cost_artcraft_flux_1_dev(
is_unlimited: false,
is_rate_limited: false,
has_watermark: false,
failures_are_refunded: None,
}
}
@@ -12,6 +12,7 @@ pub(crate) fn estimate_image_cost_artcraft_flux_1_schnell(
is_unlimited: true,
is_rate_limited: false,
has_watermark: false,
failures_are_refunded: None,
}
}
@@ -25,6 +25,7 @@ pub(crate) fn estimate_image_cost_artcraft_flux_2_lora_angles(
is_unlimited: false,
is_rate_limited: false,
has_watermark: false,
failures_are_refunded: None,
}
}
@@ -28,6 +28,7 @@ pub(crate) fn estimate_image_cost_artcraft_flux_pro_1p1(
is_unlimited: false,
is_rate_limited: false,
has_watermark: false,
failures_are_refunded: None,
}
}
@@ -25,6 +25,7 @@ pub(crate) fn estimate_image_cost_artcraft_flux_pro_1p1_ultra(
is_unlimited: false,
is_rate_limited: false,
has_watermark: false,
failures_are_refunded: None,
}
}
@@ -48,6 +48,7 @@ pub(crate) fn estimate_image_cost_artcraft_gpt_image_1(
is_unlimited: false,
is_rate_limited: false,
has_watermark: false,
failures_are_refunded: None,
}
}
@@ -54,6 +54,7 @@ pub(crate) fn estimate_image_cost_artcraft_gpt_image_1p5(
is_unlimited: false,
is_rate_limited: false,
has_watermark: false,
failures_are_refunded: None,
}
}
@@ -47,6 +47,7 @@ pub(crate) fn estimate_image_cost_artcraft_gpt_image_2(
is_unlimited: false,
is_rate_limited: false,
has_watermark: false,
failures_are_refunded: None,
}
}
@@ -25,6 +25,7 @@ pub(crate) fn estimate_image_cost_artcraft_nano_banana(
is_unlimited: false,
is_rate_limited: false,
has_watermark: false,
failures_are_refunded: None,
}
}
@@ -37,6 +37,7 @@ pub(crate) fn estimate_image_cost_artcraft_nano_banana_2(
is_unlimited: false,
is_rate_limited: false,
has_watermark: false,
failures_are_refunded: None,
}
}
@@ -31,6 +31,7 @@ pub(crate) fn estimate_image_cost_artcraft_nano_banana_pro(
is_unlimited: false,
is_rate_limited: false,
has_watermark: false,
failures_are_refunded: None,
}
}
@@ -25,6 +25,7 @@ pub(crate) fn estimate_image_cost_artcraft_qwen_edit_2511_angles(
is_unlimited: false,
is_rate_limited: false,
has_watermark: false,
failures_are_refunded: None,
}
}
@@ -25,6 +25,7 @@ pub(crate) fn estimate_image_cost_artcraft_seedream_4(
is_unlimited: false,
is_rate_limited: false,
has_watermark: false,
failures_are_refunded: None,
}
}
@@ -25,6 +25,7 @@ pub(crate) fn estimate_image_cost_artcraft_seedream_4p5(
is_unlimited: false,
is_rate_limited: false,
has_watermark: false,
failures_are_refunded: None,
}
}
@@ -25,6 +25,7 @@ pub(crate) fn estimate_image_cost_artcraft_seedream_5_lite(
is_unlimited: false,
is_rate_limited: false,
has_watermark: false,
failures_are_refunded: None,
}
}
@@ -22,5 +22,6 @@ pub(crate) fn estimate_image_cost_fal_flux_1_dev(
is_unlimited: false,
is_rate_limited: false,
has_watermark: false,
failures_are_refunded: None,
}
}
@@ -22,5 +22,6 @@ pub(crate) fn estimate_image_cost_fal_flux_1_schnell(
is_unlimited: false,
is_rate_limited: false,
has_watermark: false,
failures_are_refunded: None,
}
}
@@ -17,5 +17,6 @@ pub(crate) fn estimate_image_cost_fal_flux_pro_1p1(
is_unlimited: false,
is_rate_limited: false,
has_watermark: false,
failures_are_refunded: None,
}
}
@@ -16,5 +16,6 @@ pub(crate) fn estimate_image_cost_fal_flux_pro_1p1_ultra(
is_unlimited: false,
is_rate_limited: false,
has_watermark: false,
failures_are_refunded: None,
}
}
@@ -47,6 +47,7 @@ pub(crate) fn estimate_image_cost_fal_gpt_image_1(
is_unlimited: false,
is_rate_limited: false,
has_watermark: false,
failures_are_refunded: None,
}
}
@@ -38,6 +38,7 @@ pub(crate) fn estimate_image_cost_fal_gpt_image_1p5(
is_unlimited: false,
is_rate_limited: false,
has_watermark: false,
failures_are_refunded: None,
}
}
@@ -47,6 +47,7 @@ pub(crate) fn estimate_image_cost_fal_gpt_image_2(
is_unlimited: false,
is_rate_limited: false,
has_watermark: false,
failures_are_refunded: None,
}
}
@@ -30,5 +30,6 @@ pub(crate) fn estimate_image_cost_fal_nano_banana(
is_unlimited: false,
is_rate_limited: false,
has_watermark: false,
failures_are_refunded: None,
}
}
@@ -40,5 +40,6 @@ pub(crate) fn estimate_image_cost_fal_nano_banana_2(
is_unlimited: false,
is_rate_limited: false,
has_watermark: false,
failures_are_refunded: None,
}
}
@@ -29,6 +29,7 @@ pub(crate) fn estimate_image_cost_fal_nano_banana_pro(
is_unlimited: false,
is_rate_limited: false,
has_watermark: false,
failures_are_refunded: None,
}
}
@@ -15,5 +15,6 @@ pub(crate) fn estimate_image_cost_fal_seedream_4(
is_unlimited: false,
is_rate_limited: false,
has_watermark: false,
failures_are_refunded: None,
}
}
@@ -15,5 +15,6 @@ pub(crate) fn estimate_image_cost_fal_seedream_4p5(
is_unlimited: false,
is_rate_limited: false,
has_watermark: false,
failures_are_refunded: None,
}
}
@@ -15,5 +15,6 @@ pub(crate) fn estimate_image_cost_fal_seedream_5_lite(
is_unlimited: false,
is_rate_limited: false,
has_watermark: false,
failures_are_refunded: None,
}
}
@@ -12,6 +12,9 @@ use crate::generate::generate_image::image_generation_plan::ImageGenerationPlan;
use crate::generate::generate_image_v2::image_generation_draft_or_request::ImageGenerationDraftOrRequest;
use crate::generate::generate_image_v2::providers::fal::flux_1_dev::build::build_fal_flux_1_dev;
use crate::generate::generate_image_v2::providers::fal::flux_1_schnell::build::build_fal_flux_1_schnell;
use crate::generate::generate_image_v2::providers::fal::gpt_image_1::build::build_fal_gpt_image_1;
use crate::generate::generate_image_v2::providers::fal::gpt_image_1p5::build::build_fal_gpt_image_1p5;
use crate::generate::generate_image_v2::providers::fal::gpt_image_2::build::build_fal_gpt_image_2;
use crate::generate::generate_image_v2::providers::fal::nano_banana_2::build::build_fal_nano_banana_2;
use crate::generate::generate_image_v2::providers::fal::nano_banana_pro::build::build_fal_nano_banana_pro;
use crate::generate::generate_image::plan::artcraft::plan_generate_image_artcraft_flux_1_dev::plan_generate_image_artcraft_flux_1_dev;
@@ -99,6 +102,9 @@ impl GenerateImageRequestBuilder {
match (self.provider, self.model) {
(Provider::Fal, CommonImageModel::Flux1Dev) => true,
(Provider::Fal, CommonImageModel::Flux1Schnell) => true,
(Provider::Fal, CommonImageModel::GptImage1) => true,
(Provider::Fal, CommonImageModel::GptImage1p5) => true,
(Provider::Fal, CommonImageModel::GptImage2) => true,
(Provider::Fal, CommonImageModel::NanoBanana2) => true,
(Provider::Fal, CommonImageModel::NanoBananaPro) => true,
_ => false,
@@ -109,6 +115,9 @@ impl GenerateImageRequestBuilder {
match (self.provider, self.model) {
(Provider::Fal, CommonImageModel::Flux1Dev) => build_fal_flux_1_dev(self),
(Provider::Fal, CommonImageModel::Flux1Schnell) => build_fal_flux_1_schnell(self),
(Provider::Fal, CommonImageModel::GptImage1) => build_fal_gpt_image_1(self),
(Provider::Fal, CommonImageModel::GptImage1p5) => build_fal_gpt_image_1p5(self),
(Provider::Fal, CommonImageModel::GptImage2) => build_fal_gpt_image_2(self),
(Provider::Fal, CommonImageModel::NanoBanana2) => build_fal_nano_banana_2(self),
(Provider::Fal, CommonImageModel::NanoBananaPro) => build_fal_nano_banana_pro(self),
_ => self.unsupported_provider_and_model(),
@@ -7,4 +7,10 @@ pub struct ImageGenerationCostEstimate {
pub is_unlimited: bool,
pub is_rate_limited: bool,
pub has_watermark: bool,
/// Whether failures are refunded.
/// True: 100% yes
/// False: 100% no
/// None: Unknown or variable
pub failures_are_refunded: Option<bool>,
}
@@ -183,17 +183,6 @@ mod tests {
assert!(result.is_ok());
}
#[test]
fn rejects_multiple_media_tokens() {
let tokens = vec![make_token(), make_token()];
let request = GenerateImageRequestBuilder {
image_inputs: Some(ImageListRef::MediaFileTokens(tokens.clone())),
..base_flux_2_lora_angles_image_request()
};
let result = request.build();
assert!(result.is_err());
}
#[test]
fn image_size_none_is_none() {
let tokens = vec![make_token()];
@@ -185,17 +185,6 @@ mod tests {
assert!(result.is_ok());
}
#[test]
fn rejects_multiple_media_tokens() {
let tokens = vec![make_token(), make_token()];
let request = GenerateImageRequestBuilder {
image_inputs: Some(ImageListRef::MediaFileTokens(tokens.clone())),
..base_qwen_edit_2511_angles_image_request()
};
let result = request.build();
assert!(result.is_err());
}
#[test]
fn image_size_none_is_none() {
let tokens = vec![make_token()];
@@ -7,6 +7,12 @@ use crate::generate::generate_image_v2::providers::fal::flux_1_dev::cost::FalFlu
use crate::generate::generate_image_v2::providers::fal::flux_1_dev::request::FalFlux1DevRequestState;
use crate::generate::generate_image_v2::providers::fal::flux_1_schnell::cost::FalFlux1SchnellCostState;
use crate::generate::generate_image_v2::providers::fal::flux_1_schnell::request::FalFlux1SchnellRequestState;
use crate::generate::generate_image_v2::providers::fal::gpt_image_1::cost::FalGptImage1CostState;
use crate::generate::generate_image_v2::providers::fal::gpt_image_1::request::FalGptImage1RequestState;
use crate::generate::generate_image_v2::providers::fal::gpt_image_1p5::cost::FalGptImage1p5CostState;
use crate::generate::generate_image_v2::providers::fal::gpt_image_1p5::request::FalGptImage1p5RequestState;
use crate::generate::generate_image_v2::providers::fal::gpt_image_2::cost::FalGptImage2CostState;
use crate::generate::generate_image_v2::providers::fal::gpt_image_2::request::FalGptImage2RequestState;
use crate::generate::generate_image_v2::providers::fal::nano_banana_2::cost::FalNanoBanana2CostState;
use crate::generate::generate_image_v2::providers::fal::nano_banana_2::request::FalNanoBanana2RequestState;
use crate::generate::generate_image_v2::providers::fal::nano_banana_pro::cost::FalNanoBananaProCostState;
@@ -16,6 +22,9 @@ use crate::generate::generate_image_v2::providers::fal::nano_banana_pro::request
pub enum ImageGenerationRequest {
FalFlux1Dev(FalFlux1DevRequestState),
FalFlux1Schnell(FalFlux1SchnellRequestState),
FalGptImage1(FalGptImage1RequestState),
FalGptImage1p5(FalGptImage1p5RequestState),
FalGptImage2(FalGptImage2RequestState),
FalNanoBanana2(FalNanoBanana2RequestState),
FalNanoBananaPro(FalNanoBananaProRequestState),
}
@@ -25,6 +34,9 @@ impl ImageGenerationRequest {
match self {
Self::FalFlux1Dev(_) => Provider::Fal,
Self::FalFlux1Schnell(_) => Provider::Fal,
Self::FalGptImage1(_) => Provider::Fal,
Self::FalGptImage1p5(_) => Provider::Fal,
Self::FalGptImage2(_) => Provider::Fal,
Self::FalNanoBanana2(_) => Provider::Fal,
Self::FalNanoBananaPro(_) => Provider::Fal,
}
@@ -38,6 +50,15 @@ impl ImageGenerationRequest {
Self::FalFlux1Schnell(request) => {
Ok(FalFlux1SchnellCostState::from_request(request).estimate_cost())
}
Self::FalGptImage1(request) => {
Ok(FalGptImage1CostState::from_request(request).estimate_cost())
}
Self::FalGptImage1p5(request) => {
Ok(FalGptImage1p5CostState::from_request(request).estimate_cost())
}
Self::FalGptImage2(request) => {
Ok(FalGptImage2CostState::from_request(request).estimate_cost())
}
Self::FalNanoBanana2(request) => {
Ok(FalNanoBanana2CostState::from_request(request).estimate_cost())
}
@@ -57,6 +78,18 @@ impl ImageGenerationRequest {
let fal_client = client.get_fal_webhook_optional_client_ref()?;
request.send(fal_client).await
}
Self::FalGptImage1(request) => {
let fal_client = client.get_fal_webhook_optional_client_ref()?;
request.send(fal_client).await
}
Self::FalGptImage1p5(request) => {
let fal_client = client.get_fal_webhook_optional_client_ref()?;
request.send(fal_client).await
}
Self::FalGptImage2(request) => {
let fal_client = client.get_fal_webhook_optional_client_ref()?;
request.send(fal_client).await
}
Self::FalNanoBanana2(request) => {
let fal_client = client.get_fal_webhook_optional_client_ref()?;
request.send(fal_client).await
@@ -23,7 +23,8 @@ impl FalFlux1DevCostState {
is_free: false,
is_unlimited: false,
is_rate_limited: false,
has_watermark: false,
has_watermark: false,
failures_are_refunded: None,
}
}
}
@@ -24,6 +24,7 @@ impl FalFlux1SchnellCostState {
is_unlimited: false,
is_rate_limited: false,
has_watermark: false,
failures_are_refunded: None,
}
}
}
@@ -0,0 +1,401 @@
use fal_client::requests::api::image::edit::gpt_image_1_edit_image::api::{
GptImage1EditImageNumImages, GptImage1EditImageQuality, GptImage1EditImageRequest,
GptImage1EditImageSize,
};
use fal_client::requests::api::image::text::gpt_image_1_text_to_image::api::{
GptImage1TextToImageNumImages, GptImage1TextToImageQuality,
GptImage1TextToImageRequest, GptImage1TextToImageSize,
};
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_builder::GenerateImageRequestBuilder;
use crate::generate::generate_image_v2::image_generation_draft_or_request::ImageGenerationDraftOrRequest;
use crate::generate::generate_image_v2::image_generation_request::ImageGenerationRequest;
use crate::generate::generate_image_v2::providers::fal::gpt_image_1::request::FalGptImage1RequestState;
pub fn build_fal_gpt_image_1(
builder: GenerateImageRequestBuilder,
) -> Result<ImageGenerationDraftOrRequest, ArtcraftRouterError> {
let prompt = builder.prompt.clone().unwrap_or_default();
let image_urls = resolve_image_urls(builder.image_inputs.clone())?;
let num_images = plan_num_images(builder.image_batch_count, builder.request_mismatch_mitigation_strategy)?;
let image_size = plan_image_size(builder.aspect_ratio);
let quality = plan_quality(builder.quality);
let state = if image_urls.is_empty() {
FalGptImage1RequestState::TextToImage(GptImage1TextToImageRequest {
prompt,
num_images: to_t2i_num_images(num_images),
image_size: image_size.map(to_t2i_image_size),
quality: Some(to_t2i_quality(quality)),
background: None,
output_format: None,
})
} else {
FalGptImage1RequestState::EditImage(GptImage1EditImageRequest {
prompt,
image_urls,
num_images: to_edit_num_images(num_images),
mask_image_url: None,
image_size: image_size.map(to_edit_image_size),
quality: Some(to_edit_quality(quality)),
input_fidelity: None,
background: None,
output_format: None,
})
};
Ok(ImageGenerationDraftOrRequest::Request(
ImageGenerationRequest::FalGptImage1(state),
))
}
#[derive(Copy, Clone, Debug)]
enum PlannedNumImages {
One,
Two,
Three,
Four,
}
#[derive(Copy, Clone, Debug)]
enum PlannedQuality {
Low,
Medium,
High,
}
#[derive(Copy, Clone, Debug)]
enum PlannedImageSize {
Square,
Horizontal,
Vertical,
}
fn plan_num_images(
count: Option<u16>,
strategy: RequestMismatchMitigationStrategy,
) -> Result<PlannedNumImages, ArtcraftRouterError> {
let count = count.unwrap_or(1);
match count {
0 => Err(ArtcraftRouterError::Client(ClientError::UserRequestedZeroGenerations)),
1 => Ok(PlannedNumImages::One),
2 => Ok(PlannedNumImages::Two),
3 => Ok(PlannedNumImages::Three),
4 => Ok(PlannedNumImages::Four),
_ => match strategy {
RequestMismatchMitigationStrategy::ErrorOut => {
Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption {
field: "image_batch_count",
value: format!("{}", count),
}))
}
_ => Ok(PlannedNumImages::Four),
},
}
}
fn plan_quality(quality: Option<CommonQuality>) -> PlannedQuality {
match quality {
Some(CommonQuality::Low) => PlannedQuality::Low,
Some(CommonQuality::Medium) => PlannedQuality::Medium,
Some(CommonQuality::High) | None => PlannedQuality::High,
}
}
fn plan_image_size(aspect_ratio: Option<CommonAspectRatio>) -> Option<PlannedImageSize> {
match aspect_ratio {
None
| Some(CommonAspectRatio::Auto)
| Some(CommonAspectRatio::Auto2k)
| Some(CommonAspectRatio::Auto3k)
| Some(CommonAspectRatio::Auto4k) => None,
Some(CommonAspectRatio::Square) | Some(CommonAspectRatio::SquareHd) => {
Some(PlannedImageSize::Square)
}
Some(CommonAspectRatio::WideThreeByTwo)
| Some(CommonAspectRatio::WideFourByThree)
| Some(CommonAspectRatio::WideFiveByFour)
| Some(CommonAspectRatio::WideSixteenByNine)
| Some(CommonAspectRatio::WideTwentyOneByNine)
| Some(CommonAspectRatio::Wide) => Some(PlannedImageSize::Horizontal),
Some(CommonAspectRatio::TallTwoByThree)
| Some(CommonAspectRatio::TallThreeByFour)
| Some(CommonAspectRatio::TallFourByFive)
| Some(CommonAspectRatio::TallNineBySixteen)
| Some(CommonAspectRatio::TallNineByTwentyOne)
| Some(CommonAspectRatio::Tall) => Some(PlannedImageSize::Vertical),
}
}
fn to_t2i_num_images(n: PlannedNumImages) -> GptImage1TextToImageNumImages {
match n {
PlannedNumImages::One => GptImage1TextToImageNumImages::One,
PlannedNumImages::Two => GptImage1TextToImageNumImages::Two,
PlannedNumImages::Three => GptImage1TextToImageNumImages::Three,
PlannedNumImages::Four => GptImage1TextToImageNumImages::Four,
}
}
fn to_edit_num_images(n: PlannedNumImages) -> GptImage1EditImageNumImages {
match n {
PlannedNumImages::One => GptImage1EditImageNumImages::One,
PlannedNumImages::Two => GptImage1EditImageNumImages::Two,
PlannedNumImages::Three => GptImage1EditImageNumImages::Three,
PlannedNumImages::Four => GptImage1EditImageNumImages::Four,
}
}
fn to_t2i_quality(q: PlannedQuality) -> GptImage1TextToImageQuality {
match q {
PlannedQuality::Low => GptImage1TextToImageQuality::Low,
PlannedQuality::Medium => GptImage1TextToImageQuality::Medium,
PlannedQuality::High => GptImage1TextToImageQuality::High,
}
}
fn to_edit_quality(q: PlannedQuality) -> GptImage1EditImageQuality {
match q {
PlannedQuality::Low => GptImage1EditImageQuality::Low,
PlannedQuality::Medium => GptImage1EditImageQuality::Medium,
PlannedQuality::High => GptImage1EditImageQuality::High,
}
}
fn to_t2i_image_size(s: PlannedImageSize) -> GptImage1TextToImageSize {
match s {
PlannedImageSize::Square => GptImage1TextToImageSize::Square,
PlannedImageSize::Horizontal => GptImage1TextToImageSize::Horizontal,
PlannedImageSize::Vertical => GptImage1TextToImageSize::Vertical,
}
}
fn to_edit_image_size(s: PlannedImageSize) -> GptImage1EditImageSize {
match s {
PlannedImageSize::Square => GptImage1EditImageSize::Square,
PlannedImageSize::Horizontal => GptImage1EditImageSize::Horizontal,
PlannedImageSize::Vertical => GptImage1EditImageSize::Vertical,
}
}
fn resolve_image_urls(
image_inputs: Option<ImageListRef>,
) -> Result<Vec<String>, ArtcraftRouterError> {
match image_inputs {
None => Ok(vec![]),
Some(ImageListRef::Urls(urls)) => Ok(urls),
Some(ImageListRef::MediaFileTokens(_)) => {
Err(ArtcraftRouterError::Client(ClientError::FalOnlySupportsUrls))
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::fmt::Debug;
use crate::api::common_image_model::CommonImageModel;
use crate::api::provider::Provider;
fn base_builder() -> GenerateImageRequestBuilder {
GenerateImageRequestBuilder {
model: CommonImageModel::GptImage1,
provider: Provider::Fal,
prompt: Some("a cat in space".to_string()),
image_inputs: None,
resolution: None,
aspect_ratio: None,
quality: None,
image_batch_count: None,
horizontal_angle: None,
vertical_angle: None,
zoom: None,
request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::ErrorOut,
generation_mode_mismatch_strategy: None,
idempotency_token: None,
}
}
fn unwrap_t2i(result: Result<ImageGenerationDraftOrRequest, ArtcraftRouterError>) -> GptImage1TextToImageRequest {
let ImageGenerationDraftOrRequest::Request(
ImageGenerationRequest::FalGptImage1(
FalGptImage1RequestState::TextToImage(req)
)
) = result.expect("build should succeed") else {
panic!("expected GPT Image 1 text-to-image request")
};
req
}
fn unwrap_edit(result: Result<ImageGenerationDraftOrRequest, ArtcraftRouterError>) -> GptImage1EditImageRequest {
let ImageGenerationDraftOrRequest::Request(
ImageGenerationRequest::FalGptImage1(
FalGptImage1RequestState::EditImage(req)
)
) = result.expect("build should succeed") else {
panic!("expected GPT Image 1 edit-image request")
};
req
}
fn assert_debug<T: Debug>(actual: T, expected: &str) {
assert_eq!(format!("{:?}", actual), expected);
}
#[test]
fn build2_routes_to_gpt_image_1_request() {
let req = unwrap_t2i(base_builder().build2());
assert_eq!(req.prompt, "a cat in space");
}
#[test]
fn mode_detection_is_based_on_image_urls() {
let text_req = unwrap_t2i(build_fal_gpt_image_1(base_builder()));
assert_eq!(text_req.prompt, "a cat in space");
let edit_req = unwrap_edit(build_fal_gpt_image_1(GenerateImageRequestBuilder {
image_inputs: Some(ImageListRef::Urls(vec!["https://example.com/img.jpg".to_string()])),
..base_builder()
}));
assert_eq!(edit_req.image_urls, vec!["https://example.com/img.jpg"]);
}
#[test]
fn media_file_tokens_are_rejected() {
let result = build_fal_gpt_image_1(GenerateImageRequestBuilder {
image_inputs: Some(ImageListRef::MediaFileTokens(vec![])),
..base_builder()
});
assert!(matches!(
result,
Err(ArtcraftRouterError::Client(ClientError::FalOnlySupportsUrls))
));
}
#[test]
fn num_images_maps_exhaustively_for_text_and_edit() {
let text_cases = [
(1, "One"),
(2, "Two"),
(3, "Three"),
(4, "Four"),
];
for (count, expected) in text_cases {
let req = unwrap_t2i(build_fal_gpt_image_1(GenerateImageRequestBuilder {
image_batch_count: Some(count),
..base_builder()
}));
assert_debug(req.num_images, expected);
}
for (count, expected) in text_cases {
let req = unwrap_edit(build_fal_gpt_image_1(GenerateImageRequestBuilder {
image_inputs: Some(ImageListRef::Urls(vec!["https://example.com/img.jpg".to_string()])),
image_batch_count: Some(count),
..base_builder()
}));
assert_debug(req.num_images, expected);
}
}
#[test]
fn num_images_rejects_zero_and_handles_overflow_by_strategy() {
assert!(matches!(
build_fal_gpt_image_1(GenerateImageRequestBuilder {
image_batch_count: Some(0),
..base_builder()
}),
Err(ArtcraftRouterError::Client(ClientError::UserRequestedZeroGenerations))
));
assert!(matches!(
build_fal_gpt_image_1(GenerateImageRequestBuilder {
image_batch_count: Some(5),
..base_builder()
}),
Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption { .. }))
));
let req = unwrap_t2i(build_fal_gpt_image_1(GenerateImageRequestBuilder {
image_batch_count: Some(5),
request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::PayMoreUpgrade,
..base_builder()
}));
assert_debug(req.num_images, "Four");
}
#[test]
fn quality_maps_exhaustively_for_text_and_edit() {
let cases = [
(None, "Some(High)"),
(Some(CommonQuality::Low), "Some(Low)"),
(Some(CommonQuality::Medium), "Some(Medium)"),
(Some(CommonQuality::High), "Some(High)"),
];
for (quality, expected) in cases {
let req = unwrap_t2i(build_fal_gpt_image_1(GenerateImageRequestBuilder {
quality,
..base_builder()
}));
assert_debug(req.quality, expected);
}
for (quality, expected) in cases {
let req = unwrap_edit(build_fal_gpt_image_1(GenerateImageRequestBuilder {
image_inputs: Some(ImageListRef::Urls(vec!["https://example.com/img.jpg".to_string()])),
quality,
..base_builder()
}));
assert_debug(req.quality, expected);
}
}
#[test]
fn aspect_ratio_maps_exhaustively_for_text_and_edit() {
let cases = [
(None, "None"),
(Some(CommonAspectRatio::Auto), "None"),
(Some(CommonAspectRatio::Auto2k), "None"),
(Some(CommonAspectRatio::Auto3k), "None"),
(Some(CommonAspectRatio::Auto4k), "None"),
(Some(CommonAspectRatio::Square), "Some(Square)"),
(Some(CommonAspectRatio::SquareHd), "Some(Square)"),
(Some(CommonAspectRatio::WideThreeByTwo), "Some(Horizontal)"),
(Some(CommonAspectRatio::WideFourByThree), "Some(Horizontal)"),
(Some(CommonAspectRatio::WideFiveByFour), "Some(Horizontal)"),
(Some(CommonAspectRatio::WideSixteenByNine), "Some(Horizontal)"),
(Some(CommonAspectRatio::WideTwentyOneByNine), "Some(Horizontal)"),
(Some(CommonAspectRatio::Wide), "Some(Horizontal)"),
(Some(CommonAspectRatio::TallTwoByThree), "Some(Vertical)"),
(Some(CommonAspectRatio::TallThreeByFour), "Some(Vertical)"),
(Some(CommonAspectRatio::TallFourByFive), "Some(Vertical)"),
(Some(CommonAspectRatio::TallNineBySixteen), "Some(Vertical)"),
(Some(CommonAspectRatio::TallNineByTwentyOne), "Some(Vertical)"),
(Some(CommonAspectRatio::Tall), "Some(Vertical)"),
];
for (aspect_ratio, expected) in cases {
let req = unwrap_t2i(build_fal_gpt_image_1(GenerateImageRequestBuilder {
aspect_ratio,
..base_builder()
}));
assert_debug(req.image_size, expected);
}
for (aspect_ratio, expected) in cases {
let req = unwrap_edit(build_fal_gpt_image_1(GenerateImageRequestBuilder {
image_inputs: Some(ImageListRef::Urls(vec!["https://example.com/img.jpg".to_string()])),
aspect_ratio,
..base_builder()
}));
assert_debug(req.image_size, expected);
}
}
}
@@ -0,0 +1,115 @@
use fal_client::requests::traits::fal_request_cost_calculator_trait::FalRequestCostCalculator;
use crate::generate::generate_image::image_generation_cost_estimate::ImageGenerationCostEstimate;
use crate::generate::generate_image_v2::providers::fal::gpt_image_1::request::FalGptImage1RequestState;
#[derive(Clone, Debug)]
pub struct FalGptImage1CostState {
request: FalGptImage1RequestState,
}
impl FalGptImage1CostState {
pub fn from_request(request: &FalGptImage1RequestState) -> Self {
Self { request: request.clone() }
}
pub fn estimate_cost(&self) -> ImageGenerationCostEstimate {
let cost_in_usd_cents = match &self.request {
FalGptImage1RequestState::TextToImage(req) => req.calculate_cost_in_cents(),
FalGptImage1RequestState::EditImage(req) => req.calculate_cost_in_cents(),
};
ImageGenerationCostEstimate {
cost_in_credits: None,
cost_in_usd_cents: Some(cost_in_usd_cents),
is_free: false,
is_unlimited: false,
is_rate_limited: false,
has_watermark: false,
failures_are_refunded: None,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::generate::generate_image::image_generation_cost_estimate::ImageGenerationCostEstimate;
use fal_client::requests::api::image::edit::gpt_image_1_edit_image::api::{GptImage1EditImageNumImages, GptImage1EditImageQuality, GptImage1EditImageRequest, GptImage1EditImageSize};
use fal_client::requests::api::image::text::gpt_image_1_text_to_image::api::{GptImage1TextToImageNumImages, GptImage1TextToImageQuality, GptImage1TextToImageRequest, GptImage1TextToImageSize};
#[test]
fn text_to_image_cost_table_covers_quality_size_and_count() {
for (quality, image_size, expected_by_count) in text_to_image_price_cases() {
for (num_images, expected) in text_to_image_num_image_cases(expected_by_count) {
let request = FalGptImage1RequestState::TextToImage(GptImage1TextToImageRequest { prompt: "test".to_string(), num_images, image_size, quality, background: None, output_format: None });
let cost = FalGptImage1CostState::from_request(&request).estimate_cost();
assert_standard_cost_estimate(cost, expected);
}
}
}
#[test]
fn edit_image_cost_table_covers_quality_size_and_count() {
for (quality, image_size, expected_by_count) in edit_image_price_cases() {
for (num_images, expected) in edit_image_num_image_cases(expected_by_count) {
let request = FalGptImage1RequestState::EditImage(GptImage1EditImageRequest { prompt: "test".to_string(), image_urls: vec!["https://example.com/image.png".to_string()], num_images, mask_image_url: None, image_size, quality, input_fidelity: None, background: None, output_format: None });
let cost = FalGptImage1CostState::from_request(&request).estimate_cost();
assert_standard_cost_estimate(cost, expected);
}
}
}
fn text_to_image_price_cases() -> [(Option<GptImage1TextToImageQuality>, Option<GptImage1TextToImageSize>, [u64; 4]); 13] {
[
(None, None, [5, 10, 15, 20]),
(Some(GptImage1TextToImageQuality::Low), Some(GptImage1TextToImageSize::Auto), [2, 4, 6, 8]),
(Some(GptImage1TextToImageQuality::Low), Some(GptImage1TextToImageSize::Square), [2, 4, 6, 8]),
(Some(GptImage1TextToImageQuality::Low), Some(GptImage1TextToImageSize::Horizontal), [2, 4, 6, 8]),
(Some(GptImage1TextToImageQuality::Low), Some(GptImage1TextToImageSize::Vertical), [2, 4, 6, 8]),
(Some(GptImage1TextToImageQuality::Medium), Some(GptImage1TextToImageSize::Auto), [5, 10, 15, 20]),
(Some(GptImage1TextToImageQuality::Medium), Some(GptImage1TextToImageSize::Square), [5, 10, 15, 20]),
(Some(GptImage1TextToImageQuality::Medium), Some(GptImage1TextToImageSize::Horizontal), [7, 14, 21, 28]),
(Some(GptImage1TextToImageQuality::Medium), Some(GptImage1TextToImageSize::Vertical), [7, 14, 21, 28]),
(Some(GptImage1TextToImageQuality::High), Some(GptImage1TextToImageSize::Auto), [17, 34, 51, 68]),
(Some(GptImage1TextToImageQuality::High), Some(GptImage1TextToImageSize::Square), [17, 34, 51, 68]),
(Some(GptImage1TextToImageQuality::High), Some(GptImage1TextToImageSize::Horizontal), [25, 50, 75, 100]),
(Some(GptImage1TextToImageQuality::High), Some(GptImage1TextToImageSize::Vertical), [25, 50, 75, 100]),
]
}
fn edit_image_price_cases() -> [(Option<GptImage1EditImageQuality>, Option<GptImage1EditImageSize>, [u64; 4]); 13] {
[
(None, None, [5, 10, 15, 20]),
(Some(GptImage1EditImageQuality::Low), Some(GptImage1EditImageSize::Auto), [2, 4, 6, 8]),
(Some(GptImage1EditImageQuality::Low), Some(GptImage1EditImageSize::Square), [2, 4, 6, 8]),
(Some(GptImage1EditImageQuality::Low), Some(GptImage1EditImageSize::Horizontal), [2, 4, 6, 8]),
(Some(GptImage1EditImageQuality::Low), Some(GptImage1EditImageSize::Vertical), [2, 4, 6, 8]),
(Some(GptImage1EditImageQuality::Medium), Some(GptImage1EditImageSize::Auto), [5, 10, 15, 20]),
(Some(GptImage1EditImageQuality::Medium), Some(GptImage1EditImageSize::Square), [5, 10, 15, 20]),
(Some(GptImage1EditImageQuality::Medium), Some(GptImage1EditImageSize::Horizontal), [7, 14, 21, 28]),
(Some(GptImage1EditImageQuality::Medium), Some(GptImage1EditImageSize::Vertical), [7, 14, 21, 28]),
(Some(GptImage1EditImageQuality::High), Some(GptImage1EditImageSize::Auto), [17, 34, 51, 68]),
(Some(GptImage1EditImageQuality::High), Some(GptImage1EditImageSize::Square), [17, 34, 51, 68]),
(Some(GptImage1EditImageQuality::High), Some(GptImage1EditImageSize::Horizontal), [25, 50, 75, 100]),
(Some(GptImage1EditImageQuality::High), Some(GptImage1EditImageSize::Vertical), [25, 50, 75, 100]),
]
}
fn text_to_image_num_image_cases(expected_by_count: [u64; 4]) -> [(GptImage1TextToImageNumImages, u64); 4] {
[(GptImage1TextToImageNumImages::One, expected_by_count[0]), (GptImage1TextToImageNumImages::Two, expected_by_count[1]), (GptImage1TextToImageNumImages::Three, expected_by_count[2]), (GptImage1TextToImageNumImages::Four, expected_by_count[3])]
}
fn edit_image_num_image_cases(expected_by_count: [u64; 4]) -> [(GptImage1EditImageNumImages, u64); 4] {
[(GptImage1EditImageNumImages::One, expected_by_count[0]), (GptImage1EditImageNumImages::Two, expected_by_count[1]), (GptImage1EditImageNumImages::Three, expected_by_count[2]), (GptImage1EditImageNumImages::Four, expected_by_count[3])]
}
fn assert_standard_cost_estimate(cost: ImageGenerationCostEstimate, expected_usd_cents: u64) {
assert_eq!(cost.cost_in_usd_cents, Some(expected_usd_cents));
assert_eq!(cost.cost_in_credits, None);
assert!(!cost.is_free);
assert!(!cost.is_unlimited);
assert!(!cost.is_rate_limited);
assert!(!cost.has_watermark);
}
}
@@ -0,0 +1,3 @@
pub mod build;
pub mod cost;
pub mod request;
@@ -0,0 +1,125 @@
use std::fmt::Debug;
use std::sync::Arc;
use fal_client::requests::api::image::edit::gpt_image_1_edit_image::api::GptImage1EditImageRequest;
use fal_client::requests::api::image::text::gpt_image_1_text_to_image::api::GptImage1TextToImageRequest;
use fal_client::requests::traits::fal_endpoint_trait::FalEndpoint;
use crate::client::router_fal_webhook_optional_client::RouterFalWebhookOptionalClient;
use crate::errors::artcraft_router_error::ArtcraftRouterError;
use crate::generate::generate_image::generate_image_response::{FalImageResponsePayload, GenerateImageResponse};
#[derive(Clone, Debug)]
pub enum FalGptImage1RequestState {
TextToImage(GptImage1TextToImageRequest),
EditImage(GptImage1EditImageRequest),
}
impl FalGptImage1RequestState {
pub async fn send(&self, client: &RouterFalWebhookOptionalClient) -> Result<GenerateImageResponse, ArtcraftRouterError> {
match self {
Self::TextToImage(request) => send_request(request, client).await,
Self::EditImage(request) => send_request(request, client).await,
}
}
}
struct FalResponseIds {
request_id: Option<String>,
gateway_request_id: Option<String>,
status_url: Option<String>,
response_url: Option<String>,
}
async fn send_request<T>(request: &T, client: &RouterFalWebhookOptionalClient) -> Result<GenerateImageResponse, ArtcraftRouterError>
where
T: FalEndpoint + Clone + Debug + Send + Sync + 'static,
{
let outbound: Arc<dyn Debug + Send + Sync> = Arc::new(request.clone());
let payload = send_fal_request(request, client).await?;
Ok(GenerateImageResponse::Fal(FalImageResponsePayload {
request_id: payload.request_id,
gateway_request_id: payload.gateway_request_id,
maybe_status_url: payload.status_url,
maybe_response_url: payload.response_url,
maybe_outbound_request: Some(outbound),
}))
}
async fn send_fal_request<T: FalEndpoint>(
request: &T,
client: &RouterFalWebhookOptionalClient,
) -> Result<FalResponseIds, ArtcraftRouterError> {
if let Some(webhook_url) = &client.webhook_url {
let response = request.send_webhook_request(&client.api_key, webhook_url).await?;
Ok(FalResponseIds {
request_id: response.request_id,
gateway_request_id: response.gateway_request_id,
status_url: None,
response_url: None,
})
} else {
let response = request.send_queue_request(&client.api_key).await?;
Ok(FalResponseIds {
request_id: Some(response.request_id),
gateway_request_id: None,
status_url: Some(response.status_url),
response_url: Some(response.response_url),
})
}
}
#[cfg(test)]
mod tests {
use super::*;
use fal_client::creds::fal_api_key::FalApiKey;
use fal_client::requests::api::image::edit::gpt_image_1_edit_image::api::{
GptImage1EditImageNumImages, GptImage1EditImageQuality, GptImage1EditImageSize,
};
use fal_client::requests::api::image::text::gpt_image_1_text_to_image::api::{
GptImage1TextToImageNumImages, GptImage1TextToImageQuality,
GptImage1TextToImageSize,
};
use test_data::web::image_urls::JUNO_AT_LAKE_IMAGE_URL;
fn client_without_webhook() -> RouterFalWebhookOptionalClient {
let secret = std::fs::read_to_string("/Users/bt/Artcraft/credentials/fal_api_key.txt")
.expect("Failed to read fal_api_key.txt");
RouterFalWebhookOptionalClient::new(FalApiKey::from_str(secret.trim()))
}
#[tokio::test]
#[ignore] // requires real API key, incurs cost
async fn send_text_to_image_queue() {
let request = FalGptImage1RequestState::TextToImage(GptImage1TextToImageRequest {
prompt: "a small ceramic robot reading by a window".to_string(),
num_images: GptImage1TextToImageNumImages::One,
image_size: Some(GptImage1TextToImageSize::Square),
quality: Some(GptImage1TextToImageQuality::Low),
background: None,
output_format: None,
});
let response = request.send(&client_without_webhook()).await.expect("send should succeed");
let payload = response.get_fal_payload().expect("expected Fal payload");
assert!(payload.request_id.is_some());
}
#[tokio::test]
#[ignore] // requires real API key, incurs cost
async fn send_edit_image_queue() {
let request = FalGptImage1RequestState::EditImage(GptImage1EditImageRequest {
prompt: "make the image feel like a quiet watercolor study".to_string(),
image_urls: vec![JUNO_AT_LAKE_IMAGE_URL.to_string()],
num_images: GptImage1EditImageNumImages::One,
mask_image_url: None,
image_size: Some(GptImage1EditImageSize::Square),
quality: Some(GptImage1EditImageQuality::Low),
input_fidelity: None,
background: None,
output_format: None,
});
let response = request.send(&client_without_webhook()).await.expect("send should succeed");
let payload = response.get_fal_payload().expect("expected Fal payload");
assert!(payload.request_id.is_some());
}
}
@@ -0,0 +1,401 @@
use fal_client::requests::api::image::edit::gpt_image_1p5_edit_image::api::{
GptImage1p5EditImageNumImages, GptImage1p5EditImageQuality,
GptImage1p5EditImageRequest, GptImage1p5EditImageSize,
};
use fal_client::requests::api::image::text::gpt_image_1p5_text_to_image::api::{
GptImage1p5TextToImageNumImages, GptImage1p5TextToImageQuality,
GptImage1p5TextToImageRequest, GptImage1p5TextToImageSize,
};
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_builder::GenerateImageRequestBuilder;
use crate::generate::generate_image_v2::image_generation_draft_or_request::ImageGenerationDraftOrRequest;
use crate::generate::generate_image_v2::image_generation_request::ImageGenerationRequest;
use crate::generate::generate_image_v2::providers::fal::gpt_image_1p5::request::FalGptImage1p5RequestState;
pub fn build_fal_gpt_image_1p5(
builder: GenerateImageRequestBuilder,
) -> Result<ImageGenerationDraftOrRequest, ArtcraftRouterError> {
let prompt = builder.prompt.clone().unwrap_or_default();
let image_urls = resolve_image_urls(builder.image_inputs.clone())?;
let num_images = plan_num_images(builder.image_batch_count, builder.request_mismatch_mitigation_strategy)?;
let image_size = plan_image_size(builder.aspect_ratio);
let quality = plan_quality(builder.quality);
let state = if image_urls.is_empty() {
FalGptImage1p5RequestState::TextToImage(GptImage1p5TextToImageRequest {
prompt,
num_images: to_t2i_num_images(num_images),
image_size: image_size.map(to_t2i_image_size),
background: None,
quality: Some(to_t2i_quality(quality)),
output_format: None,
})
} else {
FalGptImage1p5RequestState::EditImage(GptImage1p5EditImageRequest {
prompt,
image_urls,
num_images: to_edit_num_images(num_images),
mask_image_url: None,
image_size: image_size.map(to_edit_image_size),
background: None,
quality: Some(to_edit_quality(quality)),
input_fidelity: None,
output_format: None,
})
};
Ok(ImageGenerationDraftOrRequest::Request(
ImageGenerationRequest::FalGptImage1p5(state),
))
}
#[derive(Copy, Clone, Debug)]
enum PlannedNumImages {
One,
Two,
Three,
Four,
}
#[derive(Copy, Clone, Debug)]
enum PlannedQuality {
Low,
Medium,
High,
}
#[derive(Copy, Clone, Debug)]
enum PlannedImageSize {
Square,
Wide,
Tall,
}
fn plan_num_images(
count: Option<u16>,
strategy: RequestMismatchMitigationStrategy,
) -> Result<PlannedNumImages, ArtcraftRouterError> {
let count = count.unwrap_or(1);
match count {
0 => Err(ArtcraftRouterError::Client(ClientError::UserRequestedZeroGenerations)),
1 => Ok(PlannedNumImages::One),
2 => Ok(PlannedNumImages::Two),
3 => Ok(PlannedNumImages::Three),
4 => Ok(PlannedNumImages::Four),
_ => match strategy {
RequestMismatchMitigationStrategy::ErrorOut => {
Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption {
field: "image_batch_count",
value: format!("{}", count),
}))
}
_ => Ok(PlannedNumImages::Four),
},
}
}
fn plan_quality(quality: Option<CommonQuality>) -> PlannedQuality {
match quality {
Some(CommonQuality::Low) => PlannedQuality::Low,
Some(CommonQuality::Medium) => PlannedQuality::Medium,
Some(CommonQuality::High) | None => PlannedQuality::High,
}
}
fn plan_image_size(aspect_ratio: Option<CommonAspectRatio>) -> Option<PlannedImageSize> {
match aspect_ratio {
None
| Some(CommonAspectRatio::Auto)
| Some(CommonAspectRatio::Auto2k)
| Some(CommonAspectRatio::Auto3k)
| Some(CommonAspectRatio::Auto4k) => None,
Some(CommonAspectRatio::Square) | Some(CommonAspectRatio::SquareHd) => {
Some(PlannedImageSize::Square)
}
Some(CommonAspectRatio::WideThreeByTwo)
| Some(CommonAspectRatio::WideFourByThree)
| Some(CommonAspectRatio::WideFiveByFour)
| Some(CommonAspectRatio::WideSixteenByNine)
| Some(CommonAspectRatio::WideTwentyOneByNine)
| Some(CommonAspectRatio::Wide) => Some(PlannedImageSize::Wide),
Some(CommonAspectRatio::TallTwoByThree)
| Some(CommonAspectRatio::TallThreeByFour)
| Some(CommonAspectRatio::TallFourByFive)
| Some(CommonAspectRatio::TallNineBySixteen)
| Some(CommonAspectRatio::TallNineByTwentyOne)
| Some(CommonAspectRatio::Tall) => Some(PlannedImageSize::Tall),
}
}
fn to_t2i_num_images(n: PlannedNumImages) -> GptImage1p5TextToImageNumImages {
match n {
PlannedNumImages::One => GptImage1p5TextToImageNumImages::One,
PlannedNumImages::Two => GptImage1p5TextToImageNumImages::Two,
PlannedNumImages::Three => GptImage1p5TextToImageNumImages::Three,
PlannedNumImages::Four => GptImage1p5TextToImageNumImages::Four,
}
}
fn to_edit_num_images(n: PlannedNumImages) -> GptImage1p5EditImageNumImages {
match n {
PlannedNumImages::One => GptImage1p5EditImageNumImages::One,
PlannedNumImages::Two => GptImage1p5EditImageNumImages::Two,
PlannedNumImages::Three => GptImage1p5EditImageNumImages::Three,
PlannedNumImages::Four => GptImage1p5EditImageNumImages::Four,
}
}
fn to_t2i_quality(q: PlannedQuality) -> GptImage1p5TextToImageQuality {
match q {
PlannedQuality::Low => GptImage1p5TextToImageQuality::Low,
PlannedQuality::Medium => GptImage1p5TextToImageQuality::Medium,
PlannedQuality::High => GptImage1p5TextToImageQuality::High,
}
}
fn to_edit_quality(q: PlannedQuality) -> GptImage1p5EditImageQuality {
match q {
PlannedQuality::Low => GptImage1p5EditImageQuality::Low,
PlannedQuality::Medium => GptImage1p5EditImageQuality::Medium,
PlannedQuality::High => GptImage1p5EditImageQuality::High,
}
}
fn to_t2i_image_size(s: PlannedImageSize) -> GptImage1p5TextToImageSize {
match s {
PlannedImageSize::Square => GptImage1p5TextToImageSize::Square,
PlannedImageSize::Wide => GptImage1p5TextToImageSize::Wide,
PlannedImageSize::Tall => GptImage1p5TextToImageSize::Tall,
}
}
fn to_edit_image_size(s: PlannedImageSize) -> GptImage1p5EditImageSize {
match s {
PlannedImageSize::Square => GptImage1p5EditImageSize::Square,
PlannedImageSize::Wide => GptImage1p5EditImageSize::Wide,
PlannedImageSize::Tall => GptImage1p5EditImageSize::Tall,
}
}
fn resolve_image_urls(
image_inputs: Option<ImageListRef>,
) -> Result<Vec<String>, ArtcraftRouterError> {
match image_inputs {
None => Ok(vec![]),
Some(ImageListRef::Urls(urls)) => Ok(urls),
Some(ImageListRef::MediaFileTokens(_)) => {
Err(ArtcraftRouterError::Client(ClientError::FalOnlySupportsUrls))
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::fmt::Debug;
use crate::api::common_image_model::CommonImageModel;
use crate::api::provider::Provider;
fn base_builder() -> GenerateImageRequestBuilder {
GenerateImageRequestBuilder {
model: CommonImageModel::GptImage1p5,
provider: Provider::Fal,
prompt: Some("a cat in space".to_string()),
image_inputs: None,
resolution: None,
aspect_ratio: None,
quality: None,
image_batch_count: None,
horizontal_angle: None,
vertical_angle: None,
zoom: None,
request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::ErrorOut,
generation_mode_mismatch_strategy: None,
idempotency_token: None,
}
}
fn unwrap_t2i(result: Result<ImageGenerationDraftOrRequest, ArtcraftRouterError>) -> GptImage1p5TextToImageRequest {
let ImageGenerationDraftOrRequest::Request(
ImageGenerationRequest::FalGptImage1p5(
FalGptImage1p5RequestState::TextToImage(req)
)
) = result.expect("build should succeed") else {
panic!("expected GPT Image 1.5 text-to-image request")
};
req
}
fn unwrap_edit(result: Result<ImageGenerationDraftOrRequest, ArtcraftRouterError>) -> GptImage1p5EditImageRequest {
let ImageGenerationDraftOrRequest::Request(
ImageGenerationRequest::FalGptImage1p5(
FalGptImage1p5RequestState::EditImage(req)
)
) = result.expect("build should succeed") else {
panic!("expected GPT Image 1.5 edit-image request")
};
req
}
fn assert_debug<T: Debug>(actual: T, expected: &str) {
assert_eq!(format!("{:?}", actual), expected);
}
#[test]
fn build2_routes_to_gpt_image_1p5_request() {
let req = unwrap_t2i(base_builder().build2());
assert_eq!(req.prompt, "a cat in space");
}
#[test]
fn mode_detection_is_based_on_image_urls() {
let text_req = unwrap_t2i(build_fal_gpt_image_1p5(base_builder()));
assert_eq!(text_req.prompt, "a cat in space");
let edit_req = unwrap_edit(build_fal_gpt_image_1p5(GenerateImageRequestBuilder {
image_inputs: Some(ImageListRef::Urls(vec!["https://example.com/img.jpg".to_string()])),
..base_builder()
}));
assert_eq!(edit_req.image_urls, vec!["https://example.com/img.jpg"]);
}
#[test]
fn media_file_tokens_are_rejected() {
let result = build_fal_gpt_image_1p5(GenerateImageRequestBuilder {
image_inputs: Some(ImageListRef::MediaFileTokens(vec![])),
..base_builder()
});
assert!(matches!(
result,
Err(ArtcraftRouterError::Client(ClientError::FalOnlySupportsUrls))
));
}
#[test]
fn num_images_maps_exhaustively_for_text_and_edit() {
let cases = [
(1, "One"),
(2, "Two"),
(3, "Three"),
(4, "Four"),
];
for (count, expected) in cases {
let req = unwrap_t2i(build_fal_gpt_image_1p5(GenerateImageRequestBuilder {
image_batch_count: Some(count),
..base_builder()
}));
assert_debug(req.num_images, expected);
}
for (count, expected) in cases {
let req = unwrap_edit(build_fal_gpt_image_1p5(GenerateImageRequestBuilder {
image_inputs: Some(ImageListRef::Urls(vec!["https://example.com/img.jpg".to_string()])),
image_batch_count: Some(count),
..base_builder()
}));
assert_debug(req.num_images, expected);
}
}
#[test]
fn num_images_rejects_zero_and_handles_overflow_by_strategy() {
assert!(matches!(
build_fal_gpt_image_1p5(GenerateImageRequestBuilder {
image_batch_count: Some(0),
..base_builder()
}),
Err(ArtcraftRouterError::Client(ClientError::UserRequestedZeroGenerations))
));
assert!(matches!(
build_fal_gpt_image_1p5(GenerateImageRequestBuilder {
image_batch_count: Some(5),
..base_builder()
}),
Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption { .. }))
));
let req = unwrap_t2i(build_fal_gpt_image_1p5(GenerateImageRequestBuilder {
image_batch_count: Some(5),
request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::PayMoreUpgrade,
..base_builder()
}));
assert_debug(req.num_images, "Four");
}
#[test]
fn quality_maps_exhaustively_for_text_and_edit() {
let cases = [
(None, "Some(High)"),
(Some(CommonQuality::Low), "Some(Low)"),
(Some(CommonQuality::Medium), "Some(Medium)"),
(Some(CommonQuality::High), "Some(High)"),
];
for (quality, expected) in cases {
let req = unwrap_t2i(build_fal_gpt_image_1p5(GenerateImageRequestBuilder {
quality,
..base_builder()
}));
assert_debug(req.quality, expected);
}
for (quality, expected) in cases {
let req = unwrap_edit(build_fal_gpt_image_1p5(GenerateImageRequestBuilder {
image_inputs: Some(ImageListRef::Urls(vec!["https://example.com/img.jpg".to_string()])),
quality,
..base_builder()
}));
assert_debug(req.quality, expected);
}
}
#[test]
fn aspect_ratio_maps_exhaustively_for_text_and_edit() {
let cases = [
(None, "None"),
(Some(CommonAspectRatio::Auto), "None"),
(Some(CommonAspectRatio::Auto2k), "None"),
(Some(CommonAspectRatio::Auto3k), "None"),
(Some(CommonAspectRatio::Auto4k), "None"),
(Some(CommonAspectRatio::Square), "Some(Square)"),
(Some(CommonAspectRatio::SquareHd), "Some(Square)"),
(Some(CommonAspectRatio::WideThreeByTwo), "Some(Wide)"),
(Some(CommonAspectRatio::WideFourByThree), "Some(Wide)"),
(Some(CommonAspectRatio::WideFiveByFour), "Some(Wide)"),
(Some(CommonAspectRatio::WideSixteenByNine), "Some(Wide)"),
(Some(CommonAspectRatio::WideTwentyOneByNine), "Some(Wide)"),
(Some(CommonAspectRatio::Wide), "Some(Wide)"),
(Some(CommonAspectRatio::TallTwoByThree), "Some(Tall)"),
(Some(CommonAspectRatio::TallThreeByFour), "Some(Tall)"),
(Some(CommonAspectRatio::TallFourByFive), "Some(Tall)"),
(Some(CommonAspectRatio::TallNineBySixteen), "Some(Tall)"),
(Some(CommonAspectRatio::TallNineByTwentyOne), "Some(Tall)"),
(Some(CommonAspectRatio::Tall), "Some(Tall)"),
];
for (aspect_ratio, expected) in cases {
let req = unwrap_t2i(build_fal_gpt_image_1p5(GenerateImageRequestBuilder {
aspect_ratio,
..base_builder()
}));
assert_debug(req.image_size, expected);
}
for (aspect_ratio, expected) in cases {
let req = unwrap_edit(build_fal_gpt_image_1p5(GenerateImageRequestBuilder {
image_inputs: Some(ImageListRef::Urls(vec!["https://example.com/img.jpg".to_string()])),
aspect_ratio,
..base_builder()
}));
assert_debug(req.image_size, expected);
}
}
}
@@ -0,0 +1,87 @@
use fal_client::requests::traits::fal_request_cost_calculator_trait::FalRequestCostCalculator;
use crate::generate::generate_image::image_generation_cost_estimate::ImageGenerationCostEstimate;
use crate::generate::generate_image_v2::providers::fal::gpt_image_1p5::request::FalGptImage1p5RequestState;
#[derive(Clone, Debug)]
pub struct FalGptImage1p5CostState {
request: FalGptImage1p5RequestState,
}
impl FalGptImage1p5CostState {
pub fn from_request(request: &FalGptImage1p5RequestState) -> Self {
Self { request: request.clone() }
}
pub fn estimate_cost(&self) -> ImageGenerationCostEstimate {
let cost_in_usd_cents = match &self.request {
FalGptImage1p5RequestState::TextToImage(req) => req.calculate_cost_in_cents(),
FalGptImage1p5RequestState::EditImage(req) => req.calculate_cost_in_cents(),
};
ImageGenerationCostEstimate {
cost_in_credits: None,
cost_in_usd_cents: Some(cost_in_usd_cents),
is_free: false,
is_unlimited: false,
is_rate_limited: false,
has_watermark: false,
failures_are_refunded: None,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::generate::generate_image::image_generation_cost_estimate::ImageGenerationCostEstimate;
use fal_client::requests::api::image::edit::gpt_image_1p5_edit_image::api::{GptImage1p5EditImageNumImages, GptImage1p5EditImageQuality, GptImage1p5EditImageRequest, GptImage1p5EditImageSize};
use fal_client::requests::api::image::text::gpt_image_1p5_text_to_image::api::{GptImage1p5TextToImageNumImages, GptImage1p5TextToImageQuality, GptImage1p5TextToImageRequest, GptImage1p5TextToImageSize};
#[test]
fn text_to_image_cost_table_covers_quality_size_and_count() {
for (quality, image_size, expected_by_count) in text_to_image_price_cases() {
for (num_images, expected) in text_to_image_num_image_cases(expected_by_count) {
let request = FalGptImage1p5RequestState::TextToImage(GptImage1p5TextToImageRequest { prompt: "test".to_string(), num_images, image_size, background: None, quality, output_format: None });
let cost = FalGptImage1p5CostState::from_request(&request).estimate_cost();
assert_standard_cost_estimate(cost, expected);
}
}
}
#[test]
fn edit_image_cost_table_covers_quality_size_and_count() {
for (quality, image_size, expected_by_count) in edit_image_price_cases() {
for (num_images, expected) in edit_image_num_image_cases(expected_by_count) {
let request = FalGptImage1p5RequestState::EditImage(GptImage1p5EditImageRequest { prompt: "test".to_string(), image_urls: vec!["https://example.com/image.png".to_string()], num_images, mask_image_url: None, image_size, background: None, quality, input_fidelity: None, output_format: None });
let cost = FalGptImage1p5CostState::from_request(&request).estimate_cost();
assert_standard_cost_estimate(cost, expected);
}
}
}
fn text_to_image_price_cases() -> [(Option<GptImage1p5TextToImageQuality>, Option<GptImage1p5TextToImageSize>, [u64; 4]); 10] {
[(None, None, [3, 6, 9, 12]), (Some(GptImage1p5TextToImageQuality::Low), Some(GptImage1p5TextToImageSize::Square), [1, 2, 3, 4]), (Some(GptImage1p5TextToImageQuality::Low), Some(GptImage1p5TextToImageSize::Wide), [1, 2, 3, 4]), (Some(GptImage1p5TextToImageQuality::Low), Some(GptImage1p5TextToImageSize::Tall), [1, 2, 3, 4]), (Some(GptImage1p5TextToImageQuality::Medium), Some(GptImage1p5TextToImageSize::Square), [3, 6, 9, 12]), (Some(GptImage1p5TextToImageQuality::Medium), Some(GptImage1p5TextToImageSize::Wide), [5, 10, 15, 20]), (Some(GptImage1p5TextToImageQuality::Medium), Some(GptImage1p5TextToImageSize::Tall), [5, 10, 15, 20]), (Some(GptImage1p5TextToImageQuality::High), Some(GptImage1p5TextToImageSize::Square), [13, 26, 39, 52]), (Some(GptImage1p5TextToImageQuality::High), Some(GptImage1p5TextToImageSize::Wide), [20, 40, 60, 80]), (Some(GptImage1p5TextToImageQuality::High), Some(GptImage1p5TextToImageSize::Tall), [20, 40, 60, 80])]
}
fn edit_image_price_cases() -> [(Option<GptImage1p5EditImageQuality>, Option<GptImage1p5EditImageSize>, [u64; 4]); 10] {
[(None, None, [3, 6, 9, 12]), (Some(GptImage1p5EditImageQuality::Low), Some(GptImage1p5EditImageSize::Square), [1, 2, 3, 4]), (Some(GptImage1p5EditImageQuality::Low), Some(GptImage1p5EditImageSize::Wide), [1, 2, 3, 4]), (Some(GptImage1p5EditImageQuality::Low), Some(GptImage1p5EditImageSize::Tall), [1, 2, 3, 4]), (Some(GptImage1p5EditImageQuality::Medium), Some(GptImage1p5EditImageSize::Square), [3, 6, 9, 12]), (Some(GptImage1p5EditImageQuality::Medium), Some(GptImage1p5EditImageSize::Wide), [5, 10, 15, 20]), (Some(GptImage1p5EditImageQuality::Medium), Some(GptImage1p5EditImageSize::Tall), [5, 10, 15, 20]), (Some(GptImage1p5EditImageQuality::High), Some(GptImage1p5EditImageSize::Square), [13, 26, 39, 52]), (Some(GptImage1p5EditImageQuality::High), Some(GptImage1p5EditImageSize::Wide), [20, 40, 60, 80]), (Some(GptImage1p5EditImageQuality::High), Some(GptImage1p5EditImageSize::Tall), [20, 40, 60, 80])]
}
fn text_to_image_num_image_cases(expected_by_count: [u64; 4]) -> [(GptImage1p5TextToImageNumImages, u64); 4] {
[(GptImage1p5TextToImageNumImages::One, expected_by_count[0]), (GptImage1p5TextToImageNumImages::Two, expected_by_count[1]), (GptImage1p5TextToImageNumImages::Three, expected_by_count[2]), (GptImage1p5TextToImageNumImages::Four, expected_by_count[3])]
}
fn edit_image_num_image_cases(expected_by_count: [u64; 4]) -> [(GptImage1p5EditImageNumImages, u64); 4] {
[(GptImage1p5EditImageNumImages::One, expected_by_count[0]), (GptImage1p5EditImageNumImages::Two, expected_by_count[1]), (GptImage1p5EditImageNumImages::Three, expected_by_count[2]), (GptImage1p5EditImageNumImages::Four, expected_by_count[3])]
}
fn assert_standard_cost_estimate(cost: ImageGenerationCostEstimate, expected_usd_cents: u64) {
assert_eq!(cost.cost_in_usd_cents, Some(expected_usd_cents));
assert_eq!(cost.cost_in_credits, None);
assert!(!cost.is_free);
assert!(!cost.is_unlimited);
assert!(!cost.is_rate_limited);
assert!(!cost.has_watermark);
}
}
@@ -0,0 +1,3 @@
pub mod build;
pub mod cost;
pub mod request;
@@ -0,0 +1,126 @@
use std::fmt::Debug;
use std::sync::Arc;
use fal_client::requests::api::image::edit::gpt_image_1p5_edit_image::api::GptImage1p5EditImageRequest;
use fal_client::requests::api::image::text::gpt_image_1p5_text_to_image::api::GptImage1p5TextToImageRequest;
use fal_client::requests::traits::fal_endpoint_trait::FalEndpoint;
use crate::client::router_fal_webhook_optional_client::RouterFalWebhookOptionalClient;
use crate::errors::artcraft_router_error::ArtcraftRouterError;
use crate::generate::generate_image::generate_image_response::{FalImageResponsePayload, GenerateImageResponse};
#[derive(Clone, Debug)]
pub enum FalGptImage1p5RequestState {
TextToImage(GptImage1p5TextToImageRequest),
EditImage(GptImage1p5EditImageRequest),
}
impl FalGptImage1p5RequestState {
pub async fn send(&self, client: &RouterFalWebhookOptionalClient) -> Result<GenerateImageResponse, ArtcraftRouterError> {
match self {
Self::TextToImage(request) => send_request(request, client).await,
Self::EditImage(request) => send_request(request, client).await,
}
}
}
struct FalResponseIds {
request_id: Option<String>,
gateway_request_id: Option<String>,
status_url: Option<String>,
response_url: Option<String>,
}
async fn send_request<T>(request: &T, client: &RouterFalWebhookOptionalClient) -> Result<GenerateImageResponse, ArtcraftRouterError>
where
T: FalEndpoint + Clone + Debug + Send + Sync + 'static,
{
let outbound: Arc<dyn Debug + Send + Sync> = Arc::new(request.clone());
let payload = send_fal_request(request, client).await?;
Ok(GenerateImageResponse::Fal(FalImageResponsePayload {
request_id: payload.request_id,
gateway_request_id: payload.gateway_request_id,
maybe_status_url: payload.status_url,
maybe_response_url: payload.response_url,
maybe_outbound_request: Some(outbound),
}))
}
async fn send_fal_request<T: FalEndpoint>(
request: &T,
client: &RouterFalWebhookOptionalClient,
) -> Result<FalResponseIds, ArtcraftRouterError> {
if let Some(webhook_url) = &client.webhook_url {
let response = request.send_webhook_request(&client.api_key, webhook_url).await?;
Ok(FalResponseIds {
request_id: response.request_id,
gateway_request_id: response.gateway_request_id,
status_url: None,
response_url: None,
})
} else {
let response = request.send_queue_request(&client.api_key).await?;
Ok(FalResponseIds {
request_id: Some(response.request_id),
gateway_request_id: None,
status_url: Some(response.status_url),
response_url: Some(response.response_url),
})
}
}
#[cfg(test)]
mod tests {
use super::*;
use fal_client::creds::fal_api_key::FalApiKey;
use fal_client::requests::api::image::edit::gpt_image_1p5_edit_image::api::{
GptImage1p5EditImageNumImages, GptImage1p5EditImageQuality,
GptImage1p5EditImageSize,
};
use fal_client::requests::api::image::text::gpt_image_1p5_text_to_image::api::{
GptImage1p5TextToImageNumImages, GptImage1p5TextToImageQuality,
GptImage1p5TextToImageSize,
};
use test_data::web::image_urls::JUNO_AT_LAKE_IMAGE_URL;
fn client_without_webhook() -> RouterFalWebhookOptionalClient {
let secret = std::fs::read_to_string("/Users/bt/Artcraft/credentials/fal_api_key.txt")
.expect("Failed to read fal_api_key.txt");
RouterFalWebhookOptionalClient::new(FalApiKey::from_str(secret.trim()))
}
#[tokio::test]
#[ignore] // requires real API key, incurs cost
async fn send_text_to_image_queue() {
let request = FalGptImage1p5RequestState::TextToImage(GptImage1p5TextToImageRequest {
prompt: "a tiny brass observatory in a snowy field".to_string(),
num_images: GptImage1p5TextToImageNumImages::One,
image_size: Some(GptImage1p5TextToImageSize::Square),
background: None,
quality: Some(GptImage1p5TextToImageQuality::Low),
output_format: None,
});
let response = request.send(&client_without_webhook()).await.expect("send should succeed");
let payload = response.get_fal_payload().expect("expected Fal payload");
assert!(payload.request_id.is_some());
}
#[tokio::test]
#[ignore] // requires real API key, incurs cost
async fn send_edit_image_queue() {
let request = FalGptImage1p5RequestState::EditImage(GptImage1p5EditImageRequest {
prompt: "turn this into a softly lit editorial photograph".to_string(),
image_urls: vec![JUNO_AT_LAKE_IMAGE_URL.to_string()],
num_images: GptImage1p5EditImageNumImages::One,
mask_image_url: None,
image_size: Some(GptImage1p5EditImageSize::Square),
background: None,
quality: Some(GptImage1p5EditImageQuality::Low),
input_fidelity: None,
output_format: None,
});
let response = request.send(&client_without_webhook()).await.expect("send should succeed");
let payload = response.get_fal_payload().expect("expected Fal payload");
assert!(payload.request_id.is_some());
}
}
@@ -0,0 +1,458 @@
use fal_client::requests::api::image::common::gpt_image_2_resolution::GptImage2Resolution;
use fal_client::requests::api::image::edit::gpt_image_2_edit_image::api::{
GptImage2EditImageNumImages, GptImage2EditImageQuality, GptImage2EditImageRequest,
GptImage2EditImageSize,
};
use fal_client::requests::api::image::text::gpt_image_2_text_to_image::api::{
GptImage2TextToImageNumImages, GptImage2TextToImageQuality,
GptImage2TextToImageRequest, GptImage2TextToImageSize,
};
use crate::api::common_aspect_ratio::CommonAspectRatio;
use crate::api::common_quality::CommonQuality;
use crate::api::common_resolution::CommonResolution;
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_builder::GenerateImageRequestBuilder;
use crate::generate::generate_image_v2::image_generation_draft_or_request::ImageGenerationDraftOrRequest;
use crate::generate::generate_image_v2::image_generation_request::ImageGenerationRequest;
use crate::generate::generate_image_v2::providers::fal::gpt_image_2::request::FalGptImage2RequestState;
pub fn build_fal_gpt_image_2(
builder: GenerateImageRequestBuilder,
) -> Result<ImageGenerationDraftOrRequest, ArtcraftRouterError> {
let prompt = builder.prompt.clone().unwrap_or_default();
let image_urls = resolve_image_urls(builder.image_inputs.clone())?;
let num_images = plan_num_images(builder.image_batch_count, builder.request_mismatch_mitigation_strategy)?;
let image_size = plan_image_size(builder.aspect_ratio);
let resolution = plan_resolution(builder.resolution);
let quality = plan_quality(builder.quality);
let state = if image_urls.is_empty() {
FalGptImage2RequestState::TextToImage(GptImage2TextToImageRequest {
prompt,
num_images: to_t2i_num_images(num_images),
image_size: image_size.and_then(to_t2i_image_size),
resolution,
quality: Some(to_t2i_quality(quality)),
output_format: None,
})
} else {
FalGptImage2RequestState::EditImage(GptImage2EditImageRequest {
prompt,
image_urls,
num_images: to_edit_num_images(num_images),
mask_url: None,
image_size: image_size.map(to_edit_image_size),
resolution,
quality: Some(to_edit_quality(quality)),
output_format: None,
})
};
Ok(ImageGenerationDraftOrRequest::Request(
ImageGenerationRequest::FalGptImage2(state),
))
}
#[derive(Copy, Clone, Debug)]
enum PlannedNumImages {
One,
Two,
Three,
Four,
}
#[derive(Copy, Clone, Debug)]
enum PlannedQuality {
Low,
Medium,
High,
}
#[derive(Copy, Clone, Debug)]
enum PlannedImageSize {
SquareHd,
Square,
Portrait4x3,
Portrait16x9,
Landscape4x3,
Landscape16x9,
Auto,
}
fn plan_num_images(
count: Option<u16>,
strategy: RequestMismatchMitigationStrategy,
) -> Result<PlannedNumImages, ArtcraftRouterError> {
let count = count.unwrap_or(1);
match count {
0 => Err(ArtcraftRouterError::Client(ClientError::UserRequestedZeroGenerations)),
1 => Ok(PlannedNumImages::One),
2 => Ok(PlannedNumImages::Two),
3 => Ok(PlannedNumImages::Three),
4 => Ok(PlannedNumImages::Four),
_ => match strategy {
RequestMismatchMitigationStrategy::ErrorOut => {
Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption {
field: "image_batch_count",
value: format!("{}", count),
}))
}
_ => Ok(PlannedNumImages::Four),
},
}
}
fn plan_quality(quality: Option<CommonQuality>) -> PlannedQuality {
match quality {
Some(CommonQuality::Low) => PlannedQuality::Low,
Some(CommonQuality::Medium) => PlannedQuality::Medium,
Some(CommonQuality::High) | None => PlannedQuality::High,
}
}
fn plan_resolution(resolution: Option<CommonResolution>) -> Option<GptImage2Resolution> {
resolution.map(|r| match r {
CommonResolution::HalfK
| CommonResolution::FourEightyP
| CommonResolution::SevenTwentyP
| CommonResolution::OneK => GptImage2Resolution::OneK,
CommonResolution::TenEightyP | CommonResolution::TwoK => GptImage2Resolution::TwoK,
CommonResolution::ThreeK => GptImage2Resolution::ThreeK,
CommonResolution::FourK => GptImage2Resolution::FourK,
})
}
fn plan_image_size(aspect_ratio: Option<CommonAspectRatio>) -> Option<PlannedImageSize> {
match aspect_ratio {
None => None,
Some(CommonAspectRatio::Auto)
| Some(CommonAspectRatio::Auto2k)
| Some(CommonAspectRatio::Auto3k)
| Some(CommonAspectRatio::Auto4k) => Some(PlannedImageSize::Auto),
Some(CommonAspectRatio::Square) => Some(PlannedImageSize::Square),
Some(CommonAspectRatio::SquareHd) => Some(PlannedImageSize::SquareHd),
Some(CommonAspectRatio::WideFourByThree)
| Some(CommonAspectRatio::WideFiveByFour) => Some(PlannedImageSize::Landscape4x3),
Some(CommonAspectRatio::WideThreeByTwo)
| Some(CommonAspectRatio::WideSixteenByNine)
| Some(CommonAspectRatio::WideTwentyOneByNine)
| Some(CommonAspectRatio::Wide) => Some(PlannedImageSize::Landscape16x9),
Some(CommonAspectRatio::TallThreeByFour)
| Some(CommonAspectRatio::TallFourByFive) => Some(PlannedImageSize::Portrait4x3),
Some(CommonAspectRatio::TallTwoByThree)
| Some(CommonAspectRatio::TallNineBySixteen)
| Some(CommonAspectRatio::TallNineByTwentyOne)
| Some(CommonAspectRatio::Tall) => Some(PlannedImageSize::Portrait16x9),
}
}
fn to_t2i_num_images(n: PlannedNumImages) -> GptImage2TextToImageNumImages {
match n {
PlannedNumImages::One => GptImage2TextToImageNumImages::One,
PlannedNumImages::Two => GptImage2TextToImageNumImages::Two,
PlannedNumImages::Three => GptImage2TextToImageNumImages::Three,
PlannedNumImages::Four => GptImage2TextToImageNumImages::Four,
}
}
fn to_edit_num_images(n: PlannedNumImages) -> GptImage2EditImageNumImages {
match n {
PlannedNumImages::One => GptImage2EditImageNumImages::One,
PlannedNumImages::Two => GptImage2EditImageNumImages::Two,
PlannedNumImages::Three => GptImage2EditImageNumImages::Three,
PlannedNumImages::Four => GptImage2EditImageNumImages::Four,
}
}
fn to_t2i_quality(q: PlannedQuality) -> GptImage2TextToImageQuality {
match q {
PlannedQuality::Low => GptImage2TextToImageQuality::Low,
PlannedQuality::Medium => GptImage2TextToImageQuality::Medium,
PlannedQuality::High => GptImage2TextToImageQuality::High,
}
}
fn to_edit_quality(q: PlannedQuality) -> GptImage2EditImageQuality {
match q {
PlannedQuality::Low => GptImage2EditImageQuality::Low,
PlannedQuality::Medium => GptImage2EditImageQuality::Medium,
PlannedQuality::High => GptImage2EditImageQuality::High,
}
}
fn to_t2i_image_size(s: PlannedImageSize) -> Option<GptImage2TextToImageSize> {
match s {
PlannedImageSize::SquareHd => Some(GptImage2TextToImageSize::SquareHd),
PlannedImageSize::Square => Some(GptImage2TextToImageSize::Square),
PlannedImageSize::Portrait4x3 => Some(GptImage2TextToImageSize::Portrait4x3),
PlannedImageSize::Portrait16x9 => Some(GptImage2TextToImageSize::Portrait16x9),
PlannedImageSize::Landscape4x3 => Some(GptImage2TextToImageSize::Landscape4x3),
PlannedImageSize::Landscape16x9 => Some(GptImage2TextToImageSize::Landscape16x9),
PlannedImageSize::Auto => None,
}
}
fn to_edit_image_size(s: PlannedImageSize) -> GptImage2EditImageSize {
match s {
PlannedImageSize::SquareHd => GptImage2EditImageSize::SquareHd,
PlannedImageSize::Square => GptImage2EditImageSize::Square,
PlannedImageSize::Portrait4x3 => GptImage2EditImageSize::Portrait4x3,
PlannedImageSize::Portrait16x9 => GptImage2EditImageSize::Portrait16x9,
PlannedImageSize::Landscape4x3 => GptImage2EditImageSize::Landscape4x3,
PlannedImageSize::Landscape16x9 => GptImage2EditImageSize::Landscape16x9,
PlannedImageSize::Auto => GptImage2EditImageSize::Auto,
}
}
fn resolve_image_urls(
image_inputs: Option<ImageListRef>,
) -> Result<Vec<String>, ArtcraftRouterError> {
match image_inputs {
None => Ok(vec![]),
Some(ImageListRef::Urls(urls)) => Ok(urls),
Some(ImageListRef::MediaFileTokens(_)) => {
Err(ArtcraftRouterError::Client(ClientError::FalOnlySupportsUrls))
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::fmt::Debug;
use crate::api::common_image_model::CommonImageModel;
use crate::api::provider::Provider;
fn base_builder() -> GenerateImageRequestBuilder {
GenerateImageRequestBuilder {
model: CommonImageModel::GptImage2,
provider: Provider::Fal,
prompt: Some("a cat in space".to_string()),
image_inputs: None,
resolution: None,
aspect_ratio: None,
quality: None,
image_batch_count: None,
horizontal_angle: None,
vertical_angle: None,
zoom: None,
request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::ErrorOut,
generation_mode_mismatch_strategy: None,
idempotency_token: None,
}
}
fn unwrap_t2i(result: Result<ImageGenerationDraftOrRequest, ArtcraftRouterError>) -> GptImage2TextToImageRequest {
let ImageGenerationDraftOrRequest::Request(
ImageGenerationRequest::FalGptImage2(
FalGptImage2RequestState::TextToImage(req)
)
) = result.expect("build should succeed") else {
panic!("expected GPT Image 2 text-to-image request")
};
req
}
fn unwrap_edit(result: Result<ImageGenerationDraftOrRequest, ArtcraftRouterError>) -> GptImage2EditImageRequest {
let ImageGenerationDraftOrRequest::Request(
ImageGenerationRequest::FalGptImage2(
FalGptImage2RequestState::EditImage(req)
)
) = result.expect("build should succeed") else {
panic!("expected GPT Image 2 edit-image request")
};
req
}
fn assert_debug<T: Debug>(actual: T, expected: &str) {
assert_eq!(format!("{:?}", actual), expected);
}
#[test]
fn build2_routes_to_gpt_image_2_request() {
let req = unwrap_t2i(base_builder().build2());
assert_eq!(req.prompt, "a cat in space");
}
#[test]
fn mode_detection_is_based_on_image_urls() {
let text_req = unwrap_t2i(build_fal_gpt_image_2(base_builder()));
assert_eq!(text_req.prompt, "a cat in space");
let edit_req = unwrap_edit(build_fal_gpt_image_2(GenerateImageRequestBuilder {
image_inputs: Some(ImageListRef::Urls(vec!["https://example.com/img.jpg".to_string()])),
..base_builder()
}));
assert_eq!(edit_req.image_urls, vec!["https://example.com/img.jpg"]);
}
#[test]
fn media_file_tokens_are_rejected() {
let result = build_fal_gpt_image_2(GenerateImageRequestBuilder {
image_inputs: Some(ImageListRef::MediaFileTokens(vec![])),
..base_builder()
});
assert!(matches!(
result,
Err(ArtcraftRouterError::Client(ClientError::FalOnlySupportsUrls))
));
}
#[test]
fn num_images_maps_exhaustively_for_text_and_edit() {
let cases = [
(1, "One"),
(2, "Two"),
(3, "Three"),
(4, "Four"),
];
for (count, expected) in cases {
let req = unwrap_t2i(build_fal_gpt_image_2(GenerateImageRequestBuilder {
image_batch_count: Some(count),
..base_builder()
}));
assert_debug(req.num_images, expected);
}
for (count, expected) in cases {
let req = unwrap_edit(build_fal_gpt_image_2(GenerateImageRequestBuilder {
image_inputs: Some(ImageListRef::Urls(vec!["https://example.com/img.jpg".to_string()])),
image_batch_count: Some(count),
..base_builder()
}));
assert_debug(req.num_images, expected);
}
}
#[test]
fn num_images_rejects_zero_and_handles_overflow_by_strategy() {
assert!(matches!(
build_fal_gpt_image_2(GenerateImageRequestBuilder {
image_batch_count: Some(0),
..base_builder()
}),
Err(ArtcraftRouterError::Client(ClientError::UserRequestedZeroGenerations))
));
assert!(matches!(
build_fal_gpt_image_2(GenerateImageRequestBuilder {
image_batch_count: Some(5),
..base_builder()
}),
Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption { .. }))
));
let req = unwrap_t2i(build_fal_gpt_image_2(GenerateImageRequestBuilder {
image_batch_count: Some(5),
request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::PayMoreUpgrade,
..base_builder()
}));
assert_debug(req.num_images, "Four");
}
#[test]
fn quality_maps_exhaustively_for_text_and_edit() {
let cases = [
(None, "Some(High)"),
(Some(CommonQuality::Low), "Some(Low)"),
(Some(CommonQuality::Medium), "Some(Medium)"),
(Some(CommonQuality::High), "Some(High)"),
];
for (quality, expected) in cases {
let req = unwrap_t2i(build_fal_gpt_image_2(GenerateImageRequestBuilder {
quality,
..base_builder()
}));
assert_debug(req.quality, expected);
}
for (quality, expected) in cases {
let req = unwrap_edit(build_fal_gpt_image_2(GenerateImageRequestBuilder {
image_inputs: Some(ImageListRef::Urls(vec!["https://example.com/img.jpg".to_string()])),
quality,
..base_builder()
}));
assert_debug(req.quality, expected);
}
}
#[test]
fn resolution_maps_exhaustively_for_text_and_edit() {
let cases = [
(None, "None"),
(Some(CommonResolution::HalfK), "Some(OneK)"),
(Some(CommonResolution::FourEightyP), "Some(OneK)"),
(Some(CommonResolution::SevenTwentyP), "Some(OneK)"),
(Some(CommonResolution::OneK), "Some(OneK)"),
(Some(CommonResolution::TenEightyP), "Some(TwoK)"),
(Some(CommonResolution::TwoK), "Some(TwoK)"),
(Some(CommonResolution::ThreeK), "Some(ThreeK)"),
(Some(CommonResolution::FourK), "Some(FourK)"),
];
for (resolution, expected) in cases {
let req = unwrap_t2i(build_fal_gpt_image_2(GenerateImageRequestBuilder {
resolution,
..base_builder()
}));
assert_debug(req.resolution, expected);
}
for (resolution, expected) in cases {
let req = unwrap_edit(build_fal_gpt_image_2(GenerateImageRequestBuilder {
image_inputs: Some(ImageListRef::Urls(vec!["https://example.com/img.jpg".to_string()])),
resolution,
..base_builder()
}));
assert_debug(req.resolution, expected);
}
}
#[test]
fn aspect_ratio_maps_exhaustively_for_text_and_edit() {
let cases = [
(None, "None", "None"),
(Some(CommonAspectRatio::Auto), "None", "Some(Auto)"),
(Some(CommonAspectRatio::Auto2k), "None", "Some(Auto)"),
(Some(CommonAspectRatio::Auto3k), "None", "Some(Auto)"),
(Some(CommonAspectRatio::Auto4k), "None", "Some(Auto)"),
(Some(CommonAspectRatio::Square), "Some(Square)", "Some(Square)"),
(Some(CommonAspectRatio::SquareHd), "Some(SquareHd)", "Some(SquareHd)"),
(Some(CommonAspectRatio::WideThreeByTwo), "Some(Landscape16x9)", "Some(Landscape16x9)"),
(Some(CommonAspectRatio::WideFourByThree), "Some(Landscape4x3)", "Some(Landscape4x3)"),
(Some(CommonAspectRatio::WideFiveByFour), "Some(Landscape4x3)", "Some(Landscape4x3)"),
(Some(CommonAspectRatio::WideSixteenByNine), "Some(Landscape16x9)", "Some(Landscape16x9)"),
(Some(CommonAspectRatio::WideTwentyOneByNine), "Some(Landscape16x9)", "Some(Landscape16x9)"),
(Some(CommonAspectRatio::Wide), "Some(Landscape16x9)", "Some(Landscape16x9)"),
(Some(CommonAspectRatio::TallTwoByThree), "Some(Portrait16x9)", "Some(Portrait16x9)"),
(Some(CommonAspectRatio::TallThreeByFour), "Some(Portrait4x3)", "Some(Portrait4x3)"),
(Some(CommonAspectRatio::TallFourByFive), "Some(Portrait4x3)", "Some(Portrait4x3)"),
(Some(CommonAspectRatio::TallNineBySixteen), "Some(Portrait16x9)", "Some(Portrait16x9)"),
(Some(CommonAspectRatio::TallNineByTwentyOne), "Some(Portrait16x9)", "Some(Portrait16x9)"),
(Some(CommonAspectRatio::Tall), "Some(Portrait16x9)", "Some(Portrait16x9)"),
];
for (aspect_ratio, expected_t2i, _) in cases {
let req = unwrap_t2i(build_fal_gpt_image_2(GenerateImageRequestBuilder {
aspect_ratio,
..base_builder()
}));
assert_debug(req.image_size, expected_t2i);
}
for (aspect_ratio, _, expected_edit) in cases {
let req = unwrap_edit(build_fal_gpt_image_2(GenerateImageRequestBuilder {
image_inputs: Some(ImageListRef::Urls(vec!["https://example.com/img.jpg".to_string()])),
aspect_ratio,
..base_builder()
}));
assert_debug(req.image_size, expected_edit);
}
}
}
@@ -0,0 +1,216 @@
use fal_client::requests::traits::fal_request_cost_calculator_trait::FalRequestCostCalculator;
use crate::generate::generate_image::image_generation_cost_estimate::ImageGenerationCostEstimate;
use crate::generate::generate_image_v2::providers::fal::gpt_image_2::request::FalGptImage2RequestState;
#[derive(Clone, Debug)]
pub struct FalGptImage2CostState {
request: FalGptImage2RequestState,
}
impl FalGptImage2CostState {
pub fn from_request(request: &FalGptImage2RequestState) -> Self {
Self { request: request.clone() }
}
pub fn estimate_cost(&self) -> ImageGenerationCostEstimate {
let cost_in_usd_cents = match &self.request {
FalGptImage2RequestState::TextToImage(req) => req.calculate_cost_in_cents(),
FalGptImage2RequestState::EditImage(req) => req.calculate_cost_in_cents(),
};
ImageGenerationCostEstimate {
cost_in_credits: None,
cost_in_usd_cents: Some(cost_in_usd_cents),
is_free: false,
is_unlimited: false,
is_rate_limited: false,
has_watermark: false,
failures_are_refunded: None,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::generate::generate_image::image_generation_cost_estimate::ImageGenerationCostEstimate;
use fal_client::requests::api::image::common::gpt_image_2_resolution::GptImage2Resolution;
use fal_client::requests::api::image::edit::gpt_image_2_edit_image::api::{GptImage2EditImageNumImages, GptImage2EditImageQuality, GptImage2EditImageRequest, GptImage2EditImageSize};
use fal_client::requests::api::image::text::gpt_image_2_text_to_image::api::{GptImage2TextToImageNumImages, GptImage2TextToImageQuality, GptImage2TextToImageRequest, GptImage2TextToImageSize};
type TextPriceCase = (Option<GptImage2TextToImageQuality>, Option<GptImage2TextToImageSize>, Option<GptImage2Resolution>, [u64; 4]);
type EditPriceCase = (Option<GptImage2EditImageQuality>, Option<GptImage2EditImageSize>, Option<GptImage2Resolution>, [u64; 4]);
#[test]
fn text_to_image_cost_table_covers_quality_size_resolution_and_count() {
for (quality, image_size, resolution, expected_by_count) in text_to_image_price_cases() {
for (num_images, expected) in text_to_image_num_image_cases(expected_by_count) {
let request = FalGptImage2RequestState::TextToImage(GptImage2TextToImageRequest { prompt: "test".to_string(), num_images, image_size, resolution, quality, output_format: None });
let cost = FalGptImage2CostState::from_request(&request).estimate_cost();
assert_standard_cost_estimate(cost, expected);
}
}
}
#[test]
fn edit_image_cost_table_covers_quality_size_resolution_and_count() {
for (quality, image_size, resolution, expected_by_count) in edit_image_price_cases() {
for (num_images, expected) in edit_image_num_image_cases(expected_by_count) {
let request = FalGptImage2RequestState::EditImage(GptImage2EditImageRequest { prompt: "test".to_string(), image_urls: vec!["https://example.com/image.png".to_string()], num_images, mask_url: None, image_size, resolution, quality, output_format: None });
let cost = FalGptImage2CostState::from_request(&request).estimate_cost();
assert_standard_cost_estimate(cost, expected);
}
}
}
fn text_to_image_price_cases() -> Vec<TextPriceCase> {
let mut cases = vec![
// Default: High quality, Square
(None, None, None, [16, 32, 48, 64]),
// Presets (no resolution)
(Some(GptImage2TextToImageQuality::Low), Some(GptImage2TextToImageSize::Landscape4x3), None, [1, 2, 3, 4]),
(Some(GptImage2TextToImageQuality::Low), Some(GptImage2TextToImageSize::Portrait4x3), None, [1, 2, 3, 4]),
(Some(GptImage2TextToImageQuality::Low), Some(GptImage2TextToImageSize::Square), None, [1, 2, 3, 4]),
(Some(GptImage2TextToImageQuality::Low), Some(GptImage2TextToImageSize::Landscape16x9), None, [2, 4, 6, 8]),
(Some(GptImage2TextToImageQuality::Low), Some(GptImage2TextToImageSize::Portrait16x9), None, [2, 4, 6, 8]),
(Some(GptImage2TextToImageQuality::Low), Some(GptImage2TextToImageSize::SquareHd), None, [2, 4, 6, 8]),
(Some(GptImage2TextToImageQuality::Medium), Some(GptImage2TextToImageSize::Landscape4x3), None, [5, 10, 15, 20]),
(Some(GptImage2TextToImageQuality::Medium), Some(GptImage2TextToImageSize::Portrait4x3), None, [5, 10, 15, 20]),
(Some(GptImage2TextToImageQuality::Medium), Some(GptImage2TextToImageSize::Square), None, [5, 10, 15, 20]),
(Some(GptImage2TextToImageQuality::Medium), Some(GptImage2TextToImageSize::Landscape16x9), None, [6, 12, 18, 24]),
(Some(GptImage2TextToImageQuality::Medium), Some(GptImage2TextToImageSize::Portrait16x9), None, [6, 12, 18, 24]),
(Some(GptImage2TextToImageQuality::Medium), Some(GptImage2TextToImageSize::SquareHd), None, [8, 16, 24, 32]),
(Some(GptImage2TextToImageQuality::High), Some(GptImage2TextToImageSize::Landscape4x3), None, [15, 30, 45, 60]),
(Some(GptImage2TextToImageQuality::High), Some(GptImage2TextToImageSize::Portrait4x3), None, [15, 30, 45, 60]),
(Some(GptImage2TextToImageQuality::High), Some(GptImage2TextToImageSize::Square), None, [16, 32, 48, 64]),
(Some(GptImage2TextToImageQuality::High), Some(GptImage2TextToImageSize::Landscape16x9), None, [20, 40, 60, 80]),
(Some(GptImage2TextToImageQuality::High), Some(GptImage2TextToImageSize::Portrait16x9), None, [20, 40, 60, 80]),
(Some(GptImage2TextToImageQuality::High), Some(GptImage2TextToImageSize::SquareHd), None, [27, 54, 81, 108]),
];
for (size, resolution, low, medium, high) in text_to_image_resolution_price_cases() {
cases.push((Some(GptImage2TextToImageQuality::Low), Some(size), Some(resolution), low));
cases.push((Some(GptImage2TextToImageQuality::Medium), Some(size), Some(resolution), medium));
cases.push((Some(GptImage2TextToImageQuality::High), Some(size), Some(resolution), high));
}
cases
}
fn edit_image_price_cases() -> Vec<EditPriceCase> {
let mut cases = vec![
// Default: High quality, Square
(None, None, None, [15, 30, 45, 60]),
// Auto (with and without resolution — same cost, conservative max)
(Some(GptImage2EditImageQuality::Low), Some(GptImage2EditImageSize::Auto), None, [2, 4, 6, 8]),
(Some(GptImage2EditImageQuality::Medium), Some(GptImage2EditImageSize::Auto), None, [10, 20, 30, 40]),
(Some(GptImage2EditImageQuality::High), Some(GptImage2EditImageSize::Auto), None, [41, 82, 123, 164]),
(Some(GptImage2EditImageQuality::Low), Some(GptImage2EditImageSize::Auto), Some(GptImage2Resolution::OneK), [2, 4, 6, 8]),
(Some(GptImage2EditImageQuality::Medium), Some(GptImage2EditImageSize::Auto), Some(GptImage2Resolution::OneK), [10, 20, 30, 40]),
(Some(GptImage2EditImageQuality::High), Some(GptImage2EditImageSize::Auto), Some(GptImage2Resolution::OneK), [41, 82, 123, 164]),
// Presets (no resolution)
(Some(GptImage2EditImageQuality::Low), Some(GptImage2EditImageSize::Landscape4x3), None, [1, 2, 3, 4]),
(Some(GptImage2EditImageQuality::Low), Some(GptImage2EditImageSize::Portrait4x3), None, [1, 2, 3, 4]),
(Some(GptImage2EditImageQuality::Low), Some(GptImage2EditImageSize::Square), None, [1, 2, 3, 4]),
(Some(GptImage2EditImageQuality::Low), Some(GptImage2EditImageSize::Landscape16x9), None, [1, 2, 3, 4]),
(Some(GptImage2EditImageQuality::Low), Some(GptImage2EditImageSize::Portrait16x9), None, [1, 2, 3, 4]),
(Some(GptImage2EditImageQuality::Low), Some(GptImage2EditImageSize::SquareHd), None, [1, 2, 3, 4]),
(Some(GptImage2EditImageQuality::Medium), Some(GptImage2EditImageSize::Landscape4x3), None, [4, 8, 12, 16]),
(Some(GptImage2EditImageQuality::Medium), Some(GptImage2EditImageSize::Portrait4x3), None, [4, 8, 12, 16]),
(Some(GptImage2EditImageQuality::Medium), Some(GptImage2EditImageSize::Square), None, [4, 8, 12, 16]),
(Some(GptImage2EditImageQuality::Medium), Some(GptImage2EditImageSize::Landscape16x9), None, [5, 10, 15, 20]),
(Some(GptImage2EditImageQuality::Medium), Some(GptImage2EditImageSize::Portrait16x9), None, [5, 10, 15, 20]),
(Some(GptImage2EditImageQuality::Medium), Some(GptImage2EditImageSize::SquareHd), None, [7, 14, 21, 28]),
(Some(GptImage2EditImageQuality::High), Some(GptImage2EditImageSize::Landscape4x3), None, [14, 28, 42, 56]),
(Some(GptImage2EditImageQuality::High), Some(GptImage2EditImageSize::Portrait4x3), None, [14, 28, 42, 56]),
(Some(GptImage2EditImageQuality::High), Some(GptImage2EditImageSize::Square), None, [15, 30, 45, 60]),
(Some(GptImage2EditImageQuality::High), Some(GptImage2EditImageSize::Landscape16x9), None, [19, 38, 57, 76]),
(Some(GptImage2EditImageQuality::High), Some(GptImage2EditImageSize::Portrait16x9), None, [19, 38, 57, 76]),
(Some(GptImage2EditImageQuality::High), Some(GptImage2EditImageSize::SquareHd), None, [26, 52, 78, 104]),
];
for (size, resolution, low, medium, high) in edit_image_resolution_price_cases() {
cases.push((Some(GptImage2EditImageQuality::Low), Some(size), Some(resolution), low));
cases.push((Some(GptImage2EditImageQuality::Medium), Some(size), Some(resolution), medium));
cases.push((Some(GptImage2EditImageQuality::High), Some(size), Some(resolution), high));
}
cases
}
fn text_to_image_resolution_price_cases() -> Vec<(GptImage2TextToImageSize, GptImage2Resolution, [u64; 4], [u64; 4], [u64; 4])> {
vec![
(GptImage2TextToImageSize::Landscape4x3, GptImage2Resolution::OneK, [1, 2, 3, 4], [5, 10, 15, 20], [15, 30, 45, 60]),
(GptImage2TextToImageSize::Landscape4x3, GptImage2Resolution::TwoK, [2, 4, 6, 8], [7, 14, 21, 28], [24, 48, 72, 96]),
(GptImage2TextToImageSize::Landscape4x3, GptImage2Resolution::ThreeK, [2, 4, 6, 8], [11, 22, 33, 44], [37, 74, 111, 148]),
(GptImage2TextToImageSize::Landscape4x3, GptImage2Resolution::FourK, [3, 6, 9, 12], [12, 24, 36, 48], [41, 82, 123, 164]),
(GptImage2TextToImageSize::Landscape16x9, GptImage2Resolution::OneK, [1, 2, 3, 4], [5, 10, 15, 20], [15, 30, 45, 60]),
(GptImage2TextToImageSize::Landscape16x9, GptImage2Resolution::TwoK, [2, 4, 6, 8], [6, 12, 18, 24], [21, 42, 63, 84]),
(GptImage2TextToImageSize::Landscape16x9, GptImage2Resolution::ThreeK, [2, 4, 6, 8], [9, 18, 27, 36], [31, 62, 93, 124]),
(GptImage2TextToImageSize::Landscape16x9, GptImage2Resolution::FourK, [3, 6, 9, 12], [12, 24, 36, 48], [42, 84, 126, 168]),
(GptImage2TextToImageSize::Portrait4x3, GptImage2Resolution::OneK, [1, 2, 3, 4], [5, 10, 15, 20], [15, 30, 45, 60]),
(GptImage2TextToImageSize::Portrait4x3, GptImage2Resolution::TwoK, [2, 4, 6, 8], [7, 14, 21, 28], [24, 48, 72, 96]),
(GptImage2TextToImageSize::Portrait4x3, GptImage2Resolution::ThreeK, [2, 4, 6, 8], [11, 22, 33, 44], [37, 74, 111, 148]),
(GptImage2TextToImageSize::Portrait4x3, GptImage2Resolution::FourK, [3, 6, 9, 12], [12, 24, 36, 48], [41, 82, 123, 164]),
(GptImage2TextToImageSize::Portrait16x9, GptImage2Resolution::OneK, [1, 2, 3, 4], [5, 10, 15, 20], [15, 30, 45, 60]),
(GptImage2TextToImageSize::Portrait16x9, GptImage2Resolution::TwoK, [2, 4, 6, 8], [6, 12, 18, 24], [21, 42, 63, 84]),
(GptImage2TextToImageSize::Portrait16x9, GptImage2Resolution::ThreeK, [2, 4, 6, 8], [9, 18, 27, 36], [31, 62, 93, 124]),
(GptImage2TextToImageSize::Portrait16x9, GptImage2Resolution::FourK, [3, 6, 9, 12], [12, 24, 36, 48], [42, 84, 126, 168]),
(GptImage2TextToImageSize::Square, GptImage2Resolution::OneK, [1, 2, 3, 4], [5, 10, 15, 20], [16, 32, 48, 64]),
(GptImage2TextToImageSize::Square, GptImage2Resolution::TwoK, [2, 4, 6, 8], [8, 16, 24, 32], [27, 54, 81, 108]),
(GptImage2TextToImageSize::Square, GptImage2Resolution::ThreeK, [3, 6, 9, 12], [12, 24, 36, 48], [42, 84, 126, 168]),
(GptImage2TextToImageSize::Square, GptImage2Resolution::FourK, [3, 6, 9, 12], [12, 24, 36, 48], [42, 84, 126, 168]),
(GptImage2TextToImageSize::SquareHd, GptImage2Resolution::OneK, [1, 2, 3, 4], [5, 10, 15, 20], [16, 32, 48, 64]),
(GptImage2TextToImageSize::SquareHd, GptImage2Resolution::TwoK, [2, 4, 6, 8], [8, 16, 24, 32], [27, 54, 81, 108]),
(GptImage2TextToImageSize::SquareHd, GptImage2Resolution::ThreeK, [3, 6, 9, 12], [12, 24, 36, 48], [42, 84, 126, 168]),
(GptImage2TextToImageSize::SquareHd, GptImage2Resolution::FourK, [3, 6, 9, 12], [12, 24, 36, 48], [42, 84, 126, 168]),
]
}
fn edit_image_resolution_price_cases() -> Vec<(GptImage2EditImageSize, GptImage2Resolution, [u64; 4], [u64; 4], [u64; 4])> {
vec![
(GptImage2EditImageSize::Landscape4x3, GptImage2Resolution::OneK, [1, 2, 3, 4], [4, 8, 12, 16], [14, 28, 42, 56]),
(GptImage2EditImageSize::Landscape4x3, GptImage2Resolution::TwoK, [1, 2, 3, 4], [6, 12, 18, 24], [23, 46, 69, 92]),
(GptImage2EditImageSize::Landscape4x3, GptImage2Resolution::ThreeK, [2, 4, 6, 8], [9, 18, 27, 36], [36, 72, 108, 144]),
(GptImage2EditImageSize::Landscape4x3, GptImage2Resolution::FourK, [2, 4, 6, 8], [10, 20, 30, 40], [40, 80, 120, 160]),
(GptImage2EditImageSize::Landscape16x9, GptImage2Resolution::OneK, [1, 2, 3, 4], [4, 8, 12, 16], [14, 28, 42, 56]),
(GptImage2EditImageSize::Landscape16x9, GptImage2Resolution::TwoK, [1, 2, 3, 4], [5, 10, 15, 20], [20, 40, 60, 80]),
(GptImage2EditImageSize::Landscape16x9, GptImage2Resolution::ThreeK, [1, 2, 3, 4], [8, 16, 24, 32], [30, 60, 90, 120]),
(GptImage2EditImageSize::Landscape16x9, GptImage2Resolution::FourK, [2, 4, 6, 8], [10, 20, 30, 40], [41, 82, 123, 164]),
(GptImage2EditImageSize::Portrait4x3, GptImage2Resolution::OneK, [1, 2, 3, 4], [4, 8, 12, 16], [14, 28, 42, 56]),
(GptImage2EditImageSize::Portrait4x3, GptImage2Resolution::TwoK, [1, 2, 3, 4], [6, 12, 18, 24], [23, 46, 69, 92]),
(GptImage2EditImageSize::Portrait4x3, GptImage2Resolution::ThreeK, [2, 4, 6, 8], [9, 18, 27, 36], [36, 72, 108, 144]),
(GptImage2EditImageSize::Portrait4x3, GptImage2Resolution::FourK, [2, 4, 6, 8], [10, 20, 30, 40], [40, 80, 120, 160]),
(GptImage2EditImageSize::Portrait16x9, GptImage2Resolution::OneK, [1, 2, 3, 4], [4, 8, 12, 16], [14, 28, 42, 56]),
(GptImage2EditImageSize::Portrait16x9, GptImage2Resolution::TwoK, [1, 2, 3, 4], [5, 10, 15, 20], [20, 40, 60, 80]),
(GptImage2EditImageSize::Portrait16x9, GptImage2Resolution::ThreeK, [1, 2, 3, 4], [8, 16, 24, 32], [30, 60, 90, 120]),
(GptImage2EditImageSize::Portrait16x9, GptImage2Resolution::FourK, [2, 4, 6, 8], [10, 20, 30, 40], [41, 82, 123, 164]),
(GptImage2EditImageSize::Square, GptImage2Resolution::OneK, [1, 2, 3, 4], [4, 8, 12, 16], [15, 30, 45, 60]),
(GptImage2EditImageSize::Square, GptImage2Resolution::TwoK, [1, 2, 3, 4], [7, 14, 21, 28], [26, 52, 78, 104]),
(GptImage2EditImageSize::Square, GptImage2Resolution::ThreeK, [2, 4, 6, 8], [10, 20, 30, 40], [41, 82, 123, 164]),
(GptImage2EditImageSize::Square, GptImage2Resolution::FourK, [2, 4, 6, 8], [10, 20, 30, 40], [41, 82, 123, 164]),
(GptImage2EditImageSize::SquareHd, GptImage2Resolution::OneK, [1, 2, 3, 4], [4, 8, 12, 16], [15, 30, 45, 60]),
(GptImage2EditImageSize::SquareHd, GptImage2Resolution::TwoK, [1, 2, 3, 4], [7, 14, 21, 28], [26, 52, 78, 104]),
(GptImage2EditImageSize::SquareHd, GptImage2Resolution::ThreeK, [2, 4, 6, 8], [10, 20, 30, 40], [41, 82, 123, 164]),
(GptImage2EditImageSize::SquareHd, GptImage2Resolution::FourK, [2, 4, 6, 8], [10, 20, 30, 40], [41, 82, 123, 164]),
]
}
fn text_to_image_num_image_cases(expected_by_count: [u64; 4]) -> [(GptImage2TextToImageNumImages, u64); 4] {
[(GptImage2TextToImageNumImages::One, expected_by_count[0]), (GptImage2TextToImageNumImages::Two, expected_by_count[1]), (GptImage2TextToImageNumImages::Three, expected_by_count[2]), (GptImage2TextToImageNumImages::Four, expected_by_count[3])]
}
fn edit_image_num_image_cases(expected_by_count: [u64; 4]) -> [(GptImage2EditImageNumImages, u64); 4] {
[(GptImage2EditImageNumImages::One, expected_by_count[0]), (GptImage2EditImageNumImages::Two, expected_by_count[1]), (GptImage2EditImageNumImages::Three, expected_by_count[2]), (GptImage2EditImageNumImages::Four, expected_by_count[3])]
}
fn assert_standard_cost_estimate(cost: ImageGenerationCostEstimate, expected_usd_cents: u64) {
assert_eq!(cost.cost_in_usd_cents, Some(expected_usd_cents));
assert_eq!(cost.cost_in_credits, None);
assert!(!cost.is_free);
assert!(!cost.is_unlimited);
assert!(!cost.is_rate_limited);
assert!(!cost.has_watermark);
}
}
@@ -0,0 +1,3 @@
pub mod build;
pub mod cost;
pub mod request;
@@ -0,0 +1,125 @@
use std::fmt::Debug;
use std::sync::Arc;
use fal_client::requests::api::image::edit::gpt_image_2_edit_image::api::GptImage2EditImageRequest;
use fal_client::requests::api::image::text::gpt_image_2_text_to_image::api::GptImage2TextToImageRequest;
use fal_client::requests::traits::fal_endpoint_trait::FalEndpoint;
use crate::client::router_fal_webhook_optional_client::RouterFalWebhookOptionalClient;
use crate::errors::artcraft_router_error::ArtcraftRouterError;
use crate::generate::generate_image::generate_image_response::{FalImageResponsePayload, GenerateImageResponse};
#[derive(Clone, Debug)]
pub enum FalGptImage2RequestState {
TextToImage(GptImage2TextToImageRequest),
EditImage(GptImage2EditImageRequest),
}
impl FalGptImage2RequestState {
pub async fn send(&self, client: &RouterFalWebhookOptionalClient) -> Result<GenerateImageResponse, ArtcraftRouterError> {
match self {
Self::TextToImage(request) => send_request(request, client).await,
Self::EditImage(request) => send_request(request, client).await,
}
}
}
struct FalResponseIds {
request_id: Option<String>,
gateway_request_id: Option<String>,
status_url: Option<String>,
response_url: Option<String>,
}
async fn send_request<T>(request: &T, client: &RouterFalWebhookOptionalClient) -> Result<GenerateImageResponse, ArtcraftRouterError>
where
T: FalEndpoint + Clone + Debug + Send + Sync + 'static,
{
let outbound: Arc<dyn Debug + Send + Sync> = Arc::new(request.clone());
let payload = send_fal_request(request, client).await?;
Ok(GenerateImageResponse::Fal(FalImageResponsePayload {
request_id: payload.request_id,
gateway_request_id: payload.gateway_request_id,
maybe_status_url: payload.status_url,
maybe_response_url: payload.response_url,
maybe_outbound_request: Some(outbound),
}))
}
async fn send_fal_request<T: FalEndpoint>(
request: &T,
client: &RouterFalWebhookOptionalClient,
) -> Result<FalResponseIds, ArtcraftRouterError> {
if let Some(webhook_url) = &client.webhook_url {
let response = request.send_webhook_request(&client.api_key, webhook_url).await?;
Ok(FalResponseIds {
request_id: response.request_id,
gateway_request_id: response.gateway_request_id,
status_url: None,
response_url: None,
})
} else {
let response = request.send_queue_request(&client.api_key).await?;
Ok(FalResponseIds {
request_id: Some(response.request_id),
gateway_request_id: None,
status_url: Some(response.status_url),
response_url: Some(response.response_url),
})
}
}
#[cfg(test)]
mod tests {
use super::*;
use fal_client::creds::fal_api_key::FalApiKey;
use fal_client::requests::api::image::common::gpt_image_2_resolution::GptImage2Resolution;
use fal_client::requests::api::image::edit::gpt_image_2_edit_image::api::{
GptImage2EditImageNumImages, GptImage2EditImageQuality, GptImage2EditImageSize,
};
use fal_client::requests::api::image::text::gpt_image_2_text_to_image::api::{
GptImage2TextToImageNumImages, GptImage2TextToImageQuality,
GptImage2TextToImageSize,
};
use test_data::web::image_urls::JUNO_AT_LAKE_IMAGE_URL;
fn client_without_webhook() -> RouterFalWebhookOptionalClient {
let secret = std::fs::read_to_string("/Users/bt/Artcraft/credentials/fal_api_key.txt")
.expect("Failed to read fal_api_key.txt");
RouterFalWebhookOptionalClient::new(FalApiKey::from_str(secret.trim()))
}
#[tokio::test]
#[ignore] // requires real API key, incurs cost
async fn send_text_to_image_queue() {
let request = FalGptImage2RequestState::TextToImage(GptImage2TextToImageRequest {
prompt: "a polished product photo of a glass compass".to_string(),
num_images: GptImage2TextToImageNumImages::One,
image_size: Some(GptImage2TextToImageSize::Square),
resolution: Some(GptImage2Resolution::OneK),
quality: Some(GptImage2TextToImageQuality::Low),
output_format: None,
});
let response = request.send(&client_without_webhook()).await.expect("send should succeed");
let payload = response.get_fal_payload().expect("expected Fal payload");
assert!(payload.request_id.is_some());
}
#[tokio::test]
#[ignore] // requires real API key, incurs cost
async fn send_edit_image_queue() {
let request = FalGptImage2RequestState::EditImage(GptImage2EditImageRequest {
prompt: "make this look like an elegant magazine cover image".to_string(),
image_urls: vec![JUNO_AT_LAKE_IMAGE_URL.to_string()],
num_images: GptImage2EditImageNumImages::One,
mask_url: None,
image_size: Some(GptImage2EditImageSize::Square),
resolution: Some(GptImage2Resolution::OneK),
quality: Some(GptImage2EditImageQuality::Low),
output_format: None,
});
let response = request.send(&client_without_webhook()).await.expect("send should succeed");
let payload = response.get_fal_payload().expect("expected Fal payload");
assert!(payload.request_id.is_some());
}
}
@@ -1,4 +1,7 @@
pub mod flux_1_dev;
pub mod flux_1_schnell;
pub mod gpt_image_1;
pub mod gpt_image_1p5;
pub mod gpt_image_2;
pub mod nano_banana_2;
pub mod nano_banana_pro;
@@ -24,6 +24,7 @@ impl FalNanoBanana2CostState {
is_unlimited: false,
is_rate_limited: false,
has_watermark: false,
failures_are_refunded: None,
}
}
}
@@ -24,6 +24,7 @@ impl FalNanoBananaProCostState {
is_unlimited: false,
is_rate_limited: false,
has_watermark: false,
failures_are_refunded: None,
}
}
}
@@ -22,6 +22,7 @@ pub(crate) fn estimate_splat_cost_artcraft_marble_0p1_mini(
is_unlimited: false,
is_rate_limited: false,
has_watermark: false,
failures_are_refunded: None,
}
}
@@ -22,6 +22,7 @@ pub(crate) fn estimate_splat_cost_artcraft_marble_0p1_plus(
is_unlimited: false,
is_rate_limited: false,
has_watermark: false,
failures_are_refunded: None,
}
}
@@ -6,4 +6,5 @@ pub struct SplatGenerationCostEstimate {
pub is_unlimited: bool,
pub is_rate_limited: bool,
pub has_watermark: bool,
pub failures_are_refunded: Option<bool>,
}
@@ -47,6 +47,7 @@ pub(crate) fn estimate_video_cost_artcraft_kling3p0_pro(
is_unlimited: false,
is_rate_limited: false,
has_watermark: false,
failures_are_refunded: None,
}
}
@@ -47,6 +47,7 @@ pub(crate) fn estimate_video_cost_artcraft_kling3p0_standard(
is_unlimited: false,
is_rate_limited: false,
has_watermark: false,
failures_are_refunded: None,
}
}
@@ -14,5 +14,6 @@ pub(crate) fn estimate_video_cost_artcraft_kling_1_6_pro(
is_unlimited: false,
is_rate_limited: false,
has_watermark: false,
failures_are_refunded: None,
}
}
@@ -14,5 +14,6 @@ pub(crate) fn estimate_video_cost_artcraft_kling_2_1_master(
is_unlimited: false,
is_rate_limited: false,
has_watermark: false,
failures_are_refunded: None,
}
}
@@ -14,5 +14,6 @@ pub(crate) fn estimate_video_cost_artcraft_kling_2_1_pro(
is_unlimited: false,
is_rate_limited: false,
has_watermark: false,
failures_are_refunded: None,
}
}
@@ -14,5 +14,6 @@ pub(crate) fn estimate_video_cost_artcraft_kling_2_5_turbo_pro(
is_unlimited: false,
is_rate_limited: false,
has_watermark: false,
failures_are_refunded: None,
}
}
@@ -22,5 +22,6 @@ pub(crate) fn estimate_video_cost_artcraft_kling_2_6_pro(
is_unlimited: false,
is_rate_limited: false,
has_watermark: false,
failures_are_refunded: None,
}
}
@@ -50,6 +50,7 @@ pub(crate) fn estimate_video_cost_artcraft_seedance1p5_pro(
is_unlimited: false,
is_rate_limited: false,
has_watermark: false,
failures_are_refunded: None,
}
}
@@ -47,6 +47,7 @@ pub (crate) fn estimate_video_cost_artcraft_seedance2p0(
is_unlimited: false,
is_rate_limited: false,
has_watermark: false,
failures_are_refunded: None,
}
}
@@ -47,6 +47,7 @@ pub(crate) fn estimate_video_cost_artcraft_seedance2p0_fast(
is_unlimited: false,
is_rate_limited: false,
has_watermark: false,
failures_are_refunded: None,
}
}
@@ -42,5 +42,6 @@ pub(crate) fn estimate_video_cost_artcraft_seedance_1_0_lite(
is_unlimited: false,
is_rate_limited: false,
has_watermark: false,
failures_are_refunded: None,
}
}
@@ -14,5 +14,6 @@ pub(crate) fn estimate_video_cost_artcraft_sora_2(
is_unlimited: false,
is_rate_limited: false,
has_watermark: false,
failures_are_refunded: None,
}
}
@@ -17,5 +17,6 @@ pub(crate) fn estimate_video_cost_artcraft_sora_2_pro(
is_unlimited: false,
is_rate_limited: false,
has_watermark: false,
failures_are_refunded: None,
}
}
@@ -16,5 +16,6 @@ pub(crate) fn estimate_video_cost_artcraft_veo_2(
is_unlimited: false,
is_rate_limited: false,
has_watermark: false,
failures_are_refunded: None,
}
}
@@ -17,5 +17,6 @@ pub(crate) fn estimate_video_cost_artcraft_veo_3(
is_unlimited: false,
is_rate_limited: false,
has_watermark: false,
failures_are_refunded: None,
}
}
@@ -17,5 +17,6 @@ pub(crate) fn estimate_video_cost_artcraft_veo_3_fast(
is_unlimited: false,
is_rate_limited: false,
has_watermark: false,
failures_are_refunded: None,
}
}
@@ -17,5 +17,6 @@ pub(crate) fn estimate_video_cost_artcraft_veo_3p1(
is_unlimited: false,
is_rate_limited: false,
has_watermark: false,
failures_are_refunded: None,
}
}

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